diff --git a/examples/webpage/README.md b/examples/webpage/README.md
index 7000063..8c2d9c5 100644
--- a/examples/webpage/README.md
+++ b/examples/webpage/README.md
@@ -1,5 +1,9 @@
# DWIND webpage
+The dwind documentation site — and the largest worked example of dwind + dwui in
+the repository. Everything on it is compiled Rust running as WebAssembly; there
+is no JavaScript beyond the wasm-bindgen glue and no CSS build pipeline.
+
## Running the webpage in local developer mode
You need a recent (>= 1.79.0) version of rust, please refer to https://rustup.rs/ for how to install this.
@@ -20,4 +24,45 @@ then do
```shell
trunk serve --open
-```
\ No newline at end of file
+```
+
+> Trunk does not watch the sibling workspace crates. After editing `crates/dwind`
+> or `crates/dwui`, restart `trunk serve` to pick up the rebuild.
+
+## How the site is put together
+
+| Module | What lives there |
+| --- | --- |
+| `lib.rs` | App shell: router, sticky header with active-route indicator, scroll-progress rail, docs shell and prev/next pager, footer |
+| `fx.rs` | Reusable reactive effects — pointer spotlight, 3D tilt, magnetic controls, aurora background, film grain, scroll-spy, marquee, kinetic headlines |
+| `palette.rs` | The ⌘K command palette: fuzzy search over every route, full keyboard control |
+| `styles.rs` | App-level raw CSS — keyframes, glass surfaces, grain, masks |
+| `reveal.rs` | `IntersectionObserver`-driven progressive reveal on scroll |
+| `pages/signal_lab.rs` | The reactivity demo on the home page |
+| `pages/docs/` | Documentation pages, sidebar, live example frames, syntax-highlighted source |
+
+### The effects are signals, not an animation library
+
+Nothing here reaches for a JS animation runtime. Each effect is a `Mutable` that
+an event writes and a `style_signal` reads, so a pointer move updates exactly the
+CSS properties that depend on it — no `requestAnimationFrame` loop, no re-render,
+no diff:
+
+```rust
+// fx.rs — the whole spotlight, minus bookkeeping
+.event(move |e: events::PointerMove| {
+ pos.set_neq(normalised(&element, e.mouse_x() as f64, e.mouse_y() as f64));
+})
+.style_signal("--sx", pos.signal().map(|(x, _)| format!("{:.2}%", x * 100.0)))
+```
+
+The home page's reactivity lab makes this measurable: it counts every property
+write as it happens, next to a re-render count that stays at zero.
+
+### Keyboard
+
+- ⌘K / Ctrl+K or / — open the command palette
+- ↑ ↓ — 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.
diff --git a/examples/webpage/public/index.html b/examples/webpage/public/index.html
index 5d1ae51..c510cfb 100644
--- a/examples/webpage/public/index.html
+++ b/examples/webpage/public/index.html
@@ -40,6 +40,19 @@
color: #020203;
}
+ /* Firefox scrollbars; the WebKit equivalent lives in styles.rs */
+ * {
+ scrollbar-color: #26262C transparent;
+ scrollbar-width: thin;
+ }
+
+ /* One focus treatment for every custom control on the site. */
+ :focus-visible {
+ outline: 2px solid #D5B65F;
+ outline-offset: 2px;
+ border-radius: 4px;
+ }
+
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
diff --git a/examples/webpage/src/fx.rs b/examples/webpage/src/fx.rs
new file mode 100644
index 0000000..e9b1386
--- /dev/null
+++ b/examples/webpage/src/fx.rs
@@ -0,0 +1,386 @@
+//! Pointer- and scroll-reactive effects, built the DOMINATOR way.
+//!
+//! Every effect in here is a plain `Mutable` feeding a `style_signal`. There is
+//! no animation library, no `requestAnimationFrame` loop and no virtual DOM
+//! diff: an event writes a number, the signal writes one CSS property on one
+//! node, and the compositor does the rest.
+
+use dominator::{events, html, Dom, DomBuilder};
+use dwind::prelude::*;
+use dwind_macros::dwclass;
+use futures_signals::signal::{Mutable, SignalExt};
+use web_sys::HtmlElement;
+
+/// Normalised pointer position inside an element, plus whether it is hovered.
+#[derive(Clone, Default)]
+struct PointerState {
+ /// 0.0 – 1.0 across the element's width / height.
+ pos: Mutable<(f64, f64)>,
+ hot: Mutable,
+}
+
+impl PointerState {
+ fn new() -> Self {
+ Self {
+ pos: Mutable::new((0.5, 0.5)),
+ hot: Mutable::new(false),
+ }
+ }
+}
+
+/// Reads the pointer position relative to `element` and normalises it.
+fn normalised(element: &HtmlElement, client_x: f64, client_y: f64) -> (f64, f64) {
+ let rect = element.get_bounding_client_rect();
+ let (w, h) = (rect.width().max(1.0), rect.height().max(1.0));
+
+ (
+ ((client_x - rect.left()) / w).clamp(0.0, 1.0),
+ ((client_y - rect.top()) / h).clamp(0.0, 1.0),
+ )
+}
+
+/// Wires pointer tracking into a builder and exposes the state.
+///
+/// `--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.
+fn track_pointer(
+ builder: DomBuilder,
+ state: &PointerState,
+) -> DomBuilder {
+ let (pos, hot) = (state.pos.clone(), state.hot.clone());
+
+ dominator::with_node!(builder, element => {
+ .event({
+ let pos = pos.clone();
+ let element = element.clone();
+ move |e: events::PointerMove| {
+ pos.set_neq(normalised(&element, e.mouse_x() as f64, e.mouse_y() as f64));
+ }
+ })
+ .event({
+ let hot = hot.clone();
+ move |_: events::PointerEnter| hot.set_neq(true)
+ })
+ .event({
+ let hot = hot.clone();
+ let pos = pos.clone();
+ move |_: events::PointerLeave| {
+ hot.set_neq(false);
+ pos.set_neq((0.5, 0.5));
+ }
+ })
+ })
+ .class("dw-spot")
+ .attr_signal(
+ "data-hot",
+ hot.signal().map(|h| Some(if h { "1" } else { "0" })),
+ )
+ .style_signal(
+ "--sx",
+ pos.signal().map(|(x, _)| format!("{:.2}%", x * 100.0)),
+ )
+ .style_signal(
+ "--sy",
+ pos.signal().map(|(_, y)| format!("{:.2}%", y * 100.0)),
+ )
+}
+
+/// A card that lights up under the cursor.
+///
+/// ```ignore
+/// html!("div", { .apply(spotlight) .child(...) })
+/// ```
+pub fn spotlight(builder: DomBuilder) -> DomBuilder {
+ track_pointer(builder, &PointerState::new())
+}
+
+/// A spotlight card that also tips towards the cursor in 3D.
+///
+/// `strength` is the maximum rotation in degrees.
+pub fn spotlight_tilt(
+ strength: f64,
+) -> impl Fn(DomBuilder) -> DomBuilder {
+ move |builder| {
+ let state = PointerState::new();
+ let (pos, hot) = (state.pos.clone(), state.hot.clone());
+
+ track_pointer(builder, &state).style_signal(
+ "transform",
+ futures_signals::map_ref! {
+ let (x, y) = pos.signal(),
+ let hot = hot.signal() => move {
+ if *hot {
+ 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()
+ }
+ }
+ },
+ )
+ }
+}
+
+/// A control that drifts toward the cursor while hovered — the classic
+/// "magnetic button", in about twenty lines of signal plumbing.
+pub fn magnetic(pull: f64) -> impl Fn(DomBuilder) -> DomBuilder {
+ move |builder| {
+ let pos = Mutable::new((0.5f64, 0.5f64));
+ let hot = Mutable::new(false);
+
+ dominator::with_node!(builder, element => {
+ .event({
+ let pos = pos.clone();
+ let element = element.clone();
+ move |e: events::PointerMove| {
+ pos.set_neq(normalised(&element, e.mouse_x() as f64, e.mouse_y() as f64));
+ }
+ })
+ .event({
+ let hot = hot.clone();
+ move |_: events::PointerEnter| hot.set_neq(true)
+ })
+ .event({
+ let hot = hot.clone();
+ let pos = pos.clone();
+ move |_: events::PointerLeave| {
+ hot.set_neq(false);
+ pos.set_neq((0.5, 0.5));
+ }
+ })
+ })
+ .style(
+ "transition",
+ "transform 320ms cubic-bezier(0.16, 1, 0.3, 1)",
+ )
+ .style_signal(
+ "transform",
+ futures_signals::map_ref! {
+ let (x, y) = pos.signal(),
+ let hot = hot.signal() => 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()
+ }
+ }
+ },
+ )
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Ambient background layers
+// ---------------------------------------------------------------------------
+
+/// Slowly drifting colour field behind the whole app.
+pub fn aurora() -> Dom {
+ html!("div", {
+ .attr("aria-hidden", "true")
+ .style("position", "fixed")
+ .style("inset", "0")
+ .style("z-index", "0")
+ .style("pointer-events", "none")
+ .style("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",
+ ))
+ .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",
+ ))
+ .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",
+ ))
+ })
+}
+
+fn aurora_blob(background: &str, top: &str, left: &str, size: &str, animation: &str) -> Dom {
+ html!("div", {
+ .style("position", "absolute")
+ .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.
+pub fn grain() -> Dom {
+ html!("div", {
+ .attr("aria-hidden", "true")
+ .class("dw-grain")
+ })
+}
+
+/// Faint engineering grid, masked to fade out towards the bottom.
+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")
+ .style("mask-image", mask)
+ .style("-webkit-mask-image", mask)
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Scroll spy
+// ---------------------------------------------------------------------------
+
+/// Reports into `active` whenever this element occupies the reading band —
+/// the middle slice of the viewport. Pair with [`spy_rail`].
+pub fn scroll_spy(
+ id: &'static str,
+ active: Mutable<&'static str>,
+) -> impl Fn(DomBuilder) -> DomBuilder {
+ move |builder| {
+ let active = active.clone();
+
+ builder.after_inserted(move |element| {
+ let callback = wasm_bindgen::prelude::Closure::::new(
+ move |entries: web_sys::js_sys::Array| {
+ for entry in entries.iter() {
+ let entry: web_sys::IntersectionObserverEntry =
+ wasm_bindgen::JsCast::unchecked_into(entry);
+
+ if entry.is_intersecting() {
+ active.set_neq(id);
+ }
+ }
+ },
+ );
+
+ let options = web_sys::IntersectionObserverInit::new();
+ options.set_root_margin("-25% 0px -60% 0px");
+
+ if let Ok(observer) = web_sys::IntersectionObserver::new_with_options(
+ wasm_bindgen::JsCast::unchecked_ref(callback.as_ref()),
+ &options,
+ ) {
+ observer.observe(element.as_ref());
+ // both live for the page lifetime
+ std::mem::forget(observer);
+ callback.forget();
+ }
+ })
+ }
+}
+
+/// A sticky chapter index that highlights whatever [`scroll_spy`] last saw.
+pub fn spy_rail(
+ sections: &'static [(&'static str, &'static str)],
+ active: Mutable<&'static str>,
+) -> Dom {
+ html!("nav", {
+ .attr("aria-label", "On this page")
+ .dwclass!("flex flex-col gap-1 flex-none w-44 @ 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")
+ .text(content)
+ })
+}
+
+/// Splits a headline into words and staggers them in. Words wrapped in `*` are
+/// rendered with the animated gold sheen.
+pub fn kinetic_headline(headline: &str) -> Vec {
+ headline
+ .split_whitespace()
+ .enumerate()
+ .map(|(i, w)| {
+ if let Some(stripped) = w.strip_prefix('*').and_then(|w| w.strip_suffix('*')) {
+ word(stripped, i, true)
+ } else {
+ word(w, i, false)
+ }
+ })
+ .collect()
+}
+
+// ---------------------------------------------------------------------------
+// Marquee
+// ---------------------------------------------------------------------------
+
+/// An infinite horizontal ticker. The track is rendered twice so the
+/// `-50%` keyframe loops seamlessly.
+pub fn marquee(items: &[&str]) -> Dom {
+ let chip = |label: &str| {
+ html!("span", {
+ .class("font-code")
+ .dwclass!("text-xs text-woodsmoke-400 flex-none")
+ .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")
+ .text(label)
+ })
+ };
+
+ html!("div", {
+ .attr("aria-hidden", "true")
+ .class("dw-marquee")
+ .dwclass!("w-full overflow-hidden")
+ .child(html!("div", {
+ .class("dw-marquee-track")
+ .children(items.iter().chain(items.iter()).map(|i| chip(i)))
+ }))
+ })
+}
diff --git a/examples/webpage/src/lib.rs b/examples/webpage/src/lib.rs
index 37710d5..ae57bcb 100644
--- a/examples/webpage/src/lib.rs
+++ b/examples/webpage/src/lib.rs
@@ -1,6 +1,9 @@
+mod fx;
mod pages;
+mod palette;
mod reveal;
mod router;
+mod styles;
#[macro_use]
extern crate log;
@@ -11,64 +14,25 @@ extern crate dominator;
#[macro_use]
extern crate dwui;
+use crate::fx::magnetic;
use crate::pages::components_page::components_page;
use crate::pages::docs::doc_main::doc_main_view;
use crate::pages::docs::doc_sidebar::doc_sidebar;
use crate::pages::docs::{doc_sections, DocPage};
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, Dom};
+use dominator::{body, events, Dom};
use dwind::prelude::*;
use dwind_macros::dwclass;
use dwui::theme::prelude::ColorsCssVariables;
-use futures_signals::signal::{always, SignalExt};
+use futures_signals::signal::{always, Mutable, SignalExt};
use std::sync::Arc;
use web_sys::window;
-const APP_KEYFRAMES: &str = r#"
-@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); }
-}
-
-/* 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);
-}
-"#;
-
#[cfg(not(test))]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
async fn main() {
@@ -85,29 +49,86 @@ fn main_view() -> Dom {
&DWIND_COLORS["woodsmoke"],
&DWIND_COLORS["red"],
)));
- dominator::stylesheet_raw(APP_KEYFRAMES);
+ dominator::stylesheet_raw(APP_STYLES);
+
+ let palette = palette::global();
+ let page = make_app_router().signal().broadcast();
+
+ // How far down the page we are, 0.0 – 1.0. One number, written by a scroll
+ // listener and read by exactly one style_signal.
+ let scrolled = Mutable::new(0.0f64);
html!("div", {
+ .class("dw-scrollbar")
.dwclass!("text-woodsmoke-100 bg-woodsmoke-950")
.dwclass!("h-full overflow-y-auto overflow-x-hidden")
- .child(top_nav())
+ .style("position", "relative")
+ .apply(palette.shortcuts())
+ .with_node!(element => {
+ .event(clone!(scrolled => move |_: events::Scroll| {
+ let max = (element.scroll_height() - element.client_height()).max(1) as f64;
+ scrolled.set_neq((element.scroll_top() as f64 / max).clamp(0.0, 1.0));
+ }))
+ })
+ .child(fx::aurora())
+ .child(scroll_progress(scrolled.clone()))
+ .child(top_nav(&palette, page.signal()))
.child(html!("main", {
- .child_signal(make_app_router().signal().map(|page| {
- Some(match page {
- DocPage::Home => home_page(),
- DocPage::DwuiExamples => components_page(),
- DocPage::Examples => dwind_examples_page(),
- other => docs_shell(other),
- })
+ .style("position", "relative")
+ .style("z-index", "1")
+ .child_signal(page.signal().map(|page| {
+ Some(html!("div", {
+ .class("dw-route")
+ .after_inserted(|_| scroll_to_top())
+ .child(match page {
+ DocPage::Home => home_page(),
+ DocPage::DwuiExamples => components_page(),
+ DocPage::Examples => dwind_examples_page(),
+ other => docs_shell(other),
+ })
+ }))
}))
}))
.child(footer())
+ .child(fx::grain())
+ .child(palette.render())
+ })
+}
+
+fn scroll_to_top() {
+ if let Some(win) = window() {
+ win.scroll_to_with_x_and_y(0.0, 0.0);
+ }
+}
+
+/// Reading-progress rail pinned under the header.
+fn scroll_progress(scrolled: Mutable) -> Dom {
+ html!("div", {
+ .attr("aria-hidden", "true")
+ .style("position", "fixed")
+ .style("top", "0")
+ .style("left", "0")
+ .style("right", "0")
+ .style("height", "2px")
+ .style("z-index", "60")
+ .style("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")
+ .style_signal("transform", scrolled.signal().map(|p| {
+ format!("scaleX({:.4})", p.max(0.001))
+ }))
+ .style("width", "100%")
+ }))
})
}
fn docs_shell(page: DocPage) -> Dom {
html!("div", {
- .dwclass!("m-x-auto flex max-w-5xl w-full p-t-6 p-l-4 p-r-4")
+ .dwclass!("m-x-auto flex max-w-6xl w-full p-t-8 p-l-4 p-r-4")
.style("min-height", "70vh")
.child_signal(doc_sidebar(
doc_sections(),
@@ -115,28 +136,90 @@ fn docs_shell(page: DocPage) -> Dom {
Arc::new(|v: DocPage| v.goto()),
move || {
html!("div", {
- .dwclass!("m-l-4 m-r-0 w-full")
+ .dwclass!("m-l-8 m-r-0 w-full")
+ .style("min-width", "0")
.child_signal(doc_main_view(always(Some(page))))
+ .child(docs_pager(page))
})
},
))
})
}
-fn top_nav() -> Dom {
+/// Prev / next between doc pages, in reading order.
+fn docs_pager(page: DocPage) -> Dom {
+ let order: Vec = doc_sections()
+ .into_iter()
+ .flat_map(|section| section.docs)
+ .collect();
+
+ let index = order.iter().position(|p| *p == page);
+ let prev = index
+ .and_then(|i| i.checked_sub(1))
+ .and_then(|i| order.get(i))
+ .copied();
+ let next = index.map(|i| i + 1).and_then(|i| order.get(i)).copied();
+
+ html!("div", {
+ .dwclass!("flex flex-row justify-between gap-4 m-t-16 p-t-8 border-t border-woodsmoke-800")
+ .child(pager_link(prev, "← prev", true))
+ .child(pager_link(next, "next →", false))
+ })
+}
+
+fn pager_link(page: Option, hint: &str, left: bool) -> Dom {
+ let Some(page) = page else {
+ return html!("div", { .dwclass!("grow") });
+ };
+
+ html!("button", {
+ .attr("type", "button")
+ .class("dw-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)
+ .apply(move |b| if left {
+ dwclass!(b, "text-left align-items-start")
+ } else {
+ dwclass!(b, "text-right align-items-end")
+ })
+ .style("color", "inherit")
+ .style("font", "inherit")
+ .child(html!("span", {
+ .class("font-code")
+ .dwclass!("text-xs text-woodsmoke-500")
+ .text(hint)
+ }))
+ .child(html!("span", {
+ .class("font-display")
+ .dwclass!("text-l font-bold text-woodsmoke-100")
+ .text(&page.to_string())
+ }))
+ .event(move |_: events::Click| page.goto())
+ })
+}
+
+fn top_nav(
+ palette: &Palette,
+ page: impl futures_signals::signal::Signal- + 'static,
+) -> Dom {
+ let page = page.broadcast();
+ let palette = palette.clone();
+
html!("header", {
.dwclass!("sticky top-0 z-50 w-full")
- .style("backdrop-filter", "blur(12px)")
- .style("background", "rgba(2, 2, 3, 0.72)")
+ .style("backdrop-filter", "blur(16px) saturate(1.3)")
+ .style("background", "rgba(2, 2, 3, 0.62)")
.dwclass!("border-b border-woodsmoke-800")
.child(html!("div", {
- .dwclass!("m-x-auto max-w-6xl flex align-items-center justify-between h-14 p-l-4 p-r-4")
+ .dwclass!("m-x-auto max-w-6xl flex align-items-center justify-between h-16 p-l-4 p-r-4")
// Wordmark
.child(html!("a", {
.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")
+ .apply(magnetic(5.0))
.child(html!("span", {
.dwclass!("text-candlelight-400")
.text("λ")
@@ -153,28 +236,73 @@ fn top_nav() -> Dom {
.attr("aria-label", "Main")
.class("font-code")
.dwclass!("flex align-items-center @sm:gap-6 @ Dom {
+/// The ⌘K affordance. Also the only way to discover the palette exists.
+fn palette_trigger(palette: &Palette) -> Dom {
+ let palette = palette.clone();
+
+ html!("button", {
+ .attr("type", "button")
+ .attr("aria-label", "Open command palette")
+ .class("font-code")
+ .dwclass!("flex flex-row align-items-center gap-2 cursor-pointer transition-all")
+ .dwclass!("border border-woodsmoke-800 rounded-md p-l-2 p-r-2 p-t-1 p-b-1")
+ .dwclass!("text-woodsmoke-400 hover:text-candlelight-300 hover:border-candlelight-700")
+ .dwclass!("@ + 'static,
+) -> Dom {
let href = href.to_string();
+ let active = active.broadcast();
html!("a", {
.attr("href", &href)
.dwclass!("cursor-pointer transition-colors select-none")
.dwclass!("text-woodsmoke-300 hover:text-candlelight-300")
- .apply_if(emphasized, |b| dwclass!(b, "text-candlelight-400"))
.style("text-decoration", "none")
- .text(label)
- .event(move |_: dominator::events::Click| {
+ .style("position", "relative")
+ .style_signal("color", active.signal().map(|a| {
+ if a { Some("#E5CE8F") } else { None }
+ }))
+ .child(dominator::text(label))
+ // an underline that grows in when the route becomes active
+ .child(html!("span", {
+ .attr("aria-hidden", "true")
+ .style("position", "absolute")
+ .style("left", "0")
+ .style("right", "0")
+ .style("bottom", "-6px")
+ .style("height", "1px")
+ .style("background", "linear-gradient(90deg, transparent, #D5B65F, transparent)")
+ .style("transform-origin", "center")
+ .style("transition", "transform 320ms cubic-bezier(0.16, 1, 0.3, 1)")
+ .style_signal("transform", active.signal().map(|a| {
+ if a { "scaleX(1)" } else { "scaleX(0)" }
+ }))
+ }))
+ .event(move |_: events::Click| {
go_to_url(&href);
})
})
@@ -191,7 +319,7 @@ fn nav_external(label: &str, url: &str) -> Dom {
.dwclass!("text-woodsmoke-300 hover:text-candlelight-300")
.style("text-decoration", "none")
.text(label)
- .event(move |_: dominator::events::Click| {
+ .event(move |_: events::Click| {
window()
.unwrap()
.open_with_url_and_target(&url, "_blank")
@@ -203,8 +331,10 @@ 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")
+ .style("z-index", "1")
.child(html!("div", {
- .dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 p-t-10 p-b-10")
+ .dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 p-t-12 p-b-12")
.dwclass!("flex @sm:flex-row @ Dom {
.dwclass!("flex flex-row gap-10")
.child(footer_column("explore", vec![
("components", "#/components"),
- ("docs", "#/docs/colors"),
+ ("docs", "#/docs/getting-started"),
("examples", "#/examples"),
]))
.child(footer_column("project", vec![
diff --git a/examples/webpage/src/pages/components_page.rs b/examples/webpage/src/pages/components_page.rs
index e4885cb..c4f9eaf 100644
--- a/examples/webpage/src/pages/components_page.rs
+++ b/examples/webpage/src/pages/components_page.rs
@@ -117,6 +117,7 @@ fn section_card(title: &str, description: &str, demo: Dom, source: Dom) -> Dom {
card!({
.scheme(ColorScheme::Void)
+ .apply(crate::fx::spotlight)
.apply(move |b| {
dwclass!(b, "p-6 flex flex-col gap-4")
.child(heading!({
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 a8a31bd..94a50a7 100644
--- a/examples/webpage/src/pages/docs/doc_pages/doc_page.rs
+++ b/examples/webpage/src/pages/docs/doc_pages/doc_page.rs
@@ -2,26 +2,40 @@ use dominator::Dom;
use dwind::prelude::*;
use dwind_macros::dwclass;
+/// The masthead every documentation page opens with.
pub fn doc_page_title(title: &str) -> Dom {
- html!("div", {
- .dwclass!("flex flex-col gap-1 m-t-8")
+ html!("header", {
+ .dwclass!("flex flex-col gap-3 m-t-2 m-b-2 p-b-6 border-b border-woodsmoke-800")
.child(html!("div", {
.class("font-code")
- .dwclass!("text-candlelight-400 text-sm")
- .text("// docs")
+ .dwclass!("flex flex-row align-items-center gap-2 text-xs text-woodsmoke-500")
+ .child(html!("span", { .text("docs") }))
+ .child(html!("span", { .dwclass!("text-woodsmoke-700") .text("/") }))
+ .child(html!("span", {
+ .dwclass!("text-candlelight-400")
+ .text(&title.to_lowercase())
+ }))
}))
.child(html!("h1", {
.class("font-display")
- .dwclass!("text-3xl font-bold text-woodsmoke-50 m-0")
+ .dwclass!("@sm:text-5xl @ Dom {
html!("h2", {
.class("font-display")
- .dwclass!("text-xl font-bold text-woodsmoke-100 m-t-10 m-b-2")
- .text(title)
+ .dwclass!("flex flex-row align-items-center gap-3 text-xl font-bold text-woodsmoke-100 m-t-12 m-b-2")
+ .child(html!("span", {
+ .attr("aria-hidden", "true")
+ .dwclass!("w-1 h-6 rounded-full flex-none")
+ .style("background", "linear-gradient(180deg, #D5B65F, rgba(213, 182, 95, 0))")
+ }))
+ .child(dominator::text(title))
})
}
diff --git a/examples/webpage/src/pages/docs/doc_sidebar.rs b/examples/webpage/src/pages/docs/doc_sidebar.rs
index 3604d3d..5a0630c 100644
--- a/examples/webpage/src/pages/docs/doc_sidebar.rs
+++ b/examples/webpage/src/pages/docs/doc_sidebar.rs
@@ -74,6 +74,27 @@ where
.map(Some)
}
+/// Opens the shared ⌘K palette. Cheaper than a second search implementation,
+/// and it teaches the shortcut in the place people look for search.
+fn sidebar_search() -> Dom {
+ html!("button", {
+ .attr("type", "button")
+ .class("font-code")
+ .dwclass!("flex flex-row align-items-center justify-between gap-2 w-full cursor-pointer")
+ .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")
+ .style("font-size", "0.72rem")
+ .child(html!("span", { .text("search docs…") }))
+ .child(html!("kbd", {
+ .dwclass!("text-woodsmoke-600 border border-woodsmoke-800 rounded-md p-l-1 p-r-1")
+ .text("⌘K")
+ }))
+ .event(|_: events::Click| crate::palette::global().open())
+ })
+}
+
pub fn doc_sidebar_inline(
doc_sections: Vec,
selected_doc: impl Signal
- + 'static,
@@ -83,7 +104,15 @@ pub fn doc_sidebar_inline(
html!("nav", {
.attr("aria-label", "Documentation")
- .dwclass!("w-44 m-l-0 border-r border-woodsmoke-800 border-solid text-woodsmoke-50 flex-none flex flex-col gap-6 p-t-2")
+ .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")
+ .style("top", "5.5rem")
+ .style("align-self", "flex-start")
+ .style("max-height", "calc(100vh - 8rem)")
+ .style("overflow-y", "auto")
+ .class("dw-scrollbar")
+ .child(sidebar_search())
.children(doc_sections.into_iter().map(clone!(goto => move |section| {
let section_cloned = section.clone();
let selected_index_signal = map_ref! {
diff --git a/examples/webpage/src/pages/docs/example_box.rs b/examples/webpage/src/pages/docs/example_box.rs
index fe08e20..b9643f7 100644
--- a/examples/webpage/src/pages/docs/example_box.rs
+++ b/examples/webpage/src/pages/docs/example_box.rs
@@ -13,7 +13,9 @@ pub fn example_box(child: Dom, resizeable: bool) -> Dom {
let dragging = Mutable::new(false);
html!("div", {
+ .class("dw-glass")
.dwclass!("m-t-6 rounded-lg border border-woodsmoke-800 overflow-hidden w-full")
+ .apply(crate::fx::spotlight)
// header bar
.child(html!("div", {
.dwclass!("flex flex-row justify-between align-items-center h-8 p-l-4 p-r-4 border-b border-woodsmoke-800")
diff --git a/examples/webpage/src/pages/dwind_examples.rs b/examples/webpage/src/pages/dwind_examples.rs
index 93a62de..09c4cf1 100644
--- a/examples/webpage/src/pages/dwind_examples.rs
+++ b/examples/webpage/src/pages/dwind_examples.rs
@@ -1,6 +1,7 @@
//! The dwind utility showcase: every artifact on this page is built from
//! dwind utility classes alone — no dwui components anywhere.
+use crate::fx;
use crate::pages::docs::code_widget::code;
use crate::pages::docs::example_box::example_box;
use crate::reveal::reveal_on_scroll;
@@ -11,10 +12,42 @@ use dwind::prelude::*;
use dwind::width_generator;
use dwind_macros::{dwclass, dwgenerate};
use example_html_highlight_macro::example_html;
+use futures_signals::signal::Mutable;
+
+/// Chapter index for the sticky rail. Ids double as anchor targets.
+const SECTIONS: &[(&str, &str)] = &[
+ ("layout", "Responsive layout"),
+ ("color", "Gradients & palettes"),
+ ("typography", "Type scale"),
+ ("depth", "Glass & depth"),
+ ("motion", "Transitions"),
+ ("selectors", "Variants"),
+ ("composition", "Composition"),
+ ("generators", "Generators"),
+];
pub fn dwind_examples_page() -> Dom {
+ let active = Mutable::new("layout");
+
+ html!("div", {
+ .dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 w-full m-b-20 flex flex-row gap-10")
+ .child(html!("div", {
+ .dwclass!("grow")
+ .style("min-width", "0")
+ .child(examples_body(&active))
+ }))
+ .child(html!("div", {
+ .dwclass!("p-t-20 @) -> Dom {
+ let active = active.clone();
+
html!("div", {
- .dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 w-full m-b-20")
+ .dwclass!("w-full")
.child(html!("div", {
.dwclass!("p-t-10 flex flex-col gap-3")
.apply(reveal_on_scroll)
@@ -44,6 +77,7 @@ pub fn dwind_examples_page() -> Dom {
breakpoint the bar stacks vertically.",
example_box(responsive_navbar(), true),
code(&RESPONSIVE_NAVBAR_EXAMPLE_HTML_MAP),
+ &active,
))
.child(section(
@@ -53,6 +87,7 @@ pub fn dwind_examples_page() -> Dom {
composes them at any rotation.",
example_box(gradient_showcase(), false),
code(&GRADIENT_SHOWCASE_EXAMPLE_HTML_MAP),
+ &active,
))
.child(section(
@@ -61,6 +96,7 @@ pub fn dwind_examples_page() -> Dom {
"Sizes, weights, leading, and font family utilities — from captions to display type.",
example_box(typography_specimen(), false),
code(&TYPOGRAPHY_SPECIMEN_EXAMPLE_HTML_MAP),
+ &active,
))
.child(section(
@@ -70,6 +106,7 @@ pub fn dwind_examples_page() -> Dom {
the utility vocabulary.",
example_box(glass_panel(), false),
code(&GLASS_PANEL_EXAMPLE_HTML_MAP),
+ &active,
))
.child(section(
@@ -79,6 +116,7 @@ pub fn dwind_examples_page() -> Dom {
pure CSS. The bottom row shows the built-in keyframe animations.",
example_box(motion_playground(), false),
code(&MOTION_PLAYGROUND_EXAMPLE_HTML_MAP),
+ &active,
))
.child(section(
@@ -88,6 +126,7 @@ pub fn dwind_examples_page() -> Dom {
hover states on specific descendants, all from the parent element.",
example_box(variant_zebra_list(), false),
code(&VARIANT_ZEBRA_LIST_EXAMPLE_HTML_MAP),
+ &active,
))
.child(section(
@@ -97,6 +136,7 @@ pub fn dwind_examples_page() -> Dom {
rings, and hover transforms.",
example_box(pricing_card(), false),
code(&PRICING_CARD_EXAMPLE_HTML_MAP),
+ &active,
))
.child(section(
@@ -106,14 +146,25 @@ pub fn dwind_examples_page() -> Dom {
utilities from parameterized generators at compile time.",
example_box(generator_demo(), false),
code(&GENERATOR_DEMO_EXAMPLE_HTML_MAP),
+ &active,
))
})
}
-fn section(kicker: &str, title: &str, description: &str, preview: Dom, source: Dom) -> Dom {
+/// `kicker` doubles as the section's anchor id and its scroll-spy key.
+fn section(
+ kicker: &'static str,
+ title: &str,
+ description: &str,
+ preview: Dom,
+ source: Dom,
+ active: &Mutable<&'static str>,
+) -> Dom {
html!("section", {
+ .attr("id", kicker)
.dwclass!("m-t-14 flex flex-col")
.apply(reveal_on_scroll)
+ .apply(fx::scroll_spy(kicker, active.clone()))
.child(html!("div", {
.class("font-code")
.dwclass!("text-candlelight-400 text-xs")
diff --git a/examples/webpage/src/pages/home.rs b/examples/webpage/src/pages/home.rs
index 4650ffd..a5f2f6e 100644
--- a/examples/webpage/src/pages/home.rs
+++ b/examples/webpage/src/pages/home.rs
@@ -1,3 +1,6 @@
+use crate::fx::{self, kinetic_headline, magnetic, spotlight, spotlight_tilt};
+use crate::pages::signal_lab::signal_lab;
+use crate::reveal::reveal_on_scroll;
use dominator::routing::go_to_url;
use dominator::{events, text, Dom};
use dwind::prelude::*;
@@ -9,7 +12,8 @@ pub fn home_page() -> Dom {
html!("div", {
.dwclass!("w-full")
.child(hero())
- .child(stats_strip())
+ .child(utility_ticker())
+ .child(signal_lab())
.child(bento_features())
.child(components_preview())
.child(final_cta())
@@ -24,72 +28,57 @@ fn hero() -> Dom {
html!("section", {
.dwclass!("w-full overflow-hidden")
.style("position", "relative")
- // amber glow
+ .child(fx::blueprint_grid(
+ "radial-gradient(ellipse 90% 70% at 50% 0%, black 30%, transparent 75%)",
+ ))
.child(html!("div", {
- .attr("aria-hidden", "true")
- .style("position", "absolute")
- .style("top", "-20%")
- .style("right", "-10%")
- .style("width", "60rem")
- .style("height", "60rem")
- .style("background", "radial-gradient(circle, rgba(213, 182, 95, 0.14) 0%, rgba(213, 182, 95, 0.05) 35%, transparent 65%)")
- .style("pointer-events", "none")
- .style("animation", "dwind-glow-drift 14s ease-in-out infinite")
- }))
- // engineering grid texture
- .child(html!("div", {
- .attr("aria-hidden", "true")
- .style("position", "absolute")
- .style("inset", "0")
- .style("background-image", "linear-gradient(rgba(125, 125, 135, 0.07) 1px, transparent 1px), linear-gradient(90deg, rgba(125, 125, 135, 0.07) 1px, transparent 1px)")
- .style("background-size", "44px 44px")
- .style("mask-image", "radial-gradient(ellipse 90% 70% at 50% 0%, black 30%, transparent 75%)")
- .style("pointer-events", "none")
- }))
- .child(html!("div", {
- .dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 p-t-20 p-b-16")
- .dwclass!("flex @lg:flex-row @ Dom {
}))
.child(html!("code", {
.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")
+ .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")
.text("> cargo add dwind dwui")
}))
}))
@@ -115,12 +105,12 @@ fn hero() -> Dom {
fn code_card() -> Dom {
html!("div", {
.dwclass!("flex flex-col flex-none w-full")
- .style("max-width", "32rem")
- .style("animation", "dwind-fade-up 700ms ease-out")
+ .style("max-width", "30rem")
+ .style("animation", "dwind-fade-up 900ms 200ms ease-out both")
.child(html!("div", {
+ .class("dw-glass")
.dwclass!("rounded-lg border border-woodsmoke-800 overflow-hidden shadow-2xl")
- .style("background", "rgba(10, 10, 12, 0.85)")
- .style("backdrop-filter", "blur(6px)")
+ .apply(spotlight_tilt(4.0))
// window chrome
.child(html!("div", {
.dwclass!("flex flex-row align-items-center gap-2 p-l-4 p-r-4 h-10 border-b border-woodsmoke-800")
@@ -171,7 +161,7 @@ fn window_dot(color: &str) -> Dom {
})
}
-fn code_line(parts: Vec<(&str, &str)>) -> Dom {
+pub fn code_line(parts: Vec<(&str, &str)>) -> Dom {
html!("div", {
.children(parts.into_iter().map(|(content, kind)| {
html!("span", {
@@ -190,38 +180,33 @@ fn code_line(parts: Vec<(&str, &str)>) -> Dom {
}
// ---------------------------------------------------------------------------
-// Stats strip
+// Utility ticker
// ---------------------------------------------------------------------------
-fn stats_strip() -> Dom {
+/// A slow drift of real, compiler-checked class names. Every one of these
+/// resolves to a Rust constant at build time.
+fn utility_ticker() -> Dom {
html!("section", {
- .dwclass!("border-t border-b border-woodsmoke-800 w-full")
- .style("background", "rgba(18, 18, 21, 0.5)")
- .child(html!("div", {
- .dwclass!("m-x-auto max-w-6xl grid @sm:grid-cols-4 @ Dom {
- html!("div", {
- .dwclass!("flex flex-col gap-1 p-6 align-items-center")
- .child(html!("div", {
- .class("font-display")
- .dwclass!("text-3xl font-bold text-candlelight-300")
- .text(value)
- }))
- .child(html!("div", {
- .class("font-code")
- .dwclass!("text-xs text-woodsmoke-400")
- .text(label)
- }))
+ .dwclass!("border-t border-b border-woodsmoke-800 w-full p-t-6 p-b-6 m-t-8")
+ .style("background", "rgba(10, 10, 12, 0.45)")
+ .child(fx::marquee(&[
+ "flex flex-col gap-4",
+ "@sm:grid-cols-3",
+ "hover:bg-candlelight-400",
+ "rounded-full",
+ "text-picton-blue-300",
+ "linear-gradient-45",
+ "backdrop-blur-md",
+ "@ Dom {
fn bento_features() -> Dom {
html!("section", {
.dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 p-t-20 w-full")
+ .apply(reveal_on_scroll)
.child(section_header("features", "Everything the compiler can prove"))
.child(html!("div", {
.dwclass!("grid grid-cols-6 gap-4 m-t-8")
@@ -246,7 +232,7 @@ fn bento_features() -> Dom {
})
}
-fn section_header(kicker: &str, title: &str) -> Dom {
+pub fn section_header(kicker: &str, title: &str) -> Dom {
html!("div", {
.dwclass!("flex flex-col gap-2")
.child(html!("div", {
@@ -257,6 +243,7 @@ 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", {
- .dwclass!("rounded-lg border border-woodsmoke-800 p-6 flex flex-col gap-3 transition-all")
+ .class("dw-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))
.apply(move |b| {
if span_large {
dwclass!(b, "@md:col-span-4 @) -> Dom {
dwclass!(b, "@md:col-span-2 @ Dom {
tile_text("Components read CSS variables — swap palettes at runtime, light and dark included."),
html!("div", {
.dwclass!("flex flex-row gap-2 m-t-2")
- .children(["#D5B65F", "#5FB0D5", "#75D55F", "#D55FA8", "#BD4C4C"].map(|c| {
+ .children(["#D5B65F", "#5FB0D5", "#75D55F", "#D55FA8", "#BD4C4C"].into_iter().enumerate().map(|(i, c)| {
html!("span", {
- .dwclass!("w-6 h-6 rounded-full border border-woodsmoke-700")
+ .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))
})
}))
}),
@@ -394,6 +384,7 @@ fn components_preview() -> Dom {
html!("section", {
.dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 p-t-20 w-full")
+ .apply(reveal_on_scroll)
.child(section_header("components", "A component library that ships with the stack"))
.child(html!("p", {
.dwclass!("text-woodsmoke-400 m-t-4 m-b-8")
@@ -458,6 +449,7 @@ fn components_preview() -> Dom {
.dwclass!("flex justify-center m-t-8")
.child(html!("div", {
.dwclass!("w-64")
+ .apply(magnetic(6.0))
.child(button!({
.button_type(ButtonType::Border)
.content(Some(text("Browse all components →")))
@@ -472,8 +464,9 @@ fn components_preview() -> Dom {
fn preview_card(label: &str, children: Vec) -> Dom {
html!("div", {
- .dwclass!("rounded-lg border border-woodsmoke-800 p-6 flex flex-col gap-5 transition-all hover:border-candlelight-700")
- .style("background", "rgba(18, 18, 21, 0.55)")
+ .class("dw-glass")
+ .dwclass!("rounded-lg border border-woodsmoke-800 p-6 flex flex-col gap-5 hover:border-candlelight-700")
+ .apply(spotlight)
.child(html!("div", {
.class("font-code")
.dwclass!("text-xs text-woodsmoke-500")
@@ -490,15 +483,17 @@ fn preview_card(label: &str, children: Vec) -> Dom {
fn final_cta() -> Dom {
html!("section", {
.dwclass!("w-full p-t-20")
+ .style("position", "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 {
.dwclass!("flex @sm:flex-row @ Dom {
}))
}))
}))
+ .child(html!("div", {
+ .class("font-code")
+ .dwclass!("text-xs text-woodsmoke-600 m-t-4 flex flex-row gap-2 align-items-center")
+ .child(html!("kbd", {
+ .dwclass!("border border-woodsmoke-800 rounded-md p-l-2 p-r-2 p-t-1 p-b-1")
+ .text("⌘K")
+ }))
+ .child(html!("span", { .text("opens the command palette from anywhere") }))
+ }))
}))
})
}
diff --git a/examples/webpage/src/pages/mod.rs b/examples/webpage/src/pages/mod.rs
index b576b06..68c8ce2 100644
--- a/examples/webpage/src/pages/mod.rs
+++ b/examples/webpage/src/pages/mod.rs
@@ -3,3 +3,4 @@ pub mod docs;
pub mod dwind_examples;
pub mod dwui;
pub mod home;
+pub mod signal_lab;
diff --git a/examples/webpage/src/pages/signal_lab.rs b/examples/webpage/src/pages/signal_lab.rs
new file mode 100644
index 0000000..e6831f2
--- /dev/null
+++ b/examples/webpage/src/pages/signal_lab.rs
@@ -0,0 +1,260 @@
+//! The reactivity lab.
+//!
+//! Three `Mutable`s drive eleven live CSS properties across nine nodes, and the
+//! Rust that would produce them is regenerated as you drag. This is the whole
+//! pitch in one panel: there is no virtual DOM pass, no re-render, no diff —
+//! a slider writes a number and DOMINATOR writes exactly the properties that
+//! depend on it.
+
+use crate::fx;
+use dominator::{html, Dom};
+use dwind::prelude::*;
+use dwind_macros::dwclass;
+use dwui::prelude::*;
+use futures_signals::map_ref;
+use futures_signals::signal::{Mutable, SignalExt};
+
+pub fn signal_lab() -> Dom {
+ // The entire state of the demo. Everything below is derived.
+ let spread = Mutable::new(58.0f32);
+ let twist = Mutable::new(34.0f32);
+ let glow = Mutable::new(46.0f32);
+ let stacked = Mutable::new(true);
+
+ // A cheap honesty check: bumped once per property write, so the counter in
+ // the corner is a real measurement rather than a marketing number.
+ let writes = Mutable::new(0u32);
+
+ html!("section", {
+ .dwclass!("m-x-auto max-w-6xl p-l-4 p-r-4 p-t-20 w-full")
+ .apply(crate::reveal::reveal_on_scroll)
+ .child(super::home::section_header(
+ "reactivity",
+ "Move a slider. Watch the DOM barely flinch.",
+ ))
+ .child(html!("div", {
+ .dwclass!("grid @md:grid-cols-5 @,
+ twist: &Mutable,
+ glow: &Mutable,
+ stacked: &Mutable,
+ writes: &Mutable,
+) -> Dom {
+ const CARD_TINTS: [&str; 5] = ["#D5B65F", "#D59A5F", "#5FB0D5", "#8F5FD5", "#5FD59A"];
+
+ html!("div", {
+ .class("dw-glass")
+ .dwclass!("rounded-lg border border-woodsmoke-800 overflow-hidden")
+ .style("position", "relative")
+ .style("min-height", "22rem")
+ .child(fx::blueprint_grid("radial-gradient(ellipse 80% 80% at 50% 50%, black 20%, transparent 78%)"))
+ // the fan of cards
+ .child(html!("div", {
+ .dwclass!("flex flex-row justify-center align-items-center w-full")
+ .style("position", "relative")
+ .style("height", "22rem")
+ .style("perspective", "1200px")
+ .children((0..5).map(|i| {
+ fan_card(i, CARD_TINTS[i], spread, twist, glow, stacked, writes)
+ }))
+ }))
+ .child(write_counter(writes))
+ })
+}
+
+#[allow(clippy::too_many_arguments)]
+fn fan_card(
+ index: usize,
+ tint: &'static str,
+ spread: &Mutable,
+ twist: &Mutable,
+ glow: &Mutable,
+ stacked: &Mutable,
+ writes: &Mutable,
+) -> Dom {
+ // -2 .. 2, so the fan opens symmetrically around the middle card.
+ let offset = index as f32 - 2.0;
+
+ let transform = map_ref! {
+ let spread = spread.signal(),
+ let twist = twist.signal(),
+ let stacked = stacked.signal() => move {
+ let fan = if *stacked { 1.0 } else { 0.15 };
+
+ format!(
+ "translateX({:.1}px) translateY({:.1}px) rotate({:.2}deg) rotateY({:.2}deg) scale({:.3})",
+ offset * spread * fan,
+ offset.abs() * spread * 0.16 * fan,
+ offset * twist * 0.22 * fan,
+ offset * twist * 0.5 * fan,
+ 1.0 - offset.abs() * 0.035,
+ )
+ }
+ };
+
+ html!("div", {
+ .dwclass!("rounded-lg border border-woodsmoke-700 flex flex-col justify-between p-4")
+ .style("position", "absolute")
+ .style("width", "9.5rem")
+ .style("height", "13rem")
+ .style("background", "linear-gradient(160deg, rgba(30, 30, 36, 0.96), rgba(10, 10, 13, 0.96))")
+ .style("transition", "transform 260ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 260ms ease")
+ .style("z-index", &(10 - index.abs_diff(2)).to_string())
+ .style_signal("transform", {
+ let writes = writes.clone();
+
+ transform.map(move |t| {
+ writes.set(writes.get().wrapping_add(1));
+ t
+ })
+ })
+ .style_signal("box-shadow", {
+ let writes = writes.clone();
+ let tint = tint.to_string();
+
+ glow.signal().map(move |g| {
+ writes.set(writes.get().wrapping_add(1));
+ format!("0 0 {:.0}px -4px {}{:02X}", g * 0.9, tint, ((g / 100.0) * 190.0) as u8)
+ })
+ })
+ .style_signal("border-color", glow.signal().map(move |g| {
+ format!("{}{:02X}", tint, ((g / 100.0) * 150.0) as u8 + 30)
+ }))
+ .child(html!("div", {
+ .class("font-code")
+ .dwclass!("text-xs")
+ .style("color", tint)
+ .text(&format!("node[{index}]"))
+ }))
+ .child(html!("div", {
+ .dwclass!("flex flex-col gap-2")
+ .child(html!("div", {
+ .dwclass!("h-1 rounded-full")
+ .style("background", tint)
+ .style("opacity", "0.55")
+ .style_signal("width", spread.signal().map(move |s| {
+ format!("{:.0}%", 30.0 + (s * 0.6).min(65.0))
+ }))
+ }))
+ .child(html!("div", {
+ .dwclass!("h-1 rounded-full w-p-40 bg-woodsmoke-700")
+ }))
+ }))
+ })
+}
+
+fn write_counter(writes: &Mutable) -> Dom {
+ html!("div", {
+ .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")
+ .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")
+ }))
+ .child(html!("span", {
+ .dwclass!("text-woodsmoke-400")
+ .text_signal(writes.signal().map(|n| format!("{n} property writes")))
+ }))
+ .child(html!("span", { .text("· 0 re-renders") }))
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Controls
+// ---------------------------------------------------------------------------
+
+fn controls(
+ spread: &Mutable,
+ twist: &Mutable,
+ glow: &Mutable,
+ stacked: &Mutable,
+) -> Dom {
+ let stacked = stacked.clone();
+
+ html!("div", {
+ .class("dw-glass")
+ .dwclass!("rounded-lg border border-woodsmoke-800 p-6 flex flex-col gap-5")
+ .apply(fx::spotlight)
+ .child(html!("div", {
+ .class("font-code")
+ .dwclass!("text-xs text-woodsmoke-500")
+ .text("// Mutable × 3, Mutable × 1")
+ }))
+ .child(slider!({
+ .value(spread.clone())
+ .label("spread".to_string())
+ }))
+ .child(slider!({
+ .value(twist.clone())
+ .label("twist".to_string())
+ }))
+ .child(slider!({
+ .value(glow.clone())
+ .label("glow".to_string())
+ }))
+ .child(switch!({
+ .checked_signal(stacked.signal())
+ .label("fan out".to_string())
+ .on_change(clone!(stacked => move |v| stacked.set(v)))
+ }))
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Live source view
+// ---------------------------------------------------------------------------
+
+/// The code panel is itself a signal — it re-derives from the same three
+/// `Mutable`s the stage reads, so what you see is what is running.
+fn generated_code(spread: &Mutable, twist: &Mutable, glow: &Mutable) -> Dom {
+ let values = map_ref! {
+ let spread = spread.signal(),
+ let twist = twist.signal(),
+ let glow = glow.signal() => (*spread, *twist, *glow)
+ };
+
+ html!("div", {
+ .dwclass!("rounded-lg border border-woodsmoke-800 overflow-hidden")
+ .style("background", "rgba(6, 6, 8, 0.8)")
+ .child(html!("div", {
+ .class("font-code")
+ .dwclass!("text-xs text-woodsmoke-500 p-l-4 p-r-4 p-t-2 p-b-2 border-b border-woodsmoke-800")
+ .text("stage.rs — live")
+ }))
+ .child(html!("pre", {
+ .class("font-code")
+ .dwclass!("text-xs m-0 p-4 leading-relaxed overflow-x-auto text-woodsmoke-400")
+ .text_signal(values.map(|(spread, twist, glow)| {
+ format!(
+ ".style_signal(\"transform\", spread.signal().map(|s| {{\n \
+ format!(\"translateX({{:.1}}px) rotate({{:.2}}deg)\", i * s, i * t)\n\
+ }}))\n\n\
+ // current: spread = {spread:.0}, twist = {twist:.0}, glow = {glow:.0}"
+ )
+ }))
+ }))
+ })
+}
diff --git a/examples/webpage/src/palette.rs b/examples/webpage/src/palette.rs
new file mode 100644
index 0000000..e0a90b4
--- /dev/null
+++ b/examples/webpage/src/palette.rs
@@ -0,0 +1,451 @@
+//! ⌘K command palette.
+//!
+//! A fuzzy launcher over every route in the site. Worth reading as a DOMINATOR
+//! sample: open/query/cursor are three `Mutable`s, the result list is a
+//! `child_signal` over a `map_ref!` of two of them, and keyboard handling is a
+//! single `global_event_preventable`. No component framework, no store, no
+//! effect hooks.
+
+use crate::pages::docs::{doc_sections, DocPage};
+use dominator::routing::go_to_url;
+use dominator::{events, html, Dom, DomBuilder, EventOptions};
+use dwind::prelude::*;
+use dwind_macros::dwclass;
+use futures_signals::map_ref;
+use futures_signals::signal::{Mutable, SignalExt};
+use once_cell::sync::Lazy;
+use web_sys::{window, HtmlElement};
+
+#[derive(Clone)]
+pub struct Command {
+ pub label: String,
+ pub group: String,
+ pub target: Target,
+}
+
+#[derive(Clone)]
+pub enum Target {
+ Route(&'static str),
+ Page(DocPage),
+ External(&'static str),
+}
+
+impl Target {
+ fn run(&self) {
+ match self {
+ Target::Route(url) => go_to_url(url),
+ Target::Page(page) => page.goto(),
+ Target::External(url) => {
+ let _ = window().unwrap().open_with_url_and_target(url, "_blank");
+ }
+ }
+ }
+
+ fn glyph(&self) -> &'static str {
+ match self {
+ Target::External(_) => "↗",
+ _ => "→",
+ }
+ }
+}
+
+fn commands() -> Vec {
+ let mut out = vec![
+ Command {
+ label: "Home".into(),
+ group: "go".into(),
+ target: Target::Route("#/"),
+ },
+ Command {
+ label: "Component gallery".into(),
+ group: "go".into(),
+ target: Target::Route("#/components"),
+ },
+ Command {
+ label: "Live examples".into(),
+ group: "go".into(),
+ target: Target::Route("#/examples"),
+ },
+ ];
+
+ for section in doc_sections() {
+ for page in section.docs {
+ out.push(Command {
+ label: page.to_string(),
+ group: format!("docs / {}", section.title.to_lowercase()),
+ target: Target::Page(page),
+ });
+ }
+ }
+
+ out.push(Command {
+ label: "GitHub repository".into(),
+ group: "external".into(),
+ target: Target::External("https://github.com/JedimEmO/dwind"),
+ });
+ out.push(Command {
+ label: "dwind on crates.io".into(),
+ group: "external".into(),
+ target: Target::External("https://crates.io/crates/dwind"),
+ });
+ out.push(Command {
+ label: "DOMINATOR framework".into(),
+ group: "external".into(),
+ target: Target::External("https://github.com/Pauan/rust-dominator"),
+ });
+
+ out
+}
+
+static COMMANDS: Lazy> = Lazy::new(commands);
+
+/// Subsequence match with a bonus for consecutive and word-start hits — enough
+/// fuzziness to feel modern without pulling in a matcher crate.
+fn score(haystack: &str, needle: &str) -> Option {
+ if needle.is_empty() {
+ return Some(0);
+ }
+
+ let hay: Vec = haystack.to_lowercase().chars().collect();
+ let lowered = needle.to_lowercase();
+ let mut needle = lowered.chars().peekable();
+ let mut total = 0;
+ let mut streak = 0;
+
+ for (i, c) in hay.iter().enumerate() {
+ match needle.peek() {
+ Some(n) if n == c => {
+ let word_start = i == 0 || !hay[i - 1].is_alphanumeric();
+ total += 10 + streak * 5 + if word_start { 12 } else { 0 };
+ streak += 1;
+ needle.next();
+ }
+ Some(_) => streak = 0,
+ None => break,
+ }
+ }
+
+ if needle.peek().is_none() {
+ Some(total)
+ } else {
+ None
+ }
+}
+
+fn matches(query: &str) -> Vec<&'static Command> {
+ let mut scored: Vec<(i32, &Command)> = COMMANDS
+ .iter()
+ .filter_map(|c| {
+ let direct = score(&c.label, query);
+ let grouped = score(&format!("{} {}", c.group, c.label), query).map(|s| s - 30);
+
+ direct.or(grouped).map(|s| (s, c))
+ })
+ .collect();
+
+ // Best score first; ties keep declaration order, which puts routes above
+ // external links.
+ scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
+ scored.into_iter().map(|(_, c)| c).collect()
+}
+
+#[derive(Clone)]
+pub struct Palette {
+ open: Mutable,
+ query: Mutable,
+ cursor: Mutable,
+}
+
+impl Default for Palette {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+thread_local! {
+ static GLOBAL: Palette = Palette::new();
+}
+
+/// The app has exactly one palette; anything that wants to open it (the header
+/// button, the docs sidebar, a keyboard shortcut) shares this handle.
+pub fn global() -> Palette {
+ GLOBAL.with(|p| p.clone())
+}
+
+impl Palette {
+ pub fn new() -> Self {
+ Self {
+ open: Mutable::new(false),
+ query: Mutable::new(String::new()),
+ cursor: Mutable::new(0),
+ }
+ }
+
+ pub fn open(&self) {
+ self.query.set(String::new());
+ self.cursor.set(0);
+ self.open.set(true);
+ }
+
+ fn close(&self) {
+ self.open.set(false);
+ }
+
+ fn commit(&self) {
+ let results = matches(&self.query.lock_ref());
+
+ if let Some(cmd) = results.get(self.cursor.get()) {
+ cmd.target.run();
+ }
+
+ self.close();
+ }
+
+ fn move_cursor(&self, delta: i32) {
+ let len = matches(&self.query.lock_ref()).len();
+
+ if len == 0 {
+ return;
+ }
+
+ let next = (self.cursor.get() as i32 + delta).rem_euclid(len as i32);
+ self.cursor.set_neq(next as usize);
+ }
+
+ /// Installs the global shortcut handler. Apply on the app root.
+ pub fn shortcuts(&self) -> impl Fn(DomBuilder) -> DomBuilder + use<> {
+ let this = self.clone();
+
+ move |builder| {
+ let this = this.clone();
+
+ builder.global_event_with_options(
+ &EventOptions::preventable(),
+ move |e: events::KeyDown| {
+ let key = e.key();
+ let open = this.open.get();
+
+ if e.ctrl_key() && key.eq_ignore_ascii_case("k") {
+ e.prevent_default();
+
+ if open {
+ this.close();
+ } else {
+ this.open();
+ }
+
+ return;
+ }
+
+ if !open {
+ // "/" opens search, the way every good docs site does.
+ if key == "/" {
+ e.prevent_default();
+ this.open();
+ }
+
+ return;
+ }
+
+ match key.as_str() {
+ "Escape" => {
+ e.prevent_default();
+ this.close();
+ }
+ "ArrowDown" => {
+ e.prevent_default();
+ this.move_cursor(1);
+ }
+ "ArrowUp" => {
+ e.prevent_default();
+ this.move_cursor(-1);
+ }
+ "Enter" => {
+ e.prevent_default();
+ this.commit();
+ }
+ _ => {}
+ }
+ },
+ )
+ }
+ }
+
+ /// The overlay itself. Renders nothing while closed.
+ pub fn render(&self) -> Dom {
+ let this = self.clone();
+
+ html!("div", {
+ .child_signal(self.open.signal().map(move |open| {
+ if !open {
+ return None;
+ }
+
+ Some(this.panel())
+ }))
+ })
+ }
+
+ fn panel(&self) -> Dom {
+ let this = self.clone();
+
+ html!("div", {
+ .attr("role", "dialog")
+ .attr("aria-modal", "true")
+ .attr("aria-label", "Command palette")
+ .style("position", "fixed")
+ .style("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)")
+ .event({
+ let this = this.clone();
+ move |_: events::Click| this.close()
+ })
+ }))
+ // Centring lives on the wrapper: the panel's entrance keyframes own
+ // `transform`, and a fill-mode animation beats an inline style.
+ .child(html!("div", {
+ .dwclass!("flex justify-center w-full")
+ .style("position", "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)")
+ .child(this.search_row())
+ .child(this.results())
+ .child(this.hint_row())
+ }))
+ }))
+ })
+ }
+
+ fn search_row(&self) -> Dom {
+ let this = self.clone();
+
+ html!("div", {
+ .dwclass!("flex flex-row align-items-center gap-3 p-l-5 p-r-5 h-14 border-b border-woodsmoke-800")
+ .child(html!("span", {
+ .class("font-code")
+ .dwclass!("text-candlelight-400 text-l flex-none")
+ .text("⌘")
+ }))
+ .child(html!("input" => web_sys::HtmlInputElement, {
+ .class("dw-palette-input")
+ .attr("type", "text")
+ .attr("placeholder", "Jump to a page, a utility group, the repo…")
+ .attr("aria-label", "Search")
+ .attr("autocomplete", "off")
+ .focused(true)
+ .with_node!(element => {
+ .event({
+ let this = this.clone();
+ move |_: events::Input| {
+ this.query.set(element.value());
+ this.cursor.set_neq(0);
+ }
+ })
+ })
+ }))
+ .child(html!("kbd", {
+ .class("font-code")
+ .dwclass!("text-xs text-woodsmoke-500 border border-woodsmoke-800 rounded-md p-l-2 p-r-2 p-t-1 p-b-1 flex-none")
+ .text("esc")
+ }))
+ })
+ }
+
+ fn results(&self) -> Dom {
+ let this = self.clone();
+
+ html!("div", {
+ .class("dw-scrollbar")
+ .dwclass!("flex flex-col p-2 overflow-y-auto")
+ .style("max-height", "min(24rem, 50vh)")
+ .child_signal(map_ref! {
+ let query = self.query.signal_cloned(),
+ let cursor = self.cursor.signal() => move {
+ let results = matches(query);
+ let cursor = *cursor;
+
+ if results.is_empty() {
+ Some(html!("div", {
+ .class("font-code")
+ .dwclass!("text-sm text-woodsmoke-500 p-6 text-center")
+ .text("// no matches")
+ }))
+ } else {
+ Some(html!("div", {
+ .dwclass!("flex flex-col gap-1")
+ .children(results.into_iter().enumerate().map(|(i, cmd)| {
+ this.result_row(cmd, i, i == cursor)
+ }))
+ }))
+ }
+ }
+ })
+ })
+ }
+
+ fn result_row(&self, cmd: &'static Command, index: usize, active: bool) -> Dom {
+ let this = self.clone();
+
+ html!("button", {
+ .attr("type", "button")
+ .dwclass!("flex flex-row align-items-center gap-3 w-full text-left")
+ .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")
+ .child(html!("span", {
+ .class("font-code")
+ .dwclass!("text-xs flex-none w-4")
+ .apply(move |b| if active {
+ dwclass!(b, "text-candlelight-400")
+ } else {
+ dwclass!(b, "text-woodsmoke-600")
+ })
+ .text(cmd.target.glyph())
+ }))
+ .child(html!("span", {
+ .dwclass!("text-sm grow")
+ .apply(move |b| if active {
+ dwclass!(b, "text-woodsmoke-50")
+ } else {
+ dwclass!(b, "text-woodsmoke-300")
+ })
+ .text(&cmd.label)
+ }))
+ .child(html!("span", {
+ .class("font-code")
+ .dwclass!("text-xs text-woodsmoke-600 flex-none")
+ .text(&cmd.group)
+ }))
+ .event({
+ let this = this.clone();
+ move |_: events::PointerEnter| this.cursor.set_neq(index)
+ })
+ .event(move |_: events::Click| {
+ cmd.target.run();
+ this.close();
+ })
+ })
+ }
+
+ fn hint_row(&self) -> Dom {
+ html!("div", {
+ .class("font-code")
+ .dwclass!("flex flex-row gap-4 p-l-5 p-r-5 p-t-2 p-b-2 border-t border-woodsmoke-800 text-xs text-woodsmoke-600")
+ .child(html!("span", { .text("↑↓ navigate") }))
+ .child(html!("span", { .text("⏎ open") }))
+ .child(html!("span", { .text("esc dismiss") }))
+ })
+ }
+}
diff --git a/examples/webpage/src/styles.rs b/examples/webpage/src/styles.rs
new file mode 100644
index 0000000..88b7aef
--- /dev/null
+++ b/examples/webpage/src/styles.rs
@@ -0,0 +1,297 @@
+//! 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; }
+}
+"#;