Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,125 @@
# dwind changelog

## dwind 0.8.0 / dwind-macros 0.5.0 / dwind-base 0.1.2 / dwui 0.9.1 - 2026-07-25

Everything here is additive. The theme: raw CSS in an application was almost
always needed because of a *selector* or an *at-rule*, never because of a
property. These three additions close that gap.

### `dwkeyframes!` — declare animations without a raw stylesheet

Utility classes are single declaration blocks, so a `@keyframes` could never be
one; the CSS binding pipeline has no at-rule support at all. Every crate worked
around it with a hand-written `&str` blob pushed through `stylesheet_raw` — no
deduplication, no collision detection, and every keyframe paying its cost whether
the page used it or not.

```rust
dwkeyframes! {
#[animation("900ms cubic-bezier(0.16, 1, 0.3, 1) both")]
fade_up {
"from" => "opacity: 0; transform: translateY(14px);",
"to" => "opacity: 1; transform: translateY(0);",
}
}

html!("div", { .dwclass!("animate-fade-up") })
```

- Emits the at-rule *and* a compile-time-checked `animate-*` utility, so a typo
in the class name is still a build error.
- The rule is injected the first time anything reads the declaration or the name,
and never twice. That includes modified forms — `hover:animate-fade-up`,
`[&::before]:animate-fade-up` — which compile the declaration text into a fresh
class and never touch the generated one; the emitted `*_RAW` is an
`AnimationDecl` whose `Deref` registers, so those paths are covered too.
`format!("{FADE_UP_KEYFRAMES} 600ms {delay}ms")` registers as well.
- Names are namespaced by the consuming crate by default (`#![prefix = "..."]`
to override, `#[name = "..."]` to pin an exact name). Registering one name with
two different bodies now panics in debug builds instead of silently winning.
- New `dwind::prelude::keyframes` module (`Keyframes`, `register`,
`is_registered`, `registered_css`) backs it.
- dwind's own `spin`/`ping`/`pulse`/`bounce` and dwui's five `dwui-*` keyframes
now use it. Names and injection timing are unchanged;
`append_animation_keyframe_style()` keeps its signature, and
`dwui::theme::apply_style_sheet` is now idempotent.

### Arbitrary declarations — `[property:value]`

The escape hatch for properties with no utility, matching Tailwind:

```rust
.dwclass!("[mask-composite:exclude] [--sx:50%] hover:[color:red]")
```

Values pass through verbatim. Spaces are legal inside the brackets — the bracket
delimits the class, not the space — so there is no `_`-means-space convention,
which also means `[color:var(--brand_color)]` keeps its underscore. Parsing is
quote-aware, so a value may contain any character — `[content:'→']` and
`[content:'[']` both work, since a bracket inside a CSS string is content rather
than a delimiter.

Unambiguous against the variant syntax because a variant's `]` is always followed
by `:`.

**`dwclass!` no longer discards what it cannot parse.** `many0` stops at the first
unparseable class and reports success with the remainder untouched, and that
remainder was ignored — so one malformed class silently deleted itself *and every
class after it*. `dwclass!("foo [a:b] bar")` yielded one class, not three. The
parser now rejects a non-whitespace remainder with a message naming the offending
text, and classes may be separated by any whitespace, so multi-line class strings
parse instead of truncating.

### Pseudo-elements that actually render

`[&::before]:` variants already parsed, but a `::before` with no `content` never
generates a box, so the utility did nothing on its own. dwind now emits
`content: var(--dw-content, "")` for any variant whose last compound targets
`::before`/`::after`, and redirects a `content` declaration written under such a
variant to that property.

The indirection is load-bearing: each utility compiles to its own class with its
own rule, so a literal `content: ""` from `before:absolute` would win by source
order over the `content: 'x'` from `before:[content:'x']`. Going through the
property means the `content` declaration is identical everywhere and only the
value varies, so `before:[content:'x'] before:absolute` composes. The
`content-empty` / `content-none` utilities set the property for the same reason.

Added shorthands: `before:`, `after:`, `placeholder:`, `marker:`, `selection:`,
`backdrop:`, `first-letter:`, `first-line:`.

**Behaviour change:** `before:`/`after:` previously rendered as the legacy
single-colon `:before`. They now render `::before` (equivalent in every engine)
*and* gain generated content. If you wrote `before:` and supplied `content`
elsewhere, check it. This is the only currently-working input whose meaning
changes.

**Bug fix:** `render_generator` ignored the bracketed variant entirely, so
`[&::before]:bg-color-[red]` compiled and silently styled the element itself.
Generators now honour variants.

### `dwgenerate!` can alias any class

`dwgenerate!("my-flex", "flex")` never compiled — the generated static tried to
hold a `&String` in a `String`, so only generator-based selectors worked. Aliasing
now works for a plain utility and for a `dwkeyframes!` animation alike, and an
aliased animation still registers its keyframes.

### New utilities

`delay-0`…`delay-1000`, `underline` / `overline` / `line-through` /
`no-underline`, `tracking-tighter`…`tracking-widest`, `whitespace-*`,
`list-none` / `list-disc` / `list-decimal`, `content-empty` / `content-none`,
`font-inherit`, `isolate` / `isolation-auto`, `mix-blend-*`, `will-change-*`,
`outline-none` / `outline-hidden`.

### Tests

`dwind-macros` had no tests at all; it now has 29 covering the grammar and, for
the first time, codegen. New browser suite at `crates/dwui/tests/styling.rs`
asserts lazy injection, deduplication, that `::before` materialises, and that
arbitrary declarations reach `getComputedStyle`.

## dwui 0.9.0 - 2026-06-10

### Heavy components
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/dwind-base/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "dwind-base"
version = "0.1.1"
version = "0.1.2"
edition = "2021"
description = "DWIND base library used by the dwind generated code"
homepage = "https://github.com/JedimEmO/dwind"
Expand Down
211 changes: 211 additions & 0 deletions crates/dwind-base/src/keyframes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
//! Runtime registry for `@keyframes` rules.
//!
//! dwind's utility classes are generated as single declaration blocks, and the
//! CSS binding pipeline has no notion of at-rules — so a `@keyframes` cannot be
//! expressed as a utility. Historically every crate worked around that by
//! hand-writing a `&str` blob and pushing it through
//! [`dominator::stylesheet_raw`] at startup, which meant no deduplication, no
//! collision detection, and every keyframe paying its cost whether or not the
//! page used it.
//!
//! This module is the shared alternative. A [`Keyframes`] is a `const`-
//! constructible handle that injects its rule the first time anything asks for
//! its name, and never again.
//!
//! Prefer declaring these with the `dwkeyframes!` macro rather than by hand —
//! it mints the matching `animate-*` utility class at the same time, so the
//! class name stays checked by rustc.

use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;

/// Every keyframe name injected so far, mapped to the body it was injected
/// with. Both constructors are `const`, so this needs no lazy-init machinery.
static REGISTRY: Mutex<BTreeMap<&'static str, &'static str>> = Mutex::new(BTreeMap::new());

/// A declared `@keyframes` rule.
///
/// The rule is injected lazily — constructing a `Keyframes` costs nothing, and
/// the stylesheet is only touched once something calls [`Keyframes::name`],
/// [`Keyframes::ensure`], or formats the handle with `{}`.
///
/// ```ignore
/// static FADE: Keyframes = Keyframes::new("app-fade", "from{opacity:0;}to{opacity:1;}");
///
/// // `Display` registers, so composed shorthands work without ceremony:
/// el.style("animation", &format!("{FADE} 600ms ease-out both"));
/// ```
pub struct Keyframes {
name: &'static str,
body: &'static str,
injected: AtomicBool,
}

impl Keyframes {
pub const fn new(name: &'static str, body: &'static str) -> Self {
Self {
name,
body,
injected: AtomicBool::new(false),
}
}

/// Injects the rule if it has not been injected yet, then returns the CSS
/// keyframe name for use in an `animation` shorthand.
pub fn name(&self) -> &'static str {
self.ensure();
self.name
}

/// The keyframe name *without* injecting the rule.
///
/// Only useful in `const` contexts. If you are building an `animation`
/// value at runtime, use [`Keyframes::name`] or `{}` formatting instead so
/// the rule actually reaches the document.
pub const fn name_unregistered(&self) -> &'static str {
self.name
}

pub const fn body(&self) -> &'static str {
self.body
}

/// Injects the rule. Idempotent, and after the first call this is a single
/// relaxed atomic load.
pub fn ensure(&self) {
if self.injected.load(Ordering::Relaxed) {
return;
}

register(self.name, self.body);
self.injected.store(true, Ordering::Relaxed);
}

/// The full rule text, for server-side rendering or debugging. Does not
/// inject anything.
pub fn css(&self) -> String {
format!("@keyframes {} {{ {} }}", self.name, self.body)
}
}

impl std::fmt::Display for Keyframes {
/// Writes the keyframe name — and registers the rule as a side effect, so
/// that `format!("{KEYFRAMES} 1s linear")` cannot produce a shorthand
/// pointing at a rule that was never injected.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}

/// The declaration body of a generated `animate-*` utility.
///
/// This exists because registration has to hang off the *declaration text*
/// rather than off the utility class. `dwclass!` compiles a bare
/// `animate-fade-up` into a reference to the class, but a modified
/// `hover:animate-fade-up` or `[&::before]:animate-fade-up` builds a fresh class
/// out of the declaration text alone and never touches the original. Attaching
/// the side effect here is what makes every one of those paths inject the rule.
///
/// Reading the value — which `.raw(&*DECL)` does — registers.
pub struct AnimationDecl {
keyframes: &'static Keyframes,
css: &'static str,
}

impl AnimationDecl {
pub const fn new(keyframes: &'static Keyframes, css: &'static str) -> Self {
Self { keyframes, css }
}
}

impl std::ops::Deref for AnimationDecl {
type Target = str;

fn deref(&self) -> &str {
self.keyframes.ensure();
self.css
}
}

impl std::fmt::Display for AnimationDecl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self)
}
}

/// Injects `@keyframes {name} { {body} }` unless `name` is already registered.
///
/// Registering the same name twice with different bodies is a bug — two crates
/// have picked the same keyframe name and one is silently winning. In debug
/// builds that panics; in release the first registration stands.
pub fn register(name: &'static str, body: &'static str) {
let mut registry = match REGISTRY.lock() {
Ok(registry) => registry,
// A poisoned lock means an earlier registration panicked. Injecting
// styles is not worth propagating that.
Err(poisoned) => poisoned.into_inner(),
};

if let Some(existing) = registry.get(name) {
debug_assert!(
*existing == body,
"@keyframes `{name}` was registered twice with different bodies. \
Give one of them a distinct name — dwkeyframes! namespaces by crate \
unless you override it with #[name = \"...\"]."
);

return;
}

registry.insert(name, body);
dominator::stylesheet_raw(format!("@keyframes {name} {{ {body} }}"));
}

/// Whether `name` has already been injected.
pub fn is_registered(name: &str) -> bool {
match REGISTRY.lock() {
Ok(registry) => registry.contains_key(name),
Err(poisoned) => poisoned.into_inner().contains_key(name),
}
}

/// Every rule injected so far, concatenated. Intended for prerendering.
pub fn registered_css() -> String {
let registry = match REGISTRY.lock() {
Ok(registry) => registry,
Err(poisoned) => poisoned.into_inner(),
};

registry
.iter()
.map(|(name, body)| format!("@keyframes {name} {{ {body} }}"))
.collect::<Vec<_>>()
.join("\n")
}

#[cfg(test)]
mod test {
use super::*;

// These exercise the bookkeeping only. `register` reaches into the DOM, so
// the injection itself is covered by the browser tests in `dwui`.

#[test]
fn css_renders_a_complete_rule() {
let kf = Keyframes::new("test-fade", "from{opacity:0;}to{opacity:1;}");

assert_eq!(
kf.css(),
"@keyframes test-fade { from{opacity:0;}to{opacity:1;} }"
);
}

#[test]
fn name_unregistered_does_not_register() {
let kf = Keyframes::new("test-untouched", "from{opacity:0;}");

assert_eq!(kf.name_unregistered(), "test-untouched");
assert!(!is_registered("test-untouched"));
}
}
1 change: 1 addition & 0 deletions crates/dwind-base/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod keyframes;
pub mod media_queries;
Loading