Skip to content

feat(season): Halloween skin for the whole site - #384

Open
TheMeinerLP wants to merge 5 commits into
mainfrom
feat/halloween-season
Open

TheMeinerLP wants to merge 5 commits into
mainfrom
feat/halloween-season

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Collaborator

Summary

A calendar-driven Halloween skin for the whole site, 20 October – 2 November (Berlin time).

  • Colours from the MD3 generator. scripts/md3-tokens.mjs gains a SEASONS register. Each season's seeds (Halloween: violet #5B2A86, pumpkin #FF7518, poison green #7CB518) go through the same Fidelity recipe as the base scheme and land in assets/css/seasons.css as one html[data-season="halloween"] block. The block redeclares every role, every custom colour and the fixed brand colours outside the roles (gradients, the connect box's magenta/cyan glow).
  • season layer. useSeason() decides once, on the server, from NUXT_PUBLIC_SEASON and then the calendar. app.vue sets data-season, swaps favicon and theme-color, and renders SeasonDecor.
  • Decoration. Cobwebs in both top corners. From md up, up to three drifting bats, and only under motion-safe. The layer is aria-hidden, pointer-events-none and sits below the navigation (z-30).
  • Logo and favicon. Derived from the current feather: every facet is recoloured so the shape stays exact, and a crescent moon is added to the logo. Both files are smaller than the originals.
  • Preview and kill switch. ?season=halloween or ?season=none applies in the browser after hydration. The cached HTML may not read the query (request-independent-render.spec.ts). NUXT_PUBLIC_SEASON=none turns the season off in production without a deploy.

Rationale

The OpenSpec change add-halloween-season holds the plan: proposal, two specs, design and tasks. The central choice is to generate a season from seeds instead of hand-picking hex values. That makes a season complete by construction, and the existing contrast pairs cover it: every pair passes AA in both schemes.

seasons.css is a separate file on purpose. The token tests parse tailwind.css whole and a later declaration wins, so a season written there would be read back as the base scheme.

Also in here

  • Duplicate favicon fixed. nuxt-seo-utils added its own icon link for public/favicon.svg unless app.head already had one, so every page carried two rel="icon" links before this change. The base icon now lives in app.head with key: 'favicon', and app.vue replaces it by key.
  • The adopt-md3-design-system design now says that calendar-driven seasons are an exception to its "no runtime themes" non-goal.

Known, not caused by this change

  • SiteFooter.vue computes the copyright year with new Date() on both sides. A browser clock in a different year than the server produces a hydration warning on every page, with or without a season.
  • Lighthouse color-contrast fails on the home page's bg-white/70 pill, with and without the season.

Verification

  • pnpm test: 84 files, 526 tests
  • pnpm build
  • pnpm quality: at the baseline
  • Browser, Playwright against pnpm preview:
    • The server HTML carries no data-season for any query.
    • ?season=halloween recolours the page after load and swaps favicon, theme-color and logo.
    • A typo in the query changes nothing.
    • 390 px shows only the webs, 1280 px shows 3 bats, reduced motion shows 0 bats.
    • The mobile menu sits above the decoration, and clicks under the webs reach the nav.
    • The page keeps exactly one rel="icon".
  • Lighthouse, before rebasing onto main: desktop and mobile scores were identical with and without ?season=halloween (desktop a11y 96, perf 99).

Screenshots (light, dark, phone, logo comparison) follow as a comment.

https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s

Extend scripts/md3-tokens.mjs with a SEASONS register: each season is a
set of seeds run through the same Fidelity recipe as the base scheme and
written to assets/css/seasons.css as one html[data-season="…"] block.
The block redeclares every MD3 role and custom colour, plus the fixed
brand colours outside the roles (gradients, the glow's raw magenta and
cyan), so nothing keeps its base value mid-costume.

seasons.css is a file of its own because the token tests parse
tailwind.css whole and a later declaration wins there. Tests hold the
file to the generator, require every role and decoration colour, and run
the contrast pairs against each season. Halloween: violet, pumpkin and
poison green, every pair at AA.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
A new season layer decides once, on the server, whether a season is in
effect: NUXT_PUBLIC_SEASON first (the kill switch that needs no deploy),
then the calendar in Berlin time. Halloween runs 20 October to
2 November. app.vue sets data-season, swaps favicon and theme-color, and
renders a decoration layer: cobwebs in the top corners and, from md up
and only when motion is welcome, three drifting bats. The layout hands
the season's logo to the navigation, so the two domains stay apart.

?season=halloween / ?season=none preview or hide a season in the browser
after hydration: the cached HTML may not read the query.

The base favicon moves into app.head with a key. nuxt-seo-utils added
its own icon link otherwise, so every page already carried two.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
Add the add-halloween-season OpenSpec change (proposal, specs, design,
tasks), carve calendar-driven seasons out of the MD3 design's
"no runtime themes" non-goal, and document seasons in AGENTS.md and the
tailwind-design skill.

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

cloudflare-workers-and-pages Bot commented Sep 24, 2026 •

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 3653a8e Commit Preview URL

Branch Preview URL
Sep 28 2026, 01:20 PM

scripts/cross-browser-colors.mjs compiles tailwind.css with its own
stylesheet loader, which looked up every @import inside the tailwindcss
package. The new `@import './seasons.css'` therefore failed with ENOENT.
Resolve relative imports next to tailwind.css, as tests/helpers/theme.ts
already does.

Claude-Session: https://claude.ai/code/session_012ZzvomAnwjAS3et96Avp7s
* feat(season): add winter, new year and spring to the register

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

* feat(season): split the decoration per season

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

* feat(season): add seasonal logos and favicons

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

* test(season): run colour effect checks per season

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

* docs(season): list all seasons in agents guide

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

* feat(season): let snowflakes and petals fall across the viewport

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

* feat(season): tidy the season register and decoration frame

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

* feat(season): show corner decoration below the navigation

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

* feat(season): draw proper six-fold snowflakes

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

* feat(season): give the new year a midnight surface

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

* feat(season): calm the new year and spring accents

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

* feat(season): recolour the seasonal logos throughout

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

* feat(season): light a gentle firework for the new year

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

* feat(season): pick a calmer, clearer new year gold

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

* feat(season): draw a real firework and a sparkle cluster for new year

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

* feat(season): make the spring branch bloom

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
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