Files
nuxt__ui/docs/app/utils/theme/engine/presets.ts
Mike Newbon 5fd94e1365 docs: add theme editor (#6675)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 14:55:51 +02:00

476 lines
16 KiB
TypeScript

import type { ThemeDoc, StyleOptions } from './types'
export interface ThemePreset {
id: string
name: string
description: string
doc: ThemeDoc
}
/**
* The five tokens the library pins to white, routed through the neutral ramp
* the way picking a neutral in the studio does (selectPalette's remaps), so a
* tinted ramp reaches the page and the export reproduces the preview.
*
* The page moving to 50 takes the muted surface with it: the library sits it
* at 50 too, so it would land ON the page and every muted panel (a code
* block, a table head) would lose its shape.
*/
const tintedNeutralBase = {
'--ui-bg': { light: 50 },
'--ui-bg-muted': { light: 100 },
'--ui-text-inverted': { light: 50 },
'--ui-text-highlighted': { dark: 50 },
'--ui-bg-inverted': { dark: 50 },
'--ui-border-inverted': { dark: 50 }
} satisfies StyleOptions['tokenShades']
/**
* A fresh copy per preset. `deriveStyle` puts these objects straight into
* reactive studio state, so a spread would hand six presets the same
* per-token object and one in-place write would corrupt all of them.
*/
const tintedNeutral = () => structuredClone(tintedNeutralBase)
/**
* Presets are plain ThemeDocs: applying one replaces the current document.
*/
export const presets: ThemePreset[] = [{
id: 'default',
name: 'Default',
description: 'The stock theme, everything inherited.',
doc: {
version: 1
}
}, {
id: 'mono',
name: 'Mono',
description: 'Black on a pure gray neutral, generous radius, quiet surfaces.',
doc: {
version: 1,
blackAsPrimary: true,
colors: {
neutral: 'neutral'
},
radius: 0.5,
font: { sans: 'Geist', mono: 'Geist Mono' },
style: {
defaults: {
variants: {
inputs: 'subtle'
}
},
tokenShades: {
'--ui-bg': { dark: 950 },
'--ui-bg-muted': { light: 100, dark: 900 },
'--ui-bg-elevated': { light: 100, dark: 900 },
'--ui-bg-accented': { light: 200, dark: 800 },
'--ui-text': { light: 900, dark: 100 },
'--ui-text-highlighted': { light: 950, dark: 50 }
}
}
}
}, {
id: 'cobalt',
name: 'Cobalt',
description: 'Utility blue on cool grays, tight corners, flat bordered surfaces.',
doc: {
version: 1,
// Prefixed names: a palette named plainly 'blue'/'gray' would override
// the same-named tailwind ramp app-wide while active.
palettes: {
'cobalt': {
shades: {
50: 'oklch(95.3% 0.022 260.723)',
100: 'oklch(90.8% 0.045 258.763)',
200: 'oklch(81.6% 0.091 257.776)',
300: 'oklch(72.9% 0.14 258.068)',
400: 'oklch(64.7% 0.186 258.256)',
500: 'oklch(57.8% 0.228 260.025)',
600: 'oklch(49.2% 0.19 259.799)',
700: 'oklch(40.2% 0.152 259.656)',
800: 'oklch(30.7% 0.109 258.934)',
900: 'oklch(20.4% 0.063 257.52)',
950: 'oklch(14.7% 0.037 249.929)'
}
},
// A flat cool gray, its mid stops lighter than tailwind's so borders
// and muted text stay soft against the tinted page.
'cobalt-gray': {
shades: {
50: 'oklch(99.1% 0 0)',
100: 'oklch(98.2% 0.002 247.839)',
200: 'oklch(94.2% 0.005 247.879)',
300: 'oklch(91.1% 0.007 247.901)',
400: 'oklch(86.7% 0.011 247.949)',
500: 'oklch(76.9% 0.015 248.017)',
600: 'oklch(55.8% 0.016 244.893)',
700: 'oklch(42.8% 0.015 248.172)',
800: 'oklch(34.5% 0.013 248.212)',
900: 'oklch(26.2% 0.009 248.19)',
950: 'oklch(20.7% 0.008 248.192)'
}
}
},
// The gray ramp serves as secondary too: a muted second button colour.
colors: {
primary: 'cobalt',
secondary: 'cobalt-gray',
info: 'cyan',
warning: 'amber',
neutral: 'cobalt-gray'
},
radius: 0.125,
// a point under stock, utility UIs run dense
fontSize: 15,
font: { sans: 'Roboto' },
icons: 'bootstrap',
// Light rides the gray ramp one step deeper than stock.
tokens: {
light: {
'--ui-secondary': 'var(--ui-color-secondary-600)',
'--ui-bg-muted': 'var(--ui-color-neutral-200)',
'--ui-text': 'var(--ui-color-neutral-800)',
'--ui-border': 'var(--ui-color-neutral-400)',
'--ui-border-muted': 'var(--ui-color-neutral-300)',
'--ui-bg': 'var(--ui-color-neutral-100)',
'--ui-text-toned': 'var(--ui-color-neutral-700)',
'--ui-text-muted': 'var(--ui-color-neutral-600)',
'--ui-text-dimmed': 'var(--ui-color-neutral-600)',
'--ui-bg-elevated': 'var(--ui-color-neutral-200)',
'--ui-bg-accented': 'var(--ui-color-neutral-300)'
},
// Dark keeps the same brand colours rather than lifting to the 400s.
dark: {
'--ui-primary': 'var(--ui-color-primary-500)',
'--ui-secondary': 'var(--ui-color-secondary-500)',
'--ui-success': 'var(--ui-color-success-500)',
'--ui-info': 'var(--ui-color-info-500)',
'--ui-warning': 'var(--ui-color-warning-500)',
'--ui-error': 'var(--ui-color-error-500)',
'--ui-bg-muted': 'var(--ui-color-neutral-700)',
'--ui-text': 'var(--ui-color-neutral-300)'
}
}
}
}, {
id: 'sky',
name: 'Sky',
description: 'Sky blue on a mist neutral, pastel fills everywhere, airy type.',
doc: {
version: 1,
colors: {
primary: 'sky',
neutral: 'mist'
},
radius: 0.75,
font: { sans: 'Figtree', lineHeight: 1.6 },
style: {
// nothing solid anywhere: one app-wide pastel fill
defaults: { variants: { buttons: 'soft', inputs: 'soft' } },
tokenShades: tintedNeutral()
}
}
}, {
id: 'mint',
name: 'Mint',
description: 'Teal on an olive neutral, pill buttons and fields, chunky rounded type, large controls.',
doc: {
version: 1,
colors: {
primary: 'teal',
neutral: 'olive'
},
radius: 0.75,
// a step heavier across the ladder, Nunito reads thin at stock weights
font: { sans: 'Nunito', weights: { normal: 500, medium: 600, semibold: 700, bold: 800 } },
icons: 'iconoir',
style: {
defaults: { size: 'lg', variants: { inputs: 'soft' } },
tokenShades: tintedNeutral()
},
// the radius ladder stops at 0.75rem, the pill has to come from the slots
components: {
button: { slots: { base: 'rounded-full' } },
input: { slots: { base: 'rounded-full' } },
select: { slots: { base: 'rounded-full' } },
selectMenu: { slots: { base: 'rounded-full' } },
inputMenu: { slots: { base: 'rounded-full' } }
}
}
}, {
id: 'iris',
name: 'Iris',
description: 'Violet outlines on a mauve neutral, fuchsia secondary, tinted fields.',
doc: {
version: 1,
colors: {
primary: 'violet',
secondary: 'fuchsia',
neutral: 'mauve'
},
radius: 0.5,
font: { sans: 'Manrope', letterSpacing: -0.01 },
icons: 'remix',
style: {
// the violet stays a line: outlined actions over tinted fields
defaults: { variants: { buttons: 'outline', inputs: 'subtle' } },
tokenShades: tintedNeutral()
}
}
}, {
id: 'crimson',
name: 'Crimson',
description: 'Cinema red on pure gray, square corners, filled fields, near-black in dark mode.',
doc: {
version: 1,
colors: {
primary: 'red',
neutral: 'neutral'
},
radius: 0,
font: { sans: 'Inter', weights: { semibold: 700, bold: 800 } },
icons: 'material',
style: {
defaults: { variants: { inputs: 'soft' } },
tokenShades: {
...tintedNeutral(),
// 600 is the deep cinema red, 500 leans orange; dark holds it rather
// than lifting to the salmon 400
'--ui-primary': { light: 600, dark: 500 },
'--ui-bg': { light: 50, dark: 950 },
// light restates tintedNeutral's step: this key replaces it wholesale
'--ui-bg-muted': { light: 100, dark: 900 },
'--ui-bg-elevated': { dark: 900 },
'--ui-bg-accented': { dark: 800 }
}
}
}
}, {
id: 'coral',
name: 'Coral',
description: 'Rose on warm stone, cards floating on shadows, teal for success, neutral focus rings.',
doc: {
version: 1,
colors: {
primary: 'rose',
success: 'teal',
neutral: 'stone'
},
radius: 0.5,
font: { sans: 'Plus Jakarta Sans' },
icons: 'phosphor',
style: {
// fields ring in neutral, the rose stays on actions
defaults: { colors: { inputs: 'neutral' } },
tokenShades: tintedNeutral()
},
components: {
// Cards float on a shadow instead of sitting in a ring. Dark keeps the
// ring, a shadow has nothing to fall on there. A compound entry rather
// than the slot: the variant's own `ring` lands after the slot string
// and would win the merge.
card: { compoundVariants: [{ variant: 'outline', class: { root: 'ring-0 dark:ring shadow-xl shadow-black/5' } }] }
}
}
}, {
id: 'sunset',
name: 'Sunset',
description: 'Orange on a warm taupe neutral, glowing actions, yellow secondary, subtle panels.',
doc: {
version: 1,
colors: {
primary: 'orange',
secondary: 'yellow',
neutral: 'taupe'
},
radius: 0.625,
font: { sans: 'Bricolage Grotesque' },
icons: 'tabler',
style: {
defaults: { variants: { panels: 'subtle' } },
tokenShades: {
...tintedNeutral(),
// orange-500 is too light to carry white text, 600 is the burnt stop
'--ui-primary': { light: 600 }
}
},
components: {
// solid primary actions cast a warm glow
button: { compoundVariants: [{ color: 'primary', variant: 'solid', class: 'shadow-md shadow-primary/30' }] }
}
}
}, {
id: 'carbon',
name: 'Carbon',
description: 'Amber on a warm carbon neutral, with ink-dark borders throughout.',
doc: {
version: 1,
// Sculpted warm neutral: lilac-tinted paper into pure carbon.
palettes: {
carbon: {
shades: {
50: 'oklch(98.5% 0.017 447.457)',
100: 'oklch(95.9% 0.036 438.639)',
200: 'oklch(92.6% 0.054 428.8)',
300: 'oklch(87% 0.053 417.734)',
400: 'oklch(70.5% 0.032 405.184)',
500: 'oklch(55.3% 0.014 390.836)',
600: 'oklch(44.7% 0.001 374.343)',
700: 'oklch(35.9% 0 0)',
800: 'oklch(28% 0 0)',
900: 'oklch(20.8% 0 0)',
950: 'oklch(14.1% 0.005 285.805)'
}
}
},
colors: {
primary: 'amber',
secondary: 'yellow',
neutral: 'carbon'
},
radius: 0.5,
font: { sans: 'Outfit' },
icons: 'tabler',
style: {
defaults: { variants: { buttons: 'solid', panels: 'subtle', inputs: 'subtle' } },
tokenShades: {
'--ui-bg': { light: 50, dark: 800 },
'--ui-bg-muted': { light: 300, dark: 700 },
'--ui-bg-elevated': { light: 300, dark: 700 },
'--ui-bg-accented': { light: 400, dark: 600 },
'--ui-bg-inverted': { light: 900, dark: 50 },
'--ui-text-inverted': { light: 50 },
'--ui-text-dimmed': { light: 500, dark: 400 },
'--ui-text-muted': { light: 800, dark: 300 },
'--ui-text-toned': { light: 900 },
'--ui-text': { light: 900 },
'--ui-text-highlighted': { light: 950, dark: 100 },
// Light keeps the ink hairline; dark has to run the other way, a
// border below the surface's own lightness just reads as a seam.
'--ui-border': { light: 950, dark: 600 },
// dark is left at the library's own neutral-700, restating it would
// only add a line the importer then has to recognise as generated
'--ui-border-muted': { light: 400 },
'--ui-border-accented': { light: 950, dark: 500 },
'--ui-border-inverted': { light: 500, dark: 50 }
}
}
}
}, {
id: 'bubblegum',
name: 'Bubblegum',
description: 'Pastel pink softness with mauve-tinted grays.',
doc: {
version: 1,
// Sculpted pink-mauve neutral, chroma peaks mid-ramp.
palettes: {
'saturated-mauve': {
shades: {
50: 'oklch(96.1% 0.021 325.68)',
100: 'oklch(92.4% 0.039 325.829)',
200: 'oklch(88% 0.057 325.83)',
300: 'oklch(81.2% 0.073 325.398)',
400: 'oklch(66.1% 0.084 323.292)',
500: 'oklch(52.4% 0.079 322.443)',
600: 'oklch(42.7% 0.064 322.128)',
700: 'oklch(34.6% 0.048 322.004)',
800: 'oklch(27.4% 0.034 321.983)',
900: 'oklch(20.7% 0.02 322.028)',
950: 'oklch(14.5% 0.008 322.12)'
}
}
},
colors: {
primary: 'pink',
secondary: 'violet',
neutral: 'saturated-mauve'
},
radius: 0.375,
font: { sans: 'Poppins' },
icons: 'phosphor',
tokens: {
light: {
'--ui-bg': 'var(--ui-color-neutral-50)',
'--ui-text-inverted': 'var(--ui-color-neutral-50)',
'--ui-bg-muted': 'var(--ui-color-neutral-100)'
},
dark: {
'--ui-bg-inverted': 'var(--ui-color-neutral-50)',
'--ui-text-highlighted': 'var(--ui-color-neutral-50)',
'--ui-border-inverted': 'var(--ui-color-neutral-50)'
}
}
}
}, {
id: 'parchment',
name: 'Parchment',
description: 'Warm parchment neutrals with a book-cloth clay primary.',
doc: {
version: 1,
palettes: {
// Book-cloth clay: the 500 anchors the ramp, the rest is fitted around it.
clay: {
shades: {
50: 'oklch(97.4% 0.009 48.308)',
100: 'oklch(94.2% 0.019 52.207)',
200: 'oklch(88.5% 0.036 51.142)',
300: 'oklch(82.1% 0.058 50.392)',
400: 'oklch(74.1% 0.094 47.255)',
500: 'oklch(67.2% 0.131 38.798)',
600: 'oklch(58.9% 0.138 37.63)',
700: 'oklch(51.4% 0.123 37.45)',
800: 'oklch(44.4% 0.103 36.916)',
900: 'oklch(38.8% 0.086 36.46)',
950: 'oklch(25.9% 0.054 38.197)'
}
},
// Warm paper grays, the light end is cream rather than white: the page
// sits at 100, its surfaces a step below.
parchment: {
shades: {
50: 'oklch(98% 0.006 100)',
100: 'oklch(96.5% 0.011 99)',
200: 'oklch(93.6% 0.014 97.348)',
300: 'oklch(85.8% 0.018 100)',
400: 'oklch(72.1% 0.015 102.54)',
500: 'oklch(57.8% 0.008 88.877)',
600: 'oklch(43.2% 0.006 91.526)',
700: 'oklch(38.2% 0.003 84.572)',
800: 'oklch(29.3% 0.003 106.588)',
900: 'oklch(21.7% 0.002 106.561)',
950: 'oklch(14.6% 0 0)'
}
}
},
colors: {
primary: 'clay',
neutral: 'parchment'
},
radius: 0.375,
font: { sans: 'DM Sans', serif: 'Source Serif 4' },
icons: 'heroicons',
// Surfaces and borders stepped one deeper to hold on the tinted cream
// page. Elevated has to stay below the page: the library's hover and
// highlight tints are bg-elevated at half opacity, a lighter elevated
// vanishes into it.
tokens: {
light: {
'--ui-bg': 'var(--ui-color-neutral-100)',
'--ui-bg-muted': 'var(--ui-color-neutral-200)',
'--ui-bg-elevated': 'var(--ui-color-neutral-200)',
'--ui-bg-accented': 'var(--ui-color-neutral-300)',
'--ui-border': 'var(--ui-color-neutral-300)',
// the library leaves this at 200, where the muted surface now sits:
// a code block's frame would land on its own background
'--ui-border-muted': 'var(--ui-color-neutral-300)',
'--ui-border-accented': 'var(--ui-color-neutral-400)'
},
dark: {
'--ui-primary': 'var(--ui-color-primary-500)',
'--ui-bg-accented': 'var(--ui-color-neutral-800)'
}
}
}
}] satisfies ThemePreset[]