Skip to content

feat(season): add winter, new year and spring skins - #386

Merged
TheMeinerLP merged 16 commits into
feat/halloween-seasonfrom
feat/more-seasons
Sep 28, 2026
Merged

TheMeinerLP merged 16 commits into
feat/halloween-seasonfrom
feat/more-seasons

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Collaborator

Summary

Extends the seasonal-theming mechanism from #384 with three more seasons, each Berlin-time and non-overlapping:

  • winter: 1 Dec – 26 Dec
  • new-year: 27 Dec – 6 Jan (wraps the year end)
  • spring: 20 Mar – 20 Apr

Each season gets its own decoration component (SeasonDecorWinter/SeasonDecorNewYear/SeasonDecorSpring), dispatched from SeasonDecor.vue alongside the existing Halloween one. A new pure overlappingSeasons() check guards the register against two seasons ever claiming the same calendar day. The colour generator (scripts/md3-tokens.mjs) gains an optional per-season neutralChroma, used for new-year so its dark surface reads midnight-violet instead of near-black. Logos and favicons for all three new seasons are recoloured from the base mark (region + lightness based, no leftover base hues). New year also gets a gentle firework: bursts fade in over ≥1s and never flash more than once per second (WCAG 2.3.1).

Rationale

The Halloween skin (#384) proved the mechanism (register, generator, decoration dispatcher, preview/kill-switch) works for one season; this change makes it season-agnostic and ships three more so the site is dressed for winter, the new year and spring, not just October. Winter in particular must be in production before 1 December 2026.

Depends on

#384 (feat/halloween-season) — this PR is based on it and should be rebased onto main once #384 merges.

How to test

Preview deployment URL: see the Cloudflare Pages check/comment on this PR once it appears.

  • ?season=winter, ?season=new-year, ?season=spring to preview each skin ahead of its window; ?season=none to force it off.
  • Check both light and dark mode for each season.
  • Moving decoration (snowflakes, twinkling lights, fireworks, drifting petals) only appears at ≥768px (md) and only when prefers-reduced-motion is not set to reduce — with reduced motion, nothing should move.
  • Corner motifs (ice crystals, sparkle cluster, blossom branch) are visible on every viewport width, below the navigation bar.

Checks

  • pnpm test: 594/594 passing
  • pnpm quality: ESLint/TypeScript error counts unchanged from quality-baseline.json (no rise)
  • pnpm build: succeeds

Open

  • Visual sign-off and a Lighthouse run per season (?season=winter/new-year/spring) are still pending.
  • Spring's blossoms may need a lighter touch in light mode — worth a look during visual review.

Implements the OpenSpec change add-winter-new-year-spring-seasons.

https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s

Three new seasons join Halloween: winter (1-26 Dec), new-year (27 Dec -
6 Jan, wrapping the year end) and spring (20 Mar - 20 Apr), each with
its own colour seeds in scripts/md3-tokens.mjs and generated block in
assets/css/seasons.css. The register is sorted by calendar start and a
new pure overlappingSeasons() brute-forces a leap year to guarantee no
two windows claim the same day, adjacent windows excepted.

The halloween block in seasons.css stays byte-identical.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
SeasonDecor becomes a dispatcher: it keeps the shared frame
(aria-hidden, pointer-events-none, fixed inset-0 z-30) and renders one
explicitly-imported child per season id. The halloween content moves
unchanged into SeasonDecorHalloween. New SeasonDecorWinter (ice
crystals + up to six falling snowflakes), SeasonDecorNewYear (stars +
up to five twinkling, flash-free light points) and SeasonDecorSpring
(blossom branches + up to four drifting petals) follow the same
motion-safe:md:block opt-in as the halloween bats.

Two new @theme animations back the moving pieces: --animate-season-fall
(translate/rotate with an opacity fade at the cycle's end) and
--animate-season-twinkle (opacity/scale, 4.5s per cycle, nowhere near
the WCAG 2.3.1 flash limit).

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
Each new season gets its own logo and favicon, derived from
public/images/logo.svg the way halloween's are: every fill hue is
remapped from the base brand's primary/secondary/tertiary/brand-orange/
brand-purple seeds to the season's own, saturation and lightness kept,
so the shading survives. The logo also carries one small motif — an ice
crystal for winter, a sparkle for new-year, a blossom for spring — the
favicon carries none. Both are SVGO-optimised and smaller than the
base assets they replace.

navigation-logo.spec.ts now runs over every registered season instead
of hard-coding halloween.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
Replaces the halloween-only colour-effect block in
tests/design-system/seasons.spec.ts with a COLOUR_EFFECT table keyed
by season id, covering the hue ranges from each season's spec. A
completeness check requires a table row for every registered season,
the light-scheme luminance check now runs for all of them, and winter
keeps its own 30°-from-error assertion. Two new tests hold the
--animate-season-* tokens to the >=3s cycle and transform/opacity-only
keyframes that spec seasonal-theming requires.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
AGENTS.md's seasonal-skins section and the NUXT_PUBLIC_SEASON comment
in nuxt.config.ts now list all four seasons and their windows instead
of only halloween.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
season-fall moved a flake by a percentage of its own box (~130% of a
12px element), so it barely drifted. The end state now translates
45vh — viewport-relative, not element-relative — and the start moves
to -2rem so the flake enters from just above the frame. Rotate and the
opacity fade at both ends of the cycle are unchanged, transform/opacity
only, same 24s duration.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
SEASONS now formats as a normal multi-line array instead of the
one-element-per-line-with-no-indent shape ESLint's autofix left
behind. overlappingSeasons computes everyDayOfTheYear() once per call
instead of once per pair. SeasonDecor's doc comment no longer claims a
season missing from DECORATIONS is a build error — the mapping is not
typed against season ids, so a gap there renders a blank frame and is
caught by the "mapping" test in season-decor.spec.ts instead.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
Halloween's webs, new year's stars and spring's blossom branches sat
at -top-1 (halloween, spring) or -top-1 (new year), largely covered by
the 64px navigation bar (z-50). They now start at top-16, below it,
and new year's and spring's corner motifs grow to h-28/sm:h-36 so they
still read clearly. New year's twinkling light points switch from
bg-tertiary (silver, close to no chroma — read as grey dust) to
bg-secondary (gold).

Winter's crystal keeps its old offset for now; its own position and
size need more than a one-line move (see the next commit).

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
The winter crystal and falling snowflake read as arrows and an
asterisk: the crystal's arms sat on an uneven ~33° grid with V-shaped
tips, and the flake was three plain strokes. Both are now a proper
six-fold shape — arms on an exact 60° grid from the centre, with side
branches partway along each arm, shortening towards the tip.

The corner crystal also moves below the navigation (top-16, following
the other seasons) and out towards the viewport edge (-left-6/-right-6
instead of -1, capped at lg:h-24/w-24) so it no longer overlaps the
carousel frame in the ~1024-1280px range where the content gutter
(lg:px-8) is at its narrowest.

The new year star and spring branch were checked for the same kind of
geometry problem and rendered cleanly — no change needed there.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
new-year's dark surface came out #141317 — practically neutral black,
no midnight-violet undertone, because Fidelity derives its neutral
palette from the primary seed but flattens its chroma almost to zero.

scripts/md3-tokens.mjs gains an optional per-season neutralChroma: when
set, the neutral and neutral-variant palettes are built from
TonalPalette.fromHueAndChroma(primary's HCT hue, chroma) instead of
Fidelity's own. The neutral-variant palette uses chroma + 4, matching
the small extra tint Fidelity's own neutral-variant carries over
neutral. Seasons without the parameter are byte-identical — verified
against halloween and winter.

new-year sets neutralChroma: 16, tuned by comparing a few values (8-12
stayed too grey, 20+ started to compete with on-surface); its dark
surface is now #141123 and themeColor follows.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
new-year's gold (#D4AF37, HCT chroma 47.8) and spring's blossom pink
(#E86A9A, chroma 60.1) generated a secondary-container so saturated
the active navigation pill read as screaming yellow/pink in light
mode, even though their chroma looked unremarkable next to the base
secondary's (51.2).

Both seeds keep their HCT hue (inside the spec's hue bands: new-year
35-55°, spring 320-355°) with chroma roughly halved and a slightly
lower tone. new-year's brand-orange follows its secondary, as it did
before. Resulting secondary-container / on-secondary-container:

  new-year light  #f7e1a6 / #736334   dark  #56481b / #cbb780
  spring   light  #ffb9ce / #7b4658   dark  #6c394b / #eaa5bb

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
Winter, new year and spring's logos and favicons kept patches of the
base mark's orange, magenta and violet, and each covered five
different seed hues (primary/secondary/tertiary/brand-orange/
brand-purple), so spring in particular read as a rainbow rather than a
season.

Recoloured from scratch: every fill is bucketed into one of three
regions along the feather (base/quill, middle vane, tip — split by
each path's own transform translation, verified against a debug render)
and mapped to that region's primary/secondary/tertiary tonal palette at
a tone derived from the fill's own lightness. That leaves each logo
with one coherent 2-3 hue story — winter frost-blue-to-lilac with a
gold accent, new year midnight-violet-to-silver with a gold accent,
spring green-to-yellow with a blossom-pink accent — and no leftover
base hue survives, verified by eye against a side-by-side render.

Each logo's motif (a snow crystal, a sparkle, a blossom) now sits on
the feather's tip instead of floating beside it. Favicons keep the
recolour without a motif, as before. Both optimised with SVGO, all six
files smaller than the 26.47 KiB base asset (20.8-21.1 KiB).

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
Up to four firework buckets join the corner stars and light points
(spec new-year-season, "Neujahrs-Deko mit sanftem Feuerwerk"): a
ten-spoke spark ring per bucket, sitting at the left/right page edges
outside the reading column (further out again from lg), colour cycling
through secondary/tertiary/brand-orange — never red, never a
full-surface brightening.

--animate-season-burst (tailwind.css) opens a bucket over 15% of a 7s
cycle (1.05s, clearing the spec's 1s floor for the fade-in alone) via
scale + opacity only, then glimmers it out with a slight droop and
holds it fully faded for the rest of the cycle. A 1.75s stagger across
the four buckets means at most one is ever open. Each bucket stays
within h-28/lg:h-36 (112px/144px), under the spec's 160px cap.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
The previous calming pass halved chroma (~24) to fix a screaming
secondary-container, but that made the rendered `secondary` role —
used broadly for buttons, chips and pills — read as muddy khaki/olive
rather than gold: a dark tone at low chroma desaturates into brown,
it doesn't calm down.

secondary keeps roughly its original chroma (#B8922E, HCT hue 88,
chroma 42.6 — close to winter's own gold at 48.6, which nobody
objected to). Only the seed's own moved; the rendered secondary role
is nearly identical to before (#775a00 / #ecc159), and the container
is a touch calmer than the original #D4AF37's (#fed268 / #8c6b00 vs.
#fed65b / #af8d11).

Custom brand-orange gets its own seed instead of following secondary:
#E2B64A, a bright festive gold reserved for decoration (fireworks,
stars). It isn't harmonised (customColor blend:false), so it can glow
without dragging a UI role's text contrast down with it — though HCT's
gamut caps how much brighter it can get at this hue before clipping,
so in practice it renders close to secondary in the light scheme and
more clearly brighter in dark (#f0c048 vs. secondary's #ecc159).

Hue unchanged at ~45° (rendered), inside the spec's 35-55° band either
way; contrast and colour-effect tests stay green without a baseline
change.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
The firework buckets were ten lines all meeting at a centre hub, each
ending in a dot — a ball-and-stick molecule model, not a firework, and
coloured khaki from the old gold seed. Redrawn as sixteen rays on a
22.5° grid starting away from the centre (inner radius ~30% of the
viewBox), alternating long and short, with a small dot just beyond
each long ray's tip; no line crosses the centre. Two buckets use the
brighter, unharmonised brand-orange, one each tertiary and secondary
for variety.

Bucket placement now keeps to the page gutter properly:
left-[max(0.5rem,2%)]/right-[...] instead of a bare 2% that could
drift, sized h-24/lg:h-28/xl:h-36 (all ≤160px, spec's cap), vertical
spread 25-75%.

The corner star shrinks from h-28/sm:h-36 to h-16/sm:h-20 and moves to
text-brand-orange/50 (from text-secondary/25, which read as one big
khaki cutout), with two small satellite stars alongside it so the
corner reads as a sparkle cluster.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
The corner branch was a green twig with plain green dots along it —
recognisable as a vine, not a blossom branch. Each dot is now a
five-petal blossom (five small circles on a pentagon around a centre,
petals in text-secondary/60, a tiny text-tertiary-container centre —
tertiary itself has the same dark-in-light-scheme problem secondary
had before the gold fix, tertiary-container stays a steady gold in
both schemes), plus two small leaves along the curve.

The falling petals switch from text-secondary/40 to
text-secondary-container/70: secondary is tuned dark for text contrast
in the light scheme (#854e61, read as muddy mauve-grey against the
pale green surface), where secondary-container stays a clear pink
(#ffb9ce light, a dustier #6c394b in dark — still rose-toned, not grey).

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
launchpad 4bd6c45 Commit Preview URL

Branch Preview URL
Sep 24 2026, 11:37 AM

@TheMeinerLP
TheMeinerLP merged commit 3653a8e into feat/halloween-season Sep 28, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant