Files
nuxt__ui/docs/app/plugins/theme.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

254 lines
14 KiB
TypeScript

import { defu } from 'defu'
import { watchDebounced } from '@vueuse/core'
import { themeIcons } from '../utils/theme/icons'
import { cssVariableDefaults } from '../utils/theme/tokens'
import { THEME_STATE_KEYS, THEME_STORAGE_KEY, clamped, readStoredTheme, snapshotStoredTheme, writeStoredTheme } from '../utils/theme/storage'
import type { StoredTheme } from '../utils/theme/storage'
import { mergeUi, styleComponents, DEFAULT_COLORS, THEME_DEFAULTS } from '../utils/theme/engine/types'
import { SAFE_NAME, sanitizeCustomColors, sanitizeCSSVariables } from '../utils/theme/sanitize'
export default defineNuxtPlugin({
enforce: 'post',
setup() {
const appConfig = useAppConfig()
// Client only, see below; the server's copy is inert.
let restoreStoredTheme = () => {}
if (import.meta.client) {
// Everything a stored theme touches, in one pass, with defaults for
// absent fields so it also serves the cross-tab listener below (where
// "absent" can mean "the other tab reset it"). At boot the deferred
// bits ride onNuxtReady; on a storage event they run directly.
//
// Assign, never seed: at boot this runs before the root component's
// setup but AFTER the SSR payload is hydrated, so every useState key
// already holds the server's default and its initializer will not
// re-run.
let appliedUiKeys: string[] = []
const distribute = (saved: StoredTheme, defer: (fn: () => void) => void) => {
// Both end up interpolated into <style> text, and storage is writable
// by anything on the origin, so they get the same clamp the FOUC
// script (num) and applyThemeSettings already apply.
useState('nuxt-ui-radius').value = clamped(saved.radius, 0, 4) ?? THEME_DEFAULTS.radius
useState('nuxt-ui-font-size').value = clamped(saved.fontSize, 12, 20) ?? THEME_DEFAULTS.fontSize
useState('nuxt-ui-font').value = saved.font ?? {}
useState('nuxt-ui-icons').value = saved.icons ?? THEME_DEFAULTS.icons
useState('nuxt-ui-black-as-primary').value = saved.blackAsPrimary ?? false
// Through the same boundary the AI path uses: these two are
// concatenated into <style> text, and storage is writable by anything
// on the origin. (The inline FOUC script still paints the raw values
// once, before the boot pass runs.)
useState('nuxt-ui-custom-colors').value = sanitizeCustomColors(saved.customColors ?? {})
useState('nuxt-ui-css-variables').value = saved.cssVariables ? sanitizeCSSVariables(saved.cssVariables) : {}
useState(THEME_STATE_KEYS.stylePrefs).value = saved.style ?? {}
useState(THEME_STATE_KEYS.paletteParams).value = saved.paletteParams ?? {}
useState(THEME_STATE_KEYS.themePreset).value = saved.preset
useState<Record<string, any>>('nuxt-ui-ai-theme').value = {
...(saved.colors ? { colors: { ...saved.colors } } : {}),
...(saved.components ? { ui: { ...saved.components } } : {})
}
// Same distribution order the per-key restores used, which carries
// two fixes worth keeping. Colors land now, before hydration, because
// the FOUC script already rewrote the style tags before first paint.
// Icons land deferred: icon names compile into element CLASSES and
// Vue only warns about a class that disagrees with the server, it
// never patches it, so assigning pre-hydration silently dropped the
// saved pack every reload. Same SAFE_NAME gate the live path
// (applyThemeSettings) enforces: these names end up dereferenced
// inside <style> text.
for (const alias of Object.keys(DEFAULT_COLORS) as Array<keyof typeof DEFAULT_COLORS>) {
const value = alias === 'primary' ? saved.primary : alias === 'neutral' ? saved.neutral : saved.colors?.[alias]
;(appConfig.ui.colors as any)[alias] = typeof value === 'string' && SAFE_NAME.test(value) ? value : DEFAULT_COLORS[alias]
}
const pack = saved.icons && Object.hasOwn(themeIcons, saved.icons) ? themeIcons[saved.icons as keyof typeof themeIcons] : themeIcons.lucide
defer(() => (appConfig.ui.icons = pack as any))
// The class bundle is DERIVED from the style prefs, so it is rebuilt
// here rather than stored. A generator change therefore reaches
// already-saved themes instead of serving a frozen bundle. Guarded
// like the studio's own rebuild: a corrupt persisted style must not
// take the whole plugin down before the persistence watcher even
// registers.
let styleUi: Record<string, any> = {}
try {
styleUi = saved.style ? styleComponents(saved.style) : {}
} catch {
// ignored: the theme still restores, minus the style bundle
}
useState<Record<string, any>>('nuxt-ui-style-ui').value = styleUi
defer(() => {
// same order as the live path: style bundle first, explicit wins;
// keys a previous distribution touched but this one doesn't reset
const merged = mergeUi(styleUi, saved.components || {})
for (const key of appliedUiKeys) {
if (!(key in merged)) (appConfig.ui as any)[key] = undefined
}
for (const [key, value] of Object.entries(merged)) {
if (key === 'colors' || key === 'icons') continue
(appConfig.ui as any)[key] = defu(value as Record<string, any>, (appConfig.ui as any)[key] || {})
}
appliedUiKeys = Object.keys(merged).filter(key => key !== 'colors' && key !== 'icons')
})
}
// A shared theme link (/theme?doc=, see pages/theme.vue) renders
// server-side in its own theme; restoring the stored one here would
// paint over it between SSR and hydration. The page applies the link
// instead and stores it, or calls the restore back when the payload
// turns out not to be a theme.
restoreStoredTheme = () => distribute(readStoredTheme(), onNuxtReady)
const linked = /^\/theme\/?$/.test(window.location.pathname) && /[?&]doc=/.test(window.location.search)
if (!linked) {
restoreStoredTheme()
}
// Another tab wrote the theme: adopt it, or this tab's next debounced
// write would clobber it with stale state (the single key is atomic
// per write, not across tabs).
window.addEventListener('storage', (event) => {
if (event.key !== null && event.key !== THEME_STORAGE_KEY) return
distribute(readStoredTheme(), fn => fn())
})
// One watcher owns every write. Each setting used to persist itself, so
// a reload could restore them out of step and the derived stores needed
// self-heals to reconcile; one atomic write makes that impossible.
// Debounced: the getter still runs per flush, but a slider drag no
// longer costs a synchronous localStorage write per frame.
watchDebounced(() => JSON.stringify(snapshotStoredTheme()), json => writeStoredTheme(JSON.parse(json)), { debounce: 250, flush: 'post' })
}
if (import.meta.server) {
// One FOUC script over the one storage key. Every setting used to ship
// its own inline script re-reading its own key, eight of them, each
// duplicating a slice of useTheme's reactive style tags. This still
// duplicates the derivations (it has to run before paint, before any
// Vue code exists) but it parses once and writes every tag in order.
// It stands down on a theme link, like the boot restore above: the
// server already rendered the linked theme.
useHead({
script: [{
innerHTML: `
(function() {
if (/^\\/theme\\/?$/.test(location.pathname) && /[?&]doc=/.test(location.search)) { return; }
var T = {};
try { T = JSON.parse(localStorage.getItem('nuxt-ui-theme') || '{}') || {}; } catch (e) { return; }
var SAFE = /^[\\w -]{1,50}$/;
function num(v, lo, hi) { var n = parseFloat(v); return isFinite(n) ? Math.min(hi, Math.max(lo, n)) : undefined; }
function set(id, css) { var el = document.getElementById(id); if (el) { el.textContent = css; } }
var primaryColor = SAFE.test(T.primary || '') ? T.primary : undefined;
var neutralColor = SAFE.test(T.neutral || '') ? T.neutral : undefined;
if (primaryColor || neutralColor) {
var swapColors = function(el) {
var html = el.innerHTML;
if (primaryColor && primaryColor !== 'black') {
html = html.replace(/(--ui-color-primary-\\d{2,3}:\\s*var\\(--color-)${appConfig.ui.colors.primary}(-\\d{2,3}.*?\\))/g, '$1' + primaryColor + '$2');
}
if (neutralColor) {
html = html.replace(/(--ui-color-neutral-\\d{2,3}:\\s*var\\(--color-)${appConfig.ui.colors.neutral}(-\\d{2,3}.*?\\))/g, '$1' + (neutralColor === 'neutral' ? 'old-neutral' : neutralColor) + '$2');
}
el.innerHTML = html;
};
var colorsEl = document.querySelector('style#nuxt-ui-colors');
if (colorsEl) { swapColors(colorsEl); }
else {
var obs = new MutationObserver(function(mutations) {
for (var i = 0; i < mutations.length; i++) {
for (var j = 0; j < mutations[i].addedNodes.length; j++) {
var node = mutations[i].addedNodes[j];
if (node.id === 'nuxt-ui-colors') { swapColors(node); obs.disconnect(); return; }
}
}
});
obs.observe(document.head, { childList: true });
}
}
var radius = num(T.radius, 0, 4);
if (radius !== undefined) { set('nuxt-ui-radius', ':root { --ui-radius: ' + radius + 'rem; }'); }
var fontSize = num(T.fontSize, 12, 20);
if (fontSize !== undefined && fontSize !== 16) { set('nuxt-ui-font-size', 'html { font-size: ' + fontSize + 'px; }'); }
set('nuxt-ui-black-as-primary', T.blackAsPrimary ? ':root { --ui-primary: black; } .dark { --ui-primary: white; }' : '');
var prefs = T.font || {};
var font = (prefs.sans && SAFE.test(prefs.sans)) ? prefs.sans : 'Public Sans';
var css = ':root { --font-sans: \\'' + font + '\\', sans-serif; }';
var serif = (prefs.serif && SAFE.test(prefs.serif)) ? prefs.serif : undefined;
var mono = (prefs.mono && SAFE.test(prefs.mono)) ? prefs.mono : undefined;
if (serif) { css += ' :root { --font-serif: \\'' + serif + '\\', serif; }'; }
if (mono) { css += ' :root { --font-mono: \\'' + mono + '\\', monospace; }'; }
var w = prefs.weights || {};
var wVars = Object.keys(w).map(function(step) { var n = num(w[step], 100, 900); return (SAFE.test(step) && n !== undefined) ? '--font-weight-' + step + ': ' + n + ';' : ''; }).filter(Boolean).join(' ');
if (wVars) { css += ' :root { ' + wVars + ' }'; }
var bodyRules = '';
var wn = num(w.normal, 100, 900);
if (wn !== undefined) { bodyRules += 'font-weight: ' + wn + '; '; }
if (prefs.uppercase) { bodyRules += 'text-transform: uppercase; '; }
if (prefs.italic) { bodyRules += 'font-style: italic; '; }
var ls = num(prefs.letterSpacing, -0.2, 1);
if (ls !== undefined && ls !== 0) { bodyRules += 'letter-spacing: ' + ls + 'em; '; }
var lh = num(prefs.lineHeight, 0.8, 3);
if (lh !== undefined) { bodyRules += 'line-height: ' + lh + '; '; }
if (bodyRules) { css += ' body { ' + bodyRules + '}'; }
if (Object.keys(prefs).length) { set('nuxt-ui-font', css); }
[font, serif, mono].forEach(function(name) {
if (!name || name === 'Public Sans') return;
var id = 'font-' + name.toLowerCase().replace(/\\s+/g, '-');
if (document.getElementById(id)) return;
var lnk = document.createElement('link');
lnk.rel = 'stylesheet';
lnk.href = 'https://fonts.googleapis.com/css2?family=' + encodeURIComponent(name) + ':wght@300;400;500;600;700;800&display=swap';
lnk.id = id;
document.head.appendChild(lnk);
});
var BREAKOUT = /[;{}<>]/;
var SHADE = /^\\d{2,3}$/;
var VAR_KEY = /^--[\\w-]+$/;
var custom = T.customColors;
if (custom) {
var vars = [];
for (var name in custom) {
if (!SAFE.test(name)) continue;
for (var shade in custom[name]) { if (SHADE.test(shade) && !BREAKOUT.test(String(custom[name][shade]))) { vars.push('--color-' + name + '-' + shade + ': ' + custom[name][shade] + ';'); } }
}
if (vars.length) { set('nuxt-ui-custom-colors', ':root { ' + vars.join(' ') + ' }'); }
}
var cssVars = T.cssVariables;
if (cssVars) {
var defaults = ${JSON.stringify(cssVariableDefaults)};
var merge = function(defs, overrides) {
var result = [];
var safe = function(v) { return v != null && !BREAKOUT.test(String(v)); };
for (var key in defs) { result.push(key + ': ' + (safe(overrides[key]) ? overrides[key] : defs[key]) + ';'); }
for (var key2 in overrides) { if (!defs[key2] && VAR_KEY.test(key2) && safe(overrides[key2])) result.push(key2 + ': ' + overrides[key2] + ';'); }
return result;
};
var parts = [];
if (cssVars.light && Object.keys(cssVars.light).length) { parts.push('.light { ' + merge(defaults.light, cssVars.light).join(' ') + ' }'); }
if (cssVars.dark && Object.keys(cssVars.dark).length) { parts.push('.dark { ' + merge(defaults.dark, cssVars.dark).join(' ') + ' }'); }
if (parts.length) { set('nuxt-ui-css-variables', parts.join(' ')); }
}
})();
`.replace(/\s+/g, ' '),
type: 'text/javascript',
tagPriority: -1
}]
})
}
return {
provide: { restoreStoredTheme }
}
}
})