Added more themes

This commit is contained in:
Brian Fertig 2026-08-28 09:00:31 -06:00
parent cfd88b43c3
commit 5e23a984e1
6 changed files with 591 additions and 69 deletions

View File

@ -5,8 +5,9 @@
@import "@uiw/react-markdown-preview/markdown.css";
/* "dark:" must be true for every dark-surfaced theme, not just .dark --
Ocean is a second dark look, so both classes turn on dark utilities. */
@custom-variant dark (&:is(.dark *, .ocean *));
the dark looks (Dark, Ocean, Pine, Plum, Midnight, Ember, Rosewood) all
turn on dark utilities. */
@custom-variant dark (&:is(.dark *, .ocean *, .theme-pine *, .theme-plum *, .theme-midnight *, .theme-ember *, .theme-rosewood *));
@theme inline {
--color-background: var(--background);
@ -58,13 +59,25 @@
/* ---------------------------------------------------------------------------
Themes
The app ships four looks, chosen via the theme picker (next-themes):
default -- calm, cool neutral light theme (the safe, focused one)
sunset -- warm paper + persimmon accent, a cozier light theme
dark -- deep charcoal with an indigo accent
ocean -- deep blue-green night theme with a seafoam accent
The app ships fourteen looks, chosen via the theme picker:
Light:
default -- calm, cool neutral light theme (the safe, focused one)
sunset -- warm paper + persimmon accent, a cozier light theme
meadow -- fresh green paper with an emerald accent
honey -- buttercream paper with a honey-amber accent
rose -- soft pink paper with a raspberry accent ("Ros\xe9")
lavender -- lilac paper with a violet accent
slate -- cool grey-blue paper with a steel-blue accent
Dark:
dark -- deep charcoal with an indigo accent
ocean -- deep blue-green night theme with a seafoam accent
pine -- deep forest green with a mint accent
plum -- deep plum night with an orchid accent
midnight -- near-black navy with an ice-periwinkle accent
ember -- warm hearth charcoal with a glowing amber accent
rosewood -- deep wine with a rose accent
Each is a complete token set; components only ever use tokens, so adding
a fifth theme later is just another block like these.
another theme later is just another block like these.
--------------------------------------------------------------------------- */
:root,
@ -225,6 +238,413 @@
--sidebar-ring: oklch(0.72 0.11 192);
}
/* Meadow -- fresh green paper + emerald accent: a spring morning on the desk.
Light-surfaced; pairs with the neutral group palette as well as anything.
*/
.theme-meadow {
color-scheme: light;
--background: oklch(0.975 0.012 145);
--foreground: oklch(0.26 0.03 150);
--card: oklch(0.99 0.006 145);
--card-foreground: oklch(0.26 0.03 150);
--popover: oklch(0.992 0.005 145);
--popover-foreground: oklch(0.26 0.03 150);
/* Emerald -- leafy enough to feel grown, saturated enough to lead. */
--primary: oklch(0.55 0.16 155);
--primary-foreground: oklch(0.98 0.01 145);
--secondary: oklch(0.945 0.018 145);
--secondary-foreground: oklch(0.31 0.05 150);
--muted: oklch(0.938 0.016 145);
--muted-foreground: oklch(0.53 0.04 150);
--accent: oklch(0.925 0.03 150);
--accent-foreground: oklch(0.35 0.1 155);
--destructive: oklch(0.57 0.21 25);
--border: oklch(0.89 0.018 145);
--input: oklch(0.86 0.02 145);
--ring: oklch(0.57 0.14 155);
--lane: oklch(0.94 0.015 145);
--chart-1: oklch(0.8 0.08 145);
--chart-2: oklch(0.68 0.11 152);
--chart-3: oklch(0.55 0.12 158);
--chart-4: oklch(0.44 0.1 152);
--chart-5: oklch(0.33 0.08 148);
--radius: 0.8rem;
--sidebar: oklch(0.965 0.01 145);
--sidebar-foreground: oklch(0.26 0.03 150);
--sidebar-primary: oklch(0.55 0.16 155);
--sidebar-primary-foreground: oklch(0.98 0.01 145);
--sidebar-accent: oklch(0.925 0.03 150);
--sidebar-accent-foreground: oklch(0.35 0.1 155);
--sidebar-border: oklch(0.89 0.018 145);
--sidebar-ring: oklch(0.57 0.14 155);
}
/* Honey -- buttercream paper + honey-amber accent: golden hour, mellowed.
Light-surfaced; warmer than Sunset's terracotta, softer and yellower. */
.theme-honey {
color-scheme: light;
--background: oklch(0.972 0.024 95);
--foreground: oklch(0.28 0.04 80);
--card: oklch(0.988 0.015 95);
--card-foreground: oklch(0.28 0.04 80);
--popover: oklch(0.99 0.014 95);
--popover-foreground: oklch(0.28 0.04 80);
/* Honey-amber -- glowing but gentle, like light through glass. */
--primary: oklch(0.6 0.16 70);
--primary-foreground: oklch(0.98 0.015 90);
--secondary: oklch(0.948 0.03 92);
--secondary-foreground: oklch(0.33 0.06 78);
--muted: oklch(0.94 0.028 92);
--muted-foreground: oklch(0.55 0.05 82);
--accent: oklch(0.93 0.04 88);
--accent-foreground: oklch(0.37 0.11 75);
--destructive: oklch(0.57 0.21 25);
--border: oklch(0.885 0.03 90);
--input: oklch(0.855 0.032 90);
--ring: oklch(0.62 0.14 78);
--lane: oklch(0.942 0.026 92);
--chart-1: oklch(0.82 0.1 85);
--chart-2: oklch(0.7 0.13 78);
--chart-3: oklch(0.58 0.14 72);
--chart-4: oklch(0.47 0.12 75);
--chart-5: oklch(0.36 0.09 80);
/* A touch softer than default -- honey is meant to feel mellow. */
--radius: 0.85rem;
--sidebar: oklch(0.962 0.02 95);
--sidebar-foreground: oklch(0.28 0.04 80);
--sidebar-primary: oklch(0.6 0.16 70);
--sidebar-primary-foreground: oklch(0.98 0.015 90);
--sidebar-accent: oklch(0.93 0.04 88);
--sidebar-accent-foreground: oklch(0.37 0.11 75);
--sidebar-border: oklch(0.885 0.03 90);
--sidebar-ring: oklch(0.62 0.14 78);
}
/* Ros\xe9 -- soft pink paper + raspberry accent: a blush, not a ballroom.
Light-surfaced. */
.theme-rose {
color-scheme: light;
--background: oklch(0.972 0.014 15);
--foreground: oklch(0.27 0.035 15);
--card: oklch(0.988 0.008 18);
--card-foreground: oklch(0.27 0.035 15);
--popover: oklch(0.99 0.008 18);
--popover-foreground: oklch(0.27 0.035 15);
/* Raspberry -- enough punch to be the accent, sweet enough to sit
comfortably inside the pink paper. */
--primary: oklch(0.55 0.19 12);
--primary-foreground: oklch(0.98 0.01 20);
--secondary: oklch(0.948 0.02 15);
--secondary-foreground: oklch(0.32 0.06 12);
--muted: oklch(0.94 0.018 15);
--muted-foreground: oklch(0.54 0.045 12);
--accent: oklch(0.93 0.032 14);
--accent-foreground: oklch(0.36 0.11 12);
/* A deeper red than the rest of the pink, so errors still read as errors. */
--destructive: oklch(0.55 0.22 20);
--border: oklch(0.888 0.022 15);
--input: oklch(0.86 0.024 15);
--ring: oklch(0.58 0.16 14);
--lane: oklch(0.942 0.02 15);
--chart-1: oklch(0.8 0.07 20);
--chart-2: oklch(0.68 0.11 15);
--chart-3: oklch(0.56 0.13 12);
--chart-4: oklch(0.45 0.12 10);
--chart-5: oklch(0.34 0.09 15);
/* Softer corners -- the most romantic look in the set. */
--radius: 0.9rem;
--sidebar: oklch(0.962 0.012 15);
--sidebar-foreground: oklch(0.27 0.035 15);
--sidebar-primary: oklch(0.55 0.19 12);
--sidebar-primary-foreground: oklch(0.98 0.01 20);
--sidebar-accent: oklch(0.93 0.032 14);
--sidebar-accent-foreground: oklch(0.36 0.11 12);
--sidebar-border: oklch(0.888 0.022 15);
--sidebar-ring: oklch(0.58 0.16 14);
}
/* Lavender -- lilac paper + violet accent: dusk without the dark.
Light-surfaced. */
.theme-lavender {
color-scheme: light;
--background: oklch(0.972 0.012 300);
--foreground: oklch(0.27 0.035 305);
--card: oklch(0.988 0.008 300);
--card-foreground: oklch(0.27 0.035 305);
--popover: oklch(0.99 0.008 300);
--popover-foreground: oklch(0.27 0.035 305);
/* Violet -- a little more saturated than the paper's tint so it leads. */
--primary: oklch(0.53 0.19 300);
--primary-foreground: oklch(0.98 0.01 300);
--secondary: oklch(0.948 0.018 300);
--secondary-foreground: oklch(0.32 0.06 305);
--muted: oklch(0.94 0.016 300);
--muted-foreground: oklch(0.54 0.045 305);
--accent: oklch(0.93 0.03 302);
--accent-foreground: oklch(0.36 0.11 305);
--destructive: oklch(0.57 0.21 25);
--border: oklch(0.888 0.018 300);
--input: oklch(0.86 0.02 300);
--ring: oklch(0.56 0.16 300);
--lane: oklch(0.942 0.015 300);
--chart-1: oklch(0.8 0.07 300);
--chart-2: oklch(0.68 0.11 302);
--chart-3: oklch(0.56 0.13 305);
--chart-4: oklch(0.45 0.12 308);
--chart-5: oklch(0.34 0.09 305);
--radius: 0.85rem;
--sidebar: oklch(0.962 0.012 300);
--sidebar-foreground: oklch(0.27 0.035 305);
--sidebar-primary: oklch(0.53 0.19 300);
--sidebar-primary-foreground: oklch(0.98 0.01 300);
--sidebar-accent: oklch(0.93 0.03 302);
--sidebar-accent-foreground: oklch(0.36 0.11 305);
--sidebar-border: oklch(0.888 0.018 300);
--sidebar-ring: oklch(0.56 0.16 300);
}
/* Slate -- cool grey-blue paper + steel-blue accent: the "work mode" look.
Light-surfaced; the crispest corners in the set. */
.theme-slate {
color-scheme: light;
--background: oklch(0.968 0.006 240);
--foreground: oklch(0.25 0.02 245);
--card: oklch(0.985 0.004 240);
--card-foreground: oklch(0.25 0.02 245);
--popover: oklch(0.988 0.004 240);
--popover-foreground: oklch(0.25 0.02 245);
/* Steel blue -- calmer than Default's indigo, quieter by design. */
--primary: oklch(0.52 0.11 240);
--primary-foreground: oklch(0.98 0.005 240);
--secondary: oklch(0.945 0.008 240);
--secondary-foreground: oklch(0.31 0.03 245);
--muted: oklch(0.938 0.008 240);
--muted-foreground: oklch(0.52 0.02 245);
--accent: oklch(0.925 0.014 242);
--accent-foreground: oklch(0.35 0.08 245);
--destructive: oklch(0.57 0.2 25);
--border: oklch(0.895 0.006 240);
--input: oklch(0.875 0.006 240);
--ring: oklch(0.55 0.09 240);
--lane: oklch(0.94 0.007 240);
--chart-1: oklch(0.82 0.04 240);
--chart-2: oklch(0.7 0.06 242);
--chart-3: oklch(0.58 0.08 244);
--chart-4: oklch(0.47 0.07 246);
--chart-5: oklch(0.36 0.05 248);
/* Crisper corners -- the most "engineered" look in the set. */
--radius: 0.7rem;
--sidebar: oklch(0.958 0.005 240);
--sidebar-foreground: oklch(0.25 0.02 245);
--sidebar-primary: oklch(0.52 0.11 240);
--sidebar-primary-foreground: oklch(0.98 0.005 240);
--sidebar-accent: oklch(0.925 0.014 242);
--sidebar-accent-foreground: oklch(0.35 0.08 245);
--sidebar-border: oklch(0.895 0.006 240);
--sidebar-ring: oklch(0.55 0.09 240);
}
/* Pine -- deep forest green night + mint accent: the woods after rain.
Dark-surfaced (see the `dark` custom-variant above). */
.theme-pine {
color-scheme: dark;
--background: oklch(0.2 0.035 160);
--foreground: oklch(0.92 0.02 150);
--card: oklch(0.245 0.04 158);
--card-foreground: oklch(0.92 0.02 150);
--popover: oklch(0.255 0.042 156);
--popover-foreground: oklch(0.92 0.02 150);
/* Mint -- luminous against the deep green, without Ocean's cool cast. */
--primary: oklch(0.76 0.12 165);
--primary-foreground: oklch(0.2 0.05 160);
--secondary: oklch(0.295 0.042 158);
--secondary-foreground: oklch(0.92 0.02 150);
--muted: oklch(0.285 0.04 158);
--muted-foreground: oklch(0.7 0.03 150);
--accent: oklch(0.33 0.05 160);
--accent-foreground: oklch(0.9 0.03 165);
--destructive: oklch(0.7 0.17 25);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.72 0.1 165);
--lane: oklch(1 0 0 / 6%);
--chart-1: oklch(0.76 0.1 165);
--chart-2: oklch(0.63 0.11 160);
--chart-3: oklch(0.5 0.11 155);
--chart-4: oklch(0.4 0.09 150);
--chart-5: oklch(0.3 0.07 150);
--radius: 0.8rem;
--sidebar: oklch(0.225 0.04 158);
--sidebar-foreground: oklch(0.92 0.02 150);
--sidebar-primary: oklch(0.76 0.12 165);
--sidebar-primary-foreground: oklch(0.2 0.05 160);
--sidebar-accent: oklch(0.33 0.05 160);
--sidebar-accent-foreground: oklch(0.9 0.03 165);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.72 0.1 165);
}
/* Plum -- deep plum night + orchid accent: the richest dark in the set.
Dark-surfaced. */
.theme-plum {
color-scheme: dark;
--background: oklch(0.2 0.03 315);
--foreground: oklch(0.92 0.02 320);
--card: oklch(0.245 0.035 315);
--card-foreground: oklch(0.92 0.02 320);
--popover: oklch(0.255 0.038 315);
--popover-foreground: oklch(0.92 0.02 320);
/* Orchid -- bright enough to lead the eye, deep enough to stay nocturnal. */
--primary: oklch(0.74 0.13 320);
--primary-foreground: oklch(0.2 0.05 315);
--secondary: oklch(0.295 0.04 315);
--secondary-foreground: oklch(0.92 0.02 320);
--muted: oklch(0.285 0.04 315);
--muted-foreground: oklch(0.7 0.03 320);
--accent: oklch(0.33 0.05 318);
--accent-foreground: oklch(0.9 0.03 322);
--destructive: oklch(0.7 0.17 25);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.7 0.12 320);
--lane: oklch(1 0 0 / 6%);
--chart-1: oklch(0.76 0.1 320);
--chart-2: oklch(0.63 0.11 318);
--chart-3: oklch(0.5 0.11 315);
--chart-4: oklch(0.4 0.1 312);
--chart-5: oklch(0.3 0.08 315);
--radius: 0.8rem;
--sidebar: oklch(0.225 0.035 315);
--sidebar-foreground: oklch(0.92 0.02 320);
--sidebar-primary: oklch(0.74 0.13 320);
--sidebar-primary-foreground: oklch(0.2 0.05 315);
--sidebar-accent: oklch(0.33 0.05 318);
--sidebar-accent-foreground: oklch(0.9 0.03 322);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.7 0.12 320);
}
/* Midnight -- the darkest surface in the set, near-black navy, with an
ice-periwinkle accent: a clear winter night.
Dark-surfaced. */
.theme-midnight {
color-scheme: dark;
--background: oklch(0.16 0.02 262);
--foreground: oklch(0.93 0.01 258);
--card: oklch(0.2 0.022 260);
--card-foreground: oklch(0.93 0.01 258);
--popover: oklch(0.21 0.024 260);
--popover-foreground: oklch(0.93 0.01 258);
/* Ice periwinkle -- pale and cool, a thin light against the black. */
--primary: oklch(0.76 0.09 255);
--primary-foreground: oklch(0.17 0.03 260);
--secondary: oklch(0.255 0.024 262);
--secondary-foreground: oklch(0.93 0.01 258);
--muted: oklch(0.245 0.024 262);
--muted-foreground: oklch(0.7 0.015 258);
--accent: oklch(0.29 0.035 260);
--accent-foreground: oklch(0.9 0.02 255);
--destructive: oklch(0.7 0.17 25);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.74 0.08 255);
--lane: oklch(1 0 0 / 6%);
--chart-1: oklch(0.78 0.07 255);
--chart-2: oklch(0.66 0.08 258);
--chart-3: oklch(0.54 0.09 260);
--chart-4: oklch(0.44 0.08 262);
--chart-5: oklch(0.34 0.06 265);
--radius: 0.75rem;
--sidebar: oklch(0.185 0.022 262);
--sidebar-foreground: oklch(0.93 0.01 258);
--sidebar-primary: oklch(0.76 0.09 255);
--sidebar-primary-foreground: oklch(0.17 0.03 260);
--sidebar-accent: oklch(0.29 0.035 260);
--sidebar-accent-foreground: oklch(0.9 0.02 255);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.74 0.08 255);
}
/* Ember -- warm hearth charcoal + glowing amber accent: Sunset's dark twin.
Dark-surfaced. */
.theme-ember {
color-scheme: dark;
--background: oklch(0.2 0.025 45);
--foreground: oklch(0.92 0.015 60);
--card: oklch(0.245 0.028 48);
--card-foreground: oklch(0.92 0.015 60);
--popover: oklch(0.255 0.03 48);
--popover-foreground: oklch(0.92 0.015 60);
/* Ember amber -- warm glow, like coals catching the evening. */
--primary: oklch(0.72 0.14 62);
--primary-foreground: oklch(0.2 0.04 50);
--secondary: oklch(0.295 0.03 50);
--secondary-foreground: oklch(0.92 0.015 60);
--muted: oklch(0.285 0.03 50);
--muted-foreground: oklch(0.7 0.025 55);
--accent: oklch(0.33 0.04 52);
--accent-foreground: oklch(0.9 0.03 62);
--destructive: oklch(0.7 0.17 25);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.7 0.12 62);
--lane: oklch(1 0 0 / 6%);
--chart-1: oklch(0.78 0.1 58);
--chart-2: oklch(0.66 0.12 55);
--chart-3: oklch(0.54 0.12 52);
--chart-4: oklch(0.44 0.1 50);
--chart-5: oklch(0.34 0.08 52);
--radius: 0.85rem;
--sidebar: oklch(0.225 0.028 48);
--sidebar-foreground: oklch(0.92 0.015 60);
--sidebar-primary: oklch(0.72 0.14 62);
--sidebar-primary-foreground: oklch(0.2 0.04 50);
--sidebar-accent: oklch(0.33 0.04 52);
--sidebar-accent-foreground: oklch(0.9 0.03 62);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.7 0.12 62);
}
/* Rosewood -- deep wine + rose accent: Ros\xe9's dark twin.
Dark-surfaced. */
.theme-rosewood {
color-scheme: dark;
--background: oklch(0.2 0.04 18);
--foreground: oklch(0.92 0.02 20);
--card: oklch(0.245 0.045 18);
--card-foreground: oklch(0.92 0.02 20);
--popover: oklch(0.255 0.048 18);
--popover-foreground: oklch(0.92 0.02 20);
/* Rose -- bright enough to lead, warm enough to stay in the wine. */
--primary: oklch(0.74 0.13 20);
--primary-foreground: oklch(0.2 0.05 15);
--secondary: oklch(0.295 0.045 18);
--secondary-foreground: oklch(0.92 0.02 20);
--muted: oklch(0.285 0.045 18);
--muted-foreground: oklch(0.7 0.03 20);
--accent: oklch(0.33 0.055 18);
--accent-foreground: oklch(0.9 0.03 22);
--destructive: oklch(0.7 0.17 25);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.72 0.11 20);
--lane: oklch(1 0 0 / 6%);
--chart-1: oklch(0.78 0.09 20);
--chart-2: oklch(0.66 0.11 18);
--chart-3: oklch(0.54 0.12 16);
--chart-4: oklch(0.44 0.1 15);
--chart-5: oklch(0.34 0.08 18);
--radius: 0.85rem;
--sidebar: oklch(0.225 0.045 18);
--sidebar-foreground: oklch(0.92 0.02 20);
--sidebar-primary: oklch(0.74 0.13 20);
--sidebar-primary-foreground: oklch(0.2 0.05 15);
--sidebar-accent: oklch(0.33 0.055 18);
--sidebar-accent-foreground: oklch(0.9 0.03 22);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.72 0.11 20);
}
@layer base {
* {
@apply border-border outline-ring/50;

View File

@ -57,14 +57,15 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">
{/* Four looks: Default (calm light), Sunset (warm light), Dark, Ocean
(deep blue-green) -- complete token sets in app/globals.css.
"system" resolves to Default/Dark by OS preference.
No-FOUC theme restore: runs before first paint, applies the
stored preference to <html> (class + color-scheme). */}
{/* Fourteen looks: seven light (Default, Sunset, Meadow, Honey,
Rosé, Lavender, Slate) and seven dark (Dark, Ocean, Pine, Plum,
Midnight, Ember, Rosewood) -- complete token sets in
app/globals.css. "system" resolves to Default/Dark by OS
preference. No-FOUC theme restore: runs before first paint,
applies the stored preference to <html> (class + color-scheme). */}
<script
dangerouslySetInnerHTML={{
__html: `!(function(){try{var r=document.documentElement,c=['theme-default','theme-sunset','dark','ocean','light'];for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}var v=null;try{v=localStorage.getItem('theme');}catch(e){}var m={default:'theme-default',sunset:'theme-sunset',dark:'dark',ocean:'ocean'};if(v==='system'||!v){v=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'default';}if(m[v]){r.classList.add(m[v]);r.style.colorScheme=(v==='dark'||v==='ocean')?'dark':'light';}}catch(e){}})();`,
__html: `!(function(){try{var r=document.documentElement,c=['theme-default','theme-sunset','theme-meadow','theme-honey','theme-rose','theme-lavender','theme-slate','dark','ocean','theme-pine','theme-plum','theme-midnight','theme-ember','theme-rosewood','light'];for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}var v=null;try{v=localStorage.getItem('theme');}catch(e){}var m={default:'theme-default',sunset:'theme-sunset',meadow:'theme-meadow',honey:'theme-honey',rose:'theme-rose',lavender:'theme-lavender',slate:'theme-slate',dark:'dark',ocean:'ocean',pine:'theme-pine',plum:'theme-plum',midnight:'theme-midnight',ember:'theme-ember',rosewood:'theme-rosewood'};var d=['dark','ocean','pine','plum','midnight','ember','rosewood'];if(v==='system'||!v){v=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'default';}if(m[v]){r.classList.add(m[v]);r.style.colorScheme=d.indexOf(v)>=0?'dark':'light';}}catch(e){}})();`,
}}
/>
<ThemeProvider defaultTheme="system">

View File

@ -22,13 +22,29 @@ import type { ReactNode } from "react";
* it renders as plain server HTML and actually executes.
*
* Semantics (kept compatible for all existing consumers):
* - `theme` the stored preference: default | sunset | dark | ocean | system
* - `theme` the stored preference: one of THEMES, or "system"
* - `resolvedTheme` the theme actually applied (system resolved via
* prefers-color-scheme), always one of the four
* - html classes .theme-default | .theme-sunset | .dark | .ocean (see globals.css)
* prefers-color-scheme), always one of THEMES
* - html classes .theme-default | .theme-sunset | ... (see globals.css)
*/
export const THEMES = ["default", "sunset", "dark", "ocean"] as const;
/* Order is the picker order: all the light looks, then the dark looks. */
export const THEMES = [
"default",
"sunset",
"meadow",
"honey",
"rose",
"lavender",
"slate",
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
] as const;
export type ThemeName = (typeof THEMES)[number];
export type RawTheme = ThemeName | "system";
@ -36,11 +52,32 @@ const STORAGE_KEY = "theme";
const THEME_CLASSES: Record<ThemeName, string> = {
default: "theme-default",
sunset: "theme-sunset",
meadow: "theme-meadow",
honey: "theme-honey",
rose: "theme-rose",
lavender: "theme-lavender",
slate: "theme-slate",
dark: "dark",
ocean: "ocean",
pine: "theme-pine",
plum: "theme-plum",
midnight: "theme-midnight",
ember: "theme-ember",
rosewood: "theme-rosewood",
};
const ALL_CLASSES = Object.values(THEME_CLASSES);
const DARK_SURFACES: ThemeName[] = ["dark", "ocean"];
/* Themes whose *surfaces* are dark (see app/globals.css). The single source
* of truth for every dark-surface check: isDarkTheme, the sonner toaster,
* color-scheme, and the `dark:` custom-variant in globals.css. */
export const DARK_SURFACES: ThemeName[] = [
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
];
interface ThemeContextValue {
theme: RawTheme;

View File

@ -1,8 +1,27 @@
"use client";
import { useTheme } from "@/components/theme/theme-provider";
import { Moon, Sun, Monitor, Sunset, Waves, Check } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import {
Monitor,
Check,
Sun,
Moon,
Sunset,
Waves,
Leaf,
Hexagon,
Rose,
Flower2,
Mountain,
TreePine,
Grape,
Star,
Flame,
Wine,
} from "lucide-react";
import { useTheme } from "@/components/theme/theme-provider";
import type { ThemeName } from "@/components/theme/theme-provider";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -12,31 +31,72 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// The four looks, shown as two "modes" with a shared System row. Each entry
// is one next-themes value (see app/layout.tsx for the class mapping); the
// little dot is the theme's own accent so the picker previews the vibe.
const OPTIONS = [
type ThemeOption = {
value: ThemeName;
label: string;
icon: LucideIcon;
swatch: string;
};
// The looks, grouped by surface. Each value is one next-themes value (see
// app/layout.tsx for the class mapping); the little dot is the theme's own
// accent so the picker previews the vibe.
const LIGHT_OPTIONS: ThemeOption[] = [
{ value: "default", label: "Default", icon: Sun, swatch: "#4f56e0" },
{ value: "sunset", label: "Sunset", icon: Sunset, swatch: "#c2410c" },
{ value: "meadow", label: "Meadow", icon: Leaf, swatch: "#059669" },
{ value: "honey", label: "Honey", icon: Hexagon, swatch: "#d97706" },
{ value: "rose", label: "Rosé", icon: Rose, swatch: "#e11d48" },
{ value: "lavender", label: "Lavender", icon: Flower2, swatch: "#7c3aed" },
{ value: "slate", label: "Slate", icon: Mountain, swatch: "#4a6d9c" },
];
const DARK_OPTIONS: ThemeOption[] = [
{ value: "dark", label: "Dark", icon: Moon, swatch: "#7c83f2" },
{ value: "ocean", label: "Ocean", icon: Waves, swatch: "#5eead4" },
] as const;
{ value: "pine", label: "Pine", icon: TreePine, swatch: "#34d399" },
{ value: "plum", label: "Plum", icon: Grape, swatch: "#c084fc" },
{ value: "midnight", label: "Midnight", icon: Star, swatch: "#93c5fd" },
{ value: "ember", label: "Ember", icon: Flame, swatch: "#fbbf24" },
{ value: "rosewood", label: "Rosewood", icon: Wine, swatch: "#fb7185" },
];
const ALL_OPTIONS = [...LIGHT_OPTIONS, ...DARK_OPTIONS];
const CURRENT_ICONS = {
default: Sun,
sunset: Sunset,
dark: Moon,
ocean: Waves,
system: Monitor,
} as const;
function OptionRow({
option,
active,
onSelect,
}: {
option: ThemeOption;
active: boolean;
onSelect: () => void;
}) {
const Icon = option.icon;
return (
<button
type="button"
onClick={onSelect}
className="group relative flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
>
<Icon className="size-4 text-muted-foreground group-focus:text-current" />
<span>{option.label}</span>
<span
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
style={{ backgroundColor: option.swatch }}
aria-hidden
/>
{active && <Check className="absolute right-2 -mr-4 size-4" />}
</button>
);
}
export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
const { theme, setTheme } = useTheme();
// The picker always shows the four real themes; "system" is handled by a
// dedicated row since it resolves to one of them per device preference.
const known = OPTIONS.find((o) => o.value === theme);
// The picker shows all the real themes in Light/Dark sections; "system" is
// handled by a dedicated row since it resolves to one of them per device
// preference.
const known = ALL_OPTIONS.find((o) => o.value === theme);
const isSystem = theme === "system" || theme === undefined;
const Icon = (isSystem ? CURRENT_ICONS.system : known?.icon) ?? CURRENT_ICONS.system;
const Icon = (isSystem ? Monitor : known?.icon) ?? Monitor;
const label = isSystem ? "System" : known?.label ?? "Default";
return (
@ -49,28 +109,29 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
className={collapsed ? undefined : "w-full justify-start gap-2"}
>
<Icon className="size-4" />
{!collapsed && <span>Theme</span>}
{!collapsed && <span>{label}</span>}
</Button>
}
/>
<DropdownMenuContent align="start" side="right" className="w-52">
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
{OPTIONS.map((option) => (
<button
<DropdownMenuLabel>Light</DropdownMenuLabel>
{LIGHT_OPTIONS.map((option) => (
<OptionRow
key={option.value}
type="button"
onClick={() => setTheme(option.value)}
className="group relative flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
>
<option.icon className="size-4 text-muted-foreground group-focus:text-current" />
<span>{option.label}</span>
<span
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
style={{ backgroundColor: option.swatch }}
aria-hidden
/>
{theme === option.value && <Check className="absolute right-2 -mr-4 size-4" />}
</button>
option={option}
active={theme === option.value}
onSelect={() => setTheme(option.value)}
/>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>Dark</DropdownMenuLabel>
{DARK_OPTIONS.map((option) => (
<OptionRow
key={option.value}
option={option}
active={theme === option.value}
onSelect={() => setTheme(option.value)}
/>
))}
<DropdownMenuSeparator />
<button

View File

@ -2,13 +2,13 @@
import { useEffect, useState } from "react";
import { useTheme, resolveTheme } from "./theme-provider";
import { useTheme, resolveTheme, DARK_SURFACES } from "./theme-provider";
// The themes whose *surfaces* are dark (see app/globals.css): "dark" and
// "ocean". Anything that used to compare `resolvedTheme === "dark"` to pick
// a color variant (group card fills/strokes, markdown widget color modes,
// ...) uses this instead, so Ocean is treated exactly like Dark.
const DARK_SURFACES = new Set(["dark", "ocean"]);
// The themes whose *surfaces* are dark (see app/globals.css) come from the
// provider's single source of truth -- currently Dark, Ocean, Pine, Plum,
// Midnight, Ember, Rosewood. Anything that used to compare
// `resolvedTheme === "dark"` to pick a color variant (group card
// fills/strokes, markdown widget color modes, ...) uses this instead.
function useMounted(): boolean {
const [mounted, setMounted] = useState(false);
@ -19,7 +19,8 @@ function useMounted(): boolean {
}
/**
* True when the active theme has dark surfaces (Dark or Ocean).
* True when the active theme has dark surfaces (Dark, Ocean, Pine, Plum,
* Midnight, Ember, Rosewood).
*
* Reactive to UI theme switches: the value is derived from the provider's
* theme state (the single source of truth), so the moment `setTheme` fires,
@ -34,13 +35,14 @@ export function isDarkTheme(): boolean {
const mounted = useMounted();
const { theme } = useTheme();
if (!mounted) return false;
return DARK_SURFACES.has(resolveTheme(theme));
return DARK_SURFACES.includes(resolveTheme(theme));
}
/**
* The `data-color-mode` value the markdown editor/preview widgets expect.
* They only understand light/dark, so the light "sunset" look reports as
* light and the dark "ocean" look reports as dark.
* They only understand light/dark, so the light looks (default, sunset,
* meadow, honey, rose, lavender, slate) report as light and the dark looks
* report as dark.
*/
export function colorModeFromTheme(): "light" | "dark" {
return isDarkTheme() ? "dark" : "light";

View File

@ -1,15 +1,16 @@
"use client"
import { useTheme } from "@/components/theme/theme-provider"
import { useTheme, DARK_SURFACES } from "@/components/theme/theme-provider"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { resolvedTheme } = useTheme()
// Sonner only knows light/dark -- map the app's looks (default/sunset are
// light; dark/ocean are dark) onto what it understands.
const theme: ToasterProps["theme"] =
resolvedTheme === "dark" || resolvedTheme === "ocean" ? "dark" : "light"
// Sonner only knows light/dark -- map the app's looks onto what it
// understands, using the provider's single dark-surface list.
const theme: ToasterProps["theme"] = DARK_SURFACES.includes(resolvedTheme)
? "dark"
: "light"
return (
<Sonner