Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
119 changes: 60 additions & 59 deletions christmas/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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));
}
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand Down
106 changes: 96 additions & 10 deletions christmas/src/components/cycle_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> = (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]
Expand Down Expand Up @@ -377,6 +446,7 @@ pub fn RevealRing(cycle: Vec<String>, revealed: usize, letter: Option<char>, 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 {
Expand All @@ -390,10 +460,15 @@ pub fn RevealRing(cycle: Vec<String>, revealed: usize, letter: Option<char>, 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! {
Expand All @@ -402,15 +477,26 @@ pub fn RevealRing(cycle: Vec<String>, revealed: usize, letter: Option<char>, 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}"
}
}
}
}
Expand Down
Loading
Loading