From a3f0bb7c20f9b8cc9cd90f832375663780615ac3 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 09:00:23 +0200 Subject: [PATCH 1/5] feat(design-system): generate seasonal colour schemes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/css/seasons.css | 57 +++++++++++ assets/css/tailwind.css | 17 ++++ scripts/md3-tokens.d.mts | 19 +++- scripts/md3-tokens.mjs | 142 ++++++++++++++++++++++++--- tests/design-system/contrast.spec.ts | 22 ++++- tests/design-system/seasons.spec.ts | 117 ++++++++++++++++++++++ tests/helpers/theme.ts | 29 +++++- 7 files changed, 387 insertions(+), 16 deletions(-) create mode 100644 assets/css/seasons.css create mode 100644 tests/design-system/seasons.spec.ts diff --git a/assets/css/seasons.css b/assets/css/seasons.css new file mode 100644 index 00000000..4830e18d --- /dev/null +++ b/assets/css/seasons.css @@ -0,0 +1,57 @@ +/* Written by scripts/md3-tokens.mjs — do not edit by hand. */ +/* Seasonal overrides of the MD3 roles in tailwind.css, applied while + is set (layers/season). `html[…]` outranks the + `:root` rule @theme writes, independent of bundle order. */ + +html[data-season="halloween"] { + --color-primary: light-dark(#430d6e, #dfb7ff); + --color-on-primary: light-dark(#ffffff, #471272); + --color-primary-container: light-dark(#5b2a86, #5b2a86); + --color-on-primary-container: light-dark(#cf9afd, #cf9afd); + --color-secondary: light-dark(#9e4300, #ffb691); + --color-on-secondary: light-dark(#ffffff, #552100); + --color-secondary-container: light-dark(#ff7518, #ee6803); + --color-on-secondary-container: light-dark(#5c2400, #461a00); + --color-tertiary: light-dark(#1f3300, #9cd83e); + --color-on-tertiary: light-dark(#ffffff, #213600); + --color-tertiary-container: light-dark(#304b00, #304b00); + --color-on-tertiary-container: light-dark(#87c127, #87c127); + --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(#fff7fe, #161218); + --color-on-surface: light-dark(#1e1a20, #e8e0e9); + --color-on-surface-variant: light-dark(#4c4450, #cec3d2); + --color-surface-dim: light-dark(#e0d7e0, #161218); + --color-surface-bright: light-dark(#fff7fe, #3c383e); + --color-surface-container-lowest: light-dark(#ffffff, #100d13); + --color-surface-container-low: light-dark(#faf1fa, #1e1a20); + --color-surface-container: light-dark(#f4ebf4, #221e24); + --color-surface-container-high: light-dark(#eee5ee, #2d282f); + --color-surface-container-highest: light-dark(#e8e0e9, #38333a); + --color-outline: light-dark(#7d7481, #978d9b); + --color-outline-variant: light-dark(#cec3d2, #4c4450); + --color-inverse-surface: light-dark(#332f35, #e8e0e9); + --color-inverse-on-surface: light-dark(#f7eef7, #332f35); + --color-inverse-primary: light-dark(#dfb7ff, #7847a4); + --color-scrim: light-dark(#000000, #000000); + --color-shadow: light-dark(#000000, #000000); + --color-brand-orange: light-dark(#9e4300, #ffb691); + --color-on-brand-orange: light-dark(#ffffff, #552100); + --color-brand-orange-container: light-dark(#ffdbcb, #783100); + --color-on-brand-orange-container: light-dark(#341100, #ffdbcb); + --color-brand-purple: light-dark(#8333c6, #dfb7ff); + --color-on-brand-purple: light-dark(#ffffff, #4b007e); + --color-brand-purple-container: light-dark(#f1daff, #690bac); + --color-on-brand-purple-container: light-dark(#2d004f, #f1daff); + + /* decoration:start */ + --brand-magenta: #ff7518; + --brand-cyan: #7cb518; + --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, #9361bf 0%, #ffa271 100%); + --gradient-accent-light: linear-gradient(90deg, #ab5eef 0%, #75ae0c 60%, #ffb691 100%); + /* decoration:end */ +} diff --git a/assets/css/tailwind.css b/assets/css/tailwind.css index 976dc48e..28b40595 100644 --- a/assets/css/tailwind.css +++ b/assets/css/tailwind.css @@ -1,4 +1,9 @@ @import 'tailwindcss'; +/* Seasonal overrides of the colour roles below, generated by + scripts/md3-tokens.mjs and active only under . Kept + in their own file so the token tests, which parse this file as a whole, + never read a season's value as the base scheme's. */ +@import './seasons.css'; :root { color-scheme: light dark; @@ -156,6 +161,18 @@ --ease-emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1); --ease-emphasized-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15); + /* Seasonal decoration (layers/season/components/SeasonDecor.vue). Only + `transform`, so a drifting bat costs compositing, not layout or paint. + The bats only render under motion-safe: the global reduced-motion rule + in tokens.css shortens animations but leaves a one-off jump in the + corner of the eye. */ + --animate-season-drift: season-drift 14s var(--ease-standard) infinite; + + @keyframes season-drift { + 0%, 100% { transform: translate3d(0, 0, 0); } + 50% { transform: translate3d(1.5rem, -0.75rem, 0); } + } + /* 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/scripts/md3-tokens.d.mts b/scripts/md3-tokens.d.mts index ca4dc585..1039de08 100644 --- a/scripts/md3-tokens.d.mts +++ b/scripts/md3-tokens.d.mts @@ -8,6 +8,23 @@ export const CUSTOM_COLORS: Record export const SCHEME_ROLES: Record export const START_MARKER: string export const END_MARKER: string -export function generateTokens(): Record +export function generateTokens(core?: SeasonSeeds['core'], custom?: Record): 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 +} + +export const SEASONS: Record +export const SEASONS_HEADER: string +export const BRIDGE_START: string +export const BRIDGE_END: string +export interface SeasonDeclarations { + roles: Record + bridge: Record +} + +export function seasonDeclarations(id: string): SeasonDeclarations +export function renderSeasons(): string diff --git a/scripts/md3-tokens.mjs b/scripts/md3-tokens.mjs index c9193851..019a7bb4 100644 --- a/scripts/md3-tokens.mjs +++ b/scripts/md3-tokens.mjs @@ -6,8 +6,14 @@ // @material/material-color-utilities reaches a bundle: only this script and // tests/design-system/md3-tokens.spec.ts import it. // -// node scripts/md3-tokens.mjs rewrite the generated block -// node scripts/md3-tokens.mjs --check exit 1 if the block is out of date +// Seasons (see SEASONS) come out of the same maths from their own seeds and +// are written to assets/css/seasons.css, one `html[data-season="…"]` block +// each. They live in a file of their own because every token test parses +// tailwind.css as a whole, where a later declaration wins: a season written +// there would be read back as the base scheme. +// +// node scripts/md3-tokens.mjs rewrite the generated block and seasons.css +// node scripts/md3-tokens.mjs --check exit 1 if either is out of date import { readFileSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { @@ -32,6 +38,38 @@ export const CUSTOM_COLORS = { 'brand-purple': '#91268F', } +/** + * Seasonal schemes, keyed by the id `data-season` carries on . Each + * replaces the three core colours and both custom colours; everything else — + * variant, contrast level, neutral palettes from primary — is the base recipe. + * The ids must match the registry in layers/season/utils/seasons.ts; a test + * holds the two in step. + */ +export const SEASONS = { + halloween: { + core: { + primary: '#5B2A86', // witch violet: structure and the surfaces' undertone + secondary: '#FF7518', // pumpkin: everything interactive, focus ring included + tertiary: '#7CB518', // poison green: the rare accent + }, + custom: { + 'brand-orange': '#FF7518', + 'brand-purple': '#6A0DAD', + }, + }, +} + +export const SEASONS_HEADER = '/* Written by scripts/md3-tokens.mjs — do not edit by hand. */' +/** + * Colour values outside the roles: the raw brand colours in `:root` (the + * connect box's glow) and the gradients in @theme. They are fixed brand hex, + * so without a seasonal answer they keep glowing magenta and cyan in the + * middle of a costume. A test fails as soon as tailwind.css gains one that + * this section does not answer. + */ +export const BRIDGE_START = '/* decoration:start */' +export const BRIDGE_END = '/* decoration:end */' + /** Token name → DynamicScheme getter, in the order they are written. */ export const SCHEME_ROLES = { 'primary': 'primary', @@ -78,8 +116,12 @@ function themeFile() { return fileURLToPath(new URL('../assets/css/tailwind.css', import.meta.url)) } -function scheme(isDark) { - const source = Hct.fromInt(argbFromHex(CORE_COLORS.primary)) +function seasonsFile() { + return fileURLToPath(new URL('../assets/css/seasons.css', import.meta.url)) +} + +function scheme(isDark, core) { + 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 @@ -90,24 +132,27 @@ function scheme(isDark) { variant: base.variant, contrastLevel: 0, isDark, - primaryPalette: TonalPalette.fromInt(argbFromHex(CORE_COLORS.primary)), - secondaryPalette: TonalPalette.fromInt(argbFromHex(CORE_COLORS.secondary)), - tertiaryPalette: TonalPalette.fromInt(argbFromHex(CORE_COLORS.tertiary)), + primaryPalette: TonalPalette.fromInt(argbFromHex(core.primary)), + secondaryPalette: TonalPalette.fromInt(argbFromHex(core.secondary)), + tertiaryPalette: TonalPalette.fromInt(argbFromHex(core.tertiary)), neutralPalette: base.neutralPalette, neutralVariantPalette: base.neutralVariantPalette, }) } -/** Every generated token as `{ light, dark }` lowercase hex. */ -export function generateTokens() { - const light = scheme(false) - const dark = scheme(true) +/** + * Every generated token as `{ light, dark }` lowercase hex. Without arguments + * the base scheme; a season passes its own seeds. + */ +export function generateTokens(core = CORE_COLORS, custom = CUSTOM_COLORS) { + const light = scheme(false, core) + const dark = scheme(true, core) const tokens = {} for (const [name, getter] of Object.entries(SCHEME_ROLES)) { tokens[name] = { light: hexFromArgb(light[getter]), dark: hexFromArgb(dark[getter]) } } - const sourceArgb = argbFromHex(CORE_COLORS.primary) - for (const [name, hex] of Object.entries(CUSTOM_COLORS)) { + const sourceArgb = argbFromHex(core.primary) + for (const [name, hex] of Object.entries(custom)) { const group = customColor(sourceArgb, { name, value: argbFromHex(hex), blend: false }) const roles = { [name]: 'color', @@ -143,13 +188,83 @@ export function currentBlock(css = readFileSync(themeFile(), 'utf8')) { return css.slice(lineStart, end + END_MARKER.length) } +/** + * Everything a season declares, in write order: every role and custom colour + * as a light-dark() pair, then the decoration colours outside the roles — + * raw brand colours and gradients. Values are the raw declaration text. + */ +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) + for (const [name, { light, dark }] of Object.entries(tokens)) { + roles[`--color-${name}`] = `light-dark(${light}, ${dark})` + } + const tone = (hex, t) => hexFromArgb(TonalPalette.fromInt(argbFromHex(hex)).tone(t)) + const { primary, secondary, tertiary } = season.core + const purple = season.custom['brand-purple'] + const bridge = {} + // The glow blends two raw colours with the orange custom colour; the season + // swaps in its secondary and tertiary seeds. + bridge['--brand-magenta'] = secondary.toLowerCase() + bridge['--brand-cyan'] = tertiary.toLowerCase() + // The main gradients sit on the page as text (GradientText), so they follow + // the scheme through the roles and stay as legible as the roles are. The + // -light variants are static, as in the base theme, at the tones the base + // theme's own -light stops sit on. + bridge['--gradient-brand'] = 'linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%)' + bridge['--gradient-accent'] = 'linear-gradient(90deg, var(--color-brand-purple) 0%, ' + + 'var(--color-tertiary) 60%, var(--color-secondary) 100%)' + bridge['--gradient-brand-light'] = `linear-gradient(90deg, ${tone(primary, 50)} 0%, ${tone(secondary, 75)} 100%)` + bridge['--gradient-accent-light'] = `linear-gradient(90deg, ${tone(purple, 55)} 0%, ` + + `${tone(tertiary, 65)} 60%, ${tone(secondary, 80)} 100%)` + return { roles, bridge } +} + +/** The complete contents of assets/css/seasons.css. */ +export function renderSeasons() { + const out = [ + SEASONS_HEADER, + '/* Seasonal overrides of the MD3 roles in tailwind.css, applied while', + ' is set (layers/season). `html[…]` outranks the', + ' `:root` rule @theme writes, independent of bundle order. */', + ] + for (const id of Object.keys(SEASONS)) { + const { roles, bridge } = seasonDeclarations(id) + out.push('', `html[data-season="${id}"] {`) + for (const [name, value] of Object.entries(roles)) out.push(` ${name}: ${value};`) + out.push('', ` ${BRIDGE_START}`) + for (const [name, value] of Object.entries(bridge)) out.push(` ${name}: ${value};`) + out.push(` ${BRIDGE_END}`, '}') + } + return `${out.join('\n')}\n` +} + +function readIfExists(path) { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + function main() { const css = readFileSync(themeFile(), 'utf8') const existing = currentBlock(css) const next = renderBlock() + const seasons = renderSeasons() if (process.argv.includes('--check')) { + let stale = false if (existing !== next) { console.error('MD3 colour tokens in assets/css/tailwind.css are out of date.') + stale = true + } + if (readIfExists(seasonsFile()) !== seasons) { + console.error('Seasonal colour tokens in assets/css/seasons.css are out of date.') + stale = true + } + if (stale) { console.error('Run: node scripts/md3-tokens.mjs') process.exit(1) } @@ -160,6 +275,7 @@ function main() { process.exit(1) } writeFileSync(themeFile(), css.replace(existing, next)) + writeFileSync(seasonsFile(), seasons) } if (process.argv[1] === fileURLToPath(import.meta.url)) main() diff --git a/tests/design-system/contrast.spec.ts b/tests/design-system/contrast.spec.ts index 09a801db..ac603efe 100644 --- a/tests/design-system/contrast.spec.ts +++ b/tests/design-system/contrast.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { collectSourceFiles, relativeToRepo } from '../helpers/sources' -import { schemeColors, themeCss, type SchemeColor } from '../helpers/theme' +import { schemeColors, seasonCss, seasonIds, themeCss, type SchemeColor } from '../helpers/theme' /** * WCAG 1.4.11 (Non-text Contrast, AA) asks for 3:1 between an interactive @@ -155,6 +155,26 @@ describe('MD3 colour roles', () => { }) }) +describe('seasonal colour roles', () => { + // A season is generated by the same maths from other seeds, and it is held + // to the same pairs: a costume that fails AA fails the audience it dresses. + it.each(seasonIds())('%s meets every contrast minimum in both schemes', (id) => { + const failures = contrastFailures(schemeColors(seasonCss(id) ?? '')) + expect(failures.map((failure) => `${id}: ${failure}`)).toEqual([]) + }) + + it('name the season, pair, scheme and ratio of a failing value', () => { + const tampered = seasonCss('halloween')?.replace( + /--color-on-secondary: light-dark\(#[0-9a-f]{6}/, + '--color-on-secondary: light-dark(#ff7518', + ) ?? '' + const failures = contrastFailures(schemeColors(tampered)).map((failure) => `halloween: ${failure}`) + expect(failures).toHaveLength(1) + expect(failures[0]) + .toMatch(/^halloween: on-secondary on secondary \(light\): \d\.\d{2} < 4\.5$/) + }) +}) + describe('interactive elements', () => { const files = collectSourceFiles(SOURCE_DIRS, ['.vue']) diff --git a/tests/design-system/seasons.spec.ts b/tests/design-system/seasons.spec.ts new file mode 100644 index 00000000..c42c49f5 --- /dev/null +++ b/tests/design-system/seasons.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { + BRIDGE_END, + BRIDGE_START, + generateTokens, + renderSeasons, + SEASONS, +} from '../../scripts/md3-tokens.mjs' +import { luminance, schemeColors, seasonCss, seasonIds, seasonsCss, themeCss } from '../helpers/theme' + +/** + * A season repaints the site by redeclaring the colour roles under + * `html[data-season="…"]` (assets/css/seasons.css). It only works if it + * redeclares *all* of them: one left out keeps its base value while everything + * around it changes, which is how a blue focus ring ends up on a pumpkin + * button. The generator guarantees completeness; these tests keep the + * checked-in file honest about it. + */ + +/** Season id and token name of every generated value that differs from the file. */ +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 actual = checkedIn.get(name) + if (actual?.light !== expected.light || actual?.dark !== expected.dark) stale.push(`${id}: ${name}`) + } + } + return stale +} + +/** The decoration section of one season block, one declared name per entry. */ +function bridgeNames(id: string): string[] { + const block = seasonCss(id) ?? '' + const start = block.indexOf(BRIDGE_START) + const end = block.indexOf(BRIDGE_END) + if (start === -1 || end === -1) return [] + return [...block.slice(start, end).matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1] ?? '') +} + +/** + * Colour values outside the MD3 roles: the raw brand colours in `:root` (the + * connect box's glow) and the gradients. They are fixed hex, so a season + * that does not answer them leaves them glowing in the base brand's colours. + */ +function decorationColours(): string[] { + const css = themeCss() + const names = [...css.matchAll(/(--(?:brand|gradient)-[a-z0-9-]+)\s*:/g)].map((m) => m[1] ?? '') + return [...new Set(names)].sort() +} + +describe('seasonal colour roles', () => { + it('are up to date with the generator', () => { + expect(seasonsCss()).toBe(renderSeasons()) + }) + + it('name the season and role that was edited by hand', () => { + const tampered = seasonsCss().replace( + /--color-secondary: light-dark\(#[0-9a-f]{6}/, + '--color-secondary: light-dark(#123456', + ) + expect(staleSeasonTokens(tampered)).toEqual(['halloween: secondary']) + expect(staleSeasonTokens(seasonsCss())).toEqual([]) + }) + + it('has one block for every season the generator knows', () => { + expect(seasonIds()).toEqual(Object.keys(SEASONS)) + }) + + it.each(Object.keys(SEASONS))('%s redeclares every role of the base scheme', (id) => { + const base = [...schemeColors(themeCss()).keys()].filter((name) => name in generateTokens()) + const season = schemeColors(seasonCss(id) ?? '') + expect(base.filter((name) => !season.has(name))).toEqual([]) + }) + + it.each(Object.keys(SEASONS))('%s answers every decoration colour exactly once', (id) => { + const bridged = bridgeNames(id) + expect(decorationColours().length).toBeGreaterThan(0) + expect([...bridged].sort()).toEqual(decorationColours()) + expect(bridged.length).toBe(new Set(bridged).size) + }) +}) + +/** HSL hue of `#rrggbb` in degrees, 0–360. */ +function hue(hex: string): number { + const [r = 0, +g = 0, +b = 0] = [1, +3, +5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255) + const max = Math.max(r, g, b) + const delta = max - Math.min(r, g, b) + if (delta === 0) return 0 + let raw = (r - g) / delta + 4 + if (max === r) raw = ((g - b) / delta) % 6 + else if (max === g) raw = (b - r) / delta + 2 + 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) + }) + + it('keeps the light scheme light', () => { + // A page that turns dark in light mode reads as broken, not dressed up. + expect(luminance(colors.get('surface')?.light ?? '#000000')).toBeGreaterThan(0.8) + }) +}) diff --git a/tests/helpers/theme.ts b/tests/helpers/theme.ts index 5f85129d..e5952804 100644 --- a/tests/helpers/theme.ts +++ b/tests/helpers/theme.ts @@ -8,6 +8,27 @@ export function themeCss(): string { return readFileSync(THEME_FILE, 'utf8') } +export const SEASONS_FILE = join(repoRoot, 'assets/css/seasons.css') + +export function seasonsCss(): string { + return readFileSync(SEASONS_FILE, 'utf8') +} + +/** + * The declarations of one season's `html[data-season=""]` block, or null + * when seasons.css has none. Feed it to `schemeColors()` to read the season's + * roles the way the base scheme's are read. + */ +export function seasonCss(id: string, css = seasonsCss()): string | null { + const match = new RegExp(`html\\[data-season="${id}"\\]\\s*\\{([^}]*)\\}`).exec(css) + return match?.[1] ?? null +} + +/** Ids of every season block in seasons.css, in file order. */ +export function seasonIds(css = seasonsCss()): string[] { + return [...css.matchAll(/html\[data-season="([a-z0-9-]+)"\]/g)].map((m) => m[1] ?? '') +} + /** Colour values of one scheme pair, lowercase `#rrggbb`. */ export interface SchemeColor { light: string @@ -81,9 +102,15 @@ export async function resolveCandidates( function compileOptions() { const tailwindDir = join(repoRoot, 'node_modules/tailwindcss') + const cssDir = join(repoRoot, 'assets/css') return { - base: join(repoRoot, 'assets/css'), + base: cssDir, loadStylesheet: async (id: string) => { + // The project's own imports (./seasons.css) sit next to tailwind.css. + if (id.startsWith('./')) { + const path = join(cssDir, id) + return { path, base: cssDir, content: readFileSync(path, 'utf8') } + } const file = id === 'tailwindcss' ? 'index.css' : id.replace(/^tailwindcss\//, '') return { path: join(tailwindDir, file), From 9e803b0931f4651da1cd87000b1135ea7dfb7eab Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 24 Sep 2026 09:00:23 +0200 Subject: [PATCH 2/5] feat(season): dress the site up for Halloween 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 --- app.vue | 31 +++-- .../navigation/components/NavigationBar.vue | 7 +- layers/season/components/SeasonDecor.vue | 76 ++++++++++++ layers/season/composables/useSeason.ts | 25 ++++ layers/season/index.ts | 4 + layers/season/nuxt.config.ts | 4 + .../season/plugins/season-preview.client.ts | 20 +++ layers/season/types.ts | 41 +++++++ layers/season/utils/seasons.ts | 105 ++++++++++++++++ layouts/default.vue | 7 +- nuxt.config.ts | 15 +++ public/images/seasons/halloween/favicon.svg | 1 + public/images/seasons/halloween/logo.svg | 1 + tests/design-system/md3-tokens.spec.ts | 6 +- tests/season/navigation-logo.spec.ts | 29 +++++ tests/season/resolve-season.spec.ts | 115 ++++++++++++++++++ tests/season/season-assets.spec.ts | 33 +++++ tests/season/season-decor.spec.ts | 68 +++++++++++ tests/season/season-registry.spec.ts | 26 ++++ tests/season/use-season.spec.ts | 50 ++++++++ 20 files changed, 650 insertions(+), 14 deletions(-) create mode 100644 layers/season/components/SeasonDecor.vue create mode 100644 layers/season/composables/useSeason.ts create mode 100644 layers/season/index.ts create mode 100644 layers/season/nuxt.config.ts create mode 100644 layers/season/plugins/season-preview.client.ts create mode 100644 layers/season/types.ts create mode 100644 layers/season/utils/seasons.ts create mode 100644 public/images/seasons/halloween/favicon.svg create mode 100644 public/images/seasons/halloween/logo.svg create mode 100644 tests/season/navigation-logo.spec.ts create mode 100644 tests/season/resolve-season.spec.ts create mode 100644 tests/season/season-assets.spec.ts create mode 100644 tests/season/season-decor.spec.ts create mode 100644 tests/season/season-registry.spec.ts create mode 100644 tests/season/use-season.spec.ts diff --git a/app.vue b/app.vue index 381a665f..825bc0ec 100644 --- a/app.vue +++ b/app.vue @@ -2,29 +2,44 @@ + + + diff --git a/layers/season/composables/useSeason.ts b/layers/season/composables/useSeason.ts new file mode 100644 index 00000000..1b296c49 --- /dev/null +++ b/layers/season/composables/useSeason.ts @@ -0,0 +1,25 @@ +import type { ActiveSeason } from '../types' +import { resolveSeason } from '../utils/seasons' + +/** + * The season in effect, decided once per request on the server. + * + * `useState` runs its initialiser on the server and ships the result in the + * payload, so the client adopts the server's answer instead of reading its own + * clock. Deciding on both sides would mismatch during hydration for anyone + * loading across midnight in Berlin, or from a device whose clock or zone + * disagrees — a bug that shows only on the days that matter. + * + * Only request-independent input goes in: `NUXT_PUBLIC_SEASON` (forces or + * kills a season in production without a build), then the calendar. The HTML + * is cached at the edge, so the render may not read the query + * (tests/architecture/request-independent-render.spec.ts); the `?season=` + * preview is applied in the browser after hydration instead + * (plugins/season-preview.client.ts). + */ +export function useSeason() { + return useState('season', () => { + const config = useRuntimeConfig() + return resolveSeason({ date: new Date(), overrides: [config.public.season] }) + }) +} diff --git a/layers/season/index.ts b/layers/season/index.ts new file mode 100644 index 00000000..3c573e65 --- /dev/null +++ b/layers/season/index.ts @@ -0,0 +1,4 @@ +export { HALLOWEEN, SEASONS, SEASON_OFF, previewSeason, resolveSeason } from './utils/seasons' +export type { ResolveSeasonOptions } from './utils/seasons' +export { useSeason } from './composables/useSeason' +export type * from './types' diff --git a/layers/season/nuxt.config.ts b/layers/season/nuxt.config.ts new file mode 100644 index 00000000..eece8416 --- /dev/null +++ b/layers/season/nuxt.config.ts @@ -0,0 +1,4 @@ +// Layer: season — the site's calendar-driven costume (Halloween): which season +// is in effect, its assets and its decoration. Colours come from +// assets/css/seasons.css; app.vue and the layout wire the rest in. +export default defineNuxtConfig({}) diff --git a/layers/season/plugins/season-preview.client.ts b/layers/season/plugins/season-preview.client.ts new file mode 100644 index 00000000..4ee564a6 --- /dev/null +++ b/layers/season/plugins/season-preview.client.ts @@ -0,0 +1,20 @@ +import { previewSeason } from '../utils/seasons' + +/** + * `?season=halloween` previews a season out of its window, `?season=none` + * switches the current one off — in the browser only, after hydration. + * + * The server may not read the query: the HTML is cached at the edge and must + * not depend on the request. So the page renders as the calendar says, and + * this swaps the shared season state once the app is mounted. Everything that + * reads `useSeason()` — `data-season`, favicon, theme-color, logo, decoration + * — follows reactively. A preview shows the base colours for a moment first; + * the real season, decided on the server, never does. + */ +export default defineNuxtPlugin((nuxtApp) => { + const season = useSeason() + nuxtApp.hook('app:mounted', () => { + const preview = previewSeason(window.location.search) + if (preview !== undefined) season.value = preview + }) +}) diff --git a/layers/season/types.ts b/layers/season/types.ts new file mode 100644 index 00000000..b00ff7d5 --- /dev/null +++ b/layers/season/types.ts @@ -0,0 +1,41 @@ +/** + * A seasonal costume: a named look the site puts on for a fixed part of the + * year and takes off again on its own. The colours are not part of this — they + * are generated per season id into assets/css/seasons.css and switched on by + * `data-season` on . + */ + +/** A day in the year, independent of the year. 1-based, like a calendar. */ +export interface SeasonDay { + /** 1 = January, 12 = December. */ + month: number + /** 1–31. */ + day: number +} + +export interface Season { + /** + * Reaches the DOM as `data-season` and the stylesheet as an attribute + * selector, so it stays lowercase and hyphenated. Must match a key of + * `SEASONS` in scripts/md3-tokens.mjs. + */ + id: string + /** First day in effect, inclusive. */ + start: SeasonDay + /** Last day in effect, inclusive. Before `start` means the window wraps the year end. */ + end: SeasonDay + /** Whether the decoration overlay renders. */ + decor: boolean + /** + * Browser-chrome colour per scheme. Mirrors the season's `--color-surface` + * in assets/css/seasons.css; a test holds the two together. + */ + themeColor: { light: string, dark: string } + /** Header logo, relative to `public/` as NuxtImg expects it. */ + logo: string + /** Favicon, as an absolute path. */ + favicon: string +} + +/** The season in effect. `null` for most of the year: no attribute, no overrides. */ +export type ActiveSeason = Season | null diff --git a/layers/season/utils/seasons.ts b/layers/season/utils/seasons.ts new file mode 100644 index 00000000..dd2bf90b --- /dev/null +++ b/layers/season/utils/seasons.ts @@ -0,0 +1,105 @@ +import type { ActiveSeason, Season, SeasonDay } from '../types' + +/** + * Seasonal registry and the rule that picks one. + * + * Pure functions only — no Nuxt, no browser, no clock of its own. The caller + * passes the date in, which is what lets `useSeason` resolve once on the + * server and lets the tests pin a moment. + */ + +/** The reference clock. A Cloudflare Worker runs in UTC; the audience does not. */ +const TIME_ZONE = 'Europe/Berlin' + +/** + * Opens well before 31 October so the site is dressed for the run-up rather + * than for the single day, and closes on 2 November — All Souls' Day, and one + * quiet day of overlap so nobody has to deploy at midnight. + */ +export const HALLOWEEN: Season = { + id: 'halloween', + start: { month: 10, day: 20 }, + end: { month: 11, day: 2 }, + decor: true, + themeColor: { light: '#fff7fe', dark: '#161218' }, + logo: 'images/seasons/halloween/logo.svg', + favicon: '/images/seasons/halloween/favicon.svg', +} + +export const SEASONS: readonly Season[] = [HALLOWEEN] + +/** Override value that switches every season off, whatever the date says. */ +export const SEASON_OFF = 'none' + +/** + * `month` and `day` for `date` as they read on a wall clock in Berlin. `Intl` + * converts zones without a bundled database, and the Workers runtime has it. + */ +function calendarDay(date: Date): SeasonDay { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: TIME_ZONE, + month: 'numeric', + day: 'numeric', + }).formatToParts(date) + const read = (type: 'month' | 'day') => Number(parts.find((part) => part.type === type)?.value) + return { month: read('month'), day: read('day') } +} + +/** Month and day collapsed into one sortable number. */ +function ordinal(day: SeasonDay): number { + return day.month * 100 + day.day +} + +/** Whether `day` falls inside the season's window, both ends inclusive. */ +function covers(season: Season, day: SeasonDay): boolean { + const current = ordinal(day) + const start = ordinal(season.start) + const end = ordinal(season.end) + return start <= end + ? current >= start && current <= end + : current >= start || current <= end +} + +export interface ResolveSeasonOptions { + /** The moment to resolve for. */ + date: Date + /** + * Values that force a season on (``) or off (`none`) ahead of the + * calendar, highest precedence first — the query parameter, then the + * environment variable. The first recognised value wins. They come from + * outside, so anything may arrive: an unknown or empty value is skipped + * rather than read as "no season", which keeps a typo from silently + * undressing the site during the season. + */ + overrides?: readonly (string | null | undefined)[] + /** Defaults to the registry; injectable so tests can exercise odd windows. */ + seasons?: readonly Season[] +} + +export function resolveSeason(options: ResolveSeasonOptions): ActiveSeason { + const { date, overrides = [], seasons = SEASONS } = options + + for (const override of overrides) { + if (override === SEASON_OFF) return null + const forced = seasons.find((season) => season.id === override) + if (forced) return forced + } + + const today = calendarDay(date) + return seasons.find((season) => covers(season, today)) ?? null +} + +/** + * The season a `?season=` preview asks for, from a URL search string. + * `undefined` means "no preview": no parameter, or a value that is neither a + * season id nor `none` — a typo then leaves the server's decision alone + * instead of re-deciding by the browser's clock. `null` means `none`. + */ +export function previewSeason( + search: string, + seasons: readonly Season[] = SEASONS, +): ActiveSeason | undefined { + const value = new URLSearchParams(search).get('season') + if (value === SEASON_OFF) return null + return seasons.find((season) => season.id === value) +} diff --git a/layouts/default.vue b/layouts/default.vue index 07fbfe53..105087b0 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -14,6 +14,11 @@ const routeTitle = computed(() => (route.meta?.title ? t(route.meta.title) : nul // Expose the main navigation as schema.org SiteNavigationElement so Google // has a structured signal when picking SERP sitelinks. useSiteNavigationSchema() + +// The navigation shows a season's own mark while one is in effect. Passed in +// rather than read there: navigation and season are domains, and only the +// orchestrator may combine two. +const season = useSeason()