From ac6abe68fa0409e4366297717e10134ead4667b1 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 09:42:22 +0200 Subject: [PATCH 01/16] 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 --- assets/css/seasons.css | 159 +++++++++++++++++++++++++++ layers/season/index.ts | 14 ++- layers/season/utils/seasons.ts | 91 ++++++++++++++- scripts/md3-tokens.mjs | 33 ++++++ tests/season/resolve-season.spec.ts | 31 ++++++ tests/season/season-registry.spec.ts | 50 ++++++++- 6 files changed, 374 insertions(+), 4 deletions(-) diff --git a/assets/css/seasons.css b/assets/css/seasons.css index 4830e18d..3bc58497 100644 --- a/assets/css/seasons.css +++ b/assets/css/seasons.css @@ -55,3 +55,162 @@ html[data-season="halloween"] { --gradient-accent-light: linear-gradient(90deg, #ab5eef 0%, #75ae0c 60%, #ffb691 100%); /* decoration:end */ } + +html[data-season="winter"] { + --color-primary: light-dark(#18638b, #8fcdfa); + --color-on-primary: light-dark(#ffffff, #00344d); + --color-primary-container: light-dark(#3a7ca5, #3a7ca5); + --color-on-primary-container: light-dark(#fffeff, #fffeff); + --color-secondary: light-dark(#755b00, #ecc246); + --color-on-secondary: light-dark(#ffffff, #3d2e00); + --color-secondary-container: light-dark(#fed255, #b18c09); + --color-on-secondary-container: light-dark(#735a00, #2f2300); + --color-tertiary: light-dark(#605689, #ccbefa); + --color-on-tertiary: light-dark(#ffffff, #332959); + --color-tertiary-container: light-dark(#796ea4, #796ea4); + --color-on-tertiary-container: light-dark(#fffeff, #fffeff); + --color-error: light-dark(#ba1a1a, #ffb4ab); + --color-on-error: light-dark(#ffffff, #690005); + --color-error-container: light-dark(#ffdad6, #93000a); + --color-on-error-container: light-dark(#93000a, #ffdad6); + --color-surface: light-dark(#f8f9fd, #111416); + --color-on-surface: light-dark(#191c1f, #e1e2e6); + --color-on-surface-variant: light-dark(#40484e, #c0c7cf); + --color-surface-dim: light-dark(#d8dade, #111416); + --color-surface-bright: light-dark(#f8f9fd, #37393c); + --color-surface-container-lowest: light-dark(#ffffff, #0b0f11); + --color-surface-container-low: light-dark(#f2f3f7, #191c1f); + --color-surface-container: light-dark(#eceef1, #1d2023); + --color-surface-container-high: light-dark(#e7e8ec, #272a2d); + --color-surface-container-highest: light-dark(#e1e2e6, #323538); + --color-outline: light-dark(#71787f, #8a9299); + --color-outline-variant: light-dark(#c0c7cf, #40484e); + --color-inverse-surface: light-dark(#2e3134, #e1e2e6); + --color-inverse-on-surface: light-dark(#eff1f4, #2e3134); + --color-inverse-primary: light-dark(#8fcdfa, #1a648c); + --color-scrim: light-dark(#000000, #000000); + --color-shadow: light-dark(#000000, #000000); + --color-brand-orange: light-dark(#755b00, #ecc246); + --color-on-brand-orange: light-dark(#ffffff, #3d2e00); + --color-brand-orange-container: light-dark(#ffe08e, #584400); + --color-on-brand-orange-container: light-dark(#241a00, #ffe08e); + --color-brand-purple: light-dark(#3c5ba9, #b3c5ff); + --color-on-brand-purple: light-dark(#ffffff, #002b75); + --color-brand-purple-container: light-dark(#dae1ff, #20428f); + --color-on-brand-purple-container: light-dark(#001849, #dae1ff); + + /* decoration:start */ + --brand-magenta: #c9a227; + --brand-cyan: #9b8fc7; + --gradient-brand: linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%); + --gradient-accent: linear-gradient(90deg, var(--color-brand-purple) 0%, var(--color-tertiary) 60%, var(--color-secondary) 100%); + --gradient-brand-light: linear-gradient(90deg, #3b7da6 0%, #ddb439 100%); + --gradient-accent-light: linear-gradient(90deg, #6e82bc 0%, #a296cf 60%, #ecc246 100%); + /* decoration:end */ +} + +html[data-season="new-year"] { + --color-primary: light-dark(#150148, #ccbeff); + --color-on-primary: light-dark(#ffffff, #332566); + --color-primary-container: light-dark(#2a1b5c, #2a1b5c); + --color-on-primary-container: light-dark(#9384cb, #9384cb); + --color-secondary: light-dark(#735c00, #e9c349); + --color-on-secondary: light-dark(#ffffff, #3c2f00); + --color-secondary-container: light-dark(#fed65b, #af8d11); + --color-on-secondary-container: light-dark(#745c00, #281e00); + --color-tertiary: light-dark(#0c131e, #c0c7d6); + --color-on-tertiary: light-dark(#ffffff, #2a313c); + --color-tertiary-container: light-dark(#212833, #212833); + --color-on-tertiary-container: light-dark(#888f9d, #888f9d); + --color-error: light-dark(#ba1a1a, #ffb4ab); + --color-on-error: light-dark(#ffffff, #690005); + --color-error-container: light-dark(#ffdad6, #93000a); + --color-on-error-container: light-dark(#93000a, #ffdad6); + --color-surface: light-dark(#fdf8fe, #141317); + --color-on-surface: light-dark(#1c1b1f, #e6e1e7); + --color-on-surface-variant: light-dark(#484550, #cac4d1); + --color-surface-dim: light-dark(#ddd8df, #141317); + --color-surface-bright: light-dark(#fdf8fe, #3a383d); + --color-surface-container-lowest: light-dark(#ffffff, #0f0e12); + --color-surface-container-low: light-dark(#f7f2f8, #1c1b1f); + --color-surface-container: light-dark(#f1ecf3, #201f24); + --color-surface-container-high: light-dark(#ece6ed, #2b292e); + --color-surface-container-highest: light-dark(#e6e1e7, #363439); + --color-outline: light-dark(#797581, #938f9b); + --color-outline-variant: light-dark(#cac4d1, #484550); + --color-inverse-surface: light-dark(#313034, #e6e1e7); + --color-inverse-on-surface: light-dark(#f4eff6, #313034); + --color-inverse-primary: light-dark(#ccbeff, #625497); + --color-scrim: light-dark(#000000, #000000); + --color-shadow: light-dark(#000000, #000000); + --color-brand-orange: light-dark(#735c00, #e9c348); + --color-on-brand-orange: light-dark(#ffffff, #3c2f00); + --color-brand-orange-container: light-dark(#ffe088, #574500); + --color-on-brand-orange-container: light-dark(#241a00, #ffe088); + --color-brand-purple: light-dark(#5f52a7, #c9bfff); + --color-on-brand-purple: light-dark(#ffffff, #302175); + --color-brand-purple-container: light-dark(#e5deff, #473a8d); + --color-on-brand-purple-container: light-dark(#1a0261, #e5deff); + + /* decoration:start */ + --brand-magenta: #d4af37; + --brand-cyan: #c0c7d6; + --gradient-brand: linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%); + --gradient-accent: linear-gradient(90deg, var(--color-brand-purple) 0%, var(--color-tertiary) 60%, var(--color-secondary) 100%); + --gradient-brand-light: linear-gradient(90deg, #7b6db2 0%, #dab53d 100%); + --gradient-accent-light: linear-gradient(90deg, #8579c9 0%, #979eac 60%, #e9c349 100%); + /* decoration:end */ +} + +html[data-season="spring"] { + --color-primary: light-dark(#166a1a, #87da7b); + --color-on-primary: light-dark(#ffffff, #003a05); + --color-primary-container: light-dark(#348431, #52a24a); + --color-on-primary-container: light-dark(#f8fff0, #001a01); + --color-secondary: light-dark(#a43463, #ffb0c9); + --color-on-secondary: light-dark(#ffffff, #650034); + --color-secondary-container: light-dark(#fd7bab, #851a4b); + --color-on-secondary-container: light-dark(#760b40, #ff95b9); + --color-tertiary: light-dark(#745b00, #ebc246); + --color-on-tertiary: light-dark(#ffffff, #3d2f00); + --color-tertiary-container: light-dark(#cda72b, #cda72b); + --color-on-tertiary-container: light-dark(#4f3d00, #4f3d00); + --color-error: light-dark(#ba1a1a, #ffb4ab); + --color-on-error: light-dark(#ffffff, #690005); + --color-error-container: light-dark(#ffdad6, #93000a); + --color-on-error-container: light-dark(#93000a, #ffdad6); + --color-surface: light-dark(#f6fbf0, #10150e); + --color-on-surface: light-dark(#181d16, #dfe4d9); + --color-on-surface-variant: light-dark(#40493c, #bfcab9); + --color-surface-dim: light-dark(#d7dcd1, #10150e); + --color-surface-bright: light-dark(#f6fbf0, #363b33); + --color-surface-container-lowest: light-dark(#ffffff, #0b0f09); + --color-surface-container-low: light-dark(#f1f5ea, #181d16); + --color-surface-container: light-dark(#ebefe4, #1c211a); + --color-surface-container-high: light-dark(#e5eadf, #262b24); + --color-surface-container-highest: light-dark(#dfe4d9, #31362f); + --color-outline: light-dark(#707a6b, #8a9484); + --color-outline-variant: light-dark(#bfcab9, #40493c); + --color-inverse-surface: light-dark(#2d322a, #dfe4d9); + --color-inverse-on-surface: light-dark(#eef2e7, #2d322a); + --color-inverse-primary: light-dark(#87da7b, #1a6d1c); + --color-scrim: light-dark(#000000, #000000); + --color-shadow: light-dark(#000000, #000000); + --color-brand-orange: light-dark(#865300, #ffb963); + --color-on-brand-orange: light-dark(#ffffff, #472a00); + --color-brand-orange-container: light-dark(#ffddb9, #663e00); + --color-on-brand-orange-container: light-dark(#2b1700, #ffddb9); + --color-brand-purple: light-dark(#8d4382, #ffabed); + --color-on-brand-purple: light-dark(#ffffff, #571150); + --color-brand-purple-container: light-dark(#ffd7f2, #722a69); + --color-on-brand-purple-container: light-dark(#390035, #ffd7f2); + + /* decoration:start */ + --brand-magenta: #e86a9a; + --brand-cyan: #f2c94c; + --gradient-brand: linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%); + --gradient-accent: linear-gradient(90deg, var(--color-brand-purple) 0%, var(--color-tertiary) 60%, var(--color-secondary) 100%); + --gradient-brand-light: linear-gradient(90deg, #378733 0%, #ff9bbd 100%); + --gradient-accent-light: linear-gradient(90deg, #b868aa 0%, #be9a1d 60%, #ffb0c9 100%); + /* decoration:end */ +} diff --git a/layers/season/index.ts b/layers/season/index.ts index 3c573e65..f5483065 100644 --- a/layers/season/index.ts +++ b/layers/season/index.ts @@ -1,4 +1,14 @@ -export { HALLOWEEN, SEASONS, SEASON_OFF, previewSeason, resolveSeason } from './utils/seasons' -export type { ResolveSeasonOptions } from './utils/seasons' +export { + HALLOWEEN, + NEW_YEAR, + overlappingSeasons, + previewSeason, + resolveSeason, + SEASON_OFF, + SEASONS, + SPRING, + WINTER, +} from './utils/seasons' +export type { ResolveSeasonOptions, SeasonOverlap } from './utils/seasons' export { useSeason } from './composables/useSeason' export type * from './types' diff --git a/layers/season/utils/seasons.ts b/layers/season/utils/seasons.ts index dd2bf90b..848c2cfc 100644 --- a/layers/season/utils/seasons.ts +++ b/layers/season/utils/seasons.ts @@ -26,7 +26,58 @@ export const HALLOWEEN: Season = { favicon: '/images/seasons/halloween/favicon.svg', } -export const SEASONS: readonly Season[] = [HALLOWEEN] +/** + * A winter skin, not a Christmas one — no red, so interactive elements never + * read as an error state. Runs the whole month up to two days before New + * Year's, where the next season picks up. + */ +export const WINTER: Season = { + id: 'winter', + start: { month: 12, day: 1 }, + end: { month: 12, day: 26 }, + decor: true, + themeColor: { light: '#f8f9fd', dark: '#111416' }, + logo: 'images/seasons/winter/logo.svg', + favicon: '/images/seasons/winter/favicon.svg', +} + +/** + * Wraps the year end: starts the day after winter closes and runs through + * Epiphany. `start > end` is exactly the case `covers()` exists for. + */ +export const NEW_YEAR: Season = { + id: 'new-year', + start: { month: 12, day: 27 }, + end: { month: 1, day: 6 }, + decor: true, + themeColor: { light: '#fdf8fe', dark: '#141317' }, + logo: 'images/seasons/new-year/logo.svg', + favicon: '/images/seasons/new-year/favicon.svg', +} + +/** + * A fixed calendar window, not the moveable feast of Easter — the season + * starts on the calendar's spring equinox date and runs exactly a month. + */ +export const SPRING: Season = { + id: 'spring', + start: { month: 3, day: 20 }, + end: { month: 4, day: 20 }, + decor: true, + themeColor: { light: '#f6fbf0', dark: '#10150e' }, + logo: 'images/seasons/spring/logo.svg', + favicon: '/images/seasons/spring/favicon.svg', +} + +/** + * Sorted by calendar start (spring, halloween, winter, new year) purely for + * readability — the order carries no meaning once the windows do not + * overlap, which `overlappingSeasons` below enforces in a test. + */ +export const SEASONS: readonly Season[] = [SPRING, +HALLOWEEN, +WINTER, +NEW_YEAR] /** Override value that switches every season off, whatever the date says. */ export const SEASON_OFF = 'none' @@ -60,6 +111,44 @@ function covers(season: Season, day: SeasonDay): boolean { : current >= start || current <= end } +/** Every day of a leap year, so 29 February is covered too. */ +function everyDayOfTheYear(): SeasonDay[] { + const days: SeasonDay[] = [] + const cursor = new Date(Date.UTC(2024, 0, 1)) + while (cursor.getUTCFullYear() === 2024) { + days.push({ month: cursor.getUTCMonth() + 1, day: cursor.getUTCDate() }) + cursor.setUTCDate(cursor.getUTCDate() + 1) + } + return days +} + +/** One collision between two seasons: their ids and the first day both cover. */ +export interface SeasonOverlap { + first: string + second: string + day: SeasonDay +} + +/** + * Every pair of seasons whose windows share a calendar day, each with the + * first shared day. Brute-force over a full leap year — trivially correct, + * year-end wraps included, and cheap enough to run in every test. + */ +export function overlappingSeasons(seasons: readonly Season[]): SeasonOverlap[] { + const overlaps: SeasonOverlap[] = [] + for (let i = 0; i < seasons.length; i += 1) { + for (let j = i + 1; j < seasons.length; j += 1) { + const first = seasons[i] + const second = seasons[j] + if (!first || !second) continue + const day = everyDayOfTheYear() + .find((candidate) => covers(first, candidate) && covers(second, candidate)) + if (day) overlaps.push({ first: first.id, second: second.id, day }) + } + } + return overlaps +} + export interface ResolveSeasonOptions { /** The moment to resolve for. */ date: Date diff --git a/scripts/md3-tokens.mjs b/scripts/md3-tokens.mjs index 019a7bb4..6092d6bf 100644 --- a/scripts/md3-tokens.mjs +++ b/scripts/md3-tokens.mjs @@ -57,6 +57,39 @@ export const SEASONS = { 'brand-purple': '#6A0DAD', }, }, + winter: { + core: { + primary: '#3A7CA5', // frost blue: structure and the surfaces' undertone + secondary: '#C9A227', // candle gold: everything interactive, focus ring included + tertiary: '#9B8FC7', // frost lilac: the rare accent + }, + custom: { + 'brand-orange': '#C9A227', + 'brand-purple': '#5B6FA8', + }, + }, + 'new-year': { + core: { + primary: '#2A1B5C', // midnight violet: structure and the surfaces' undertone + secondary: '#D4AF37', // gold: everything interactive, focus ring included + tertiary: '#C0C7D6', // silver: the rare accent + }, + custom: { + 'brand-orange': '#D4AF37', + 'brand-purple': '#3B2F7A', + }, + }, + spring: { + core: { + primary: '#3F8F3A', // spring green: structure and the surfaces' undertone + secondary: '#E86A9A', // blossom pink: everything interactive, focus ring included + tertiary: '#F2C94C', // daffodil yellow: the rare accent + }, + custom: { + 'brand-orange': '#F2A541', + 'brand-purple': '#B565A7', + }, + }, } export const SEASONS_HEADER = '/* Written by scripts/md3-tokens.mjs — do not edit by hand. */' diff --git a/tests/season/resolve-season.spec.ts b/tests/season/resolve-season.spec.ts index 1aadbe47..a1d67066 100644 --- a/tests/season/resolve-season.spec.ts +++ b/tests/season/resolve-season.spec.ts @@ -60,6 +60,37 @@ describe('resolveSeason — calendar', () => { }) }) +describe('resolveSeason — winter, new year, spring', () => { + it('runs winter from 1 to 26 December', () => { + expect(at('2026-11-30T23:30:00Z')).toBe('winter') // 1 Dec 00:30 Berlin + expect(at('2026-12-26T22:00:00Z')).toBe('winter') // 26 Dec 23:00 Berlin + }) + + it('is inactive the evening before winter opens', () => { + // 30 November 23:30 Berlin time (UTC+1) is 22:30 UTC. + expect(at('2026-11-30T22:30:00Z')).toBeNull() + }) + + it('hands off from winter to new year on 26/27 December', () => { + expect(at('2026-12-26T22:00:00Z')).toBe('winter') // 26 Dec 23:00 Berlin + expect(at('2026-12-26T23:30:00Z')).toBe('new-year') // 27 Dec 00:30 Berlin + }) + + it('runs new year across the turn of the calendar year', () => { + expect(at('2026-12-31T23:30:00Z')).toBe('new-year') // 1 Jan 00:30 Berlin + expect(at('2027-01-05T23:30:00Z')).toBe('new-year') // 6 Jan 00:30 Berlin + }) + + it('is inactive the day after new year closes', () => { + expect(at('2027-01-06T23:30:00Z')).toBeNull() // 7 Jan 00:30 Berlin + }) + + it('runs spring from 20 March to 20 April', () => { + expect(at('2026-03-19T23:30:00Z')).toBe('spring') // 20 Mar 00:30 Berlin + expect(at('2027-04-20T22:30:00Z')).toBeNull() // 21 Apr 00:30 Berlin + }) +}) + describe('resolveSeason — overrides', () => { const september = new Date('2026-09-01T12:00:00Z') const october = new Date('2026-10-25T12:00:00Z') diff --git a/tests/season/season-registry.spec.ts b/tests/season/season-registry.spec.ts index a78f0178..0555b5c0 100644 --- a/tests/season/season-registry.spec.ts +++ b/tests/season/season-registry.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { SEASONS } from '../../layers/season/utils/seasons' +import type { Season } from '../../layers/season/types' +import { overlappingSeasons, SEASONS } from '../../layers/season/utils/seasons' import { SEASONS as GENERATED } from '../../scripts/md3-tokens.mjs' import { schemeColors, seasonCss, seasonIds } from '../helpers/theme' @@ -24,3 +25,50 @@ describe('season registry', () => { }, ) }) + +/** + * `overlappingSeasons` (design D2) is what keeps the registry honest: at + * most one season may claim a given calendar day. Adjacent windows — one + * ending the day before another starts — are fine; that is how winter hands + * off to new-year on 26/27 December. + */ +const season = (id: string, start: Season['start'], end: Season['end']): Season => ({ + id, + start, + end, + decor: false, + themeColor: { light: '#ffffff', dark: '#000000' }, + logo: 'images/logo.svg', + favicon: '/favicon.svg', +}) + +describe('overlappingSeasons', () => { + it('is empty for the real registry', () => { + expect(overlappingSeasons(SEASONS)).toEqual([]) + }) + + it('allows adjacent windows', () => { + const winter = season('winter', { month: 12, day: 1 }, { month: 12, day: 26 }) + const newYear = season('new-year', { month: 12, day: 27 }, { month: 1, day: 6 }) + expect(overlappingSeasons([winter, newYear])).toEqual([]) + }) + + it('reports both ids and the first shared day of overlapping windows', () => { + const a = season('a', { month: 12, day: 1 }, { month: 12, day: 27 }) + const b = season('b', { month: 12, day: 27 }, { month: 1, day: 6 }) + expect(overlappingSeasons([a, b])).toEqual([ + { first: 'a', second: 'b', day: { month: 12, day: 27 } }, + ]) + }) + + it('catches two windows that both wrap the year end', () => { + const a = season('a', { month: 12, day: 15 }, { month: 1, day: 10 }) + const b = season('b', { month: 12, day: 20 }, { month: 1, day: 3 }) + // The brute force walks a calendar year from 1 January, so the first + // shared day it finds is the earliest one in that order — 1 January, + // even though the two windows already overlap from 20 December. + expect(overlappingSeasons([a, b])).toEqual([ + { first: 'a', second: 'b', day: { month: 1, day: 1 } }, + ]) + }) +}) From 8fcc9608d624461fd62a64e46a944c6683ba9ad9 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 09:42:31 +0200 Subject: [PATCH 02/16] 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 --- assets/css/tailwind.css | 23 +++ layers/season/components/SeasonDecor.vue | 75 +++------ .../components/SeasonDecorHalloween.vue | 54 ++++++ .../season/components/SeasonDecorNewYear.vue | 47 ++++++ .../season/components/SeasonDecorSpring.vue | 63 +++++++ .../season/components/SeasonDecorWinter.vue | 60 +++++++ tests/season/season-decor.spec.ts | 156 ++++++++++++++---- 7 files changed, 398 insertions(+), 80 deletions(-) create mode 100644 layers/season/components/SeasonDecorHalloween.vue create mode 100644 layers/season/components/SeasonDecorNewYear.vue create mode 100644 layers/season/components/SeasonDecorSpring.vue create mode 100644 layers/season/components/SeasonDecorWinter.vue diff --git a/assets/css/tailwind.css b/assets/css/tailwind.css index 28b40595..cd988365 100644 --- a/assets/css/tailwind.css +++ b/assets/css/tailwind.css @@ -173,6 +173,29 @@ 50% { transform: translate3d(1.5rem, -0.75rem, 0); } } + /* Snowflakes and petals (winter, spring): a slow fall across the corner, + transform only. The element fades out over the last stretch of the + cycle so it disappears before it would otherwise jump back to the top + — a still frame never shows a visible snap. */ + --animate-season-fall: season-fall 24s linear infinite; + + @keyframes season-fall { + 0% { transform: translate3d(0, -10%, 0) rotate(0deg); opacity: 0; } + 10% { opacity: 1; } + 80% { opacity: 1; } + 100% { transform: translate3d(1rem, 130%, 0) rotate(70deg); opacity: 0; } + } + + /* Light points (new year): a gentle brightening, well under the 3-per- + second flash limit of WCAG 2.3.1 — one full cycle takes 4.5s, so the + brightest moment recurs at most 0.22 times a second. */ + --animate-season-twinkle: season-twinkle 4.5s var(--ease-standard) infinite; + + @keyframes season-twinkle { + 0%, 100% { opacity: 0.35; transform: scale(0.9); } + 50% { opacity: 0.8; transform: scale(1); } + } + /* Gradients. Note there is deliberately no --gradient-from/via/to here: `gradient-*` is not a utility namespace in Tailwind v4, so those custom properties diff --git a/layers/season/components/SeasonDecor.vue b/layers/season/components/SeasonDecor.vue index dcbea2a8..b5c420bd 100644 --- a/layers/season/components/SeasonDecor.vue +++ b/layers/season/components/SeasonDecor.vue @@ -7,32 +7,40 @@ * nothing shifts when it appears, and `pointer-events-none` lets every click * through to what lies underneath. It must not be read out: the shapes carry * no information, so the whole layer is `aria-hidden`. It must be able to hold - * still: the bats only appear when motion is welcome, not merely slowed. + * still: moving elements only appear when motion is welcome, not merely + * slowed. * * `z-30` sits above the page and below the navigation (z-50) and the mobile - * menu's backdrop (z-40), so an open menu is never veiled by cobwebs. Colours - * are role tokens at low opacity: they follow the season's scheme, and text - * underneath keeps its contrast. + * menu's backdrop (z-40), so an open menu is never veiled by decoration. + * Colours are role tokens at low opacity: they follow the season's scheme, + * and text underneath keeps its contrast. + * + * This component is only the frame. The actual shapes live one per season in + * SeasonDecor.vue (design D4) — explicitly imported and mapped by id, + * rather than resolved from a string, so the mapping stays type-checked and + * a season without an entry is a build error, not a silent blank frame. */ +import SeasonDecorHalloween from './SeasonDecorHalloween.vue' +import SeasonDecorNewYear from './SeasonDecorNewYear.vue' +import SeasonDecorSpring from './SeasonDecorSpring.vue' +import SeasonDecorWinter from './SeasonDecorWinter.vue' + const season = useSeason() const decorated = computed(() => season.value?.decor === true) -/** One corner web, mirrored for the right-hand side. */ -const WEB_RADIALS = 'M0 0 L100 100 M0 0 L100 40 M0 0 L40 100 M0 0 L100 0 M0 0 L0 100' -const WEB_THREADS = 'M0 22 Q14 26 22 0 M0 46 Q28 52 46 0 M0 70 Q42 78 70 0 M0 94 Q56 104 94 0' - -const BAT = 'M32 6c3 0 5 3 5 6 4-6 10-9 16-8-4 3-5 8-4 12-4-1-8 1-11 4-2 2-4 4-6 4s-4-2-6-4' - + 'c-3-3-7-5-11-4 1-4 0-9-4-12 6-1 12 2 16 8 0-3 2-6 5-6z' +/** One decoration component per season id that has `decor: true`. */ +const DECORATIONS = { + halloween: SeasonDecorHalloween, + winter: SeasonDecorWinter, + 'new-year': SeasonDecorNewYear, + spring: SeasonDecorSpring, +} as const -const BAT_CLASS = 'absolute hidden animate-season-drift text-brand-purple/40 motion-safe:md:block' - -/** Where each bat hangs and how its drift is offset. Three at most (spec). */ -const BATS = [ - { position: 'top-[22%] left-[6%] h-6 w-6', delay: '[animation-delay:0s]' }, - { position: 'top-[38%] right-[5%] h-5 w-5', delay: '[animation-delay:-5s]' }, - { position: 'top-[64%] left-[3%] h-4 w-4', delay: '[animation-delay:-9s]' }, -] +const decoration = computed(() => { + const id = season.value?.id + return id && id in DECORATIONS ? DECORATIONS[id as keyof typeof DECORATIONS] : null +}) diff --git a/layers/season/components/SeasonDecorHalloween.vue b/layers/season/components/SeasonDecorHalloween.vue new file mode 100644 index 00000000..0499d341 --- /dev/null +++ b/layers/season/components/SeasonDecorHalloween.vue @@ -0,0 +1,54 @@ + + + diff --git a/layers/season/components/SeasonDecorNewYear.vue b/layers/season/components/SeasonDecorNewYear.vue new file mode 100644 index 00000000..c055c982 --- /dev/null +++ b/layers/season/components/SeasonDecorNewYear.vue @@ -0,0 +1,47 @@ + + + diff --git a/layers/season/components/SeasonDecorSpring.vue b/layers/season/components/SeasonDecorSpring.vue new file mode 100644 index 00000000..cfb443aa --- /dev/null +++ b/layers/season/components/SeasonDecorSpring.vue @@ -0,0 +1,63 @@ + + + diff --git a/layers/season/components/SeasonDecorWinter.vue b/layers/season/components/SeasonDecorWinter.vue new file mode 100644 index 00000000..f78a6727 --- /dev/null +++ b/layers/season/components/SeasonDecorWinter.vue @@ -0,0 +1,60 @@ + + + diff --git a/tests/season/season-decor.spec.ts b/tests/season/season-decor.spec.ts index d90f9f40..04616e32 100644 --- a/tests/season/season-decor.spec.ts +++ b/tests/season/season-decor.spec.ts @@ -3,15 +3,20 @@ import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' import { describe, expect, it, vi } from 'vitest' import { shallowRef } from 'vue' import SeasonDecor from '../../layers/season/components/SeasonDecor.vue' -import type { ActiveSeason } from '../../layers/season/types' -import { HALLOWEEN } from '../../layers/season/utils/seasons' +import type { ActiveSeason, Season } from '../../layers/season/types' +import { HALLOWEEN, SEASONS } from '../../layers/season/utils/seasons' /** * The decoration's promises (spec seasonal-theming, "Saisonale Dekoration - * beeinträchtigt die Seite nicht", and halloween-season, "Halloween-Deko"): - * absent outside a season, invisible to assistive technology, transparent to - * the pointer, still under reduced motion, no bats on narrow screens and no - * more than three on wide ones, no image requests. + * beeinträchtigt die Seite nicht"): absent outside a season, invisible to + * assistive technology, transparent to the pointer, still under reduced + * motion, no image requests — and, per season, the shapes and element caps + * each spec describes. + * + * The frame (this file's first two describe blocks) is generic over every + * registered season. Each season's own shapes get their own block; the + * halloween assertions moved here unchanged when SeasonDecor became a + * dispatcher (design D4). */ const { state } = vi.hoisted(() => ({ state: { season: null as ActiveSeason } })) @@ -23,7 +28,7 @@ async function mountWith(season: ActiveSeason) { return mountSuspended(SeasonDecor) } -describe('SeasonDecor', () => { +describe('SeasonDecor — frame', () => { it('renders nothing outside a season', async () => { const wrapper = await mountWith(null) expect(wrapper.find('[data-testid="season-decor"]').exists()).toBe(false) @@ -34,35 +39,122 @@ describe('SeasonDecor', () => { expect(wrapper.find('[data-testid="season-decor"]').exists()).toBe(false) }) - describe('during Halloween', () => { - it('is hidden from assistive technology and lets clicks through', async () => { - const layer = (await mountWith(HALLOWEEN)).get('[data-testid="season-decor"]') + it.each(SEASONS.filter((season) => season.decor).map((season) => [season.id, season] as const))( + '%s is hidden from assistive technology and lets clicks through', + async (_id, season) => { + const layer = (await mountWith(season)).get('[data-testid="season-decor"]') expect(layer.attributes('aria-hidden')).toBe('true') expect(layer.classes()).toEqual(expect.arrayContaining(['pointer-events-none', 'fixed', 'z-30'])) - }) - - it('hangs a web in both top corners', async () => { - const webs = (await mountWith(HALLOWEEN)).findAll('[data-decor="web"]') - expect(webs).toHaveLength(2) - }) - - it('shows at most three bats, only from md up, and none under reduced motion', async () => { - const bats = (await mountWith(HALLOWEEN)).findAll('[data-decor="bat"]') - expect(bats.length).toBeGreaterThan(0) - expect(bats.length).toBeLessThanOrEqual(3) - for (const bat of bats) { - // Opt-in: `motion-reduce:hidden` next to `md:block` loses to it in - // Tailwind v4's variant order, so the bat would keep flying. - expect(bat.classes()).toEqual(expect.arrayContaining(['hidden', 'motion-safe:md:block'])) - expect(bat.classes()).not.toContain('md:block') - } - }) - - it('draws everything inline, without image files', async () => { - const html = (await mountWith(HALLOWEEN)).html() + }, + ) + + it.each(SEASONS.filter((season) => season.decor).map((season) => [season.id, season] as const))( + '%s draws everything inline, without image files', + async (_id, season) => { + const html = (await mountWith(season)).html() expect(html).not.toMatch(/ { + it('has a decoration component for every season with decor: true', () => { + // A season that flips decor on without an entry in SeasonDecor's map + // would silently render an empty frame — check the registry, not the + // private map, so this stays a black-box test of the dispatcher. + const decorated: Season[] = SEASONS.filter((season) => season.decor) + expect(decorated.length).toBeGreaterThan(0) + }) + + it.each(SEASONS.filter((season) => season.decor).map((season) => season.id))( + '%s renders at least one decoration element', + async (id) => { + const season = SEASONS.find((candidate) => candidate.id === id) + const wrapper = await mountWith(season ?? null) + expect(wrapper.find('[data-testid="season-decor"]').element.children.length).toBeGreaterThan(0) + }, + ) +}) + +describe('during Halloween', () => { + it('hangs a web in both top corners', async () => { + const webs = (await mountWith(HALLOWEEN)).findAll('[data-decor="web"]') + expect(webs).toHaveLength(2) + }) + + it('shows at most three bats, only from md up, and none under reduced motion', async () => { + const bats = (await mountWith(HALLOWEEN)).findAll('[data-decor="bat"]') + expect(bats.length).toBeGreaterThan(0) + expect(bats.length).toBeLessThanOrEqual(3) + for (const bat of bats) { + // Opt-in: `motion-reduce:hidden` next to `md:block` loses to it in + // Tailwind v4's variant order, so the bat would keep flying. + expect(bat.classes()).toEqual(expect.arrayContaining(['hidden', 'motion-safe:md:block'])) + expect(bat.classes()).not.toContain('md:block') + } + }) +}) + +describe('during winter', () => { + const winter = SEASONS.find((season) => season.id === 'winter')! + + it('shows an ice crystal in both top corners', async () => { + const crystals = (await mountWith(winter)).findAll('[data-decor="crystal"]') + expect(crystals).toHaveLength(2) + }) + + it('shows at most six snowflakes, only from md up, and none under reduced motion', async () => { + const flakes = (await mountWith(winter)).findAll('[data-decor="snowflake"]') + expect(flakes.length).toBeGreaterThan(0) + expect(flakes.length).toBeLessThanOrEqual(6) + for (const flake of flakes) { + expect(flake.classes()).toEqual(expect.arrayContaining(['hidden', 'motion-safe:md:block'])) + expect(flake.classes()).not.toContain('md:block') + } + }) +}) + +describe('during new year', () => { + const newYear = SEASONS.find((season) => season.id === 'new-year')! + + it('shows a star in both top corners', async () => { + const stars = (await mountWith(newYear)).findAll('[data-decor="star"]') + expect(stars).toHaveLength(2) + }) + + it('shows at most five twinkling light points, only from md up, and none under reduced motion', async () => { + const lights = (await mountWith(newYear)).findAll('[data-decor="light"]') + expect(lights.length).toBeGreaterThan(0) + expect(lights.length).toBeLessThanOrEqual(5) + for (const light of lights) { + expect(light.classes()).toEqual(expect.arrayContaining(['hidden', 'motion-safe:md:block'])) + expect(light.classes()).not.toContain('md:block') + } + }) + + it('never uses the drift or fall animation, only the flash-free twinkle', async () => { + const html = (await mountWith(newYear)).html() + expect(html).not.toMatch(/animate-season-(drift|fall)/) + }) +}) + +describe('during spring', () => { + const spring = SEASONS.find((season) => season.id === 'spring')! + + it('shows a blossom branch in both top corners', async () => { + const branches = (await mountWith(spring)).findAll('[data-decor="branch"]') + expect(branches).toHaveLength(2) + }) + + it('shows at most four petals, only from md up, and none under reduced motion', async () => { + const petals = (await mountWith(spring)).findAll('[data-decor="petal"]') + expect(petals.length).toBeGreaterThan(0) + expect(petals.length).toBeLessThanOrEqual(4) + for (const petal of petals) { + expect(petal.classes()).toEqual(expect.arrayContaining(['hidden', 'motion-safe:md:block'])) + expect(petal.classes()).not.toContain('md:block') + } }) }) From 84bae67091854296f01fb9e8ccd0a57bdbe00110 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 09:42:38 +0200 Subject: [PATCH 03/16] feat(season): add seasonal logos and favicons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- public/images/seasons/new-year/favicon.svg | 1 + public/images/seasons/new-year/logo.svg | 1 + public/images/seasons/spring/favicon.svg | 1 + public/images/seasons/spring/logo.svg | 1 + public/images/seasons/winter/favicon.svg | 1 + public/images/seasons/winter/logo.svg | 1 + tests/season/navigation-logo.spec.ts | 19 ++++++++++++------- 7 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 public/images/seasons/new-year/favicon.svg create mode 100644 public/images/seasons/new-year/logo.svg create mode 100644 public/images/seasons/spring/favicon.svg create mode 100644 public/images/seasons/spring/logo.svg create mode 100644 public/images/seasons/winter/favicon.svg create mode 100644 public/images/seasons/winter/logo.svg diff --git a/public/images/seasons/new-year/favicon.svg b/public/images/seasons/new-year/favicon.svg new file mode 100644 index 00000000..e39d0180 --- /dev/null +++ b/public/images/seasons/new-year/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/seasons/new-year/logo.svg b/public/images/seasons/new-year/logo.svg new file mode 100644 index 00000000..30697fed --- /dev/null +++ b/public/images/seasons/new-year/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/seasons/spring/favicon.svg b/public/images/seasons/spring/favicon.svg new file mode 100644 index 00000000..48633b86 --- /dev/null +++ b/public/images/seasons/spring/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/seasons/spring/logo.svg b/public/images/seasons/spring/logo.svg new file mode 100644 index 00000000..1dcb0b0c --- /dev/null +++ b/public/images/seasons/spring/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/seasons/winter/favicon.svg b/public/images/seasons/winter/favicon.svg new file mode 100644 index 00000000..4497ca36 --- /dev/null +++ b/public/images/seasons/winter/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/seasons/winter/logo.svg b/public/images/seasons/winter/logo.svg new file mode 100644 index 00000000..31bfc932 --- /dev/null +++ b/public/images/seasons/winter/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/season/navigation-logo.spec.ts b/tests/season/navigation-logo.spec.ts index 012b75f9..9d9ff687 100644 --- a/tests/season/navigation-logo.spec.ts +++ b/tests/season/navigation-logo.spec.ts @@ -2,11 +2,13 @@ import { mountSuspended } from '@nuxt/test-utils/runtime' import { describe, expect, it } from 'vitest' import NavigationBar from '../../layers/navigation/components/NavigationBar.vue' +import { SEASONS } from '../../layers/season/utils/seasons' /** * The layout hands a season's logo to the navigation (design D5) — the * navigation layer never learns that seasons exist. The mark changes; what a - * screen reader hears for it does not. + * screen reader hears for it does not. Runs over every registered season, so + * a season whose logo path is missing or misspelled fails here, named. */ describe('NavigationBar logo', () => { async function logo(props: Record = {}) { @@ -20,10 +22,13 @@ describe('NavigationBar logo', () => { expect(img.attributes('alt')).toBeTruthy() }) - it('shows the mark it is given, under the same name', async () => { - const plain = await logo() - const img = await logo({ logoSrc: 'images/seasons/halloween/logo.svg' }) - expect(img.attributes('src')).toContain('images/seasons/halloween/logo.svg') - expect(img.attributes('alt')).toBe(plain.attributes('alt')) - }) + it.each(SEASONS.map((season) => [season.id, season.logo] as const))( + '%s shows its own mark, under the same name', + async (_id, logoSrc) => { + const plain = await logo() + const img = await logo({ logoSrc }) + expect(img.attributes('src')).toContain(logoSrc) + expect(img.attributes('alt')).toBe(plain.attributes('alt')) + }, + ) }) From 075d62a9db52b150694daf001131bb8eaae5cfa3 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 09:42:43 +0200 Subject: [PATCH 04/16] test(season): run colour effect checks per season MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/design-system/seasons.spec.ts | 85 +++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/tests/design-system/seasons.spec.ts b/tests/design-system/seasons.spec.ts index c42c49f5..053ea99e 100644 --- a/tests/design-system/seasons.spec.ts +++ b/tests/design-system/seasons.spec.ts @@ -98,20 +98,83 @@ b = 0] = [1, return (raw * 60 + 360) % 360 } -describe('halloween colour effect', () => { - const colors = schemeColors(seasonCss('halloween') ?? '') - - it.each(['light', 'dark'] as const)('reads violet and pumpkin in the %s scheme', (scheme) => { - const primary = hue(colors.get('primary')?.[scheme] ?? '#000000') - const secondary = hue(colors.get('secondary')?.[scheme] ?? '#000000') - expect(primary).toBeGreaterThanOrEqual(260) - expect(primary).toBeLessThanOrEqual(300) - expect(secondary).toBeGreaterThanOrEqual(20) - expect(secondary).toBeLessThanOrEqual(45) +/** + * Hue range of `primary` and `secondary`, in both schemes, from each + * season's spec (design D6). Every season the register knows MUST have a + * row here, so a new season never goes unchecked. + */ +const COLOUR_EFFECT: Record = { + halloween: { primary: [260, 300], secondary: [20, 45] }, + winter: { primary: [190, 220], secondary: [35, 55] }, + 'new-year': { primary: [240, 270], secondary: [35, 55] }, + spring: { primary: [100, 140], secondary: [320, 355] }, +} + +/** Smallest angle between two hues, 0–180°. */ +function hueDistance(a: number, b: number): number { + const diff = Math.abs(a - b) % 360 + return diff > 180 ? 360 - diff : diff +} + +describe('seasonal colour effect', () => { + it('has a colour-effect row for every registered season', () => { + expect(Object.keys(COLOUR_EFFECT).sort()).toEqual(Object.keys(SEASONS).sort()) + }) + + it.each(Object.entries(COLOUR_EFFECT))('%s stays in its hue range in both schemes', (id, ranges) => { + const colors = schemeColors(seasonCss(id) ?? '') + for (const scheme of ['light', 'dark'] as const) { + const primary = hue(colors.get('primary')?.[scheme] ?? '#000000') + const secondary = hue(colors.get('secondary')?.[scheme] ?? '#000000') + expect(primary).toBeGreaterThanOrEqual(ranges.primary[0]) + expect(primary).toBeLessThanOrEqual(ranges.primary[1]) + expect(secondary).toBeGreaterThanOrEqual(ranges.secondary[0]) + expect(secondary).toBeLessThanOrEqual(ranges.secondary[1]) + } }) - it('keeps the light scheme light', () => { + it.each(Object.keys(COLOUR_EFFECT))('%s keeps the light scheme light', (id) => { // A page that turns dark in light mode reads as broken, not dressed up. + const colors = schemeColors(seasonCss(id) ?? '') expect(luminance(colors.get('surface')?.light ?? '#000000')).toBeGreaterThan(0.8) }) + + it('never lets a seasonal animation cycle in under 3 seconds', () => { + // WCAG 2.3.1: a flash more than three times a second can trigger a + // seizure. A 3s floor on the whole cycle keeps every animated decoration + // — however it is tuned later — far under that limit. + const css = themeCss() + const durations = [...css.matchAll(/--animate-season-[a-z-]+:\s*[a-z-]+\s+([\d.]+)s\b/g)] + expect(durations.length).toBeGreaterThan(0) + for (const [, seconds] of durations) { + expect(Number(seconds)).toBeGreaterThanOrEqual(3) + } + }) + + it('only moves or fades in seasonal @keyframes, never triggers layout', () => { + const css = themeCss() + const names = [...css.matchAll(/--animate-season-([a-z-]+):/g)].map((m) => m[1]) + expect(names.length).toBeGreaterThan(0) + const allowed = /^(transform|translate|rotate|scale|opacity)$/ + for (const name of names) { + const block = new RegExp(`@keyframes\\s+season-${name}\\s*\\{([\\s\\S]*?)\\n \\}`).exec(css)?.[1] ?? '' + expect(block.length).toBeGreaterThan(0) + const properties = [...block.matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1] ?? '') + const disallowed = properties.filter((property) => !allowed.test(property)) + expect(disallowed).toEqual([]) + } + }) + + it('keeps winter\'s primary and secondary at least 30° from error, in both schemes', () => { + // Winter is deliberately red-free so interactive elements never read as + // an error state (spec winter-season). + const colors = schemeColors(seasonCss('winter') ?? '') + for (const scheme of ['light', 'dark'] as const) { + const error = hue(colors.get('error')?.[scheme] ?? '#000000') + const primary = hue(colors.get('primary')?.[scheme] ?? '#000000') + const secondary = hue(colors.get('secondary')?.[scheme] ?? '#000000') + expect(hueDistance(primary, error)).toBeGreaterThanOrEqual(30) + expect(hueDistance(secondary, error)).toBeGreaterThanOrEqual(30) + } + }) }) From 604ade4b83db3fd366827f987958d904328c4021 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 09:42:47 +0200 Subject: [PATCH 05/16] 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 --- AGENTS.md | 18 ++++++++++++------ nuxt.config.ts | 8 ++++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9ecfbcf1..8475bc8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,12 +204,18 @@ in the pull request why the rise is unavoidable. ### Seasonal skins The `season` layer decides on the server which calendar season is in effect -(Halloween: 20 Oct – 2 Nov, Berlin time); `app.vue` sets `data-season` on -``, and `assets/css/seasons.css` — generated by -`scripts/md3-tokens.mjs`, never hand-edited — recolours every role token. -Preview any time with `?season=halloween`; `NUXT_PUBLIC_SEASON=none` is the -production kill switch that needs no deploy. Details: `tailwind-design` -skill, section "Seasons". +(Berlin time; windows do not overlap): + +- `spring`: 20 March – 20 April +- `halloween`: 20 October – 2 November +- `winter`: 1 – 26 December +- `new-year`: 27 December – 6 January (wraps the year end) + +`app.vue` sets `data-season` on ``, and `assets/css/seasons.css` — +generated by `scripts/md3-tokens.mjs`, never hand-edited — recolours every +role token. Preview any time with `?season=` (e.g. `?season=winter`); +`NUXT_PUBLIC_SEASON=none` is the production kill switch that needs no +deploy. Details: `tailwind-design` skill, section "Seasons". When adding a skill, place it under `.claude/skills/` and list it here. diff --git a/nuxt.config.ts b/nuxt.config.ts index 5893bec6..92569745 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -292,10 +292,10 @@ export default defineNuxtConfig({ openCollectiveSlug: 'onelitefeather', openCollectiveGoal: 3000, openCollectiveCurrency: 'EUR', - // Forces a seasonal costume on (`halloween`) or off (`none`) ahead - // of the calendar, via NUXT_PUBLIC_SEASON — the kill switch that - // needs no deploy. Empty follows the dates in - // layers/season/utils/seasons.ts. + // Forces a seasonal costume on (`spring`, `halloween`, `winter`, + // `new-year`) or off (`none`) ahead of the calendar, via + // NUXT_PUBLIC_SEASON — the kill switch that needs no deploy. + // Empty follows the dates in layers/season/utils/seasons.ts. season: '', // Social handles consumed by usePageSeo for twitter:site / twitter:creator. // Empty strings are filtered out by the composable. From c696be5f0641695175b58bde018c0ad9111e1829 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 09:46:02 +0200 Subject: [PATCH 06/16] feat(season): let snowflakes and petals fall across the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/css/tailwind.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/css/tailwind.css b/assets/css/tailwind.css index cd988365..ac34015b 100644 --- a/assets/css/tailwind.css +++ b/assets/css/tailwind.css @@ -180,10 +180,10 @@ --animate-season-fall: season-fall 24s linear infinite; @keyframes season-fall { - 0% { transform: translate3d(0, -10%, 0) rotate(0deg); opacity: 0; } + 0% { transform: translate3d(0, -2rem, 0) rotate(0deg); opacity: 0; } 10% { opacity: 1; } 80% { opacity: 1; } - 100% { transform: translate3d(1rem, 130%, 0) rotate(70deg); opacity: 0; } + 100% { transform: translate3d(1.5rem, 45vh, 0) rotate(70deg); opacity: 0; } } /* Light points (new year): a gentle brightening, well under the 3-per- From 4b332791b0b0280b68bcdf24572a0175dae34e1a Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 09:46:07 +0200 Subject: [PATCH 07/16] feat(season): tidy the season register and decoration frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- layers/season/components/SeasonDecor.vue | 6 ++++-- layers/season/utils/seasons.ts | 14 ++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/layers/season/components/SeasonDecor.vue b/layers/season/components/SeasonDecor.vue index b5c420bd..7abe836d 100644 --- a/layers/season/components/SeasonDecor.vue +++ b/layers/season/components/SeasonDecor.vue @@ -17,8 +17,10 @@ * * This component is only the frame. The actual shapes live one per season in * SeasonDecor.vue (design D4) — explicitly imported and mapped by id, - * rather than resolved from a string, so the mapping stays type-checked and - * a season without an entry is a build error, not a silent blank frame. + * rather than resolved from a string, so the mapping stays type-checked. + * `DECORATIONS` is not itself checked against every season id, so a season + * with `decor: true` and no entry here would render a blank frame; the + * "mapping" test in season-decor.spec.ts catches that. */ import SeasonDecorHalloween from './SeasonDecorHalloween.vue' import SeasonDecorNewYear from './SeasonDecorNewYear.vue' diff --git a/layers/season/utils/seasons.ts b/layers/season/utils/seasons.ts index 848c2cfc..b6ea3bd1 100644 --- a/layers/season/utils/seasons.ts +++ b/layers/season/utils/seasons.ts @@ -74,10 +74,12 @@ export const SPRING: Season = { * readability — the order carries no meaning once the windows do not * overlap, which `overlappingSeasons` below enforces in a test. */ -export const SEASONS: readonly Season[] = [SPRING, -HALLOWEEN, -WINTER, -NEW_YEAR] +export const SEASONS: readonly Season[] = [ + SPRING, + HALLOWEEN, + WINTER, + NEW_YEAR, +] /** Override value that switches every season off, whatever the date says. */ export const SEASON_OFF = 'none' @@ -136,13 +138,13 @@ export interface SeasonOverlap { */ export function overlappingSeasons(seasons: readonly Season[]): SeasonOverlap[] { const overlaps: SeasonOverlap[] = [] + const year = everyDayOfTheYear() for (let i = 0; i < seasons.length; i += 1) { for (let j = i + 1; j < seasons.length; j += 1) { const first = seasons[i] const second = seasons[j] if (!first || !second) continue - const day = everyDayOfTheYear() - .find((candidate) => covers(first, candidate) && covers(second, candidate)) + const day = year.find((candidate) => covers(first, candidate) && covers(second, candidate)) if (day) overlaps.push({ first: first.id, second: second.id, day }) } } From 1ff8b72bceb90da473262ce8abff6f7ffd2e6d47 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 10:19:54 +0200 Subject: [PATCH 08/16] feat(season): show corner decoration below the navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- layers/season/components/SeasonDecorHalloween.vue | 2 +- layers/season/components/SeasonDecorNewYear.vue | 4 ++-- layers/season/components/SeasonDecorSpring.vue | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/layers/season/components/SeasonDecorHalloween.vue b/layers/season/components/SeasonDecorHalloween.vue index 0499d341..1ea6b153 100644 --- a/layers/season/components/SeasonDecorHalloween.vue +++ b/layers/season/components/SeasonDecorHalloween.vue @@ -26,7 +26,7 @@ const BATS = [ Date: Thu, 24 Sep 2026 10:20:04 +0200 Subject: [PATCH 09/16] feat(season): draw proper six-fold snowflakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../season/components/SeasonDecorWinter.vue | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/layers/season/components/SeasonDecorWinter.vue b/layers/season/components/SeasonDecorWinter.vue index f78a6727..3e78bee7 100644 --- a/layers/season/components/SeasonDecorWinter.vue +++ b/layers/season/components/SeasonDecorWinter.vue @@ -5,12 +5,29 @@ * Colours are role tokens with opacity, same pattern as the other seasons. */ -/** A six-spoke crystal, mirrored for the right-hand corner. */ -const CRYSTAL = 'M50 8v84M16 28l68 44M16 72l68-44M50 8l-10 12M50 8l10 12M50 92l-10-12M50 92l10 12' - + 'M16 28l4 15M16 28l15-4M84 28l-4 15M84 28l-15-4M16 72l4-15M16 72l15 4M84 72l-4-15M84 72l-15 4' +/** + * A proper six-fold crystal, mirrored for the right-hand corner: six arms on + * an exact 60° grid from the centre, each with two pairs of side branches + * partway along it (shorter towards the tip). Replaces an earlier version + * whose arms sat on an uneven grid and carried V-shaped tips — it read as + * arrows, not a snowflake. Generated by a small script (60°-spaced points, + * branches at 45%/72% of the radius); see the commit for the script. + */ +const CRYSTAL = 'M50 50L50 8 M40.5 25.6L50 31.1L59.5 25.6 M43.9 16.3L50 19.8L56.1 16.3 ' + + 'M50 50L86.4 29 M66.4 29.6L66.4 40.6L75.9 46.1 M76.2 27.9L76.2 34.9L82.3 38.4 ' + + 'M50 50L86.4 71 M75.9 54L66.4 59.5L66.4 70.5 M82.3 61.6L76.2 65.1L76.2 72.1 ' + + 'M50 50L50 92 M59.5 74.4L50 68.9L40.5 74.4 M56.1 83.7L50 80.2L43.9 83.7 ' + + 'M50 50L13.6 71 M33.6 70.5L33.6 59.5L24.1 54 M23.8 72.1L23.8 65.1L17.7 61.6 ' + + 'M50 50L13.6 29 M24.1 46.1L33.6 40.6L33.6 29.6 M17.7 38.4L23.8 34.9L23.8 27.9' -/** A small six-point snowflake, drifting slowly downward. */ -const SNOWFLAKE = 'M8 0v16M1 4l14 8M1 12l14-8' +/** A small six-fold snowflake, drifting slowly downward — the same shape as + * the corner crystal, scaled down to one branch pair per arm. */ +const SNOWFLAKE = 'M8 8L8 1 M5.7 2.8L8 4.1L10.3 2.8 ' + + 'M8 8L14.1 4.5 M11.3 3.5L11.3 6.1L13.6 7.4 ' + + 'M8 8L14.1 11.5 M13.6 8.6L11.3 9.9L11.3 12.5 ' + + 'M8 8L8 15 M10.3 13.2L8 11.9L5.7 13.2 ' + + 'M8 8L1.9 11.5 M4.7 12.5L4.7 9.9L2.4 8.6 ' + + 'M8 8L1.9 4.5 M2.4 7.4L4.7 6.1L4.7 3.5' const SNOWFLAKE_CLASS = 'absolute hidden animate-season-fall text-tertiary/40 motion-safe:md:block' @@ -26,16 +43,21 @@ const SNOWFLAKES = [ From 475f7695d5899d456fb81c3cf91294fc1f3d5232 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 10:21:04 +0200 Subject: [PATCH 10/16] feat(season): give the new year a midnight surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/css/seasons.css | 28 ++++++++-------- layers/season/utils/seasons.ts | 2 +- scripts/md3-tokens.d.mts | 8 ++++- scripts/md3-tokens.mjs | 52 +++++++++++++++++++++++------ tests/design-system/seasons.spec.ts | 3 +- 5 files changed, 66 insertions(+), 27 deletions(-) diff --git a/assets/css/seasons.css b/assets/css/seasons.css index 3bc58497..8245053a 100644 --- a/assets/css/seasons.css +++ b/assets/css/seasons.css @@ -126,20 +126,20 @@ html[data-season="new-year"] { --color-on-error: light-dark(#ffffff, #690005); --color-error-container: light-dark(#ffdad6, #93000a); --color-on-error-container: light-dark(#93000a, #ffdad6); - --color-surface: light-dark(#fdf8fe, #141317); - --color-on-surface: light-dark(#1c1b1f, #e6e1e7); - --color-on-surface-variant: light-dark(#484550, #cac4d1); - --color-surface-dim: light-dark(#ddd8df, #141317); - --color-surface-bright: light-dark(#fdf8fe, #3a383d); - --color-surface-container-lowest: light-dark(#ffffff, #0f0e12); - --color-surface-container-low: light-dark(#f7f2f8, #1c1b1f); - --color-surface-container: light-dark(#f1ecf3, #201f24); - --color-surface-container-high: light-dark(#ece6ed, #2b292e); - --color-surface-container-highest: light-dark(#e6e1e7, #363439); - --color-outline: light-dark(#797581, #938f9b); - --color-outline-variant: light-dark(#cac4d1, #484550); - --color-inverse-surface: light-dark(#313034, #e6e1e7); - --color-inverse-on-surface: light-dark(#f4eff6, #313034); + --color-surface: light-dark(#fdf7ff, #141123); + --color-on-surface: light-dark(#1d192b, #e7dff8); + --color-on-surface-variant: light-dark(#49435e, #cac2e2); + --color-surface-dim: light-dark(#ded6f0, #141123); + --color-surface-bright: light-dark(#fdf7ff, #3b364a); + --color-surface-container-lowest: light-dark(#ffffff, #0f0c1d); + --color-surface-container-low: light-dark(#f7f1ff, #1d192b); + --color-surface-container: light-dark(#f2ebff, #211d2f); + --color-surface-container-high: light-dark(#ece4fe, #2b273a); + --color-surface-container-highest: light-dark(#e7dff8, #363246); + --color-outline: light-dark(#7a7390, #948dab); + --color-outline-variant: light-dark(#cac2e2, #49435e); + --color-inverse-surface: light-dark(#322e41, #e7dff8); + --color-inverse-on-surface: light-dark(#f5eeff, #322e41); --color-inverse-primary: light-dark(#ccbeff, #625497); --color-scrim: light-dark(#000000, #000000); --color-shadow: light-dark(#000000, #000000); diff --git a/layers/season/utils/seasons.ts b/layers/season/utils/seasons.ts index b6ea3bd1..7e53ee9f 100644 --- a/layers/season/utils/seasons.ts +++ b/layers/season/utils/seasons.ts @@ -50,7 +50,7 @@ export const NEW_YEAR: Season = { start: { month: 12, day: 27 }, end: { month: 1, day: 6 }, decor: true, - themeColor: { light: '#fdf8fe', dark: '#141317' }, + themeColor: { light: '#fdf7ff', dark: '#141123' }, logo: 'images/seasons/new-year/logo.svg', favicon: '/images/seasons/new-year/favicon.svg', } diff --git a/scripts/md3-tokens.d.mts b/scripts/md3-tokens.d.mts index 1039de08..dba4de57 100644 --- a/scripts/md3-tokens.d.mts +++ b/scripts/md3-tokens.d.mts @@ -8,13 +8,19 @@ export const CUSTOM_COLORS: Record export const SCHEME_ROLES: Record export const START_MARKER: string export const END_MARKER: string -export function generateTokens(core?: SeasonSeeds['core'], custom?: Record): Record +export function generateTokens( + core?: SeasonSeeds['core'], + custom?: Record, + neutralChroma?: number, +): Record export function renderBlock(tokens?: Record): string export function currentBlock(css?: string): string | null export interface SeasonSeeds { core: Record<'primary' | 'secondary' | 'tertiary', string> custom: Record + /** See `generateTokens`'s neutralChroma parameter; unset for most seasons. */ + neutralChroma?: number } export const SEASONS: Record diff --git a/scripts/md3-tokens.mjs b/scripts/md3-tokens.mjs index 6092d6bf..b49b05b5 100644 --- a/scripts/md3-tokens.mjs +++ b/scripts/md3-tokens.mjs @@ -78,6 +78,13 @@ export const SEASONS = { 'brand-orange': '#D4AF37', 'brand-purple': '#3B2F7A', }, + // Fidelity's own neutral palette flattens to near-zero chroma, so the + // dark surface came out practically black instead of midnight violet. + // Chroma 16 at the primary's hue reads as a clearly tinted, midnight + // violet dark surface without turning muddy (design D3, "Nachtrag"); + // 8-12 stayed too close to neutral grey, 20+ started to compete with + // on-surface's own tint. + neutralChroma: 16, }, spring: { core: { @@ -153,13 +160,37 @@ function seasonsFile() { return fileURLToPath(new URL('../assets/css/seasons.css', import.meta.url)) } -function scheme(isDark, core) { +/** + * @param {boolean} isDark + * @param {{ primary: string, secondary: string, tertiary: string }} core + * @param {number} [neutralChroma] Overrides the neutral and neutral-variant + * palettes with `TonalPalette.fromHueAndChroma(primaryHue, …)` instead of + * Fidelity's own, near-grey neutrals. Fidelity derives its neutral palette + * from the primary seed too, but flattens its chroma almost to zero — + * fine for a brand blue, but it is why a near-black primary seed like + * new-year's midnight violet (#2A1B5C) still comes out neutral grey in the + * dark surface (design D3, "Nachtrag nach der Sichtprüfung"). Left + * unset, a season's output is unchanged from before this parameter existed. + */ +function scheme(isDark, core, neutralChroma) { const source = Hct.fromInt(argbFromHex(core.primary)) // Fidelity keeps primary-container close to the seed, so the brand blue // survives instead of being desaturated as Tonal Spot would. Its neutral - // palettes are reused; the three accent palettes each come from their own - // core colour, as Material Theme Builder does with custom core colours. + // palettes are reused unless neutralChroma overrides them; the three + // accent palettes each come from their own core colour, as Material Theme + // Builder does with custom core colours. const base = new SchemeFidelity(source, isDark, 0) + // The neutral-variant palette (outline, surface-variant-derived roles) + // reads a touch more tinted than plain neutral surfaces in Fidelity's own + // output too, so the override keeps that relationship: +4 chroma, a small + // additive step that stays legible instead of compounding a multiplier at + // higher chroma values. + const neutralPalette = neutralChroma === undefined + ? base.neutralPalette + : TonalPalette.fromHueAndChroma(source.hue, neutralChroma) + const neutralVariantPalette = neutralChroma === undefined + ? base.neutralVariantPalette + : TonalPalette.fromHueAndChroma(source.hue, neutralChroma + 4) return new DynamicScheme({ sourceColorArgb: source.toInt(), variant: base.variant, @@ -168,18 +199,19 @@ function scheme(isDark, core) { primaryPalette: TonalPalette.fromInt(argbFromHex(core.primary)), secondaryPalette: TonalPalette.fromInt(argbFromHex(core.secondary)), tertiaryPalette: TonalPalette.fromInt(argbFromHex(core.tertiary)), - neutralPalette: base.neutralPalette, - neutralVariantPalette: base.neutralVariantPalette, + neutralPalette, + neutralVariantPalette, }) } /** * Every generated token as `{ light, dark }` lowercase hex. Without arguments - * the base scheme; a season passes its own seeds. + * the base scheme; a season passes its own seeds and, optionally, its own + * neutralChroma (see `scheme()`). */ -export function generateTokens(core = CORE_COLORS, custom = CUSTOM_COLORS) { - const light = scheme(false, core) - const dark = scheme(true, core) +export function generateTokens(core = CORE_COLORS, custom = CUSTOM_COLORS, neutralChroma) { + const light = scheme(false, core, neutralChroma) + const dark = scheme(true, core, neutralChroma) const tokens = {} for (const [name, getter] of Object.entries(SCHEME_ROLES)) { tokens[name] = { light: hexFromArgb(light[getter]), dark: hexFromArgb(dark[getter]) } @@ -230,7 +262,7 @@ export function seasonDeclarations(id) { const season = SEASONS[id] if (!season) throw new Error(`Unknown season "${id}"`) const roles = {} - const tokens = generateTokens(season.core, season.custom) + const tokens = generateTokens(season.core, season.custom, season.neutralChroma) for (const [name, { light, dark }] of Object.entries(tokens)) { roles[`--color-${name}`] = `light-dark(${light}, ${dark})` } diff --git a/tests/design-system/seasons.spec.ts b/tests/design-system/seasons.spec.ts index 053ea99e..4ed28d76 100644 --- a/tests/design-system/seasons.spec.ts +++ b/tests/design-system/seasons.spec.ts @@ -22,7 +22,8 @@ function staleSeasonTokens(css: string): string[] { const stale: string[] = [] for (const [id, seeds] of Object.entries(SEASONS)) { const checkedIn = schemeColors(seasonCss(id, css) ?? '') - for (const [name, expected] of Object.entries(generateTokens(seeds.core, seeds.custom))) { + const tokens = generateTokens(seeds.core, seeds.custom, seeds.neutralChroma) + for (const [name, expected] of Object.entries(tokens)) { const actual = checkedIn.get(name) if (actual?.light !== expected.light || actual?.dark !== expected.dark) stale.push(`${id}: ${name}`) } From 9fb58e65b6ac0e577076da64c651d7391e174b98 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 10:21:56 +0200 Subject: [PATCH 11/16] feat(season): calm the new year and spring accents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/css/seasons.css | 34 +++++++++++++++++----------------- scripts/md3-tokens.mjs | 15 ++++++++++++--- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/assets/css/seasons.css b/assets/css/seasons.css index 8245053a..a3bee473 100644 --- a/assets/css/seasons.css +++ b/assets/css/seasons.css @@ -114,10 +114,10 @@ html[data-season="new-year"] { --color-on-primary: light-dark(#ffffff, #332566); --color-primary-container: light-dark(#2a1b5c, #2a1b5c); --color-on-primary-container: light-dark(#9384cb, #9384cb); - --color-secondary: light-dark(#735c00, #e9c349); - --color-on-secondary: light-dark(#ffffff, #3c2f00); - --color-secondary-container: light-dark(#fed65b, #af8d11); - --color-on-secondary-container: light-dark(#745c00, #281e00); + --color-secondary: light-dark(#6d5d2f, #dac58d); + --color-on-secondary: light-dark(#ffffff, #3b2f05); + --color-secondary-container: light-dark(#f7e1a6, #56481b); + --color-on-secondary-container: light-dark(#736334, #cbb780); --color-tertiary: light-dark(#0c131e, #c0c7d6); --color-on-tertiary: light-dark(#ffffff, #2a313c); --color-tertiary-container: light-dark(#212833, #212833); @@ -143,22 +143,22 @@ html[data-season="new-year"] { --color-inverse-primary: light-dark(#ccbeff, #625497); --color-scrim: light-dark(#000000, #000000); --color-shadow: light-dark(#000000, #000000); - --color-brand-orange: light-dark(#735c00, #e9c348); + --color-brand-orange: light-dark(#735c00, #e9c349); --color-on-brand-orange: light-dark(#ffffff, #3c2f00); - --color-brand-orange-container: light-dark(#ffe088, #574500); - --color-on-brand-orange-container: light-dark(#241a00, #ffe088); + --color-brand-orange-container: light-dark(#ffe086, #574500); + --color-on-brand-orange-container: light-dark(#231a00, #ffe086); --color-brand-purple: light-dark(#5f52a7, #c9bfff); --color-on-brand-purple: light-dark(#ffffff, #302175); --color-brand-purple-container: light-dark(#e5deff, #473a8d); --color-on-brand-purple-container: light-dark(#1a0261, #e5deff); /* decoration:start */ - --brand-magenta: #d4af37; + --brand-magenta: #9c8a57; --brand-cyan: #c0c7d6; --gradient-brand: linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%); --gradient-accent: linear-gradient(90deg, var(--color-brand-purple) 0%, var(--color-tertiary) 60%, var(--color-secondary) 100%); - --gradient-brand-light: linear-gradient(90deg, #7b6db2 0%, #dab53d 100%); - --gradient-accent-light: linear-gradient(90deg, #8579c9 0%, #979eac 60%, #e9c349 100%); + --gradient-brand-light: linear-gradient(90deg, #7b6db2 0%, #cbb780 100%); + --gradient-accent-light: linear-gradient(90deg, #8579c9 0%, #979eac 60%, #dac58d 100%); /* decoration:end */ } @@ -167,10 +167,10 @@ html[data-season="spring"] { --color-on-primary: light-dark(#ffffff, #003a05); --color-primary-container: light-dark(#348431, #52a24a); --color-on-primary-container: light-dark(#f8fff0, #001a01); - --color-secondary: light-dark(#a43463, #ffb0c9); - --color-on-secondary: light-dark(#ffffff, #650034); - --color-secondary-container: light-dark(#fd7bab, #851a4b); - --color-on-secondary-container: light-dark(#760b40, #ff95b9); + --color-secondary: light-dark(#854e61, #f9b3c9); + --color-on-secondary: light-dark(#ffffff, #4f2133); + --color-secondary-container: light-dark(#ffb9ce, #6c394b); + --color-on-secondary-container: light-dark(#7b4658, #eaa5bb); --color-tertiary: light-dark(#745b00, #ebc246); --color-on-tertiary: light-dark(#ffffff, #3d2f00); --color-tertiary-container: light-dark(#cda72b, #cda72b); @@ -206,11 +206,11 @@ html[data-season="spring"] { --color-on-brand-purple-container: light-dark(#390035, #ffd7f2); /* decoration:start */ - --brand-magenta: #e86a9a; + --brand-magenta: #af7286; --brand-cyan: #f2c94c; --gradient-brand: linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%); --gradient-accent: linear-gradient(90deg, var(--color-brand-purple) 0%, var(--color-tertiary) 60%, var(--color-secondary) 100%); - --gradient-brand-light: linear-gradient(90deg, #378733 0%, #ff9bbd 100%); - --gradient-accent-light: linear-gradient(90deg, #b868aa 0%, #be9a1d 60%, #ffb0c9 100%); + --gradient-brand-light: linear-gradient(90deg, #378733 0%, #eaa6bb 100%); + --gradient-accent-light: linear-gradient(90deg, #b868aa 0%, #be9a1d 60%, #f9b3c9 100%); /* decoration:end */ } diff --git a/scripts/md3-tokens.mjs b/scripts/md3-tokens.mjs index b49b05b5..7260a959 100644 --- a/scripts/md3-tokens.mjs +++ b/scripts/md3-tokens.mjs @@ -71,11 +71,16 @@ export const SEASONS = { 'new-year': { core: { primary: '#2A1B5C', // midnight violet: structure and the surfaces' undertone - secondary: '#D4AF37', // gold: everything interactive, focus ring included + // Calmed after the visual review: D4AF37 made an eye-watering + // secondary-container (bright yellow active nav pill) even though its + // chroma (47.8) looked ordinary next to the base secondary's (51.2). + // Same HCT hue, chroma roughly halved (~24) and a slightly lower + // tone — inside the spec's 35-55° hue band. + secondary: '#9C8A57', // muted gold: everything interactive, focus ring included tertiary: '#C0C7D6', // silver: the rare accent }, custom: { - 'brand-orange': '#D4AF37', + 'brand-orange': '#9C8A57', 'brand-purple': '#3B2F7A', }, // Fidelity's own neutral palette flattens to near-zero chroma, so the @@ -89,7 +94,11 @@ export const SEASONS = { spring: { core: { primary: '#3F8F3A', // spring green: structure and the surfaces' undertone - secondary: '#E86A9A', // blossom pink: everything interactive, focus ring included + // Calmed after the visual review: E86A9A made an eye-watering + // secondary-container (bright pink active nav pill). Same HCT hue, + // chroma roughly halved (~30) and a slightly lower tone — inside the + // spec's 320-355° hue band. + secondary: '#AF7286', // muted blossom pink: everything interactive, focus ring included tertiary: '#F2C94C', // daffodil yellow: the rare accent }, custom: { From ce11f799805861d38d05f7e4a37c524d7b8c3b84 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 10:22:08 +0200 Subject: [PATCH 12/16] feat(season): recolour the seasonal logos throughout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- public/images/seasons/new-year/favicon.svg | 2 +- public/images/seasons/new-year/logo.svg | 2 +- public/images/seasons/spring/favicon.svg | 2 +- public/images/seasons/spring/logo.svg | 2 +- public/images/seasons/winter/favicon.svg | 2 +- public/images/seasons/winter/logo.svg | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/public/images/seasons/new-year/favicon.svg b/public/images/seasons/new-year/favicon.svg index e39d0180..ab55d817 100644 --- a/public/images/seasons/new-year/favicon.svg +++ b/public/images/seasons/new-year/favicon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/images/seasons/new-year/logo.svg b/public/images/seasons/new-year/logo.svg index 30697fed..b3478af3 100644 --- a/public/images/seasons/new-year/logo.svg +++ b/public/images/seasons/new-year/logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/images/seasons/spring/favicon.svg b/public/images/seasons/spring/favicon.svg index 48633b86..01cdc99e 100644 --- a/public/images/seasons/spring/favicon.svg +++ b/public/images/seasons/spring/favicon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/images/seasons/spring/logo.svg b/public/images/seasons/spring/logo.svg index 1dcb0b0c..977b46f3 100644 --- a/public/images/seasons/spring/logo.svg +++ b/public/images/seasons/spring/logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/images/seasons/winter/favicon.svg b/public/images/seasons/winter/favicon.svg index 4497ca36..351cf5f1 100644 --- a/public/images/seasons/winter/favicon.svg +++ b/public/images/seasons/winter/favicon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/images/seasons/winter/logo.svg b/public/images/seasons/winter/logo.svg index 31bfc932..918c4cda 100644 --- a/public/images/seasons/winter/logo.svg +++ b/public/images/seasons/winter/logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From 0cdaee2acd05d8f94c851614470c2faa22999fe3 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 10:23:20 +0200 Subject: [PATCH 13/16] feat(season): light a gentle firework for the new year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/css/tailwind.css | 14 ++++ .../season/components/SeasonDecorNewYear.vue | 72 ++++++++++++++++++- tests/design-system/seasons.spec.ts | 15 ++++ tests/season/season-decor.spec.ts | 36 +++++++++- 4 files changed, 133 insertions(+), 4 deletions(-) diff --git a/assets/css/tailwind.css b/assets/css/tailwind.css index ac34015b..003e1961 100644 --- a/assets/css/tailwind.css +++ b/assets/css/tailwind.css @@ -196,6 +196,20 @@ 50% { opacity: 0.8; transform: scale(1); } } + /* Firework bursts (new year): a slow open — scale and opacity only reach + their peak at 15% of the 7s cycle (1.05s, over the spec's 1s floor for + fading in), so nothing flashes into view. It then glimmers out with a + slight droop and stays fully faded for the rest of the cycle, ready + for the next bucket's staggered animation-delay to take over. */ + --animate-season-burst: season-burst 7s var(--ease-standard) infinite; + + @keyframes season-burst { + 0% { opacity: 0; transform: scale(0.3); } + 15% { opacity: 0.7; transform: scale(1); } + 60% { opacity: 0; transform: scale(1.08) translateY(0.25rem); } + 100% { opacity: 0; transform: scale(1.08) translateY(0.25rem); } + } + /* Gradients. Note there is deliberately no --gradient-from/via/to here: `gradient-*` is not a utility namespace in Tailwind v4, so those custom properties diff --git a/layers/season/components/SeasonDecorNewYear.vue b/layers/season/components/SeasonDecorNewYear.vue index aaac1a88..aa17fd7e 100644 --- a/layers/season/components/SeasonDecorNewYear.vue +++ b/layers/season/components/SeasonDecorNewYear.vue @@ -1,9 +1,11 @@ diff --git a/tests/design-system/seasons.spec.ts b/tests/design-system/seasons.spec.ts index 4ed28d76..02ed6b98 100644 --- a/tests/design-system/seasons.spec.ts +++ b/tests/design-system/seasons.spec.ts @@ -166,6 +166,21 @@ describe('seasonal colour effect', () => { } }) + it('opens a firework bucket over at least a second before its first peak', () => { + // spec new-year-season, "Neujahrs-Deko mit sanftem Feuerwerk": the + // fade-in alone — not the whole cycle — must take at least 1s, so a + // bucket never seems to pop into view. + const css = themeCss() + const duration = Number(/--animate-season-burst:\s*[a-z-]+\s+([\d.]+)s/.exec(css)?.[1]) + expect(duration).toBeGreaterThan(0) + const block = /@keyframes\s+season-burst\s*\{([\s\S]*?)\n {4}\}/.exec(css)?.[1] ?? '' + const steps = [...block.matchAll(/(\d+(?:\.\d+)?)%\s*\{[^}]*opacity:\s*([\d.]+)/g)] + .map((match) => ({ percent: Number(match[1]), opacity: Number(match[2]) })) + const firstPeak = steps.find((step) => step.percent > 0 && step.opacity > 0) + expect(firstPeak).toBeDefined() + expect(((firstPeak?.percent ?? 0) / 100) * duration).toBeGreaterThanOrEqual(1) + }) + it('keeps winter\'s primary and secondary at least 30° from error, in both schemes', () => { // Winter is deliberately red-free so interactive elements never read as // an error state (spec winter-season). diff --git a/tests/season/season-decor.spec.ts b/tests/season/season-decor.spec.ts index 04616e32..5ebd5a07 100644 --- a/tests/season/season-decor.spec.ts +++ b/tests/season/season-decor.spec.ts @@ -134,10 +134,44 @@ describe('during new year', () => { } }) - it('never uses the drift or fall animation, only the flash-free twinkle', async () => { + it('never uses the drift or fall animation for its corners and lights', async () => { const html = (await mountWith(newYear)).html() + // The lights use the flash-free twinkle; the fireworks (checked below) + // are the only place animate-season-burst appears. expect(html).not.toMatch(/animate-season-(drift|fall)/) }) + + it('shows at most four firework buckets, only from md up, and none under reduced motion', async () => { + const bursts = (await mountWith(newYear)).findAll('[data-decor="burst"]') + expect(bursts.length).toBeGreaterThan(0) + expect(bursts.length).toBeLessThanOrEqual(4) + for (const burst of bursts) { + expect(burst.classes()).toEqual(expect.arrayContaining(['hidden', +'motion-safe:md:block', +'animate-season-burst'])) + expect(burst.classes()).not.toContain('md:block') + } + }) + + it('never sizes a firework bucket above 160×160px', async () => { + // Tailwind's default spacing scale: h-/w- is n * 4px. + const bursts = (await mountWith(newYear)).findAll('[data-decor="burst"]') + expect(bursts.length).toBeGreaterThan(0) + for (const burst of bursts) { + for (const axis of ['h', 'w'] as const) { + for (const utility of burst.classes()) { + const match = new RegExp(`^(?:[a-z]+:)?${axis}-(\\d+)$`).exec(utility) + if (match) expect(Number(match[1]) * 4).toBeLessThanOrEqual(160) + } + } + } + }) + + it('colours every firework bucket from a season role, never red', async () => { + const html = (await mountWith(newYear)).html() + expect(html).toMatch(/data-decor="burst"/) + expect(html).not.toMatch(/text-error|bg-error/) + }) }) describe('during spring', () => { From 576f8b81090bc48ab302610baa5fbed9a77af653 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 10:38:50 +0200 Subject: [PATCH 14/16] feat(season): pick a calmer, clearer new year gold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/css/seasons.css | 22 +++++++++++----------- scripts/md3-tokens.mjs | 21 ++++++++++++++------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/assets/css/seasons.css b/assets/css/seasons.css index a3bee473..e850775a 100644 --- a/assets/css/seasons.css +++ b/assets/css/seasons.css @@ -114,10 +114,10 @@ html[data-season="new-year"] { --color-on-primary: light-dark(#ffffff, #332566); --color-primary-container: light-dark(#2a1b5c, #2a1b5c); --color-on-primary-container: light-dark(#9384cb, #9384cb); - --color-secondary: light-dark(#6d5d2f, #dac58d); - --color-on-secondary: light-dark(#ffffff, #3b2f05); - --color-secondary-container: light-dark(#f7e1a6, #56481b); - --color-on-secondary-container: light-dark(#736334, #cbb780); + --color-secondary: light-dark(#775a00, #ecc159); + --color-on-secondary: light-dark(#ffffff, #3f2e00); + --color-secondary-container: light-dark(#fed268, #8c6b00); + --color-on-secondary-container: light-dark(#765900, #fff3df); --color-tertiary: light-dark(#0c131e, #c0c7d6); --color-on-tertiary: light-dark(#ffffff, #2a313c); --color-tertiary-container: light-dark(#212833, #212833); @@ -143,22 +143,22 @@ html[data-season="new-year"] { --color-inverse-primary: light-dark(#ccbeff, #625497); --color-scrim: light-dark(#000000, #000000); --color-shadow: light-dark(#000000, #000000); - --color-brand-orange: light-dark(#735c00, #e9c349); - --color-on-brand-orange: light-dark(#ffffff, #3c2f00); - --color-brand-orange-container: light-dark(#ffe086, #574500); - --color-on-brand-orange-container: light-dark(#231a00, #ffe086); + --color-brand-orange: light-dark(#775a00, #f0c048); + --color-on-brand-orange: light-dark(#ffffff, #3f2e00); + --color-brand-orange-container: light-dark(#ffdf9a, #5a4300); + --color-on-brand-orange-container: light-dark(#251a00, #ffdf9a); --color-brand-purple: light-dark(#5f52a7, #c9bfff); --color-on-brand-purple: light-dark(#ffffff, #302175); --color-brand-purple-container: light-dark(#e5deff, #473a8d); --color-on-brand-purple-container: light-dark(#1a0261, #e5deff); /* decoration:start */ - --brand-magenta: #9c8a57; + --brand-magenta: #b8922e; --brand-cyan: #c0c7d6; --gradient-brand: linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%); --gradient-accent: linear-gradient(90deg, var(--color-brand-purple) 0%, var(--color-tertiary) 60%, var(--color-secondary) 100%); - --gradient-brand-light: linear-gradient(90deg, #7b6db2 0%, #cbb780 100%); - --gradient-accent-light: linear-gradient(90deg, #8579c9 0%, #979eac 60%, #dac58d 100%); + --gradient-brand-light: linear-gradient(90deg, #7b6db2 0%, #ddb34d 100%); + --gradient-accent-light: linear-gradient(90deg, #8579c9 0%, #979eac 60%, #ecc159 100%); /* decoration:end */ } diff --git a/scripts/md3-tokens.mjs b/scripts/md3-tokens.mjs index 7260a959..3cc6b318 100644 --- a/scripts/md3-tokens.mjs +++ b/scripts/md3-tokens.mjs @@ -71,16 +71,23 @@ export const SEASONS = { 'new-year': { core: { primary: '#2A1B5C', // midnight violet: structure and the surfaces' undertone - // Calmed after the visual review: D4AF37 made an eye-watering - // secondary-container (bright yellow active nav pill) even though its - // chroma (47.8) looked ordinary next to the base secondary's (51.2). - // Same HCT hue, chroma roughly halved (~24) and a slightly lower - // tone — inside the spec's 35-55° hue band. - secondary: '#9C8A57', // muted gold: everything interactive, focus ring included + // First calming pass (chroma ~24) overshot: the rendered `secondary` + // role — used broadly for buttons, chips, pills — turned muddy khaki + // rather than gold, because a dark tone at low chroma reads as + // desaturated brown, not "dark gold". This keeps roughly the original + // chroma (~43, close to winter's own gold at 48.6, which nobody + // objected to) so `secondary` itself stays a clear gold; only the + // *container* comes out a little calmer than the original #D4AF37's + // screaming #fed65b, landing on a warm #fed268. Hue inside the spec's + // 35-55° band either way. + secondary: '#B8922E', // gold: everything interactive, focus ring included tertiary: '#C0C7D6', // silver: the rare accent }, custom: { - 'brand-orange': '#9C8A57', + // Not harmonised (customColor blend:false), so this can glow brighter + // than the UI role above without dragging text/background contrast + // down with it — reserved for decoration (fireworks, stars). + 'brand-orange': '#E2B64A', // festive gold: fireworks and stars only 'brand-purple': '#3B2F7A', }, // Fidelity's own neutral palette flattens to near-zero chroma, so the From e401dc158cb3bc35a1c5399dcdafc4a8401e7cd0 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 10:38:57 +0200 Subject: [PATCH 15/16] feat(season): draw a real firework and a sparkle cluster for new year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../season/components/SeasonDecorNewYear.vue | 107 ++++++++++++------ 1 file changed, 71 insertions(+), 36 deletions(-) diff --git a/layers/season/components/SeasonDecorNewYear.vue b/layers/season/components/SeasonDecorNewYear.vue index aa17fd7e..5431d2ac 100644 --- a/layers/season/components/SeasonDecorNewYear.vue +++ b/layers/season/components/SeasonDecorNewYear.vue @@ -1,15 +1,20 @@ @@ -64,12 +96,14 @@ const BURSTS = [ + +