Skip to content
Open
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
24 changes: 24 additions & 0 deletions .claude/skills/tailwind-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,30 @@ Use `focus-ring`. Inside a container that clips overflow (menus) draw it
inside: `focus-visible:outline-3 focus-visible:-outline-offset-3
focus-visible:outline-secondary`. Never a bare `focus:` ring.

## Seasons (Halloween and later)

A season repaints the site by redeclaring the MD3 colour roles under
`html[data-season="<id>"]`. Those blocks live in `assets/css/seasons.css`,
which `scripts/md3-tokens.mjs` writes from the seeds in its `SEASONS`
register — **never edit that file by hand**; change the seeds and run
`node scripts/md3-tokens.mjs`. It is a separate file on purpose: the token
tests parse `tailwind.css` whole and a later declaration wins, so a season
written there would be read as the base scheme.

- Style components with role tokens only; they recolour for free. Raw
palette colours (`gray-900`, `white/10`) stay as they are in every season.
- Each season block also answers the fixed brand colours outside the roles
(`--gradient-*`, `--brand-magenta`, `--brand-cyan`). Adding another such
value to `tailwind.css` means giving it a seasonal answer in
`seasonDeclarations()` too — `tests/design-system/seasons.spec.ts` fails
until both agree.
- Which season is active is decided on the server (`layers/season`,
`useSeason`). Preview with `?season=halloween`, switch off with
`?season=none`; `NUXT_PUBLIC_SEASON` does either in production without a
build.
- Motion in decoration is opt-in (`motion-safe:md:block`): in Tailwind v4
`motion-reduce:hidden` sorts before `md:block` and loses to it.

## References

- `references/theme-authoring.md` — `@theme` vs `@theme inline` vs `@theme static`, animation tokens.
Expand Down
17 changes: 16 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Project Structure & Module Organization
- `layers/<domain>/`: one Nuxt layer per domain — `base`, `content-core`,
`blog`, `community-poi`, `events`, `team`, `home`, `sponsoring`,
`opencollective`, `navigation`, `footer`. Each holds its own `components/`, `composables/`,
`opencollective`, `navigation`, `footer`, `season`. Each holds its own `components/`, `composables/`,
`utils/`, `types.ts` and an `index.ts` that is its public surface.
- `pages/`, `layouts/`, `app.vue`: the orchestrator. These know every layer and
are the only place allowed to combine two domains.
Expand Down Expand Up @@ -202,6 +202,21 @@ measured it, so blocking on the absolute number would stall every pull request.
Never raise a baseline number to make a change pass. Fix the errors, or explain
in the pull request why the rise is unavoidable.

### Seasonal skins
The `season` layer decides on the server which calendar season is in effect
(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.

## Caching and SEO signals
Expand Down
31 changes: 23 additions & 8 deletions app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,44 @@
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<SeasonDecor />
</template>
<script setup lang="ts">
/**
* Browser-chrome colour per scheme. Mirrors the MD3 --color-surface role in
* assets/css/tailwind.css; tests/design-system/md3-tokens.spec.ts keeps the
* two in step when the generated roles change. A season brings its own.
*/
const BASE_THEME_COLOR = { light: '#fbf8ff', dark: '#121319' }
const BASE_FAVICON = '/favicon.svg'

const season = useSeason()
const themeColor = computed(() => season.value?.themeColor ?? BASE_THEME_COLOR)
const favicon = computed(() => season.value?.favicon ?? BASE_FAVICON)

useHead({
titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} | OneLiteFeather.net` : 'OneLiteFeather.net'
},
htmlAttrs: {
// Hint browsers/embed previews about colour scheme support.
'data-color-scheme': 'light dark'
'data-color-scheme': 'light dark',
// Switches on the season's colours in assets/css/seasons.css. Absent for
// most of the year, which keeps those rules inert.
'data-season': () => season.value?.id
},
link: [
{ rel: 'manifest', href: '/site.webmanifest' },
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
{ rel: 'mask-icon', href: '/favicon.svg', color: '#0b1020' }
// Replaces the base icon from nuxt.config's app.head (same key) rather
// than adding one: with two, the browser picks whichever it likes.
{ key: 'favicon', rel: 'icon', type: 'image/svg+xml', href: favicon },
{ rel: 'mask-icon', href: favicon, color: '#0b1020' }
],
meta: [
{ name: 'application-name', content: 'OneLiteFeather' },
{ name: 'apple-mobile-web-app-title', content: 'OneLiteFeather' },
// Both values mirror the MD3 --color-surface role in
// assets/css/tailwind.css; tests/design-system/md3-tokens.spec.ts keeps
// them in step when the generated roles change.
{ name: 'theme-color', media: '(prefers-color-scheme: light)', content: '#fbf8ff' },
{ name: 'theme-color', media: '(prefers-color-scheme: dark)', content: '#121319' },
{ name: 'theme-color', media: '(prefers-color-scheme: light)', content: () => themeColor.value.light },
{ name: 'theme-color', media: '(prefers-color-scheme: dark)', content: () => themeColor.value.dark },
{ name: 'color-scheme', content: 'light dark' },
{ name: 'format-detection', content: 'telephone=no' }
]
Expand Down
216 changes: 216 additions & 0 deletions assets/css/seasons.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/* Written by scripts/md3-tokens.mjs — do not edit by hand. */
/* Seasonal overrides of the MD3 roles in tailwind.css, applied while
<html data-season="…"> 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 */
}

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 */
}
Loading
Loading