Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<html>`, 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 `<html>`, and `assets/css/seasons.css` —
generated by `scripts/md3-tokens.mjs`, never hand-edited — recolours every
role token. Preview any time with `?season=<id>` (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.

Expand Down
159 changes: 159 additions & 0 deletions assets/css/seasons.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
}
37 changes: 37 additions & 0 deletions assets/css/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 29 additions & 48 deletions layers/season/components/SeasonDecor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<Name>.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
})
</script>

<template>
Expand All @@ -42,35 +52,6 @@ const BATS = [
data-testid="season-decor"
class="pointer-events-none fixed inset-0 z-30 overflow-hidden select-none"
>
<template v-if="season?.id === 'halloween'">
<svg
v-for="side in ['-left-1', '-right-1 -scale-x-100']"
:key="side"
:class="['absolute -top-1 h-28 w-28 text-primary/25 sm:h-40 sm:w-40', side]"
data-decor="web"
viewBox="0 0 100 100"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path :d="WEB_RADIALS" />
<path :d="WEB_THREADS" />
</svg>

<!-- Bats stay off small screens, where a drifting shape crosses the
reading column, and off entirely under reduced motion. Opt-in
(motion-safe:md:block) rather than opt-out: `motion-reduce:hidden`
sorts before `md:block` in Tailwind v4 and would lose to it. -->
<svg
v-for="bat in BATS"
:key="bat.position"
:class="[BAT_CLASS, bat.position, bat.delay]"
data-decor="bat"
viewBox="0 0 64 32"
fill="currentColor"
>
<path :d="BAT" />
</svg>
</template>
<component :is="decoration" v-if="decoration" />
</div>
</template>
54 changes: 54 additions & 0 deletions layers/season/components/SeasonDecorHalloween.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<script setup lang="ts">
/**
* Halloween's decoration: two corner webs and, from md up and only when
* motion is welcome, up to three drifting bats. Moved unchanged out of
* SeasonDecor.vue when that became a per-season dispatcher (design D4).
*/

/** 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'

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]' },
]
</script>

<template>
<svg
v-for="side in ['-left-1', '-right-1 -scale-x-100']"
:key="side"
:class="['absolute top-16 h-28 w-28 text-primary/25 sm:h-40 sm:w-40', side]"
data-decor="web"
viewBox="0 0 100 100"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path :d="WEB_RADIALS" />
<path :d="WEB_THREADS" />
</svg>

<!-- Bats stay off small screens, where a drifting shape crosses the
reading column, and off entirely under reduced motion. Opt-in
(motion-safe:md:block) rather than opt-out: `motion-reduce:hidden`
sorts before `md:block` in Tailwind v4 and would lose to it. -->
<svg
v-for="bat in BATS"
:key="bat.position"
:class="[BAT_CLASS, bat.position, bat.delay]"
data-decor="bat"
viewBox="0 0 64 32"
fill="currentColor"
>
<path :d="BAT" />
</svg>
</template>
Loading
Loading