Skip to content

Let dwind express keyframes, pseudo-elements and arbitrary declarations - #22

Merged
JedimEmO merged 4 commits into
mainfrom
dwind-keyframes-and-arbitrary-values
Jul 25, 2026
Merged

Let dwind express keyframes, pseudo-elements and arbitrary declarations#22
JedimEmO merged 4 commits into
mainfrom
dwind-keyframes-and-arbitrary-values

Conversation

@JedimEmO

Copy link
Copy Markdown
Owner

Stacked on #21. Base is modernize-example-app — retarget to main once #21 lands.

Auditing every raw CSS string in the example site produced one finding:

raw CSS was reached for because of the selector or the at-rule, never because of a property.

Three additions close that gap. Everything is additive — dwind 0.7.0, dwind-macros 0.4.0, dwind-base 0.1.1 and dwui 0.9.0 are published, and no currently-successful dwclass! input changes meaning except the one case called out below.

dwkeyframes!

A utility class is a single declaration block, so a @keyframes could never be one — the CSS binding pipeline has no at-rule support at all (ParsedSelector is only SingleClass | Complex). Every crate worked around it with a hand-written &str blob pushed through stylesheet_raw: three of them, with no deduplication, no collision detection, and every keyframe paying its cost whether the page used it or not.

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") })

This is Tailwind's model — declare the keyframe body and the utility that uses it together, emit both — adapted to dwind's "the class name is a Rust constant" invariant.

  • Emits the at-rule and a compile-time-checked animate-* utility, so a typo is still a build error.
  • Injected the first time the class — or the handle's Display — is used, and never twice. format!("{FADE_UP_KEYFRAMES} 600ms {delay}ms") registers as a side effect, which is what runtime-composed shorthands need.
  • Namespaced by the consuming crate via env!("CARGO_CRATE_NAME"), so two crates declaring fade_up don't collide. #![prefix = "..."] to override, #[name = "..."] to pin an exact name. One name registered with two different bodies now panics in debug instead of silently winning.
  • dwind's spin/ping/pulse/bounce and dwui's five dwui-* keyframes migrated onto it, names pinned and injection timing unchanged.

Every CSS fragment is a string literal deliberately: Rust's lexer splits 0%, --sx and .35 in ways that don't round-trip through TokenStream::to_string().

Arbitrary declarations — [property:value]

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

Unambiguous against the variant syntax because a variant's ] is always followed by :. No new sigil needed.

This is provably non-breaking, and it fixes a footgun: a bracket group without a trailing colon previously failed every parser, so many0 stopped and parse_class_string discarded the remainder — dwclass!("foo [a:b] bar") yielded one class, not three, with no diagnostic. That silent truncation is fixed, and a bracket group with no colon is now a compile error that says so.

Underscores in the value become spaces ([transition:opacity_650ms_ease]); the property side is left alone so custom properties keep theirs.

Pseudo-elements that 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: "" when a variant's last compound targets ::before/::after.

DomBuilder::raw appends rather than replaces (dominator/src/bindings.rs:160), so a user's own content-[…] later in the same class still wins. That's why this needs none of Tailwind's --tw-content indirection — Tailwind needs it only because its variants are static stylesheet rules with fixed source order.

Shorthands added: before:, after:, placeholder:, marker:, selection:, backdrop:, first-letter:, first-line:.

Bug fix: render_generator ignored class.variant entirely, so [&::before]:bg-color-[red] compiled and silently styled the element itself.

The one behaviour change: before:/after: previously rendered as the legacy single-colon :before. They now render ::before — equivalent in every engine — and gain generated content. before: was useless without content, so this should be safe, but it's the only currently-working input whose meaning changes.

New utilities

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

Deliberately not added: a mask-* family. Every real mask in the example is a bespoke gradient or a mask-composite: xor pair — a scale would cover none of them, and the escape hatch covers all of them.

Testing

dwind-macros had zero tests. It now has 29 — the pre-existing shapes are pinned first, in their own commit-order, so the parser change can be shown not to move them (verify_child_selector_parser is the disambiguation guard). Codegen had no tests at all and now does.

New browser suite crates/dwui/tests/styling.rs — 9 tests covering what token assertions can't: an unused keyframe is absent from document.styleSheets, two instantiations produce exactly one CSSKeyframesRule, getComputedStyle(el, "::before").content is not none, and arbitrary declarations (including --sx) reach the computed style.

cargo test -p dwind-macros -p dwind -p dwind-base   # 33 pass
wasm-pack test --headless --firefox crates/dwui      # 32 + 9 pass

The example site rebuilds and renders identically — 19 routes clean, no horizontal overflow at 1440px/390px, ⌘K palette and pointer-spotlight tracking intact.

Versions

dwind-base 0.1.2, dwind-macros 0.5.0, dwind 0.8.0, dwui 0.9.1, with path-dep pins bumped in the same commit so cargo publish can't resolve a stale dwind-macros against the new dwind.

A follow-up PR migrates examples/webpage onto all of this and deletes most of styles.rs.

🤖 Generated with Claude Code

Auditing every raw CSS string in the example site produced one finding:
raw CSS was reached for because of the *selector* or the *at-rule*, never
because of a property. Three additions close that gap. Everything is
additive.

dwkeyframes!
------------
A utility class is a single declaration block, so a @Keyframes could
never be one — the CSS binding pipeline has no at-rule support at all.
Every crate worked around that with a hand-written &str blob pushed
through stylesheet_raw: three of them, with no deduplication, no
collision detection, and every keyframe paying its cost whether the page
used it or not.

    dwkeyframes! {
        #[animation("900ms ease-out both")]
        fade_up {
            "from" => "opacity: 0; transform: translateY(14px);",
            "to"   => "opacity: 1; transform: translateY(0);",
        }
    }

This emits the at-rule *and* a compile-time-checked animate-* utility, so
the class name is still resolved by rustc. The rule is injected the first
time the class or the handle's Display is used, and never twice — which
also makes format!("{FADE_UP_KEYFRAMES} 600ms {delay}ms") safe for
shorthands composed at runtime. Names are namespaced by the consuming
crate via env!("CARGO_CRATE_NAME"), so two crates declaring fade_up do
not collide, and registering one name with two bodies now panics in debug
rather than silently winning.

Every CSS fragment is a string literal on purpose: Rust's lexer splits
0%, --sx and .35 in ways that do not round-trip through
TokenStream::to_string().

dwind's spin/ping/pulse/bounce and dwui's five dwui-* keyframes now use
it, with names pinned and injection timing unchanged.

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

Unambiguous against the variant syntax because a variant's ] is always
followed by :. This is provably non-breaking: a bracket group *without* a
trailing colon previously failed every parser and was discarded silently
along with every class after it, so dwclass!("foo [a:b] bar") yielded one
class rather than three. That truncation is fixed, and a bracket group
with no colon is now a compile error with a message.

Pseudo-elements
---------------
[&::before]: variants already parsed, but a ::before with no content
never generates a box, so the utility did nothing. dwind now emits
content: "" when a variant's last compound targets ::before/::after.
DomBuilder::raw appends rather than replaces, so a user's own content
declaration later in the class still wins — none of Tailwind's
--tw-content indirection is needed.

Also fixes render_generator, which ignored the bracketed variant
entirely: [&::before]:bg-color-[red] compiled and styled the element
itself.

The one behaviour change: before:/after: previously rendered as legacy
single-colon :before. They now render ::before — equivalent in every
engine — and gain generated content.

Tests
-----
dwind-macros had no tests at all. It now has 29, covering the grammar and
for the first time codegen, with the pre-existing shapes pinned first so
the parser change could be shown not to move them. New browser suite in
crates/dwui/tests/styling.rs asserts lazy injection, deduplication, that
::before actually materialises, and that arbitrary declarations reach
getComputedStyle.

All 32 existing dwui browser tests still pass, and the example site
renders identically: 19 routes clean, palette and pointer effects intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JedimEmO added a commit that referenced this pull request Jul 25, 2026
From review of #22. All three were real; two of them undercut the exact
guarantee the feature exists to provide.

Animation variants did not register their keyframes
---------------------------------------------------
dwkeyframes! called ensure() from the generated class, but a modified form
— hover:animate-x, [&::before]:animate-x — compiles the declaration *text*
into a fresh class and never touches that class. Those animations
referenced a @Keyframes rule that was never injected.

Registration now hangs off the declaration text: the emitted *_RAW is an
AnimationDecl whose Deref registers, rather than a plain &str. Codegen is
unchanged — `.raw(&*IDENT)` derefs either one to `&str` — so every path
dwclass! can take is covered. I had documented this as a known hole; it
was too central to leave.

dwclass! silently discarded what it could not parse
--------------------------------------------------
Two bugs compounded here.

`many0` stops at the first unparseable class and reports success with the
remainder untouched, and parse_class_string ignored that remainder. One
malformed class therefore deleted itself *and every class after it*, with
no diagnostic — the precise failure mode dwclass! exists to prevent. It
now refuses, naming the offending text.

And the declaration-body parser used a character allow-list built on
nom's `is_alphanumeric`, which takes a u8 — so `c as u8` truncated every
multi-byte character and `[content:'→']` could not parse. Combined with
the above, PR #23's own docs example silently compiled to zero classes.
The body is now a deny-list of the four bracket delimiters, since a CSS
value can hold any character.

Also dropped the `_`-means-space rewrite. Spaces already work inside the
brackets, so it bought nothing and corrupted `var(--brand_color)`.

Composed pseudo-element utilities clobbered explicit content
------------------------------------------------------------
Every before:/after: utility has to emit a content, or nothing renders.
But each utility is its own class with its own rule, so the literal
`content: ""` from `before:absolute` won by source order over the
`content: 'x'` from `before:[content:'x']`.

I claimed raw()'s append semantics made Tailwind's --tw-content
indirection unnecessary. That was wrong: appending only orders
declarations *within* one class, not across composed utilities. Content
now goes through --dw-content, so the content declaration is identical in
every class and only the value varies. content-empty / content-none set
the property for the same reason.

Tests
-----
42 native (up from 29), including the dwkeyframes! parser and codegen,
which previously had none. Browser suite is 12 (up from 9); the three new
cases are the exact probes from the review, each verified failing before
the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JedimEmO added a commit that referenced this pull request Jul 25, 2026
From review of #23. Deleting styles.rs removed a
`transform: none !important` rule that suppressed the spotlight tilt under
prefers-reduced-motion, and nothing replaced it — the blanket clamp in
index.html only shortens transitions and animations, so pointer movement
still tilted cards, just instantly. The README claimed otherwise.

A @media block cannot help here: the tilt and the magnetic drift are
style_signal writes, so the preference has to be read in Rust. Both now
take it as a signal — which also means a preference changed while the page
is open takes effect immediately, where the old !important rule needed a
reload to matter for anything JS-driven.

Verified in Chrome by hovering a card under both settings:
  no-preference  rotateX(2.03deg) rotateY(-2.41deg)
  reduce         rotateX(0deg)    rotateY(0deg)

Also corrects two doc claims that the #22 fixes invalidated: values are no
longer underscore-rewritten, and pseudo-element content composes through a
custom property rather than by source order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JedimEmO added a commit that referenced this pull request Jul 25, 2026
From review of #22. All three were real; two of them undercut the exact
guarantee the feature exists to provide.

Animation variants did not register their keyframes
---------------------------------------------------
dwkeyframes! called ensure() from the generated class, but a modified form
— hover:animate-x, [&::before]:animate-x — compiles the declaration *text*
into a fresh class and never touches that class. Those animations
referenced a @Keyframes rule that was never injected.

Registration now hangs off the declaration text: the emitted *_RAW is an
AnimationDecl whose Deref registers, rather than a plain &str. Codegen is
unchanged — `.raw(&*IDENT)` derefs either one to `&str` — so every path
dwclass! can take is covered. I had documented this as a known hole; it
was too central to leave.

dwclass! silently discarded what it could not parse
--------------------------------------------------
Two bugs compounded here.

`many0` stops at the first unparseable class and reports success with the
remainder untouched, and parse_class_string ignored that remainder. One
malformed class therefore deleted itself *and every class after it*, with
no diagnostic — the precise failure mode dwclass! exists to prevent. It
now refuses, naming the offending text.

And the declaration-body parser used a character allow-list built on
nom's `is_alphanumeric`, which takes a u8 — so `c as u8` truncated every
multi-byte character and `[content:'→']` could not parse. Combined with
the above, PR #23's own docs example silently compiled to zero classes.
The body is now a deny-list of the four bracket delimiters, since a CSS
value can hold any character.

Also dropped the `_`-means-space rewrite. Spaces already work inside the
brackets, so it bought nothing and corrupted `var(--brand_color)`.

Composed pseudo-element utilities clobbered explicit content
------------------------------------------------------------
Every before:/after: utility has to emit a content, or nothing renders.
But each utility is its own class with its own rule, so the literal
`content: ""` from `before:absolute` won by source order over the
`content: 'x'` from `before:[content:'x']`.

I claimed raw()'s append semantics made Tailwind's --tw-content
indirection unnecessary. That was wrong: appending only orders
declarations *within* one class, not across composed utilities. Content
now goes through --dw-content, so the content declaration is identical in
every class and only the value varies. content-empty / content-none set
the property for the same reason.

Tests
-----
42 native (up from 29), including the dwkeyframes! parser and codegen,
which previously had none. Browser suite is 12 (up from 9); the three new
cases are the exact probes from the review, each verified failing before
the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JedimEmO
JedimEmO force-pushed the dwind-keyframes-and-arbitrary-values branch from 5fc0334 to 064b5f9 Compare July 25, 2026 13:44
JedimEmO added a commit that referenced this pull request Jul 25, 2026
From review of #23. Deleting styles.rs removed a
`transform: none !important` rule that suppressed the spotlight tilt under
prefers-reduced-motion, and nothing replaced it — the blanket clamp in
index.html only shortens transitions and animations, so pointer movement
still tilted cards, just instantly. The README claimed otherwise.

A @media block cannot help here: the tilt and the magnetic drift are
style_signal writes, so the preference has to be read in Rust. Both now
take it as a signal — which also means a preference changed while the page
is open takes effect immediately, where the old !important rule needed a
reload to matter for anything JS-driven.

Verified in Chrome by hovering a card under both settings:
  no-preference  rotateX(2.03deg) rotateY(-2.41deg)
  reduce         rotateX(0deg)    rotateY(0deg)

Also corrects two doc claims that the #22 fixes invalidated: values are no
longer underscore-rewritten, and pseudo-element content composes through a
custom property rather than by source order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From review of #22. All three were real; two of them undercut the exact
guarantee the feature exists to provide.

Animation variants did not register their keyframes
---------------------------------------------------
dwkeyframes! called ensure() from the generated class, but a modified form
— hover:animate-x, [&::before]:animate-x — compiles the declaration *text*
into a fresh class and never touches that class. Those animations
referenced a @Keyframes rule that was never injected.

Registration now hangs off the declaration text: the emitted *_RAW is an
AnimationDecl whose Deref registers, rather than a plain &str. Codegen is
unchanged — `.raw(&*IDENT)` derefs either one to `&str` — so every path
dwclass! can take is covered. I had documented this as a known hole; it
was too central to leave.

dwclass! silently discarded what it could not parse
--------------------------------------------------
Two bugs compounded here.

`many0` stops at the first unparseable class and reports success with the
remainder untouched, and parse_class_string ignored that remainder. One
malformed class therefore deleted itself *and every class after it*, with
no diagnostic — the precise failure mode dwclass! exists to prevent. It
now refuses, naming the offending text.

And the declaration-body parser used a character allow-list built on
nom's `is_alphanumeric`, which takes a u8 — so `c as u8` truncated every
multi-byte character and `[content:'→']` could not parse. Combined with
the above, PR #23's own docs example silently compiled to zero classes.
The body is now a deny-list of the four bracket delimiters, since a CSS
value can hold any character.

Also dropped the `_`-means-space rewrite. Spaces already work inside the
brackets, so it bought nothing and corrupted `var(--brand_color)`.

Composed pseudo-element utilities clobbered explicit content
------------------------------------------------------------
Every before:/after: utility has to emit a content, or nothing renders.
But each utility is its own class with its own rule, so the literal
`content: ""` from `before:absolute` won by source order over the
`content: 'x'` from `before:[content:'x']`.

I claimed raw()'s append semantics made Tailwind's --tw-content
indirection unnecessary. That was wrong: appending only orders
declarations *within* one class, not across composed utilities. Content
now goes through --dw-content, so the content declaration is identical in
every class and only the value varies. content-empty / content-none set
the property for the same reason.

Tests
-----
42 native (up from 29), including the dwkeyframes! parser and codegen,
which previously had none. Browser suite is 12 (up from 9); the three new
cases are the exact probes from the review, each verified failing before
the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JedimEmO
JedimEmO force-pushed the dwind-keyframes-and-arbitrary-values branch from 064b5f9 to 2ba788b Compare July 25, 2026 13:44
JedimEmO added a commit that referenced this pull request Jul 25, 2026
From review of #23. Deleting styles.rs removed a
`transform: none !important` rule that suppressed the spotlight tilt under
prefers-reduced-motion, and nothing replaced it — the blanket clamp in
index.html only shortens transitions and animations, so pointer movement
still tilted cards, just instantly. The README claimed otherwise.

A @media block cannot help here: the tilt and the magnetic drift are
style_signal writes, so the preference has to be read in Rust. Both now
take it as a signal — which also means a preference changed while the page
is open takes effect immediately, where the old !important rule needed a
reload to matter for anything JS-driven.

Verified in Chrome by hovering a card under both settings:
  no-preference  rotateX(2.03deg) rotateY(-2.41deg)
  reduce         rotateX(0deg)    rotateY(0deg)

Also corrects two doc claims that the #22 fixes invalidated: values are no
longer underscore-rewritten, and pseudo-element content composes through a
custom property rather than by source order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--dw-content is order-independent by construction, but that is the entire
claim behind the fix, so assert it: `before:[content:'x'] before:absolute`
and the reverse order both keep the author's content. Also covers
`before:content-none`, which has to suppress the generated default or a
utility that only wants to hide a pseudo-element cannot.

Probing dwgenerate! turned up one regression worth recording: aliasing a
dwkeyframes!-generated utility under a new name no longer compiles, since
the generated *_RAW is an AnimationDecl rather than a &str. It fails
loudly at the offending line, and the alternative reintroduces the silent
"variant references a keyframe that was never injected" bug, so this is
the better trade — noted in the changelog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JedimEmO added a commit that referenced this pull request Jul 25, 2026
From review of #23. Deleting styles.rs removed a
`transform: none !important` rule that suppressed the spotlight tilt under
prefers-reduced-motion, and nothing replaced it — the blanket clamp in
index.html only shortens transitions and animations, so pointer movement
still tilted cards, just instantly. The README claimed otherwise.

A @media block cannot help here: the tilt and the magnetic drift are
style_signal writes, so the preference has to be read in Rust. Both now
take it as a signal — which also means a preference changed while the page
is open takes effect immediately, where the old !important rule needed a
reload to matter for anything JS-driven.

Verified in Chrome by hovering a card under both settings:
  no-preference  rotateX(2.03deg) rotateY(-2.41deg)
  reduce         rotateX(0deg)    rotateY(0deg)

Also corrects two doc claims that the #22 fixes invalidated: values are no
longer underscore-rewritten, and pseudo-element content composes through a
custom property rather than by source order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From re-review of #22.

The changelog claimed "any character is allowed" in an arbitrary value.
That was an overclaim: the parser tracked `[ ] ( )` as structural
everywhere, so `[content:'[']` — valid CSS — failed to parse. Strings are
now consumed whole, with backslash escapes, so a bracket inside a quoted
value is content rather than a delimiter. An unterminated string is a
parse error rather than a silent swallow of the rest of the class list.

Separately, probing the reported dwgenerate! limitation showed it was not
animation-specific and not new: `dwgenerate!("my-flex", "flex")` never
compiled either, because the generated static tried to hold a `&String`
in a `String`. Only generator-based selectors ever worked. Both are fixed
— the alias case promotes the copied body to a `Lazy<String>`, which also
accommodates the `AnimationDecl` a dwkeyframes! utility emits — so an
aliased animation still registers its keyframes.

Browser suite is 16. The grammar test was checked by removing the
quote-handling branch and confirming it fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JedimEmO added a commit that referenced this pull request Jul 25, 2026
From review of #23. Deleting styles.rs removed a
`transform: none !important` rule that suppressed the spotlight tilt under
prefers-reduced-motion, and nothing replaced it — the blanket clamp in
index.html only shortens transitions and animations, so pointer movement
still tilted cards, just instantly. The README claimed otherwise.

A @media block cannot help here: the tilt and the magnetic drift are
style_signal writes, so the preference has to be read in Rust. Both now
take it as a signal — which also means a preference changed while the page
is open takes effect immediately, where the old !important rule needed a
reload to matter for anything JS-driven.

Verified in Chrome by hovering a card under both settings:
  no-preference  rotateX(2.03deg) rotateY(-2.41deg)
  reduce         rotateX(0deg)    rotateY(0deg)

Also corrects two doc claims that the #22 fixes invalidated: values are no
longer underscore-rewritten, and pseudo-element content composes through a
custom property rather than by source order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JedimEmO
JedimEmO changed the base branch from modernize-example-app to main July 25, 2026 14:28
@JedimEmO
JedimEmO merged commit 971c789 into main Jul 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant