From 0f996e9996ec497294028b9830d7311013507d64 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Jul 2026 13:49:09 +0200 Subject: [PATCH] Rebuild the example site as a bleeding-edge dwind/dominator showcase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs site was a competent dark landing page, but it demonstrated almost nothing that dominator can do beyond static markup. This turns it into an argument for the stack. Every effect added here is a Mutable written by an event and read by a style_signal — no animation library, no rAF loop, no re-render: - fx.rs: pointer spotlight and 3D tilt, magnetic controls, drifting aurora field, film grain, blueprint grid, scroll-spy, marquee, and word-stagger kinetic headlines. - palette.rs: a ⌘K command palette with fuzzy search over every route, word-start scoring, and full keyboard control. Reachable from the header chip, the docs sidebar, ⌘K/Ctrl+K, or "/". - pages/signal_lab.rs: the new home-page centrepiece. Four Mutables fan a card stack while a live code panel regenerates the Rust behind it, next to a counter that measures actual property writes against zero re-renders. - styles.rs: app-level keyframes, glass surfaces, grain and masks, pulled out of the old inline APP_KEYFRAMES const. The shell gains a scroll-progress rail, route transitions with scroll reset, and an active-route indicator that grows in. Docs gain a sticky glass sidebar with a search affordance, breadcrumb mastheads, gradient section rules, and prev/next pagers; the utility showcase gains a scroll-spy chapter rail. Two real bugs fixed on the way: - The hero used @lg:, which is 1920px in dwind, so it stacked on every normal desktop. It wants @md:. - The palette's entrance animation used fill-mode "both", which silently overrode the inline transform centring it. Centring moved to a wrapper node. Verified in Chrome: all 19 routes render with no console errors, no horizontal overflow at 1440px or 390px, prefers-reduced-motion honoured, and a slider drag in the lab measured 10 -> 75 property writes. Co-Authored-By: Claude Opus 5 (1M context) --- examples/webpage/README.md | 47 +- examples/webpage/public/index.html | 13 + examples/webpage/src/fx.rs | 386 +++++++++++++++ examples/webpage/src/lib.rs | 274 ++++++++--- examples/webpage/src/pages/components_page.rs | 1 + .../src/pages/docs/doc_pages/doc_page.rs | 28 +- .../webpage/src/pages/docs/doc_sidebar.rs | 31 +- .../webpage/src/pages/docs/example_box.rs | 2 + examples/webpage/src/pages/dwind_examples.rs | 55 ++- examples/webpage/src/pages/home.rs | 168 +++---- examples/webpage/src/pages/mod.rs | 1 + examples/webpage/src/pages/signal_lab.rs | 260 ++++++++++ examples/webpage/src/palette.rs | 451 ++++++++++++++++++ examples/webpage/src/styles.rs | 297 ++++++++++++ 14 files changed, 1850 insertions(+), 164 deletions(-) create mode 100644 examples/webpage/src/fx.rs create mode 100644 examples/webpage/src/pages/signal_lab.rs create mode 100644 examples/webpage/src/palette.rs create mode 100644 examples/webpage/src/styles.rs 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; } +} +"#;