From f0980d7fb5cee6b3c7769dab62aa779f9a0b2b5d Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 27 May 2026 11:29:48 +0200 Subject: [PATCH] feat: four OKLCH palette options + dev-only switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts each palette into its own module under src/themes/, scoped to :root[data-palette=''] 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=, falls back to localStorage, then to stone-warm. Active palette is written to . 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) --- src/components/layout/AppLayout.vue | 4 ++ src/components/layout/PaletteSwitcher.vue | 41 ++++++++++++++++ src/composables/usePalette.ts | 54 ++++++++++++++++++++ src/style.css | 46 +---------------- src/themes/charcoal-cream.css | 50 +++++++++++++++++++ src/themes/desert-clay.css | 49 ++++++++++++++++++ src/themes/forest-ash.css | 49 ++++++++++++++++++ src/themes/index.css | 60 +++++++++++++++++++++++ src/themes/stone-warm.css | 48 ++++++++++++++++++ 9 files changed, 356 insertions(+), 45 deletions(-) create mode 100644 src/components/layout/PaletteSwitcher.vue create mode 100644 src/composables/usePalette.ts create mode 100644 src/themes/charcoal-cream.css create mode 100644 src/themes/desert-clay.css create mode 100644 src/themes/forest-ash.css create mode 100644 src/themes/index.css create mode 100644 src/themes/stone-warm.css diff --git a/src/components/layout/AppLayout.vue b/src/components/layout/AppLayout.vue index a477f85..d32127c 100644 --- a/src/components/layout/AppLayout.vue +++ b/src/components/layout/AppLayout.vue @@ -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 diff --git a/src/components/layout/PaletteSwitcher.vue b/src/components/layout/PaletteSwitcher.vue new file mode 100644 index 0000000..7e48aaf --- /dev/null +++ b/src/components/layout/PaletteSwitcher.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/composables/usePalette.ts b/src/composables/usePalette.ts new file mode 100644 index 0000000..85ab782 --- /dev/null +++ b/src/composables/usePalette.ts @@ -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(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 } +} diff --git a/src/style.css b/src/style.css index 669819b..c2630ae 100644 --- a/src/style.css +++ b/src/style.css @@ -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); diff --git a/src/themes/charcoal-cream.css b/src/themes/charcoal-cream.css new file mode 100644 index 0000000..6faa89d --- /dev/null +++ b/src/themes/charcoal-cream.css @@ -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); +} diff --git a/src/themes/desert-clay.css b/src/themes/desert-clay.css new file mode 100644 index 0000000..b95773e --- /dev/null +++ b/src/themes/desert-clay.css @@ -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); +} diff --git a/src/themes/forest-ash.css b/src/themes/forest-ash.css new file mode 100644 index 0000000..c93af44 --- /dev/null +++ b/src/themes/forest-ash.css @@ -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); +} diff --git a/src/themes/index.css b/src/themes/index.css new file mode 100644 index 0000000..950bca9 --- /dev/null +++ b/src/themes/index.css @@ -0,0 +1,60 @@ +/* Palette registry — each module declares its tokens scoped to + * :root[data-palette='']. 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); +} diff --git a/src/themes/stone-warm.css b/src/themes/stone-warm.css new file mode 100644 index 0000000..185e07c --- /dev/null +++ b/src/themes/stone-warm.css @@ -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); +}