feat: four OKLCH palette options + dev-only switcher

Extracts each palette into its own module under src/themes/, scoped
to :root[data-palette='<slug>'] so multiple can coexist and a JS
attribute swap is enough to flip the whole site:

- stone-warm — bone + matte-black, gallery-quiet (current default)
- desert-clay — warm cream with terracotta undertone, evening light
- forest-ash — sage-tinted neutral with mossy accent, nature-leaning
- charcoal-cream — high-contrast monochrome, Asheville moody by default

usePalette() reads ?palette=<slug>, falls back to localStorage, then
to stone-warm. Active palette is written to <html data-palette>.
PaletteSwitcher (the round Palette icon at bottom-right) only renders
when import.meta.env.DEV is true — Nicholette can use it to compare
the four side-by-side, but it ships out of the production bundle.

To make a different palette the production default, edit the bare
:root + .dark blocks at the bottom of themes/index.css. That keeps
the runtime swap working as a preview channel and lets us ship a
single hard-coded chrome once she chooses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-27 11:29:48 +02:00
commit f0980d7fb5
9 changed files with 356 additions and 45 deletions

View file

@ -3,6 +3,9 @@ import { RouterView } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import SiteHeader from './SiteHeader.vue'
import SiteFooter from './SiteFooter.vue'
import PaletteSwitcher from './PaletteSwitcher.vue'
const isDev = import.meta.env.DEV
</script>
<template>
@ -13,5 +16,6 @@ import SiteFooter from './SiteFooter.vue'
</main>
<SiteFooter />
<Toaster position="bottom-right" />
<PaletteSwitcher v-if="isDev" />
</div>
</template>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import { Palette } from '@lucide/vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { PALETTES, usePalette, type PaletteSlug } from '@/composables/usePalette'
const { palette, set } = usePalette()
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger
class="border-border bg-background/85 hover:bg-background fixed right-4 bottom-4 z-50 inline-flex h-10 w-10 items-center justify-center rounded-full border shadow-lg backdrop-blur transition-colors"
aria-label="Switch color palette"
>
<Palette class="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" class="w-72">
<DropdownMenuLabel class="text-xs uppercase tracking-[0.18em]">
Palette (dev only)
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
v-for="p in PALETTES"
:key="p.slug"
class="flex flex-col items-start gap-0.5 py-2.5"
:class="palette === p.slug ? 'bg-accent' : ''"
@select="set(p.slug as PaletteSlug)"
>
<span class="font-serif text-sm">{{ p.label }}</span>
<span class="text-muted-foreground text-xs">{{ p.description }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View file

@ -0,0 +1,54 @@
import { ref, watchEffect } from 'vue'
export const PALETTES = [
{
slug: 'stone-warm',
label: 'Stone Warm',
description: 'Bone background, matte-black ink. Gallery-quiet.',
},
{
slug: 'desert-clay',
label: 'Desert Clay',
description: 'Warm cream with terracotta undertone. Evening light.',
},
{
slug: 'forest-ash',
label: 'Forest & Ash',
description: 'Sage-tinted neutral, mossy accent. Nature integrated.',
},
{
slug: 'charcoal-cream',
label: 'Charcoal & Cream',
description: 'High-contrast monochrome. Moody architectural.',
},
] as const
export type PaletteSlug = (typeof PALETTES)[number]['slug']
const STORAGE_KEY = 'ewd:palette'
const DEFAULT: PaletteSlug = 'stone-warm'
function initial(): PaletteSlug {
if (typeof window === 'undefined') return DEFAULT
const url = new URL(window.location.href)
const fromQuery = url.searchParams.get('palette') as PaletteSlug | null
if (fromQuery && PALETTES.some((p) => p.slug === fromQuery)) return fromQuery
const stored = window.localStorage.getItem(STORAGE_KEY) as PaletteSlug | null
if (stored && PALETTES.some((p) => p.slug === stored)) return stored
return DEFAULT
}
const palette = ref<PaletteSlug>(initial())
watchEffect(() => {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-palette', palette.value)
window.localStorage.setItem(STORAGE_KEY, palette.value)
})
export function usePalette() {
function set(slug: PaletteSlug) {
palette.value = slug
}
return { palette, set }
}

View file

@ -1,53 +1,9 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import './themes/index.css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.985 0.005 80);
--foreground: oklch(0.2 0.012 60);
--card: oklch(0.985 0.005 80);
--card-foreground: oklch(0.2 0.012 60);
--popover: oklch(0.985 0.005 80);
--popover-foreground: oklch(0.2 0.012 60);
--primary: oklch(0.18 0.008 60);
--primary-foreground: oklch(0.97 0.008 80);
--secondary: oklch(0.94 0.008 75);
--secondary-foreground: oklch(0.2 0.012 60);
--muted: oklch(0.94 0.008 75);
--muted-foreground: oklch(0.5 0.015 60);
--accent: oklch(0.92 0.018 65);
--accent-foreground: oklch(0.2 0.012 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 80);
--border: oklch(0.88 0.01 70);
--input: oklch(0.88 0.01 70);
--ring: oklch(0.2 0.012 60);
--radius: 0.25rem;
}
.dark {
--background: oklch(0.16 0.008 60);
--foreground: oklch(0.95 0.01 80);
--card: oklch(0.18 0.008 60);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.008 60);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.95 0.01 80);
--primary-foreground: oklch(0.18 0.008 60);
--secondary: oklch(0.24 0.012 60);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.24 0.012 60);
--muted-foreground: oklch(0.7 0.012 70);
--accent: oklch(0.3 0.022 65);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.95 0.01 80);
--border: oklch(0.28 0.012 60);
--input: oklch(0.28 0.012 60);
--ring: oklch(0.85 0.012 75);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);

View file

@ -0,0 +1,50 @@
/* Palette: Charcoal & Cream
* High-contrast monochrome. Warm cream background, near-black warm
* charcoal ink, deep-bronze accent for the active states. The
* Asheville mood by default moody, architectural, restrained.
* In dark mode it goes deeper still.
*/
:root[data-palette='charcoal-cream'] {
--background: oklch(0.95 0.013 75);
--foreground: oklch(0.16 0.005 60);
--card: oklch(0.95 0.013 75);
--card-foreground: oklch(0.16 0.005 60);
--popover: oklch(0.95 0.013 75);
--popover-foreground: oklch(0.16 0.005 60);
--primary: oklch(0.16 0.005 60);
--primary-foreground: oklch(0.95 0.013 75);
--secondary: oklch(0.9 0.014 75);
--secondary-foreground: oklch(0.16 0.005 60);
--muted: oklch(0.9 0.014 75);
--muted-foreground: oklch(0.45 0.012 60);
--accent: oklch(0.86 0.025 70);
--accent-foreground: oklch(0.16 0.005 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.95 0.013 75);
--border: oklch(0.82 0.014 70);
--input: oklch(0.82 0.014 70);
--ring: oklch(0.16 0.005 60);
}
:root[data-palette='charcoal-cream'].dark {
--background: oklch(0.12 0.004 60);
--foreground: oklch(0.94 0.013 75);
--card: oklch(0.14 0.004 60);
--card-foreground: oklch(0.94 0.013 75);
--popover: oklch(0.14 0.004 60);
--popover-foreground: oklch(0.94 0.013 75);
--primary: oklch(0.94 0.013 75);
--primary-foreground: oklch(0.14 0.004 60);
--secondary: oklch(0.2 0.006 60);
--secondary-foreground: oklch(0.94 0.013 75);
--muted: oklch(0.2 0.006 60);
--muted-foreground: oklch(0.68 0.012 70);
--accent: oklch(0.26 0.015 65);
--accent-foreground: oklch(0.94 0.013 75);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.94 0.013 75);
--border: oklch(0.24 0.008 60);
--input: oklch(0.24 0.008 60);
--ring: oklch(0.85 0.014 75);
}

View file

@ -0,0 +1,49 @@
/* Palette: Desert Clay
* Warm creamy background with a terracotta undertone, espresso ink,
* burnt-clay accent. Matches Boulder's reclaimed wood / warm pendant
* mood. Reads as evening light, not gallery white.
*/
:root[data-palette='desert-clay'] {
--background: oklch(0.97 0.012 65);
--foreground: oklch(0.22 0.018 45);
--card: oklch(0.97 0.012 65);
--card-foreground: oklch(0.22 0.018 45);
--popover: oklch(0.97 0.012 65);
--popover-foreground: oklch(0.22 0.018 45);
--primary: oklch(0.4 0.07 35);
--primary-foreground: oklch(0.97 0.012 65);
--secondary: oklch(0.92 0.018 60);
--secondary-foreground: oklch(0.22 0.018 45);
--muted: oklch(0.93 0.014 60);
--muted-foreground: oklch(0.5 0.025 45);
--accent: oklch(0.86 0.04 45);
--accent-foreground: oklch(0.22 0.018 45);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.012 65);
--border: oklch(0.86 0.018 55);
--input: oklch(0.86 0.018 55);
--ring: oklch(0.4 0.07 35);
}
:root[data-palette='desert-clay'].dark {
--background: oklch(0.17 0.014 40);
--foreground: oklch(0.94 0.014 70);
--card: oklch(0.2 0.014 40);
--card-foreground: oklch(0.94 0.014 70);
--popover: oklch(0.2 0.014 40);
--popover-foreground: oklch(0.94 0.014 70);
--primary: oklch(0.85 0.05 50);
--primary-foreground: oklch(0.2 0.014 40);
--secondary: oklch(0.27 0.018 45);
--secondary-foreground: oklch(0.94 0.014 70);
--muted: oklch(0.27 0.018 45);
--muted-foreground: oklch(0.7 0.02 55);
--accent: oklch(0.34 0.045 45);
--accent-foreground: oklch(0.94 0.014 70);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.94 0.014 70);
--border: oklch(0.3 0.018 45);
--input: oklch(0.3 0.018 45);
--ring: oklch(0.8 0.04 50);
}

49
src/themes/forest-ash.css Normal file
View file

@ -0,0 +1,49 @@
/* Palette: Forest & Ash
* Cool sage-tinted background, charcoal ink, mossy accent. The
* "nature integrated" word from the brief made literal picks up
* the Boulder emerald tile and the Asheville pine-window framing.
*/
:root[data-palette='forest-ash'] {
--background: oklch(0.97 0.008 150);
--foreground: oklch(0.2 0.012 150);
--card: oklch(0.97 0.008 150);
--card-foreground: oklch(0.2 0.012 150);
--popover: oklch(0.97 0.008 150);
--popover-foreground: oklch(0.2 0.012 150);
--primary: oklch(0.32 0.04 150);
--primary-foreground: oklch(0.97 0.008 150);
--secondary: oklch(0.93 0.014 145);
--secondary-foreground: oklch(0.2 0.012 150);
--muted: oklch(0.93 0.012 145);
--muted-foreground: oklch(0.5 0.018 145);
--accent: oklch(0.86 0.03 150);
--accent-foreground: oklch(0.2 0.012 150);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 150);
--border: oklch(0.87 0.014 150);
--input: oklch(0.87 0.014 150);
--ring: oklch(0.32 0.04 150);
}
:root[data-palette='forest-ash'].dark {
--background: oklch(0.16 0.012 150);
--foreground: oklch(0.94 0.01 150);
--card: oklch(0.19 0.012 150);
--card-foreground: oklch(0.94 0.01 150);
--popover: oklch(0.19 0.012 150);
--popover-foreground: oklch(0.94 0.01 150);
--primary: oklch(0.86 0.04 150);
--primary-foreground: oklch(0.19 0.012 150);
--secondary: oklch(0.26 0.016 150);
--secondary-foreground: oklch(0.94 0.01 150);
--muted: oklch(0.26 0.016 150);
--muted-foreground: oklch(0.7 0.014 145);
--accent: oklch(0.33 0.03 150);
--accent-foreground: oklch(0.94 0.01 150);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.94 0.01 150);
--border: oklch(0.29 0.016 150);
--input: oklch(0.29 0.016 150);
--ring: oklch(0.8 0.04 150);
}

60
src/themes/index.css Normal file
View file

@ -0,0 +1,60 @@
/* Palette registry each module declares its tokens scoped to
* :root[data-palette='<slug>']. usePalette() sets the attribute.
* A bare :root fallback below copies the stone-warm tokens so the
* page renders correctly before JS attaches the attribute and for
* users with JS disabled.
*/
@import './stone-warm.css';
@import './desert-clay.css';
@import './forest-ash.css';
@import './charcoal-cream.css';
/* Pre-hydration fallback: same as stone-warm. Kept here (not in
* stone-warm.css under :root) so swapping which palette is the
* default is one edit point this block at the values from a
* different theme module. */
:root {
--background: oklch(0.985 0.005 80);
--foreground: oklch(0.2 0.012 60);
--card: oklch(0.985 0.005 80);
--card-foreground: oklch(0.2 0.012 60);
--popover: oklch(0.985 0.005 80);
--popover-foreground: oklch(0.2 0.012 60);
--primary: oklch(0.18 0.008 60);
--primary-foreground: oklch(0.97 0.008 80);
--secondary: oklch(0.94 0.008 75);
--secondary-foreground: oklch(0.2 0.012 60);
--muted: oklch(0.94 0.008 75);
--muted-foreground: oklch(0.5 0.015 60);
--accent: oklch(0.92 0.018 65);
--accent-foreground: oklch(0.2 0.012 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 80);
--border: oklch(0.88 0.01 70);
--input: oklch(0.88 0.01 70);
--ring: oklch(0.2 0.012 60);
--radius: 0.25rem;
}
.dark {
--background: oklch(0.16 0.008 60);
--foreground: oklch(0.95 0.01 80);
--card: oklch(0.18 0.008 60);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.008 60);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.95 0.01 80);
--primary-foreground: oklch(0.18 0.008 60);
--secondary: oklch(0.24 0.012 60);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.24 0.012 60);
--muted-foreground: oklch(0.7 0.012 70);
--accent: oklch(0.3 0.022 65);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.95 0.01 80);
--border: oklch(0.28 0.012 60);
--input: oklch(0.28 0.012 60);
--ring: oklch(0.85 0.012 75);
}

48
src/themes/stone-warm.css Normal file
View file

@ -0,0 +1,48 @@
/* Palette: Stone Warm
* Bone background, matte-black ink, taupe muted. Gallery-quiet, the
* most conservative of the four. Closest to Studio McGee's chrome.
*/
:root[data-palette='stone-warm'] {
--background: oklch(0.985 0.005 80);
--foreground: oklch(0.2 0.012 60);
--card: oklch(0.985 0.005 80);
--card-foreground: oklch(0.2 0.012 60);
--popover: oklch(0.985 0.005 80);
--popover-foreground: oklch(0.2 0.012 60);
--primary: oklch(0.18 0.008 60);
--primary-foreground: oklch(0.97 0.008 80);
--secondary: oklch(0.94 0.008 75);
--secondary-foreground: oklch(0.2 0.012 60);
--muted: oklch(0.94 0.008 75);
--muted-foreground: oklch(0.5 0.015 60);
--accent: oklch(0.92 0.018 65);
--accent-foreground: oklch(0.2 0.012 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 80);
--border: oklch(0.88 0.01 70);
--input: oklch(0.88 0.01 70);
--ring: oklch(0.2 0.012 60);
}
:root[data-palette='stone-warm'].dark {
--background: oklch(0.16 0.008 60);
--foreground: oklch(0.95 0.01 80);
--card: oklch(0.18 0.008 60);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.008 60);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.95 0.01 80);
--primary-foreground: oklch(0.18 0.008 60);
--secondary: oklch(0.24 0.012 60);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.24 0.012 60);
--muted-foreground: oklch(0.7 0.012 70);
--accent: oklch(0.3 0.022 65);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.95 0.01 80);
--border: oklch(0.28 0.012 60);
--input: oklch(0.28 0.012 60);
--ring: oklch(0.85 0.012 75);
}