From 81b6ff72a5d24dc235f2342e7ba4f4a455ba8e20 Mon Sep 17 00:00:00 2001 From: Grant Azure Date: Sun, 26 Jul 2026 10:29:05 -0700 Subject: [PATCH] [christmas] more presentation adjustments --- Cargo.lock | 4 +- Cargo.toml | 2 +- christmas/assets/main.css | 119 ++++++++++++------------ christmas/src/components/cycle_graph.rs | 106 +++++++++++++++++++-- christmas/src/components/decor.rs | 56 +++++++++-- christmas/src/pages/reveal.rs | 1 - 6 files changed, 209 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11cebc1..5c7f669 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,9 +253,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" diff --git a/Cargo.toml b/Cargo.toml index e742724..0e3ae39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ keywords = [] license = "MIT or Apache-2.0" [workspace.dependencies] -anyhow = "1.0.102" +anyhow = "1.0.104" axum = "0.8.9" bevy = { version = "0.18.1" } bevy-inspector-egui = "0.36.0" diff --git a/christmas/assets/main.css b/christmas/assets/main.css index e1f5473..f4fe831 100644 --- a/christmas/assets/main.css +++ b/christmas/assets/main.css @@ -74,8 +74,7 @@ body::after { /* -------------------------------------------------------------------------- Snowfall - Three tiled gradients drifting at different speeds — one node per layer - rather than one per flake. + One element per flake, each with its own size, speed, drift and phase. -------------------------------------------------------------------------- */ .snowfall { @@ -88,58 +87,39 @@ body::after { overflow: hidden; } -/* Each layer is oversized by exactly the distance it travels — one tile left, - three tiles down — so nothing uncovers as it moves. */ -.snow-layer { +.flake { position: absolute; - top: calc(-3 * var(--tile)); - bottom: 0; - left: 0; - right: calc(-1 * var(--tile)); - background-repeat: repeat; - animation: snow-drift linear infinite; - will-change: transform; -} - -.snow-far { - --tile: 220px; - background-image: - radial-gradient(1px 1px at 18% 12%, rgba(233, 239, 236, 0.55), transparent), - radial-gradient(1px 1px at 62% 38%, rgba(233, 239, 236, 0.45), transparent), - radial-gradient(1px 1px at 84% 71%, rgba(233, 239, 236, 0.5), transparent), - radial-gradient(1px 1px at 33% 88%, rgba(233, 239, 236, 0.4), transparent); - background-size: var(--tile) var(--tile); - animation-duration: 26s; -} - -.snow-mid { - --tile: 300px; - background-image: - radial-gradient(1.6px 1.6px at 42% 22%, rgba(233, 239, 236, 0.7), transparent), - radial-gradient(1.6px 1.6px at 8% 57%, rgba(233, 239, 236, 0.6), transparent), - radial-gradient(1.6px 1.6px at 73% 81%, rgba(233, 239, 236, 0.65), transparent); - background-size: var(--tile) var(--tile); - animation-duration: 17s; -} - -.snow-near { - --tile: 420px; - background-image: - radial-gradient(2.4px 2.4px at 27% 34%, rgba(255, 255, 255, 0.8), transparent), - radial-gradient(2.4px 2.4px at 88% 64%, rgba(255, 255, 255, 0.7), transparent); - background-size: var(--tile) var(--tile); - animation-duration: 11s; + top: 0; + border-radius: 50%; + background: #fff; + /* Two animations on one element, and they have to compose rather than + overwrite: `flake-fall` animates the `translate` property while + `flake-sway` animates `transform`. Both on `transform` and only the + last would apply, leaving the snow either falling or swaying, never + both. Per-flake durations and phases arrive inline. */ + animation-name: flake-fall, flake-sway; + animation-timing-function: linear, ease-in-out; + animation-iteration-count: infinite; + /* The sway alternates, so a flake eases to one side and back rather than + snapping across at the end of every cycle. */ + animation-direction: normal, alternate; +} + +@keyframes flake-fall { + from { + translate: 0 -5vh; + } + to { + translate: 0 105vh; + } } -/* Travelling a whole number of tiles is what makes the loop seamless. - Translating the layer also keeps this on the compositor; animating - background-position would repaint every frame. */ -@keyframes snow-drift { +@keyframes flake-sway { from { - transform: translate3d(0, 0, 0); + transform: translateX(calc(-1 * var(--sway))); } to { - transform: translate3d(calc(-1 * var(--tile)), calc(3 * var(--tile)), 0); + transform: translateX(var(--sway)); } } @@ -228,9 +208,15 @@ body::after { } } -/* Still snow and steady bulbs: the decoration stays, the motion goes. */ +/* Settled snow and steady bulbs: the decoration stays, the motion goes. + Each flake keeps a resting height, or they would all pile up at the + ceiling with nothing to translate them down the window. */ @media (prefers-reduced-motion: reduce) { - .snow-layer, + .flake { + animation: none; + translate: 0 var(--rest); + } + .bulb { animation: none; } @@ -1214,7 +1200,8 @@ button.linklike:hover { .reveal { min-height: 100vh; display: grid; - grid-template-rows: auto 1fr auto auto auto; + /* Head, stage, progress pips, controls. The stage takes the slack. */ + grid-template-rows: auto 1fr auto auto; gap: 1rem; padding: 1.25rem var(--gutter) 2rem; outline: none; @@ -1358,9 +1345,28 @@ button.linklike:hover { stroke-width: 1.6; } +/* An empty seat: somewhere for a name to land, not a person yet. */ .reveal-ring .ring-node.waiting { fill: var(--sage-dim); - opacity: 0.5; + opacity: 0.35; +} + +/* A name being said out loud for the first time. */ +.reveal-ring .ring-label.arriving { + transform-box: fill-box; + transform-origin: center; + animation: name-in 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.25) both; +} + +@keyframes name-in { + from { + opacity: 0; + transform: scale(0.55); + } + to { + opacity: 1; + transform: none; + } } .reveal-ring { @@ -1442,12 +1448,6 @@ button.linklike:hover { justify-content: center; } -.reveal-hint { - text-align: center; - font-size: 0.78rem; - margin: 0; -} - .reveal-cta { display: inline-block; margin-top: 0.9rem; @@ -1490,7 +1490,8 @@ button.linklike:hover { .reveal-letter, .reveal-callout, .letter-glyph.huge, - .reveal-ring .ring-edge.newest { + .reveal-ring .ring-edge.newest, + .reveal-ring .ring-label.arriving { animation: none; } } diff --git a/christmas/src/components/cycle_graph.rs b/christmas/src/components/cycle_graph.rs index 7a9e8c1..cb04b8b 100644 --- a/christmas/src/components/cycle_graph.rs +++ b/christmas/src/components/cycle_graph.rs @@ -308,10 +308,79 @@ mod tests { assert_eq!(label_anchor(CENTRE - 100.0), "end"); assert_eq!(label_anchor(CENTRE), "middle"); } + + #[test] + fn nobody_is_named_before_the_reading_starts() { + for i in 0..5 { + assert!(!is_named(i, 0), "position {i} should be empty at the start"); + } + } + + #[test] + fn the_first_beat_names_the_giver_and_their_receiver() { + // A→B→C→D: reading "A gives to B" names exactly A and B. + assert!(is_named(0, 1)); + assert!(is_named(1, 1)); + assert!(!is_named(2, 1)); + assert!(!is_named(3, 1)); + } + + #[test] + fn each_later_beat_names_exactly_one_more_person() { + for revealed in 1..6 { + let named = (0..6).filter(|i| is_named(*i, revealed)).count(); + assert_eq!( + named, + revealed + 1, + "beat {revealed} should have named {} people", + revealed + 1 + ); + } + } + + #[test] + fn the_last_beat_leaves_nobody_hidden() { + // The closing edge points back at position 0, who was named first. + let n = 4; + assert!((0..n).all(|i| is_named(i, n)), "everyone is named once the ring closes"); + } + + #[test] + fn a_name_arrives_on_exactly_one_beat() { + for i in 0..6 { + let arrivals: Vec = (0..8).filter(|r| is_arriving(i, *r)).collect(); + assert_eq!(arrivals.len(), 1, "position {i} arrived on {arrivals:?}"); + // And it arrives on the same beat it first becomes visible. + let first_named = (0..8).find(|r| is_named(i, *r)).expect("named eventually"); + assert_eq!(arrivals[0], first_named); + } + } +} + +/// Whether position `i` of the ring has been named yet, given how much of the +/// draw has been read out. +/// +/// Reading out edge `e` names two people: `cycle[e]` as the giver and +/// `cycle[e + 1]` as the receiver. So after `revealed` edges, positions `0` +/// through `revealed` have all been said out loud, and nobody else has. The +/// first position is a special case only in that it arrives with the very first +/// edge rather than as somebody's receiver. +pub fn is_named(i: usize, revealed: usize) -> bool { + revealed > 0 && i <= revealed +} + +/// Whether position `i` is being named for the first time on this exact beat, +/// so it can be given an entrance rather than simply appearing. +pub fn is_arriving(i: usize, revealed: usize) -> bool { + if i == 0 { revealed == 1 } else { revealed == i } } /// One ring mid-ceremony: edges appear one at a time as the draw is read out. /// +/// Names stay off the board until they are called. Showing the whole roster up +/// front and merely dimming it gave the ending away — you could read who was +/// left and work out the last few pairings before they were announced. +/// /// Separate from [`CycleRing`] because the interaction is different — nothing /// responds to the pointer, and the state is "how much has been revealed". #[component] @@ -377,6 +446,7 @@ pub fn RevealRing(cycle: Vec, revealed: usize, letter: Option, sho let receiving = active.is_some_and(|a| (a + 1) % n == i); // Anyone whose turn has passed stays lit, so the ring fills in. let done = i < revealed; + let named = is_named(i, revealed); let node_class = if giving { "ring-node lit" } else if receiving { @@ -390,10 +460,15 @@ pub fn RevealRing(cycle: Vec, revealed: usize, letter: Option, sho "ring-label lit" } else if receiving { "ring-label receiving" - } else if done { + } else { "ring-label" + }; + // Re-applied on the beat a name arrives, which is what + // restarts the entrance; every later beat drops it again. + let label_class = if is_arriving(i, revealed) { + format!("{label_class} arriving") } else { - "ring-label dimmed" + label_class.to_string() }; let name = cycle[i].clone(); rsx! { @@ -402,15 +477,26 @@ pub fn RevealRing(cycle: Vec, revealed: usize, letter: Option, sho class: "{node_class}", cx: "{nx:.2}", cy: "{ny:.2}", - r: if giving || receiving { "7" } else { "4.5" }, + // An empty seat is a smaller mark than a taken + // one: the ring's shape reads without saying + // who is standing where. + r: if giving || receiving { + "7" + } else if named { + "4.5" + } else { + "3" + }, } - text { - class: "{label_class}", - x: "{lx:.2}", - y: "{ly:.2}", - text_anchor: label_anchor(lx), - dominant_baseline: "middle", - "{name}" + if named { + text { + class: "{label_class}", + x: "{lx:.2}", + y: "{ly:.2}", + text_anchor: label_anchor(lx), + dominant_baseline: "middle", + "{name}" + } } } } diff --git a/christmas/src/components/decor.rs b/christmas/src/components/decor.rs index a846241..c66c681 100644 --- a/christmas/src/components/decor.rs +++ b/christmas/src/components/decor.rs @@ -6,17 +6,61 @@ use dioxus::prelude::*; -/// Three parallax layers of falling snow. +/// How many flakes are in the air. /// -/// Each layer is a tiled radial-gradient rather than one element per flake, so -/// a whole snowstorm is three nodes. +/// Enough to read as weather, few enough that each one can be an individual +/// element — which is what buys the side-to-side flutter. A tiled background +/// is cheaper, but every flake in a tile then sways in lockstep, and the whole +/// window looks like it is rocking rather than the snow drifting. +const FLAKES: usize = 90; + +/// Deterministic pseudo-randomness from a flake's index. +/// +/// Not `fastrand`: this component renders once on the server and again in the +/// browser, and any disagreement between the two is a hydration mismatch. Same +/// index and salt, same number, both times. +fn spread(i: usize, salt: u32, range: u32) -> u32 { + let seed = u32::try_from(i) + .unwrap_or(0) + .wrapping_add(salt) + .wrapping_mul(0x9E37_79B1); + (seed >> 13) % range +} + +/// Snow, falling and fluttering across the whole window. +/// +/// Each flake carries its own size, speed, drift width and phase, so no two +/// take the same path down. The fall and the sway are separate CSS properties +/// (`translate` and `transform`) precisely so both animations can run on one +/// element and compose, rather than the second overwriting the first. #[component] pub fn Snowfall() -> Element { rsx! { div { class: "snowfall", "aria-hidden": "true", - div { class: "snow-layer snow-far" } - div { class: "snow-layer snow-mid" } - div { class: "snow-layer snow-near" } + for i in 0..FLAKES { + { + let size = 1 + spread(i, 11, 3); + let fall = 9 + spread(i, 23, 12); + let sway_secs = 3 + spread(i, 41, 6); + // Bigger flakes read as nearer, so they carry more weight. + let opacity = 35 + size * 15; + let style = format!( + "left:{left}%;width:{size}px;height:{size}px;opacity:{opacity}%;\ + --sway:{sway}px;--rest:{rest}vh;\ + animation-duration:{fall}s,{sway_secs}s;\ + animation-delay:-{fall_offset}s,-{sway_offset}s", + left = spread(i, 3, 1000) / 10, + // Negative delays start the storm mid-flight instead of + // dropping every flake from the ceiling at once. + fall_offset = spread(i, 59, fall), + sway_offset = spread(i, 73, sway_secs), + sway = 7 + spread(i, 97, 26), + // Where a flake sits when motion is switched off. + rest = spread(i, 31, 100), + ); + rsx! { span { key: "flake{i}", class: "flake", style: "{style}" } } + } + } } } } diff --git a/christmas/src/pages/reveal.rs b/christmas/src/pages/reveal.rs index d39e84b..eed5b28 100644 --- a/christmas/src/pages/reveal.rs +++ b/christmas/src/pages/reveal.rs @@ -260,7 +260,6 @@ pub fn Ceremony( "Next →" } } - p { class: "reveal-hint muted", "Space to play or pause · arrow keys to step" } } } }