From 0734de453c8295554656fa406324b36a829bc4cc Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Jul 2026 15:09:48 +0200 Subject: [PATCH 1/3] Delete the example's stylesheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit styles.rs is gone — all 297 lines of it. The site renders identically. What replaced it, using the capabilities added in the previous commit: - The 12 @keyframes move to keyframes.rs as a single dwkeyframes! block. Ones used as bare classes get #[animation(...)]; the ones composed with a runtime delay are referenced through the handle, whose Display registers the rule. The dead dwind-glow-drift keyframe is dropped. - The pointer spotlight — a ::before glow and a masked ::after ring, both parked at --sx/--sy — is now dwclass! variants. content: "" is supplied automatically, and the four co-dependent mask declarations use the [property:value] escape hatch. Verified in a browser: the computed ::before background tracks the cursor. - The scroll-reveal cascade, including the parent-state .reveal-in > * rules and the :nth-child stagger, is child-selector variants on the container. The container no longer needs a marker class at all. - Scrollbars become [&::-webkit-scrollbar-thumb]: variants, the marquee's hover-pause becomes [&:hover > *]: — the classic "hover the parent, animate the child" case that no element-level class can express — and the glass panel and gradient-text sheen become named mixins. 40 inline .style() calls that duplicated utilities dwind already had (position, z-index, text-decoration, font: inherit, ...) are now dwclass!. The only CSS left is the diff --git a/examples/webpage/src/fx.rs b/examples/webpage/src/fx.rs index e9b1386..aaf31e8 100644 --- a/examples/webpage/src/fx.rs +++ b/examples/webpage/src/fx.rs @@ -5,6 +5,7 @@ //! diff: an event writes a number, the signal writes one CSS property on one //! node, and the compositor does the rest. +use crate::keyframes::*; use dominator::{events, html, Dom, DomBuilder}; use dwind::prelude::*; use dwind_macros::dwclass; @@ -39,10 +40,14 @@ fn normalised(element: &HtmlElement, client_x: f64, client_y: f64) -> (f64, f64) ) } -/// Wires pointer tracking into a builder and exposes the state. +/// Wires pointer tracking into a builder, and paints the two pseudo-elements +/// that follow the cursor. /// -/// `--sx` / `--sy` are written on every pointer move; the `.dw-spot` rules in -/// [`crate::styles`] park a radial gradient and a lit border edge there. +/// The glow (`::before`) and the lit border edge (`::after`) are parked at +/// `--sx` / `--sy`, which the handlers below write on every pointer move. Both +/// used to be raw CSS: `content: ""` is now emitted automatically for +/// `::before` / `::after` variants, and the mask/blend declarations that have no +/// utility go through the arbitrary-declaration escape hatch. fn track_pointer( builder: DomBuilder, state: &PointerState, @@ -70,11 +75,27 @@ fn track_pointer( } }) }) - .class("dw-spot") - .attr_signal( - "data-hot", - hot.signal().map(|h| Some(if h { "1" } else { "0" })), - ) + .apply(|b| dwclass!(b, "relative isolate [transition:transform 400ms cubic-bezier(0.16, 1, 0.3, 1), border-color 300ms ease]")) + // The glow. + .apply(|b| dwclass!(b, "\ + [&::before]:absolute [&::before]:inset-0 [&::before]:[z-index:-1] \ + [&::before]:[border-radius:inherit] [&::before]:opacity-0 \ + [&::before]:[transition:opacity 320ms ease] [&.hot::before]:opacity-100 \ + [&::before]:[background:radial-gradient(22rem circle at var(--sx, 50%) var(--sy, 50%), rgba(213, 182, 95, 0.13), transparent 62%)]")) + // A one-pixel gradient ring, cut out of a solid fill with a mask so only the + // border shows. Four co-dependent declarations, none with a utility. + .apply(|b| dwclass!(b, "\ + [&::after]:absolute [&::after]:inset-0 [&::after]:[z-index:-1] \ + [&::after]:[border-radius:inherit] [&::after]:[padding:1px] \ + [&::after]:opacity-0 [&::after]:[transition:opacity 320ms ease] [&.hot::after]:opacity-100 \ + [&::after]:[background:radial-gradient(16rem circle at var(--sx, 50%) var(--sy, 50%), rgba(213, 182, 95, 0.55), transparent 55%)] \ + [&::after]:[-webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)] \ + [&::after]:[-webkit-mask-composite:xor] \ + [&::after]:[mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)] \ + [&::after]:[mask-composite:exclude]")) + // The pseudo-element rules key off this class rather than a data attribute, + // because a class is what a `[&.hot::before]:` variant can select. + .class_signal("hot", hot.signal()) .style_signal( "--sx", pos.signal().map(|(x, _)| format!("{:.2}%", x * 100.0)), @@ -184,45 +205,46 @@ pub fn magnetic(pull: f64) -> impl Fn(DomBuilder) -> DomBuilder Dom { html!("div", { .attr("aria-hidden", "true") - .style("position", "fixed") - .style("inset", "0") - .style("z-index", "0") - .style("pointer-events", "none") - .style("overflow", "hidden") + .dwclass!("fixed inset-0 z-0 pointer-events-none overflow-hidden") .child(aurora_blob( "radial-gradient(circle, rgba(213, 182, 95, 0.30) 0%, rgba(213, 182, 95, 0.08) 40%, transparent 70%)", - "-22%", "54%", "66rem", "dwind-aurora-a 22s ease-in-out infinite", + "-22%", "54%", "66rem", + &format!("{AURORA_A_KEYFRAMES} 22s ease-in-out infinite"), )) .child(aurora_blob( "radial-gradient(circle, rgba(95, 176, 213, 0.16) 0%, transparent 68%)", - "38%", "-18%", "52rem", "dwind-aurora-b 28s ease-in-out infinite", + "38%", "-18%", "52rem", + &format!("{AURORA_B_KEYFRAMES} 28s ease-in-out infinite"), )) .child(aurora_blob( "radial-gradient(circle, rgba(213, 95, 168, 0.10) 0%, transparent 70%)", - "74%", "62%", "46rem", "dwind-aurora-a 34s ease-in-out infinite reverse", + "74%", "62%", "46rem", + &format!("{AURORA_A_KEYFRAMES} 34s ease-in-out infinite reverse"), )) }) } fn aurora_blob(background: &str, top: &str, left: &str, size: &str, animation: &str) -> Dom { html!("div", { - .style("position", "absolute") + .dwclass!("absolute will-change-transform [filter:blur(20px)]") .style("top", top) .style("left", left) .style("width", size) .style("height", size) .style("background", background) - .style("filter", "blur(20px)") - .style("will-change", "transform") .style("animation", animation) }) } /// Fixed film-grain overlay. Costs one node for the whole document. +/// +/// The texture is an inline `feTurbulence` SVG. That data URI stays a plain +/// `.style()` — it is a one-off asset, not a reusable utility value. pub fn grain() -> Dom { html!("div", { .attr("aria-hidden", "true") - .class("dw-grain") + .dwclass!("fixed inset-0 pointer-events-none opacity-20 mix-blend-overlay [z-index:9998]") + .style("background-image", "url(\"data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'%3E%3Cfilter id=\'n\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.85\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3CfeColorMatrix type=\'saturate\' values=\'0\'/%3E%3C/filter%3E%3Crect width=\'140\' height=\'140\' filter=\'url(%23n)\' opacity=\'0.5\'/%3E%3C/svg%3E\")") }) } @@ -230,11 +252,9 @@ pub fn grain() -> Dom { pub fn blueprint_grid(mask: &str) -> Dom { html!("div", { .attr("aria-hidden", "true") - .style("position", "absolute") - .style("inset", "0") - .style("pointer-events", "none") - .style("background-image", "linear-gradient(rgba(125, 125, 135, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(125, 125, 135, 0.06) 1px, transparent 1px)") - .style("background-size", "48px 48px") + .dwclass!("absolute inset-0 pointer-events-none \ + [background-image:linear-gradient(rgba(125, 125, 135, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(125, 125, 135, 0.06) 1px, transparent 1px)] \ + [background-size:48px 48px]") .style("mask-image", mask) .style("-webkit-mask-image", mask) }) @@ -291,7 +311,7 @@ pub fn spy_rail( html!("nav", { .attr("aria-label", "On this page") .dwclass!("flex flex-col gap-1 flex-none w-44 @) -> DomBuilder { + dwclass!( + builder, + "animate-sheen \ + [background-image:linear-gradient(100deg, #F0E2B6 0%, #D5B65F 18%, #FFF8E2 30%, #D5B65F 42%, #A88735 60%, #D5B65F 100%)] \ + [background-size:200% auto] \ + [-webkit-background-clip:text] [background-clip:text] [color:transparent]" + ) +} + /// One headline word, animated in on its own delay. pub fn word(content: &str, index: usize, accent: bool) -> Dom { html!("span", { - .class("dw-word") - .apply_if(accent, |b| b.class("dw-sheen")) - .style("animation-delay", &format!("{}ms", 90 * index as u32)) - .style("padding-right", "0.26em") + .dwclass!("inline-block [padding-right:0.26em]") + .apply_if(accent, sheen) + // Formatting the handle registers the @keyframes, so a computed delay + // can never reference a rule that was not injected. + .style("animation", &format!( + "{WORD_IN_KEYFRAMES} 900ms {}ms cubic-bezier(0.16, 1, 0.3, 1) both", + 90 * index as u32, + )) .text(content) }) } @@ -366,21 +405,61 @@ pub fn marquee(items: &[&str]) -> Dom { let chip = |label: &str| { html!("span", { .class("font-code") - .dwclass!("text-xs text-woodsmoke-400 flex-none") + .dwclass!("text-xs text-woodsmoke-400 flex-none whitespace-nowrap") .dwclass!("border border-woodsmoke-800 rounded-full p-l-4 p-r-4 p-t-2 p-b-2 m-r-3") - .style("background", "rgba(18, 18, 21, 0.55)") - .style("white-space", "nowrap") + .dwclass!("[background:rgba(18, 18, 21, 0.55)]") .text(label) }) }; html!("div", { .attr("aria-hidden", "true") - .class("dw-marquee") - .dwclass!("w-full overflow-hidden") + .dwclass!("w-full overflow-hidden \ + [mask-image:linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent)] \ + [-webkit-mask-image:linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent)]") + // Hover on the parent, effect on the child — the classic case that no + // element-level class can express, and a plain child variant here. + .dwclass!("[&:hover > *]:[animation-play-state:paused]") .child(html!("div", { - .class("dw-marquee-track") + .dwclass!("flex animate-marquee [width:max-content]") .children(items.iter().chain(items.iter()).map(|i| chip(i))) })) }) } + +// --------------------------------------------------------------------------- +// Surfaces +// --------------------------------------------------------------------------- + +/// The translucent panel used by cards, the header and the docs sidebar. +/// +/// Three co-dependent declarations that only make sense together — a component +/// surface rather than a utility, so it lives here as one named mixin. +pub fn glass(builder: DomBuilder) -> DomBuilder { + dwclass!( + builder, + "[background:linear-gradient(160deg, rgba(28, 28, 33, 0.72) 0%, rgba(14, 14, 17, 0.62) 100%)] \ + [backdrop-filter:blur(14px) saturate(1.2)] \ + [box-shadow:inset 0 1px 0 0 rgba(255, 255, 255, 0.045)]" + ) +} + +/// Thin scrollbars that match the surface they sit on. +/// +/// WebKit exposes these as pseudo-elements, which is exactly what the bracketed +/// variant syntax selects. Firefox uses the standard `scrollbar-*` properties, +/// set alongside. +pub fn slim_scrollbar(builder: DomBuilder) -> DomBuilder { + dwclass!( + builder, + "[scrollbar-width:thin] [scrollbar-color:#26262C transparent] \ + [&::-webkit-scrollbar]:[width:10px] [&::-webkit-scrollbar]:[height:10px] \ + [&::-webkit-scrollbar-track]:[background:transparent] \ + [&::-webkit-scrollbar-thumb]:[background:#26262C] \ + [&::-webkit-scrollbar-thumb]:[border-radius:8px] \ + [&::-webkit-scrollbar-thumb]:[border:3px solid transparent] \ + [&::-webkit-scrollbar-thumb]:[background-clip:content-box] \ + [&::-webkit-scrollbar-thumb:hover]:[background:#3A3A44] \ + [&::-webkit-scrollbar-thumb:hover]:[background-clip:content-box]" + ) +} diff --git a/examples/webpage/src/keyframes.rs b/examples/webpage/src/keyframes.rs new file mode 100644 index 0000000..8a14002 --- /dev/null +++ b/examples/webpage/src/keyframes.rs @@ -0,0 +1,88 @@ +//! Every animation on the site, declared in Rust. +//! +//! These used to be a 90-line `@keyframes` blob in a raw stylesheet. `dwkeyframes!` +//! emits the at-rule *and* — where `#[animation(...)]` is given — a +//! compile-time-checked `animate-*` class, and only injects a rule if something +//! actually uses it. +//! +//! Where an animation needs a delay computed at runtime, use the handle +//! directly: `format!("{FADE_UP_KEYFRAMES} 600ms {}ms ease-out both", i * 60)`. +//! Formatting the handle registers the rule, so the shorthand can never point at +//! a keyframe that was never injected. + +use dwind_macros::dwkeyframes; + +dwkeyframes! { + #![prefix = "dw"] + + /// Terminal cursor blink on the wordmark. + #[animation("1.2s step-end infinite")] + cursor_blink { + "0%, 49%" => "opacity: 1;", + "50%, 100%" => "opacity: 0;", + } + + /// The workhorse entrance. Used with computed delays, so no class. + fade_up { + "from" => "opacity: 0; transform: translateY(14px);", + "to" => "opacity: 1; transform: translateY(0);", + } + + /// Slow drift for the background colour field. + aurora_a { + "0%, 100%" => "transform: translate3d(0, 0, 0) scale(1);", + "33%" => "transform: translate3d(6%, -8%, 0) scale(1.15);", + "66%" => "transform: translate3d(-5%, 5%, 0) scale(0.95);", + } + + aurora_b { + "0%, 100%" => "transform: translate3d(0, 0, 0) scale(1.05);", + "50%" => "transform: translate3d(-8%, 6%, 0) scale(0.9);", + } + + /// Sweeps the gold sheen across a gradient headline. + #[animation("7s linear infinite")] + sheen { + "0%" => "background-position: 0% 50%;", + "100%" => "background-position: 200% 50%;", + } + + /// Headline words ride in one at a time; the delay is per-word. + word_in { + "from" => "opacity: 0; transform: translateY(0.7em) rotate(2deg);", + "to" => "opacity: 1; transform: translateY(0) rotate(0deg);", + } + + /// The utility-class ticker. The track is rendered twice, so -50% loops. + #[animation("42s linear infinite")] + marquee { + "from" => "transform: translate3d(0, 0, 0);", + "to" => "transform: translate3d(-50%, 0, 0);", + } + + #[animation("240ms cubic-bezier(0.16, 1, 0.3, 1) both")] + palette_in { + "from" => "opacity: 0; transform: translateY(-12px) scale(0.98);", + "to" => "opacity: 1; transform: translateY(0) scale(1);", + } + + #[animation("180ms ease-out both")] + scrim_in { + "from" => "opacity: 0;", + "to" => "opacity: 1;", + } + + #[animation("420ms cubic-bezier(0.16, 1, 0.3, 1) both")] + route_in { + "from" => "opacity: 0; transform: translateY(10px);", + "to" => "opacity: 1; transform: translateY(0);", + } + + /// Expanding ring behind a status dot. + #[animation("2.2s ease-out infinite")] + pulse_ring { + "0%" => "box-shadow: 0 0 0 0 rgba(213, 182, 95, 0.35);", + "70%" => "box-shadow: 0 0 0 12px rgba(213, 182, 95, 0);", + "100%" => "box-shadow: 0 0 0 0 rgba(213, 182, 95, 0);", + } +} diff --git a/examples/webpage/src/lib.rs b/examples/webpage/src/lib.rs index ae57bcb..611d69a 100644 --- a/examples/webpage/src/lib.rs +++ b/examples/webpage/src/lib.rs @@ -1,9 +1,9 @@ mod fx; +mod keyframes; mod pages; mod palette; mod reveal; mod router; -mod styles; #[macro_use] extern crate log; @@ -15,6 +15,7 @@ extern crate dominator; extern crate dwui; use crate::fx::magnetic; +use crate::keyframes::*; use crate::pages::components_page::components_page; use crate::pages::docs::doc_main::doc_main_view; use crate::pages::docs::doc_sidebar::doc_sidebar; @@ -23,7 +24,6 @@ use crate::pages::dwind_examples::dwind_examples_page; use crate::pages::home::home_page; use crate::palette::Palette; use crate::router::make_app_router; -use crate::styles::APP_STYLES; use dominator::routing::go_to_url; use dominator::{body, events, Dom}; use dwind::prelude::*; @@ -49,7 +49,6 @@ fn main_view() -> Dom { &DWIND_COLORS["woodsmoke"], &DWIND_COLORS["red"], ))); - dominator::stylesheet_raw(APP_STYLES); let palette = palette::global(); let page = make_app_router().signal().broadcast(); @@ -59,10 +58,10 @@ fn main_view() -> Dom { let scrolled = Mutable::new(0.0f64); html!("div", { - .class("dw-scrollbar") + .apply(crate::fx::slim_scrollbar) .dwclass!("text-woodsmoke-100 bg-woodsmoke-950") .dwclass!("h-full overflow-y-auto overflow-x-hidden") - .style("position", "relative") + .dwclass!("relative") .apply(palette.shortcuts()) .with_node!(element => { .event(clone!(scrolled => move |_: events::Scroll| { @@ -74,11 +73,11 @@ fn main_view() -> Dom { .child(scroll_progress(scrolled.clone())) .child(top_nav(&palette, page.signal())) .child(html!("main", { - .style("position", "relative") + .dwclass!("relative") .style("z-index", "1") .child_signal(page.signal().map(|page| { Some(html!("div", { - .class("dw-route") + .dwclass!("animate-route-in") .after_inserted(|_| scroll_to_top()) .child(match page { DocPage::Home => home_page(), @@ -105,19 +104,19 @@ fn scroll_to_top() { fn scroll_progress(scrolled: Mutable) -> Dom { html!("div", { .attr("aria-hidden", "true") - .style("position", "fixed") + .dwclass!("fixed") .style("top", "0") .style("left", "0") .style("right", "0") .style("height", "2px") .style("z-index", "60") - .style("pointer-events", "none") + .dwclass!("pointer-events-none") .child(html!("div", { .style("height", "100%") .style("background", "linear-gradient(90deg, #A88735, #D5B65F 45%, #FFF3CF)") .style("box-shadow", "0 0 12px rgba(213, 182, 95, 0.6)") .style("transform-origin", "left center") - .style("will-change", "transform") + .dwclass!("will-change-transform") .style_signal("transform", scrolled.signal().map(|p| { format!("scaleX({:.4})", p.max(0.001)) })) @@ -174,7 +173,7 @@ fn pager_link(page: Option, hint: &str, left: bool) -> Dom { html!("button", { .attr("type", "button") - .class("dw-glass") + .apply(crate::fx::glass) .dwclass!("flex flex-col gap-1 rounded-lg border border-woodsmoke-800 p-4 grow cursor-pointer") .dwclass!("hover:border-candlelight-700 transition-all") .apply(fx::spotlight) @@ -184,7 +183,7 @@ fn pager_link(page: Option, hint: &str, left: bool) -> Dom { dwclass!(b, "text-right align-items-end") }) .style("color", "inherit") - .style("font", "inherit") + .dwclass!("font-inherit") .child(html!("span", { .class("font-code") .dwclass!("text-xs text-woodsmoke-500") @@ -218,7 +217,7 @@ fn top_nav( .attr("href", "#/") .class("font-code") .dwclass!("flex align-items-center gap-1 text-l font-bold text-woodsmoke-50 cursor-pointer") - .style("text-decoration", "none") + .dwclass!("no-underline") .apply(magnetic(5.0)) .child(html!("span", { .dwclass!("text-candlelight-400") @@ -227,7 +226,7 @@ fn top_nav( .child(html!("span", { .text("dwind") })) .child(html!("span", { .dwclass!("text-candlelight-400") - .style("animation", "dwind-cursor-blink 1.2s step-end infinite") + .dwclass!("animate-cursor-blink") .text("_") })) })) @@ -261,7 +260,7 @@ fn palette_trigger(palette: &Palette) -> Dom { .dwclass!("text-woodsmoke-400 hover:text-candlelight-300 hover:border-candlelight-700") .dwclass!("@ Dom { .attr("rel", "noopener") .dwclass!("cursor-pointer transition-colors select-none") .dwclass!("text-woodsmoke-300 hover:text-candlelight-300") - .style("text-decoration", "none") + .dwclass!("no-underline") .text(label) .event(move |_: events::Click| { window() @@ -331,7 +330,7 @@ fn nav_external(label: &str, url: &str) -> Dom { fn footer() -> Dom { html!("footer", { .dwclass!("border-t border-woodsmoke-800 w-full m-t-20") - .style("position", "relative") + .dwclass!("relative") .style("z-index", "1") .child(html!("div", { .dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 p-t-12 p-b-12") @@ -386,7 +385,7 @@ fn footer_column(title: &str, links: Vec<(&str, &str)>) -> Dom { .attr("href", &href) .apply_if(external, |b| b.attr("target", "_blank").attr("rel", "noopener")) .dwclass!("text-woodsmoke-300 hover:text-candlelight-300 text-sm transition-colors cursor-pointer") - .style("text-decoration", "none") + .dwclass!("no-underline") .text(label) }) })) diff --git a/examples/webpage/src/pages/docs/code_widget.rs b/examples/webpage/src/pages/docs/code_widget.rs index 52799e5..4f87d77 100644 --- a/examples/webpage/src/pages/docs/code_widget.rs +++ b/examples/webpage/src/pages/docs/code_widget.rs @@ -18,7 +18,7 @@ pub fn code(example_map: &BTreeMap) -> Dom { .class("font-code") .dwclass!("flex flex-row align-items-center gap-2 w-full p-l-4 p-r-4 h-10 cursor-pointer text-left text-sm") .dwclass!("bg-transparent border-none text-woodsmoke-400 hover:text-candlelight-300 transition-colors") - .style("outline", "none") + .dwclass!("outline-hidden") .child(html!("span", { .dwclass!("inline-block transition-transform") .style_signal("transform", expanded.signal().map(|v| { diff --git a/examples/webpage/src/pages/docs/doc_pages/animations.rs b/examples/webpage/src/pages/docs/doc_pages/animations.rs index f7ab6f9..84e223a 100644 --- a/examples/webpage/src/pages/docs/doc_pages/animations.rs +++ b/examples/webpage/src/pages/docs/doc_pages/animations.rs @@ -19,6 +19,59 @@ pub fn animation_page() -> Dom { .child(doc_page_sub_header("Spinning")) .child(example_box(animation_examples(), false)) .child(code(&ANIMATION_EXAMPLES_EXAMPLE_HTML_MAP)) + + .child(doc_page_sub_header("Your own keyframes")) + .child(html!("p", { + .dwclass!("text-woodsmoke-400 leading-relaxed m-0") + .text("A utility class is a single declaration block, so a @keyframes can never be one. \ + Declare them with dwkeyframes! instead: it emits the at-rule and, when you give it \ + an #[animation(...)], a matching animate-* class that rustc still checks. The rule \ + is injected the first time something uses it, and never twice.") + })) + .child(html!("pre", { + .class("font-code") + .dwclass!("text-sm p-4 m-0 m-t-4 rounded-lg border border-woodsmoke-800 text-woodsmoke-200 overflow-x-auto") + .dwclass!("[background:rgba(2, 2, 3, 0.7)]") + .text(r#"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") })"#) + })) + .child(html!("p", { + .dwclass!("text-woodsmoke-400 leading-relaxed m-t-4 m-b-0") + .text("Every CSS fragment is a string literal, because Rust's lexer splits 0%, --sx and .35 \ + in ways that do not survive a round trip through the token stream.") + })) + .child(html!("p", { + .dwclass!("text-woodsmoke-400 leading-relaxed m-t-4 m-b-0") + .text("When the shorthand has to be built at runtime — a per-item delay, say — use the handle \ + directly. Formatting it registers the rule, so the animation can never point at a \ + keyframe that was never injected:") + })) + .child(html!("pre", { + .class("font-code") + .dwclass!("text-sm p-4 m-0 m-t-2 rounded-lg border border-woodsmoke-800 text-woodsmoke-200 overflow-x-auto") + .dwclass!("[background:rgba(2, 2, 3, 0.7)]") + .text(".style(\"animation\", &format!(\"{FADE_UP_KEYFRAMES} 600ms {}ms ease-out both\", i * 60))") + })) + + .child(doc_page_sub_header("Reduced motion")) + .child(html!("p", { + .dwclass!("text-woodsmoke-400 leading-relaxed m-0") + .text("Motion preferences are just another media query, so the @(( )) conditional covers them \ + with no extra machinery:") + })) + .child(html!("pre", { + .class("font-code") + .dwclass!("text-sm p-4 m-0 m-t-2 rounded-lg border border-woodsmoke-800 text-woodsmoke-200 overflow-x-auto") + .dwclass!("[background:rgba(2, 2, 3, 0.7)]") + .text("dwclass!(\"animate-spin @((prefers-reduced-motion: reduce)):animate-none\")") + })) }) } diff --git a/examples/webpage/src/pages/docs/doc_pages/doc_page.rs b/examples/webpage/src/pages/docs/doc_pages/doc_page.rs index 94a50a7..cc5ba13 100644 --- a/examples/webpage/src/pages/docs/doc_pages/doc_page.rs +++ b/examples/webpage/src/pages/docs/doc_pages/doc_page.rs @@ -19,7 +19,7 @@ pub fn doc_page_title(title: &str) -> Dom { .child(html!("h1", { .class("font-display") .dwclass!("@sm:text-5xl @ Dom { .class("font-code") .dwclass!("p-l-4 p-r-4 p-t-2 p-b-2 text-sm rounded-md border border-woodsmoke-800 cursor-pointer transition-colors") .dwclass!("text-woodsmoke-300 hover:text-candlelight-300 hover:border-candlelight-700") - .style("text-decoration", "none") + .dwclass!("no-underline") .text(label) .event(move |_: events::Click| { go_to_url(&href); diff --git a/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs b/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs index 1430186..d028f2c 100644 --- a/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs +++ b/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs @@ -28,6 +28,76 @@ pub fn pseudo_class_themes() -> Dom { })) .child(example_box(variants(), false)) .child(code(&VARIANTS_EXAMPLE_HTML_MAP)) + + // pseudo-elements + .child(doc_page_title("Pseudo Elements")) + .child(html!("p", { + .dwclass!("text-woodsmoke-300 leading-relaxed m-t-4 m-b-2") + .text(r#"::before and ::after are variants like any other. dwind adds the content: "" they + need in order to render, so a utility is enough on its own — and because declarations are + appended in source order, your own content utility later in the same class still wins."#) + })) + .child(example_box(pseudo_elements(), false)) + .child(code(&PSEUDO_ELEMENTS_EXAMPLE_HTML_MAP)) + + // arbitrary declarations + .child(doc_page_title("Arbitrary Declarations")) + .child(html!("p", { + .dwclass!("text-woodsmoke-300 leading-relaxed m-t-4 m-b-2") + .text(r#"When a property has no utility — a vendor-prefixed mask, a custom property, a + one-off gradient — write the declaration inline in square brackets. It is unambiguous + against the variant syntax because a variant's ] is always followed by a colon."#) + })) + .child(html!("p", { + .dwclass!("text-woodsmoke-400 leading-relaxed m-0 m-b-2") + .text("Underscores in the value become spaces, since a class string is space-separated. \ + Modifiers go first: hover:[color:red], not [color:red]:hover.") + })) + .child(example_box(arbitrary_declarations(), false)) + .child(code(&ARBITRARY_DECLARATIONS_EXAMPLE_HTML_MAP)) + }) +} + +#[example_html(themes = ["base16-ocean.dark", "base16-ocean.light"])] +fn pseudo_elements() -> Dom { + html!("div", { + .dwclass!("flex flex-row flex-wrap gap-6 justify-center w-full p-4") + // A decorative corner notch, drawn entirely by ::before. + .child(html!("div", { + .dwclass!("relative rounded-lg border border-woodsmoke-700 p-6 text-woodsmoke-200") + .dwclass!("[&::before]:absolute [&::before]:[top:-6px] [&::before]:[left:-6px]") + .dwclass!("[&::before]:w-4 [&::before]:h-4 [&::before]:rounded-full") + .dwclass!("[&::before]:[background:#D5B65F]") + .text("[&::before] corner dot") + })) + // The `before:` shorthand, with an explicit content override. + .child(html!("div", { + .dwclass!("relative rounded-lg border border-woodsmoke-700 p-6 text-woodsmoke-200") + .dwclass!("before:[content:'→'] before:m-r-2 before:text-candlelight-400") + .text("before: shorthand") + })) + }) +} + +#[example_html(themes = ["base16-ocean.dark", "base16-ocean.light"])] +fn arbitrary_declarations() -> Dom { + html!("div", { + .dwclass!("flex flex-row flex-wrap gap-6 justify-center w-full p-4") + .child(html!("div", { + .dwclass!("rounded-lg p-6 text-woodsmoke-950 font-bold") + .dwclass!("[background:conic-gradient(from 210deg, #D5B65F, #5FB0D5, #D5B65F)]") + .text("conic-gradient") + })) + .child(html!("div", { + .dwclass!("rounded-lg border border-woodsmoke-700 p-6 text-woodsmoke-200") + .dwclass!("[writing-mode:vertical-rl] [letter-spacing:0.2em]") + .text("vertical-rl") + })) + .child(html!("div", { + .dwclass!("rounded-lg border border-woodsmoke-700 p-6") + .dwclass!("[--accent:#75D55F] [color:var(--accent)] [box-shadow:0 0 0 1px var(--accent)]") + .text("--accent custom property") + })) }) } diff --git a/examples/webpage/src/pages/docs/doc_pages/shadows.rs b/examples/webpage/src/pages/docs/doc_pages/shadows.rs index 38d8da0..5b692b5 100644 --- a/examples/webpage/src/pages/docs/doc_pages/shadows.rs +++ b/examples/webpage/src/pages/docs/doc_pages/shadows.rs @@ -62,7 +62,7 @@ fn shadows_example() -> Dom { .child(html!("button", { .dwclass!("w-28 h-16 rounded-lg bg-woodsmoke-800 border-none cursor-pointer text-woodsmoke-200") .dwclass!("focus-visible:ring-2 ring-picton-blue-400") - .style("outline", "none") + .dwclass!("outline-hidden") .text("tab to me") })) }) diff --git a/examples/webpage/src/pages/docs/doc_sidebar.rs b/examples/webpage/src/pages/docs/doc_sidebar.rs index 5a0630c..131baf5 100644 --- a/examples/webpage/src/pages/docs/doc_sidebar.rs +++ b/examples/webpage/src/pages/docs/doc_sidebar.rs @@ -84,7 +84,7 @@ fn sidebar_search() -> Dom { .dwclass!("border border-woodsmoke-800 rounded-md p-l-3 p-r-2 p-t-2 p-b-2 transition-all") .dwclass!("text-woodsmoke-500 hover:text-candlelight-300 hover:border-candlelight-700") .style("background", "rgba(18, 18, 21, 0.6)") - .style("font", "inherit") + .dwclass!("font-inherit") .style("font-size", "0.72rem") .child(html!("span", { .text("search docs…") })) .child(html!("kbd", { @@ -106,12 +106,12 @@ pub fn doc_sidebar_inline( .attr("aria-label", "Documentation") .dwclass!("w-52 m-l-0 text-woodsmoke-50 flex-none flex flex-col gap-6 p-t-2") // Rides along with the reader instead of scrolling off the top. - .style("position", "sticky") + .dwclass!("sticky") .style("top", "5.5rem") .style("align-self", "flex-start") .style("max-height", "calc(100vh - 8rem)") - .style("overflow-y", "auto") - .class("dw-scrollbar") + .dwclass!("overflow-y-auto") + .apply(crate::fx::slim_scrollbar) .child(sidebar_search()) .children(doc_sections.into_iter().map(clone!(goto => move |section| { let section_cloned = section.clone(); diff --git a/examples/webpage/src/pages/docs/example_box.rs b/examples/webpage/src/pages/docs/example_box.rs index b9643f7..e06638b 100644 --- a/examples/webpage/src/pages/docs/example_box.rs +++ b/examples/webpage/src/pages/docs/example_box.rs @@ -13,7 +13,7 @@ pub fn example_box(child: Dom, resizeable: bool) -> Dom { let dragging = Mutable::new(false); html!("div", { - .class("dw-glass") + .apply(crate::fx::glass) .dwclass!("m-t-6 rounded-lg border border-woodsmoke-800 overflow-hidden w-full") .apply(crate::fx::spotlight) // header bar diff --git a/examples/webpage/src/pages/dwind_examples.rs b/examples/webpage/src/pages/dwind_examples.rs index 09c4cf1..05b3c14 100644 --- a/examples/webpage/src/pages/dwind_examples.rs +++ b/examples/webpage/src/pages/dwind_examples.rs @@ -325,7 +325,7 @@ fn variant_zebra_list() -> Dom { .dwclass!("[& > *]:p-3 [& > *]:text-sm [& > *]:text-woodsmoke-200") .dwclass!("[& > *:nth-child(odd)]:bg-woodsmoke-900 [& > *:nth-child(even)]:bg-woodsmoke-800") .dwclass!("[& > *]:nth-child(3):text-candlelight-300") - .style("list-style", "none") + .dwclass!("list-none") .children([ html!("li", { .text("zebra striping from the parent") }), html!("li", { .text("no classes on the children") }), @@ -353,7 +353,7 @@ fn pricing_card() -> Dom { })) .child(html!("ul", { .dwclass!("flex flex-col gap-2 p-4 m-0 text-sm text-woodsmoke-300") - .style("list-style", "none") + .dwclass!("list-none") .children([ "✓ unlimited projects", "✓ compile-time styling", diff --git a/examples/webpage/src/pages/home.rs b/examples/webpage/src/pages/home.rs index a5f2f6e..5967555 100644 --- a/examples/webpage/src/pages/home.rs +++ b/examples/webpage/src/pages/home.rs @@ -1,4 +1,5 @@ use crate::fx::{self, kinetic_headline, magnetic, spotlight, spotlight_tilt}; +use crate::keyframes::*; use crate::pages::signal_lab::signal_lab; use crate::reveal::reveal_on_scroll; use dominator::routing::go_to_url; @@ -27,7 +28,7 @@ pub fn home_page() -> Dom { fn hero() -> Dom { html!("section", { .dwclass!("w-full overflow-hidden") - .style("position", "relative") + .dwclass!("relative") .child(fx::blueprint_grid( "radial-gradient(ellipse 90% 70% at 50% 0%, black 30%, transparent 75%)", )) @@ -35,37 +36,37 @@ fn hero() -> Dom { .dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 p-t-20 p-b-10") // @md is 1280px in dwind — the hero goes side-by-side from there up. .dwclass!("flex @md:flex-row @ Dom { .class("font-code") .dwclass!("text-woodsmoke-400 text-sm border border-woodsmoke-800 rounded-md p-l-3 p-r-3 p-t-1 p-b-1 select-all flex-none") .style("background", "rgba(18, 18, 21, 0.6)") - .style("white-space", "nowrap") + .dwclass!("whitespace-nowrap") .text("> cargo add dwind dwui") })) })) @@ -106,9 +107,9 @@ fn code_card() -> Dom { html!("div", { .dwclass!("flex flex-col flex-none w-full") .style("max-width", "30rem") - .style("animation", "dwind-fade-up 900ms 200ms ease-out both") + .style("animation", &format!("{FADE_UP_KEYFRAMES} 900ms 200ms ease-out both")) .child(html!("div", { - .class("dw-glass") + .apply(crate::fx::glass) .dwclass!("rounded-lg border border-woodsmoke-800 overflow-hidden shadow-2xl") .apply(spotlight_tilt(4.0)) // window chrome @@ -243,7 +244,7 @@ pub fn section_header(kicker: &str, title: &str) -> Dom { .child(html!("h2", { .class("font-display") .dwclass!("@sm:text-4xl @ Dom { fn bento_tile(span_large: bool, children: Vec) -> Dom { html!("div", { - .class("dw-glass") + .apply(crate::fx::glass) .dwclass!("rounded-lg border border-woodsmoke-800 p-6 flex flex-col gap-3") .dwclass!("hover:border-candlelight-700") .apply(spotlight_tilt(2.5)) @@ -344,7 +345,7 @@ fn bento_tile_themes() -> Dom { .dwclass!("w-6 h-6 rounded-full border border-woodsmoke-700 transition-all") .dwclass!("hover:scale-125") .style("background-color", c) - .style("animation", &format!("dwind-fade-up 600ms {}ms ease-out both", i * 60)) + .style("animation", &format!("{FADE_UP_KEYFRAMES} 600ms {}ms ease-out both", i * 60)) }) })) }), @@ -464,7 +465,7 @@ fn components_preview() -> Dom { fn preview_card(label: &str, children: Vec) -> Dom { html!("div", { - .class("dw-glass") + .apply(crate::fx::glass) .dwclass!("rounded-lg border border-woodsmoke-800 p-6 flex flex-col gap-5 hover:border-candlelight-700") .apply(spotlight) .child(html!("div", { @@ -483,17 +484,17 @@ fn preview_card(label: &str, children: Vec) -> Dom { fn final_cta() -> Dom { html!("section", { .dwclass!("w-full p-t-20") - .style("position", "relative") + .dwclass!("relative") .apply(reveal_on_scroll) .child(html!("div", { .dwclass!("m-x-auto max-w-3xl p-l-4 p-r-4 flex flex-col gap-6 align-items-center") .child(html!("h2", { .class("font-display") .dwclass!("@sm:text-5xl @) -> Dom { .class("font-code") .dwclass!("flex flex-row gap-3 align-items-center") .dwclass!("text-xs text-woodsmoke-500 border border-woodsmoke-800 rounded-full p-l-3 p-r-3 p-t-1 p-b-1") - .style("position", "absolute") + .dwclass!("absolute") .style("bottom", "0.9rem") .style("right", "0.9rem") .style("background", "rgba(2, 2, 3, 0.7)") .child(html!("span", { .dwclass!("w-2 h-2 rounded-full bg-apple-400 flex-none") - .style("animation", "dwind-pulse-ring 2.2s ease-out infinite") + .dwclass!("animate-pulse-ring") })) .child(html!("span", { .dwclass!("text-woodsmoke-400") @@ -195,7 +196,7 @@ fn controls( let stacked = stacked.clone(); html!("div", { - .class("dw-glass") + .apply(crate::fx::glass) .dwclass!("rounded-lg border border-woodsmoke-800 p-6 flex flex-col gap-5") .apply(fx::spotlight) .child(html!("div", { diff --git a/examples/webpage/src/palette.rs b/examples/webpage/src/palette.rs index e0a90b4..37851dd 100644 --- a/examples/webpage/src/palette.rs +++ b/examples/webpage/src/palette.rs @@ -6,6 +6,7 @@ //! single `global_event_preventable`. No component framework, no store, no //! effect hooks. +use crate::keyframes::*; use crate::pages::docs::{doc_sections, DocPage}; use dominator::routing::go_to_url; use dominator::{events, html, Dom, DomBuilder, EventOptions}; @@ -293,14 +294,13 @@ impl Palette { .attr("role", "dialog") .attr("aria-modal", "true") .attr("aria-label", "Command palette") - .style("position", "fixed") - .style("inset", "0") + .dwclass!("fixed") + .dwclass!("inset-0") .style("z-index", "9999") .child(html!("div", { - .class("dw-palette-scrim") - .style("position", "absolute") - .style("inset", "0") - .style("background", "rgba(2, 2, 3, 0.7)") + .dwclass!("absolute inset-0 animate-scrim-in \ + [background:rgba(2, 2, 3, 0.7)] \ + [backdrop-filter:blur(6px) saturate(0.7)]") .event({ let this = this.clone(); move |_: events::Click| this.close() @@ -310,15 +310,15 @@ impl Palette { // `transform`, and a fill-mode animation beats an inline style. .child(html!("div", { .dwclass!("flex justify-center w-full") - .style("position", "absolute") + .dwclass!("absolute") .style("top", "14vh") .style("left", "0") .style("padding", "0 1rem") .child(html!("div", { - .class("dw-palette") - .dwclass!("rounded-lg overflow-hidden flex flex-col w-full") - .style("max-width", "38rem") - .style("background", "rgba(12, 12, 15, 0.86)") + .dwclass!("rounded-lg overflow-hidden flex flex-col w-full animate-palette-in") + .dwclass!("[max-width:38rem] [background:rgba(12, 12, 15, 0.86)] \ + [backdrop-filter:blur(20px) saturate(1.4)] \ + [box-shadow:0 32px 80px -12px rgba(0, 0, 0, 0.85), 0 0 0 1px rgba(213, 182, 95, 0.14), inset 0 1px 0 0 rgba(255, 255, 255, 0.05)]") .child(this.search_row()) .child(this.results()) .child(this.hint_row()) @@ -338,7 +338,9 @@ impl Palette { .text("⌘") })) .child(html!("input" => web_sys::HtmlInputElement, { - .class("dw-palette-input") + .dwclass!("w-full [background:transparent] [border:none] [outline:none] \ + [color:#F5F5F6] [font-size:1.05rem] font-inherit \ + [&::placeholder]:[color:#55555F]") .attr("type", "text") .attr("placeholder", "Jump to a page, a utility group, the repo…") .attr("aria-label", "Search") @@ -366,7 +368,7 @@ impl Palette { let this = self.clone(); html!("div", { - .class("dw-scrollbar") + .apply(crate::fx::slim_scrollbar) .dwclass!("flex flex-col p-2 overflow-y-auto") .style("max-height", "min(24rem, 50vh)") .child_signal(map_ref! { @@ -403,7 +405,7 @@ impl Palette { .dwclass!("rounded-md p-l-3 p-r-3 p-t-2 p-b-2 border-none cursor-pointer transition-colors") .style("background", if active { "rgba(213, 182, 95, 0.10)" } else { "transparent" }) .style("color", "inherit") - .style("font", "inherit") + .dwclass!("font-inherit") .child(html!("span", { .class("font-code") .dwclass!("text-xs flex-none w-4") diff --git a/examples/webpage/src/reveal.rs b/examples/webpage/src/reveal.rs index d212d87..4bb0335 100644 --- a/examples/webpage/src/reveal.rs +++ b/examples/webpage/src/reveal.rs @@ -1,12 +1,17 @@ //! Scroll-triggered progressive reveal. //! -//! A single shared `IntersectionObserver` watches elements tagged with the -//! `reveal-section` class; when one scrolls into view it gains `reveal-in`, -//! and the CSS in [`crate::APP_KEYFRAMES`] cascades its children up with a -//! small stagger. Elements are unobserved after revealing, so the effect -//! plays once. +//! A single shared `IntersectionObserver` watches tagged elements; when one +//! scrolls into view it gains `reveal-in`, and the child-selector variants +//! applied by [`reveal_on_scroll`] cascade its children up with a stagger. +//! Elements are unobserved after revealing, so the effect plays once. +//! +//! The whole cascade — including the parent-state-driven `.reveal-in > *` rules +//! and the `:nth-child` stagger — is expressed with `dwclass!` variants, so it +//! needs no stylesheet. use dominator::DomBuilder; +use dwind::prelude::*; +use dwind_macros::dwclass; use std::cell::RefCell; use wasm_bindgen::prelude::Closure; use wasm_bindgen::{JsCast, JsValue, UnwrapThrowExt}; @@ -57,8 +62,32 @@ fn with_observer(f: impl FnOnce(&IntersectionObserver)) { /// Tags the element for scroll reveal. Apply on a container; its direct /// children fade up in a staggered cascade when it enters the viewport. pub fn reveal_on_scroll(builder: DomBuilder) -> DomBuilder { - builder.class("reveal-section").after_inserted(|element| { - let element: &Element = element.as_ref(); - with_observer(|observer| observer.observe(element)); - }) + builder + // Resting state: every direct child is down and invisible. + .apply(|b| { + dwclass!( + b, + "[& > *]:opacity-0 [& > *]:[transform:translateY(26px)] \ + [& > *]:[transition:opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), transform 650ms cubic-bezier(0.16, 1, 0.3, 1)]" + ) + }) + // Stagger. Each child leaves a beat after the one before it. + .apply(|b| { + dwclass!( + b, + "[& > *:nth-child(2)]:delay-75 [& > *:nth-child(3)]:delay-150 \ + [& > *:nth-child(4)]:[transition-delay:210ms] [& > *:nth-child(5)]:[transition-delay:280ms]" + ) + }) + // Revealed state, switched on by the observer adding `reveal-in`. + .apply(|b| { + dwclass!( + b, + "[&.reveal-in > *]:opacity-100 [&.reveal-in > *]:[transform:translateY(0)]" + ) + }) + .after_inserted(|element| { + let element: &Element = element.as_ref(); + with_observer(|observer| observer.observe(element)); + }) } diff --git a/examples/webpage/src/styles.rs b/examples/webpage/src/styles.rs deleted file mode 100644 index 88b7aef..0000000 --- a/examples/webpage/src/styles.rs +++ /dev/null @@ -1,297 +0,0 @@ -//! App-level raw CSS: keyframes and the handful of effects that are cheaper to -//! express as a stylesheet than as per-node signals (grain, marquee, masks). -//! -//! Everything here is decoration. All *state* still flows through signals — see -//! [`crate::fx`] for the pointer-reactive parts. - -pub const APP_STYLES: &str = r#" -/* ------------------------------------------------------------------ */ -/* keyframes */ -/* ------------------------------------------------------------------ */ - -@keyframes dwind-cursor-blink { - 0%, 49% { opacity: 1; } - 50%, 100% { opacity: 0; } -} - -@keyframes dwind-fade-up { - from { opacity: 0; transform: translateY(14px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes dwind-glow-drift { - 0%, 100% { transform: translate(0, 0) scale(1); } - 50% { transform: translate(4%, -6%) scale(1.08); } -} - -/* slow, organic drift for the aurora blobs */ -@keyframes dwind-aurora-a { - 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } - 33% { transform: translate3d(6%, -8%, 0) scale(1.15); } - 66% { transform: translate3d(-5%, 5%, 0) scale(0.95); } -} - -@keyframes dwind-aurora-b { - 0%, 100% { transform: translate3d(0, 0, 0) scale(1.05); } - 50% { transform: translate3d(-8%, 6%, 0) scale(0.9); } -} - -/* the sheen that sweeps across gradient headlines */ -@keyframes dwind-sheen { - 0% { background-position: 0% 50%; } - 100% { background-position: 200% 50%; } -} - -/* word-by-word headline reveal */ -@keyframes dwind-word-in { - from { opacity: 0; transform: translateY(0.7em) rotate(2deg); } - to { opacity: 1; transform: translateY(0) rotate(0deg); } -} - -/* infinite utility-class ticker */ -@keyframes dwind-marquee { - from { transform: translate3d(0, 0, 0); } - to { transform: translate3d(-50%, 0, 0); } -} - -/* command palette entrance */ -@keyframes dwind-palette-in { - from { opacity: 0; transform: translateY(-12px) scale(0.98); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -@keyframes dwind-scrim-in { - from { opacity: 0; } - to { opacity: 1; } -} - -/* route change transition */ -@keyframes dwind-route-in { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes dwind-pulse-ring { - 0% { box-shadow: 0 0 0 0 rgba(213, 182, 95, 0.35); } - 70% { box-shadow: 0 0 0 12px rgba(213, 182, 95, 0); } - 100% { box-shadow: 0 0 0 0 rgba(213, 182, 95, 0); } -} - -/* ------------------------------------------------------------------ */ -/* scroll-triggered progressive reveal (see reveal.rs) */ -/* ------------------------------------------------------------------ */ - -.reveal-section > * { - opacity: 0; - transform: translateY(26px); - transition: - opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), - transform 650ms cubic-bezier(0.16, 1, 0.3, 1); -} - -.reveal-section > *:nth-child(2) { transition-delay: 70ms; } -.reveal-section > *:nth-child(3) { transition-delay: 140ms; } -.reveal-section > *:nth-child(4) { transition-delay: 210ms; } -.reveal-section > *:nth-child(5) { transition-delay: 280ms; } - -.reveal-section.reveal-in > * { - opacity: 1; - transform: translateY(0); -} - -/* ------------------------------------------------------------------ */ -/* film grain — one fixed overlay for the whole app */ -/* ------------------------------------------------------------------ */ - -.dw-grain { - position: fixed; - inset: 0; - z-index: 9998; - pointer-events: none; - opacity: 0.22; - mix-blend-mode: overlay; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E"); -} - -/* ------------------------------------------------------------------ */ -/* gradient text with a slow sheen sweep */ -/* ------------------------------------------------------------------ */ - -.dw-sheen { - background-image: linear-gradient( - 100deg, - #F0E2B6 0%, - #D5B65F 18%, - #FFF8E2 30%, - #D5B65F 42%, - #A88735 60%, - #D5B65F 100% - ); - background-size: 200% auto; - -webkit-background-clip: text; - background-clip: text; - color: transparent; - animation: dwind-sheen 7s linear infinite; -} - -/* ------------------------------------------------------------------ */ -/* kinetic headline: each word rides in on its own delay */ -/* ------------------------------------------------------------------ */ - -.dw-word { - display: inline-block; - animation: dwind-word-in 900ms cubic-bezier(0.16, 1, 0.3, 1) both; -} - -/* ------------------------------------------------------------------ */ -/* pointer spotlight cards (coordinates are written by fx.rs signals) */ -/* ------------------------------------------------------------------ */ - -.dw-spot { - position: relative; - isolation: isolate; - transition: transform 400ms cubic-bezier(0.16, 1, 0.3, 1), - border-color 300ms ease; -} - -/* the glow itself — a radial gradient parked at --sx/--sy */ -.dw-spot::before { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - border-radius: inherit; - opacity: 0; - transition: opacity 320ms ease; - background: radial-gradient( - 22rem circle at var(--sx, 50%) var(--sy, 50%), - rgba(213, 182, 95, 0.13), - transparent 62% - ); -} - -.dw-spot[data-hot="1"]::before { opacity: 1; } - -/* a thin lit edge that tracks the cursor too */ -.dw-spot::after { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - border-radius: inherit; - padding: 1px; - opacity: 0; - transition: opacity 320ms ease; - background: radial-gradient( - 16rem circle at var(--sx, 50%) var(--sy, 50%), - rgba(213, 182, 95, 0.55), - transparent 55% - ); - -webkit-mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - -webkit-mask-composite: xor; - mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - mask-composite: exclude; -} - -.dw-spot[data-hot="1"]::after { opacity: 1; } - -/* ------------------------------------------------------------------ */ -/* marquee */ -/* ------------------------------------------------------------------ */ - -.dw-marquee { - -webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent); - mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent); -} - -.dw-marquee-track { - display: flex; - width: max-content; - animation: dwind-marquee 42s linear infinite; -} - -.dw-marquee:hover .dw-marquee-track { animation-play-state: paused; } - -/* ------------------------------------------------------------------ */ -/* command palette */ -/* ------------------------------------------------------------------ */ - -.dw-palette-scrim { - animation: dwind-scrim-in 180ms ease-out both; - backdrop-filter: blur(6px) saturate(0.7); -} - -.dw-palette { - animation: dwind-palette-in 240ms cubic-bezier(0.16, 1, 0.3, 1) both; - backdrop-filter: blur(20px) saturate(1.4); - box-shadow: - 0 32px 80px -12px rgba(0, 0, 0, 0.85), - 0 0 0 1px rgba(213, 182, 95, 0.14), - inset 0 1px 0 0 rgba(255, 255, 255, 0.05); -} - -.dw-palette-input { - background: transparent; - border: none; - outline: none; - color: #F5F5F6; - width: 100%; - font-size: 1.05rem; - font-family: inherit; -} - -.dw-palette-input::placeholder { color: #55555F; } - -/* ------------------------------------------------------------------ */ -/* glass surfaces */ -/* ------------------------------------------------------------------ */ - -.dw-glass { - background: linear-gradient( - 160deg, - rgba(28, 28, 33, 0.72) 0%, - rgba(14, 14, 17, 0.62) 100% - ); - backdrop-filter: blur(14px) saturate(1.2); - box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.045); -} - -/* ------------------------------------------------------------------ */ -/* route transition */ -/* ------------------------------------------------------------------ */ - -.dw-route { - animation: dwind-route-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both; -} - -/* ------------------------------------------------------------------ */ -/* docs prose */ -/* ------------------------------------------------------------------ */ - -.dw-scrollbar::-webkit-scrollbar { width: 10px; height: 10px; } -.dw-scrollbar::-webkit-scrollbar-track { background: transparent; } -.dw-scrollbar::-webkit-scrollbar-thumb { - background: #26262C; - border-radius: 8px; - border: 3px solid transparent; - background-clip: content-box; -} -.dw-scrollbar::-webkit-scrollbar-thumb:hover { background: #3A3A44; background-clip: content-box; } - -/* ------------------------------------------------------------------ */ -/* motion preferences */ -/* ------------------------------------------------------------------ */ - -@media (prefers-reduced-motion: reduce) { - .dw-marquee-track, - .dw-sheen, - .dw-word { animation: none !important; } - - .dw-sheen { color: #D5B65F; } - .dw-spot { transform: none !important; } -} -"#; From 4474a101e31af529b75cca2008ae00d671a501fb Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Jul 2026 15:42:07 +0200 Subject: [PATCH 2/3] Honour reduced motion for the pointer effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- examples/webpage/README.md | 4 +++- examples/webpage/src/fx.rs | 20 +++++++++++++++---- .../docs/doc_pages/pseudoclass_themes.rs | 10 ++++++---- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/examples/webpage/README.md b/examples/webpage/README.md index 8c1a4be..401b233 100644 --- a/examples/webpage/README.md +++ b/examples/webpage/README.md @@ -86,4 +86,6 @@ write as it happens, next to a re-render count that stays at zero. - — move through results, to open, Esc to dismiss Everything interactive is reachable by keyboard, and `prefers-reduced-motion` is -honoured throughout — the marquee, sheen, tilt, and reveal animations all stop. +honoured throughout. CSS handles the animations and transitions; the pointer tilt +and the magnetic drift are `style_signal` writes, which no `@media` block can +stop, so they read the preference as a signal and skip the transform outright. diff --git a/examples/webpage/src/fx.rs b/examples/webpage/src/fx.rs index aaf31e8..7958897 100644 --- a/examples/webpage/src/fx.rs +++ b/examples/webpage/src/fx.rs @@ -9,7 +9,7 @@ use crate::keyframes::*; use dominator::{events, html, Dom, DomBuilder}; use dwind::prelude::*; use dwind_macros::dwclass; -use futures_signals::signal::{Mutable, SignalExt}; +use futures_signals::signal::{Mutable, Signal, SignalExt}; use web_sys::HtmlElement; /// Normalised pointer position inside an element, plus whether it is hovered. @@ -115,9 +115,20 @@ pub fn spotlight(builder: DomBuilder) -> DomBuilder { track_pointer(builder, &PointerState::new()) } +/// Whether the reader has asked for less motion. +/// +/// A CSS `@media` block can shorten a transition, but it cannot stop a +/// `style_signal` from writing a transform in the first place. The effects below +/// are driven from Rust, so the preference has to be read in Rust too — as a +/// signal, so it also tracks a change made while the page is open. +fn prefers_reduced_motion() -> impl Signal { + dominator::media_query("(prefers-reduced-motion: reduce)") +} + /// A spotlight card that also tips towards the cursor in 3D. /// -/// `strength` is the maximum rotation in degrees. +/// `strength` is the maximum rotation in degrees. The tilt is suppressed +/// entirely under `prefers-reduced-motion`. pub fn spotlight_tilt( strength: f64, ) -> impl Fn(DomBuilder) -> DomBuilder { @@ -129,8 +140,9 @@ pub fn spotlight_tilt( "transform", futures_signals::map_ref! { let (x, y) = pos.signal(), - let hot = hot.signal() => move { - if *hot { + let hot = hot.signal(), + let still = prefers_reduced_motion() => move { + if *hot && !*still { format!( "perspective(1100px) rotateX({:.2}deg) rotateY({:.2}deg) translateZ(6px)", (0.5 - *y) * strength * 2.0, diff --git a/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs b/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs index d028f2c..8891261 100644 --- a/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs +++ b/examples/webpage/src/pages/docs/doc_pages/pseudoclass_themes.rs @@ -34,8 +34,9 @@ pub fn pseudo_class_themes() -> Dom { .child(html!("p", { .dwclass!("text-woodsmoke-300 leading-relaxed m-t-4 m-b-2") .text(r#"::before and ::after are variants like any other. dwind adds the content: "" they - need in order to render, so a utility is enough on its own — and because declarations are - appended in source order, your own content utility later in the same class still wins."#) + need in order to render, so a utility is enough on its own. It goes through a + --dw-content custom property, so composing several before: utilities keeps whichever + content you wrote rather than the last one winning."#) })) .child(example_box(pseudo_elements(), false)) .child(code(&PSEUDO_ELEMENTS_EXAMPLE_HTML_MAP)) @@ -50,8 +51,9 @@ pub fn pseudo_class_themes() -> Dom { })) .child(html!("p", { .dwclass!("text-woodsmoke-400 leading-relaxed m-0 m-b-2") - .text("Underscores in the value become spaces, since a class string is space-separated. \ - Modifiers go first: hover:[color:red], not [color:red]:hover.") + .text("Values pass through untouched — spaces are fine inside the brackets, and so is \ + any character, so [content:'→'] works. Modifiers go first: hover:[color:red], \ + not [color:red]:hover.") })) .child(example_box(arbitrary_declarations(), false)) .child(code(&ARBITRARY_DECLARATIONS_EXAMPLE_HTML_MAP)) From 787972f9fe32966be47e67ec68b985d38a09c2a8 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Jul 2026 16:05:31 +0200 Subject: [PATCH 3/3] Honour reduced motion in the magnetic effect too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From re-review of #23. The previous commit gated spotlight_tilt but not magnetic — I ran a scripted patch that reported success without checking it had matched, and my browser probe only hovered a spotlight card, so neither caught it. magnetic is used on every CTA on the site. Both decisions are now pure functions with unit tests, so the rule is a plain assertion rather than something only a hovering browser can check. Co-Authored-By: Claude Opus 5 (1M context) --- examples/webpage/src/fx.rs | 95 +++++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 22 deletions(-) diff --git a/examples/webpage/src/fx.rs b/examples/webpage/src/fx.rs index 7958897..efc462e 100644 --- a/examples/webpage/src/fx.rs +++ b/examples/webpage/src/fx.rs @@ -125,6 +125,35 @@ fn prefers_reduced_motion() -> impl Signal { dominator::media_query("(prefers-reduced-motion: reduce)") } +/// The 3D tilt transform for a card, or the resting one. +/// +/// Split out from the signal wiring so the reduced-motion rule is a plain +/// assertion rather than something only a browser can check. +fn tilt_transform(x: f64, y: f64, hot: bool, still: bool, strength: f64) -> String { + if !hot || still { + return "perspective(1100px) rotateX(0deg) rotateY(0deg) translateZ(0)".to_string(); + } + + format!( + "perspective(1100px) rotateX({:.2}deg) rotateY({:.2}deg) translateZ(6px)", + (0.5 - y) * strength * 2.0, + (x - 0.5) * strength * 2.0, + ) +} + +/// The magnetic offset for a control, or the resting one. +fn magnetic_transform(x: f64, y: f64, hot: bool, still: bool, pull: f64) -> String { + if !hot || still { + return "translate3d(0, 0, 0)".to_string(); + } + + format!( + "translate3d({:.2}px, {:.2}px, 0)", + (x - 0.5) * pull * 2.0, + (y - 0.5) * pull * 2.0, + ) +} + /// A spotlight card that also tips towards the cursor in 3D. /// /// `strength` is the maximum rotation in degrees. The tilt is suppressed @@ -141,17 +170,8 @@ pub fn spotlight_tilt( futures_signals::map_ref! { let (x, y) = pos.signal(), let hot = hot.signal(), - let still = prefers_reduced_motion() => move { - if *hot && !*still { - format!( - "perspective(1100px) rotateX({:.2}deg) rotateY({:.2}deg) translateZ(6px)", - (0.5 - *y) * strength * 2.0, - (*x - 0.5) * strength * 2.0, - ) - } else { - "perspective(1100px) rotateX(0deg) rotateY(0deg) translateZ(0)".to_string() - } - } + let still = prefers_reduced_motion() => + move { tilt_transform(*x, *y, *hot, *still, strength) } }, ) } @@ -159,6 +179,10 @@ pub fn spotlight_tilt( /// A control that drifts toward the cursor while hovered — the classic /// "magnetic button", in about twenty lines of signal plumbing. +/// +/// Suppressed under `prefers-reduced-motion`, for the same reason as +/// [`spotlight_tilt`]: this is a `style_signal` write, so no `@media` block can +/// stop it. pub fn magnetic(pull: f64) -> impl Fn(DomBuilder) -> DomBuilder { move |builder| { let pos = Mutable::new((0.5f64, 0.5f64)); @@ -193,17 +217,9 @@ pub fn magnetic(pull: f64) -> impl Fn(DomBuilder) -> DomBuilder move { - if *hot { - format!( - "translate3d({:.2}px, {:.2}px, 0)", - (*x - 0.5) * pull * 2.0, - (*y - 0.5) * pull * 2.0, - ) - } else { - "translate3d(0, 0, 0)".to_string() - } - } + let hot = hot.signal(), + let still = prefers_reduced_motion() => + move { magnetic_transform(*x, *y, *hot, *still, pull) } }, ) } @@ -475,3 +491,38 @@ pub fn slim_scrollbar(builder: DomBuilder) -> DomBuilder