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/assets/css/seasons.css b/assets/css/seasons.css index 4830e18d..e850775a 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(#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); + --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(#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); + --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: #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%, #ddb34d 100%); + --gradient-accent-light: linear-gradient(90deg, #8579c9 0%, #979eac 60%, #ecc159 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(#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); + --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: #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%, #eaa6bb 100%); + --gradient-accent-light: linear-gradient(90deg, #b868aa 0%, #be9a1d 60%, #f9b3c9 100%); + /* decoration:end */ +} diff --git a/assets/css/tailwind.css b/assets/css/tailwind.css index 28b40595..003e1961 100644 --- a/assets/css/tailwind.css +++ b/assets/css/tailwind.css @@ -173,6 +173,43 @@ 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, -2rem, 0) rotate(0deg); opacity: 0; } + 10% { opacity: 1; } + 80% { opacity: 1; } + 100% { transform: translate3d(1.5rem, 45vh, 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); } + } + + /* 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/SeasonDecor.vue b/layers/season/components/SeasonDecor.vue index dcbea2a8..7abe836d 100644 --- a/layers/season/components/SeasonDecor.vue +++ b/layers/season/components/SeasonDecor.vue @@ -7,32 +7,42 @@ * 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. + * `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' +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..1ea6b153 --- /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..5431d2ac --- /dev/null +++ b/layers/season/components/SeasonDecorNewYear.vue @@ -0,0 +1,148 @@ + + + diff --git a/layers/season/components/SeasonDecorSpring.vue b/layers/season/components/SeasonDecorSpring.vue new file mode 100644 index 00000000..2949aa7b --- /dev/null +++ b/layers/season/components/SeasonDecorSpring.vue @@ -0,0 +1,141 @@ + + + diff --git a/layers/season/components/SeasonDecorWinter.vue b/layers/season/components/SeasonDecorWinter.vue new file mode 100644 index 00000000..3e78bee7 --- /dev/null +++ b/layers/season/components/SeasonDecorWinter.vue @@ -0,0 +1,83 @@ + + + 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..7e53ee9f 100644 --- a/layers/season/utils/seasons.ts +++ b/layers/season/utils/seasons.ts @@ -26,7 +26,60 @@ 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: '#fdf7ff', dark: '#141123' }, + 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 +113,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[] = [] + 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 = year.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/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. diff --git a/public/images/seasons/new-year/favicon.svg b/public/images/seasons/new-year/favicon.svg new file mode 100644 index 00000000..ab55d817 --- /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..b3478af3 --- /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..01cdc99e --- /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..977b46f3 --- /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..351cf5f1 --- /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..918c4cda --- /dev/null +++ b/public/images/seasons/winter/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file 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 019a7bb4..3cc6b318 100644 --- a/scripts/md3-tokens.mjs +++ b/scripts/md3-tokens.mjs @@ -57,6 +57,62 @@ 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 + // 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: { + // 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 + // 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: { + primary: '#3F8F3A', // spring green: structure and the surfaces' undertone + // 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: { + 'brand-orange': '#F2A541', + 'brand-purple': '#B565A7', + }, + }, } export const SEASONS_HEADER = '/* Written by scripts/md3-tokens.mjs — do not edit by hand. */' @@ -120,13 +176,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, @@ -135,18 +215,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]) } @@ -197,7 +278,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 c42c49f5..02ed6b98 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}`) } @@ -98,20 +99,98 @@ b = 0] = [1, return (raw * 60 + 360) % 360 } -describe('halloween colour effect', () => { - const colors = schemeColors(seasonCss('halloween') ?? '') +/** + * 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 +} - 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) +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('keeps the light scheme light', () => { + 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.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('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). + 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) + } + }) }) 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')) + }, + ) }) 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-decor.spec.ts b/tests/season/season-decor.spec.ts index d90f9f40..5ebd5a07 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,156 @@ 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 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', () => { + 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') + } }) }) 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 } }, + ]) + }) +})