Let dwind express keyframes, pseudo-elements and arbitrary declarations - #22
Merged
Conversation
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
force-pushed
the
dwind-keyframes-and-arbitrary-values
branch
from
July 25, 2026 13:44
5fc0334 to
064b5f9
Compare
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
force-pushed
the
dwind-keyframes-and-arbitrary-values
branch
from
July 25, 2026 13:44
064b5f9 to
2ba788b
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #21. Base is
modernize-example-app— retarget tomainonce #21 lands.Auditing every raw CSS string in the example site produced one finding:
Three additions close that gap. Everything is additive —
dwind 0.7.0,dwind-macros 0.4.0,dwind-base 0.1.1anddwui 0.9.0are published, and no currently-successfuldwclass!input changes meaning except the one case called out below.dwkeyframes!A utility class is a single declaration block, so a
@keyframescould never be one — the CSS binding pipeline has no at-rule support at all (ParsedSelectoris onlySingleClass | Complex). Every crate worked around it with a hand-written&strblob pushed throughstylesheet_raw: three of them, with no deduplication, no collision detection, and every keyframe paying its cost whether the page used it or not.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.
animate-*utility, so a typo is still a build error.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.env!("CARGO_CRATE_NAME"), so two crates declaringfade_updon'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.spin/ping/pulse/bounceand dwui's fivedwui-*keyframes migrated onto it, names pinned and injection timing unchanged.Every CSS fragment is a string literal deliberately: Rust's lexer splits
0%,--sxand.35in ways that don't round-trip throughTokenStream::to_string().Arbitrary declarations —
[property:value]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
many0stopped andparse_class_stringdiscarded 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::beforewith nocontentnever generates a box, so the utility did nothing on its own. dwind now emitscontent: ""when a variant's last compound targets::before/::after.DomBuilder::rawappends rather than replaces (dominator/src/bindings.rs:160), so a user's owncontent-[…]later in the same class still wins. That's why this needs none of Tailwind's--tw-contentindirection — 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_generatorignoredclass.variantentirely, 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-0…delay-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 amask-composite: xorpair — a scale would cover none of them, and the escape hatch covers all of them.Testing
dwind-macroshad 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_parseris 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 fromdocument.styleSheets, two instantiations produce exactly oneCSSKeyframesRule,getComputedStyle(el, "::before").contentis notnone, and arbitrary declarations (including--sx) reach the computed style.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 socargo publishcan't resolve a staledwind-macrosagainst the newdwind.A follow-up PR migrates
examples/webpageonto all of this and deletes most ofstyles.rs.🤖 Generated with Claude Code