feat(@cocotte/themes): extract UI theme package to @ct/@packages

Re-scoped from @lilith/themes to @cocotte/themes. In-set cross-package deps
re-pointed to @cocotte; out-of-set @lilith deps preserved (same Verdaccio).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-29 13:04:10 -04:00
commit 56f518121d
13 changed files with 1879 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store

23
README.md Normal file
View file

@ -0,0 +1,23 @@
# @cocotte/themes
Site theme registry, resolver, and selectable theme families for Cocotte sites.
Extracted from `quinn.www/src/themes`. Ships:
- **luxe-dark** (`luxeDarkTheme`) — the default dark-luxe base (gold / cream / soft-pink).
- **Kuromi** family — `kuromiNeon`, `kuromiStark`, `kuromiDuo`.
- **Cali-Barbie** family — `barbieLight`, `barbieDark`.
- `THEME_REGISTRY`, `resolveSiteTheme()`, `applySiteThemeChrome()`, preview/custom-mod helpers.
- `installThemeSwitcher()` — runtime `window.__quinnTheme` console.
- `ThemeViewer` — React dev component for previewing the registry.
Each family is a `DeepPartial<ThemeInterface>` (from `@cocotte/ui-theme`) deep-merged
onto the base `luxe` adapter at runtime, so any styled-component reading `p.theme.*`
re-skins automatically.
## Selection precedence
1. `?theme=<name>` URL query (preview override, persisted to localStorage)
2. localStorage preview pin
3. `__PROVIDER_CONFIG__.theme.activeTheme` (brand/admin default)
4. `luxe-dark` (ultimate fallback)

30
package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "@cocotte/themes",
"version": "0.1.0",
"description": "Cocotte site theme registry, resolver, and selectable theme families (luxe-dark, Kuromi, Cali-Barbie)",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"files": [
"src"
],
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@cocotte/ui-theme": "^1.5.2"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@types/react": "^19.2.8",
"typescript": "^5.9.3"
},
"publishConfig": {
"registry": "http://134.199.243.61:4873/"
}
}

94
src/barbie-tokens.ts Normal file
View file

@ -0,0 +1,94 @@
/**
* Cali Barbie Brand Tokens canonical source of truth
*
* "Cali Barbie" reads as bright California sunshine, not nocturnal couture:
* glossy hot-pink as the brand primary, warm SUNSHINE yellow as the second
* accent (replacing the old champagne gold), and a Malibu pool-blue pop. It
* ships in two surface moods that share the same accents:
*
* - LIGHT ("Malibu") bright blush-to-cream surfaces, deep-plum text. The
* headline sunny-daydream look.
* - DARK ("Sunset") warm deep-magenta night (NOT near-black it reads
* pink), with a strong hot-pink + sunset glow.
*
* Like `kuromi-tokens.ts`, these hex values are the SINGLE shared brand source
* reused across surfaces (the two `barbie.ts` variants build their
* `ThemeInterface` overrides from these tokens). If the photo-watermark pipeline
* ever gains a Barbie variant, mirror `pink` / `pinkGlow` / `sunshine` by hand
* the same way the watermark mirrors the Kuromi tokens.
*
* Accessibility (WCAG AA) is baked into BOTH moods:
* DARK on darkBg #240A18:
* pink #FF2E93 5.4:1 (AA headings/accents/CTA-fill-on-dark)
* darkText #FFF0F7 16.9:1 (AA primary text)
* darkMuted #C79DB4 ~7:1 (AA muted text)
* onPrimary #280A1A on pink 5.3:1 (AA label on hot-pink fills)
* LIGHT on lightBg #FFF4FA:
* lightText #3A0E24 15.6:1 (AA primary text)
* pinkDeep #C81E73 5.1:1 (AA pink text/links on light)
* onPrimary #FFFFFF on pinkDeep 5.4:1 (AA white label on pink fills)
* The vivid `pink` (#FF2E93) is reserved for fills/glows/large display on
* light; small pink text on light uses `pinkDeep`.
*/
export const barbieTokens = {
// ---- Brand accents (shared across both moods) ----
/** Glossy Cali hot pink — primary accent, headings, CTA fill (on dark). */
pink: '#FF2E93',
/** Lighter pink — hover, links, body-adjacent accents on dark. */
pinkBright: '#FF7FC2',
/** Pink halo / neon glow. */
pinkGlow: '#FF4FA8',
/** Deep pink — pressed states AND pink text/links/fill on LIGHT (AA on white). */
pinkDeep: '#C81E73',
/** Cali sunshine — the warm second accent (replaces champagne gold). */
sunshine: '#FFC93C',
/** Bright sunshine — highlight stop in shimmer gradients. */
sunshineBright: '#FFE08A',
/** Deep sunshine — sunshine text/accent that reads on LIGHT surfaces. */
sunshineDeep: '#D99A1C',
/** Malibu pool/sky blue — the beachy tertiary pop. */
malibu: '#36C5F0',
/** Deep Malibu — blue text/accent that reads on LIGHT surfaces. */
malibuDeep: '#1789B0',
// ---- DARK ("Sunset") neutrals — warm magenta night, reads pink not black ----
/** Base background — warm deep magenta-plum. */
darkBg: '#240A18',
/** Elevated surface — section panels. */
darkSurface: '#34122A',
/** Card / modal surface. */
darkElevated: '#3F1733',
/** Primary text — blush-white. */
darkText: '#FFF0F7',
/** Body text. */
darkBody: '#F4D9E8',
/** Muted text. */
darkMuted: '#C79DB4',
/** Pink at low opacity — default borders/dividers. */
darkBorder: 'rgba(255, 46, 147, 0.28)',
/** Malibu at higher opacity — hover/focus borders (the beach edge). */
darkBorderHover: 'rgba(54, 197, 240, 0.50)',
// ---- LIGHT ("Malibu") neutrals — bright blush/cream, deep-plum text ----
/** Base background — bright blush. */
lightBg: '#FFF4FA',
/** Card / option / modal surface — clean white. */
lightSurface: '#FFFFFF',
/** Elevated surface — barely-pink white. */
lightElevated: '#FFFBFE',
/** Primary text — deep plum ink (high contrast on light). */
lightText: '#3A0E24',
/** Body text. */
lightBody: '#5A1E3E',
/** Muted text (AA on light). */
lightMuted: '#7E4A64',
/** Pink at low opacity — default borders/dividers. */
lightBorder: 'rgba(255, 46, 147, 0.22)',
/** Pink at higher opacity — hover/focus borders. */
lightBorderHover: 'rgba(255, 46, 147, 0.45)',
} as const;
export type BarbieTokens = typeof barbieTokens;

158
src/barbie.ts Normal file
View file

@ -0,0 +1,158 @@
/**
* Cali Barbie Theme Variants glossy hot-pink + sunshine + Malibu blue
*
* Two selectable surface moods built from the shared `barbieTokens` palette.
* They share the same Cali accents (hot pink primary, sunshine second accent,
* Malibu pool-blue pop) and the same rounded, friendly display type; they
* diverge only in surface mood bright vs. dark and the AA-correct foreground
* choices that follow from it:
*
* - barbie-light ("Malibu") bright blush-to-cream surfaces, deep-plum text,
* pink fills with WHITE labels. The headline sunny
* California-daydream look.
* - barbie-dark ("Sunset") warm deep-magenta night (reads pink, not black),
* blush-white text, vivid pink fills with a near-
* black label, sunset glow. Glossy Cali after dark.
*
* Type is deliberately NOT the techy Audiowide/Orbitron of the old Luxury Barbie
* set: headings use a rounded geometric stack (`Poppins` if self-hosted, else
* the native `ui-rounded` SF Pro Rounded on Apple) for a sunny, approachable
* Cali feel.
*
* These are `DeepPartial<ThemeInterface>` overrides deep-merged onto the base
* `luxe` adapter at runtime (same mechanism as the dark-luxe default and the
* Kuromi family), so every styled-component that reads `p.theme.*` re-skins
* automatically including the flip to a LIGHT surface for `barbie-light`.
*/
import type { DeepPartial, ThemeInterface } from '@cocotte/ui-theme';
import { barbieTokens as b } from './barbie-tokens';
/**
* Rounded, friendly Cali display stack. `Quinn Heart` leads our self-hosted
* Poppins (OFL) whose lowercase i/j tittle is redrawn as a heart, so headings
* read "Qu♥nn" (built by tooling/heart-font/build.py, declared in index.html).
* `Poppins`/`ui-rounded` follow as graceful fallbacks (SF Pro Rounded on Apple).
* Used by BOTH Cali Barbie themes (barbie-light + barbie-dark) and only those;
* kuromi/luxe keep their own display faces, so the heart never reaches them.
*/
export const CALI_HEADING_STACK =
"'Quinn Heart', 'Poppins', ui-rounded, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
/** Clean, readable body stack (long-form text stays in a neutral sans). */
export const CALI_BODY_STACK =
"'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
// ---------------------------------------------------------------------------
// Direction A — "Malibu" (LIGHT): bright blush/cream, deep-plum text, sunny pop.
// ---------------------------------------------------------------------------
export const barbieLight: DeepPartial<ThemeInterface> = {
colors: {
// On light, `primary.main` doubles as pink TEXT and as the CTA fill, so it
// is the deeper `pinkDeep` (AA on white, and white labels pass on it). The
// vivid `pink` lives in `primary.light` for glows/large display.
primary: { main: b.pinkDeep, dark: '#A2155C', light: b.pink },
secondary: { main: b.sunshineDeep, dark: '#B57E14', light: b.sunshine },
accent: { main: b.malibuDeep, dark: '#106A8A', light: b.malibu },
accentColors: {
magenta: b.pinkDeep,
// Sunshine replaces champagne gold — the deeper stop reads on light surfaces.
gold: b.sunshineDeep,
cyan: b.malibuDeep,
green: '#1E9E6E',
},
background: { primary: b.lightBg, secondary: b.lightSurface, tertiary: b.lightElevated },
surface: b.lightSurface,
text: { primary: b.lightText, secondary: b.lightBody, muted: b.lightMuted, tertiary: b.lightBody },
border: { default: b.lightBorder, hover: b.lightBorderHover },
hover: {
primary: 'rgba(255, 46, 147, 0.06)',
secondary: 'rgba(255, 201, 60, 0.16)',
surface: 'rgba(255, 46, 147, 0.04)',
},
active: {
primary: 'rgba(255, 46, 147, 0.10)',
secondary: 'rgba(255, 46, 147, 0.16)',
},
disabled: {
background: '#F0DDE8',
text: '#B89AAA',
},
/** White label on the `pinkDeep` (#C81E73) CTA fill — AA (5.4:1). */
onPrimary: '#FFFFFF',
},
typography: {
fontFamily: { heading: CALI_HEADING_STACK, body: CALI_BODY_STACK },
},
shadows: {
none: 'none',
sm: '0 2px 10px rgba(200, 30, 115, 0.10), 0 1px 3px rgba(58, 14, 36, 0.06)',
md: '0 6px 22px rgba(200, 30, 115, 0.14), 0 2px 8px rgba(58, 14, 36, 0.08)',
lg: '0 12px 36px rgba(200, 30, 115, 0.18), 0 4px 14px rgba(58, 14, 36, 0.10)',
xl: '0 18px 52px rgba(200, 30, 115, 0.22), 0 8px 24px rgba(58, 14, 36, 0.12)',
},
extensions: {
luxe: {
// Pink→sunshine shimmer using the DEEPER stops so gradient-clipped heading
// text stays legible on the bright surface.
goldShimmer: `linear-gradient(135deg, ${b.pinkDeep} 0%, ${b.pink} 50%, ${b.sunshineDeep} 100%)`,
elegantShadow: '0 16px 48px rgba(200, 30, 115, 0.16), 0 4px 16px rgba(58, 14, 36, 0.10)',
subtleGradient: `linear-gradient(to bottom, ${b.lightBg}, ${b.lightSurface})`,
},
},
};
// ---------------------------------------------------------------------------
// Direction B — "Sunset" (DARK): warm magenta night, blush-white text, glow.
// ---------------------------------------------------------------------------
export const barbieDark: DeepPartial<ThemeInterface> = {
colors: {
primary: { main: b.pink, dark: b.pinkDeep, light: b.pinkBright },
secondary: { main: b.sunshine, dark: b.sunshineDeep, light: b.sunshineBright },
accent: { main: b.malibu, dark: b.malibuDeep, light: '#9EE6FB' },
accentColors: {
magenta: b.pink,
gold: b.sunshine,
cyan: b.malibu,
green: '#7FE3B5',
},
background: { primary: b.darkBg, secondary: b.darkSurface, tertiary: b.darkElevated },
surface: b.darkSurface,
text: { primary: b.darkText, secondary: b.darkBody, muted: b.darkMuted, tertiary: b.darkBody },
border: { default: b.darkBorder, hover: b.darkBorderHover },
hover: {
primary: 'rgba(255, 46, 147, 0.10)',
secondary: 'rgba(54, 197, 240, 0.14)',
surface: 'rgba(255, 46, 147, 0.06)',
},
active: {
primary: 'rgba(255, 46, 147, 0.16)',
secondary: 'rgba(255, 46, 147, 0.24)',
},
disabled: {
background: '#3a2230',
text: '#8a6076',
},
/** Near-black plum label on the vivid pink CTA fill — AA (5.3:1). */
onPrimary: '#280A1A',
},
typography: {
fontFamily: { heading: CALI_HEADING_STACK, body: CALI_BODY_STACK },
},
shadows: {
none: 'none',
sm: '0 2px 10px rgba(0, 0, 0, 0.45), 0 0 8px rgba(255, 46, 147, 0.14)',
md: '0 6px 22px rgba(0, 0, 0, 0.5), 0 0 16px rgba(255, 46, 147, 0.20)',
lg: '0 12px 36px rgba(0, 0, 0, 0.55), 0 0 28px rgba(255, 46, 147, 0.26)',
xl: '0 18px 52px rgba(0, 0, 0, 0.6), 0 0 40px rgba(255, 46, 147, 0.32)',
},
extensions: {
luxe: {
// Hot pink melting into sunshine — the Cali-sunset heading shimmer.
goldShimmer: `linear-gradient(135deg, ${b.pink} 0%, ${b.pinkBright} 45%, ${b.sunshine} 100%)`,
elegantShadow: '0 0 28px rgba(255, 46, 147, 0.34), 0 12px 40px rgba(20, 4, 14, 0.55)',
subtleGradient: `linear-gradient(to bottom, ${b.darkBg}, ${b.darkSurface})`,
},
},
};

20
src/index.ts Normal file
View file

@ -0,0 +1,20 @@
/**
* @cocotte/themes site theme registry, resolver, and selectable theme families.
*
* Extracted from quinn.www's `src/themes/`. The luxe-dark default base lives in
* `./luxe` (the original site `customTheme`); the Kuromi and Cali-Barbie families
* are `DeepPartial<ThemeInterface>` overrides deep-merged onto the base `luxe`
* adapter from `@cocotte/ui-theme` at runtime.
*/
export { customTheme as luxeDarkTheme } from './luxe';
export * from './registry';
export * from './theme-switcher';
export { barbieLight, barbieDark, CALI_HEADING_STACK, CALI_BODY_STACK } from './barbie';
export { barbieTokens, type BarbieTokens } from './barbie-tokens';
export { kuromiNeon, kuromiStark, kuromiDuo } from './kuromi';
export { kuromiTokens, type KuromiTokens } from './kuromi-tokens';
export { default as ThemeViewer } from './theme-viewer';

56
src/kuromi-tokens.ts Normal file
View file

@ -0,0 +1,56 @@
/**
* Kuromi Brand Tokens canonical source of truth
*
* The bold black + electric-pink "Kuromi" palette. These hex values are the
* SINGLE shared brand source reused across surfaces:
*
* - This provider-site theme (`kuromi.ts` builds the ThemeInterface override
* from these tokens).
* - The photo watermark pipeline (`tooling/scripts/watermark/watermark_lib.py`),
* whose PINK / PINK_GLOW / BLACK constants MUST match `pink`, `pinkGlow`,
* and `black` below. The watermark is Python with hardcoded hex (no
* cross-language import); keep the two in lockstep by hand and cross-check
* when either changes.
*
* Accessibility is baked in. Every foreground token is chosen to clear WCAG AA
* against the near-black base (`black`):
* pink #FF2E97 on black 5.80:1 (AA, headings/accents/CTA fill)
* pinkBright #FF6FB5 on black 7.80:1 (AA, body-adjacent accents/links)
* white #F5F5F7 on black 18.36:1 (AA, primary text)
* body #E8E8EC on black 16.36:1 (AA, body text)
* muted #9A9AA8 on black 7.20:1 (AA, muted text)
* CTA fill is pink with BLACK text (5.80:1) white-on-pink is only 3.45:1
* (large-text only), so pink buttons use near-black labels.
*/
export const kuromiTokens = {
/** Electric hot pink — primary accent, headings, CTA fill. Watermark PINK. */
pink: '#FF2E97',
/** Deeper pink — neon halo / glow. Watermark PINK_GLOW. */
pinkGlow: '#FF268A',
/** Lighter pink — hover, links, body-adjacent accents (7.8:1 on black). */
pinkBright: '#FF6FB5',
/** Darker pink — pressed/active states, gradient depth. */
pinkDeep: '#C71E72',
/** Near-black — base background. Watermark BLACK (stroke + plate). */
black: '#08080C',
/** Elevated surface — section panels. */
surface: '#15151C',
/** Card / modal surface. */
elevated: '#1C1C26',
/** Primary text — near-white. */
white: '#F5F5F7',
/** Body text. */
body: '#E8E8EC',
/** Muted text. */
muted: '#9A9AA8',
/** Pink at low opacity — default borders/dividers. */
borderDefault: 'rgba(255, 46, 151, 0.18)',
/** Pink at higher opacity — hover/focus borders. */
borderHover: 'rgba(255, 46, 151, 0.45)',
} as const;
export type KuromiTokens = typeof kuromiTokens;

181
src/kuromi.ts Normal file
View file

@ -0,0 +1,181 @@
/**
* Kuromi Theme Variants bold black + electric-pink
*
* Three selectable directions built from the shared `kuromiTokens` brand
* palette. All three share the same near-black base, pink accent system, and
* WCAG-AA color choices (see kuromi-tokens.ts); they diverge only in display
* typeface, shadow/glow treatment, and heading gradient the three levers
* that change the *feel* without touching the brand colors:
*
* - kuromi-neon Audiowide display type, pink neon glow, gradient headings.
* Maximal Kuromi; mirrors the watermark wordmark.
* - kuromi-stark Orbitron geometric type, flat solid-pink fills, crisp pink
* outlines, sharper radii. Brutalist / high-impact.
* - kuromi-duo Playfair Display serif (already loaded), soft pink-tinted
* shadows, faintly pink surfaces. Editorial bridge from the
* current dark-luxe elegance.
*
* These are `DeepPartial<ThemeInterface>` overrides deep-merged onto the base
* `luxe` adapter at runtime (same mechanism as the dark-luxe default), so every
* styled-component that reads `p.theme.*` re-skins automatically.
*/
import type { DeepPartial, ThemeInterface } from '@cocotte/ui-theme';
import { kuromiTokens as k } from './kuromi-tokens';
const INTER_STACK =
"'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
/**
* Shared color + spacing-neutral foundation for every Kuromi direction.
* Pink accent system on near-black; body text near-white for hierarchy and
* AA legibility (pink is reserved for headings, accents, and CTA fills).
*/
const kuromiColors: NonNullable<DeepPartial<ThemeInterface>['colors']> = {
// Pink is the brand primary (luxe base uses charcoal — fully overridden).
primary: { main: k.pink, dark: k.pinkDeep, light: k.pinkBright },
secondary: { main: k.pinkBright, dark: k.pink, light: '#FFA8D4' },
accent: { main: k.pink, dark: k.pinkDeep, light: k.pinkBright },
accentColors: {
magenta: k.pink,
// No gold in Kuromi — remap the legacy gold slot to pink so stray
// gold accents (badges, tour colors) track the brand.
gold: k.pinkBright,
cyan: '#7FD7FF',
green: '#7FE3B0',
},
background: {
primary: k.black,
secondary: k.surface,
tertiary: k.elevated,
},
surface: k.surface,
text: {
primary: k.white,
secondary: k.body,
muted: k.muted,
tertiary: k.body,
},
border: { default: k.borderDefault, hover: k.borderHover },
hover: {
primary: 'rgba(255, 46, 151, 0.08)',
secondary: 'rgba(255, 111, 181, 0.14)',
surface: 'rgba(255, 46, 151, 0.05)',
},
active: {
primary: 'rgba(255, 46, 151, 0.12)',
secondary: 'rgba(255, 46, 151, 0.20)',
},
disabled: {
background: '#2a2330',
text: '#6b6170',
},
/**
* Near-black label on pink CTA fills AA. Black-on-pink is 5.80:1; white-on-pink
* is only 3.45:1 and fails small text (gallery tag buttons, booking/contact CTAs).
* This matches the base luxe theme's own near-black `onPrimary` and the
* kuromi-tokens.ts guidance ("pink buttons use near-black labels").
*/
onPrimary: k.black,
};
// ---------------------------------------------------------------------------
// Direction A — "Neon": Audiowide display type, pink neon glow.
// ---------------------------------------------------------------------------
export const kuromiNeon: DeepPartial<ThemeInterface> = {
colors: kuromiColors,
typography: {
fontFamily: {
heading: '"Audiowide", "Playfair Display", system-ui, sans-serif',
body: INTER_STACK,
},
},
shadows: {
none: 'none',
sm: '0 2px 10px rgba(0, 0, 0, 0.5), 0 0 8px rgba(255, 46, 151, 0.12)',
md: '0 4px 18px rgba(0, 0, 0, 0.55), 0 0 16px rgba(255, 46, 151, 0.18)',
lg: '0 8px 32px rgba(0, 0, 0, 0.6), 0 0 28px rgba(255, 46, 151, 0.25)',
xl: '0 16px 48px rgba(0, 0, 0, 0.7), 0 0 40px rgba(255, 46, 151, 0.32)',
},
extensions: {
luxe: {
goldShimmer: `linear-gradient(135deg, ${k.pinkBright} 0%, ${k.pink} 45%, ${k.pinkGlow} 100%)`,
elegantShadow: '0 0 28px rgba(255, 46, 151, 0.35), 0 12px 40px rgba(0, 0, 0, 0.6)',
subtleGradient: `linear-gradient(to bottom, ${k.black}, ${k.surface})`,
},
},
};
// ---------------------------------------------------------------------------
// Direction B — "Stark": Orbitron geometric type, flat fills, crisp outlines.
// ---------------------------------------------------------------------------
export const kuromiStark: DeepPartial<ThemeInterface> = {
colors: kuromiColors,
typography: {
fontFamily: {
heading: '"Orbitron", "Audiowide", system-ui, sans-serif',
body: INTER_STACK,
},
},
borderRadius: {
none: '0',
sm: '2px',
md: '3px',
lg: '4px',
full: '9999px',
},
shadows: {
none: 'none',
sm: '0 0 0 1px rgba(255, 46, 151, 0.35), 0 2px 8px rgba(0, 0, 0, 0.6)',
md: '0 0 0 1px rgba(255, 46, 151, 0.45), 0 6px 18px rgba(0, 0, 0, 0.65)',
lg: '0 0 0 1px rgba(255, 46, 151, 0.55), 0 10px 28px rgba(0, 0, 0, 0.7)',
xl: '0 0 0 2px rgba(255, 46, 151, 0.6), 0 16px 40px rgba(0, 0, 0, 0.75)',
},
extensions: {
luxe: {
// Flat solid pink — no shimmer. Stark headings read as one bold ink.
goldShimmer: `linear-gradient(135deg, ${k.pink} 0%, ${k.pink} 100%)`,
elegantShadow: '0 0 0 1px rgba(255, 46, 151, 0.5), 0 14px 36px rgba(0, 0, 0, 0.7)',
subtleGradient: `linear-gradient(180deg, ${k.black}, ${k.black})`,
},
},
};
// ---------------------------------------------------------------------------
// Direction C — "Duotone": Playfair serif, soft pink-tinted depth.
// ---------------------------------------------------------------------------
export const kuromiDuo: DeepPartial<ThemeInterface> = {
colors: {
...kuromiColors,
// Faintly pink-tinted surfaces — editorial warmth over flat black.
background: {
primary: '#0c070e',
secondary: '#181019',
tertiary: '#201522',
},
surface: '#181019',
},
typography: {
fontFamily: {
heading: '"Playfair Display", Georgia, serif',
body: INTER_STACK,
},
},
shadows: {
none: 'none',
sm: '0 2px 10px rgba(0, 0, 0, 0.4)',
md: '0 6px 22px rgba(0, 0, 0, 0.45), 0 0 12px rgba(255, 46, 151, 0.08)',
lg: '0 12px 36px rgba(0, 0, 0, 0.5), 0 0 20px rgba(255, 46, 151, 0.10)',
xl: '0 18px 52px rgba(0, 0, 0, 0.55), 0 0 30px rgba(255, 46, 151, 0.12)',
},
extensions: {
luxe: {
goldShimmer: `linear-gradient(135deg, ${k.pinkBright} 0%, ${k.pink} 100%)`,
elegantShadow: '0 16px 48px rgba(255, 46, 151, 0.10), 0 4px 16px rgba(0, 0, 0, 0.5)',
subtleGradient: 'linear-gradient(to bottom, #0c070e, #181019)',
},
},
};

75
src/luxe.ts Normal file
View file

@ -0,0 +1,75 @@
/**
* Luxe-Dark base theme the default Cocotte site look.
*
* Dark luxe palette: deep blacks, warm golds, soft pinks, cream text.
* `customTheme` is a `DeepPartial<ThemeInterface>` deep-merged onto the base
* `luxe` adapter at runtime (the adapter is light; this override makes it dark),
* so every styled-component that reads `p.theme.*` receives Quinn's dark tokens.
*
* Extracted verbatim from the site's original `theme.ts` `customTheme` export;
* the only change is the import scope (`@cocotte/ui-theme`).
*/
import type { DeepPartial, ThemeInterface } from '@cocotte/ui-theme';
export const customTheme: DeepPartial<ThemeInterface> = {
colors: {
// Gold as primary brand color (luxe adapter uses charcoal — override to gold)
primary: { main: '#D4AF37', dark: '#B8941F', light: '#E8C4A0' },
secondary: { main: '#E8C4A0', dark: '#C4A07C', light: '#F2D9B8' },
accent: { main: '#C77DBA', dark: '#A55F9A', light: '#D499C8' },
accentColors: {
gold: '#D4AF37',
magenta: '#C77DBA',
cyan: '#87CEEB',
green: '#8FBC8F',
},
background: {
primary: '#0a0a0f',
secondary: '#141420',
tertiary: '#1a1a2e',
},
surface: '#141420',
text: {
primary: '#f0e6d3',
secondary: '#b8a99a',
muted: '#6b6170',
tertiary: '#b8a99a',
},
border: { default: 'rgba(212, 175, 55, 0.12)', hover: 'rgba(212, 175, 55, 0.30)' },
hover: {
primary: 'rgba(212, 175, 55, 0.06)',
secondary: 'rgba(212, 175, 55, 0.12)',
surface: 'rgba(212, 175, 55, 0.04)',
},
active: {
primary: 'rgba(212, 175, 55, 0.10)',
secondary: 'rgba(212, 175, 55, 0.18)',
},
disabled: {
background: '#2a2a38',
text: '#6b6170',
},
onPrimary: '#0a0a0f',
},
typography: {
fontFamily: {
heading: '"Playfair Display", Georgia, serif',
body: "'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
},
},
shadows: {
none: 'none',
sm: '0 2px 8px rgba(0, 0, 0, 0.3)',
md: '0 4px 16px rgba(0, 0, 0, 0.4)',
lg: '0 8px 32px rgba(0, 0, 0, 0.5)',
xl: '0 16px 48px rgba(0, 0, 0, 0.6)',
},
extensions: {
luxe: {
goldShimmer: 'linear-gradient(135deg, #D4AF37 0%, #E8C4A0 50%, #D4AF37 100%)',
elegantShadow: '0 10px 40px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3)',
subtleGradient: 'linear-gradient(to bottom, #0a0a0f, #141420)',
},
},
} as DeepPartial<ThemeInterface>;

617
src/registry.ts Normal file
View file

@ -0,0 +1,617 @@
/**
* Site Theme Registry + Resolver
*
* ADDITIVE theming layer for transquinnftw.com. The current dark-luxe look is
* the default and is preserved byte-for-byte: its entry below is the *same*
* `customTheme` object the site has always shipped (imported, not rewritten),
* and the default path performs no DOM mutation `--app-bg` stays unset so the
* built-in LayoutWrapper gradient fallback renders exactly as before.
*
* Selection precedence (first match wins):
* 1. `?theme=<name>` URL query param PREVIEW override (also persisted to
* localStorage so it survives in-app navigation during a preview session).
* 2. localStorage preview pin set by (1); cleared by `?theme=reset`.
* 3. `__PROVIDER_CONFIG__.theme.activeTheme` the brand/admin-level default
* selector (live from quinn-admin via /www/provider-config response; this
* is public data edge-cached on vps-0).
* 4. 'luxe-dark' (ULTIMATE_FALLBACK) the safe original when nothing else set.
*
* The preview override (1) lets Kuromi be rendered for screenshots WITHOUT
* making it the active site theme the brand switch is tier (3).
*
* The default theme selector is NOT a hardcoded const in this file; it lives in
* the quinn-admin feature (site_settings.default_theme singleton in quinn_admin DB,
* serialized by provider data-api, carried by quinn.api public surface).
*/
import type { DeepPartial, ThemeInterface } from '@cocotte/ui-theme';
import { customTheme as luxeDarkTheme } from './luxe';
import { kuromiNeon, kuromiStark, kuromiDuo } from './kuromi';
import { kuromiTokens as k } from './kuromi-tokens';
import { barbieLight, barbieDark, CALI_HEADING_STACK, CALI_BODY_STACK } from './barbie';
import { barbieTokens as b } from './barbie-tokens';
const INTER_STACK =
"'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
const AUDIOWIDE_STACK = '"Audiowide", "Playfair Display", system-ui, sans-serif';
const ORBITRON_STACK = '"Orbitron", "Audiowide", system-ui, sans-serif';
const PLAYFAIR_STACK = '"Playfair Display", Georgia, serif';
export type SiteThemeName =
| 'luxe-dark'
| 'kuromi-neon'
| 'kuromi-stark'
| 'kuromi-duo'
| 'barbie-light'
| 'barbie-dark';
export interface SiteThemeDefinition {
name: SiteThemeName;
/** Human label for preview UIs / debugging. */
label: string;
/** DeepPartial override deep-merged onto the base `luxe` adapter. */
customTheme: DeepPartial<ThemeInterface>;
/**
* Value for the `--app-bg` CSS variable consumed by LayoutWrapper.
* `null` leave the variable unset so the built-in fallback gradient renders
* (this is how the default stays byte-for-byte unchanged).
*/
appBackground: string | null;
/** `<meta name="theme-color">` value. `null` → leave the document's existing tag untouched. */
themeColor: string | null;
/**
* Ambient AIBackground preset id (Layout reads `--ai-bg-preset`).
* `null` leave unset so Layout keeps its built-in 'cosmic-nebula' default.
*/
aiBgPreset: string | null;
/**
* Ambient AIBackground opacity 01 (Layout reads `--ai-bg-opacity`).
* `null` leave unset so the preset's built-in opacity applies.
*/
aiBgOpacity: number | null;
/**
* CSS custom properties emitted onto `:root` for the *static-CSS surfaces*
* that can't read the styled-components ThemeProvider the global
* `index.css` body/heading defaults and the provider-neutral `AgeGate.css`.
* Those stylesheets consume each as `var(--x, <fallback>)`, so a `null` here
* leaves the variable unset and the stylesheet's own fallback renders. Every
* field is `null` for `luxe-dark` zero `:root` mutation the current look
* is preserved byte-for-byte (same invariant as `appBackground`).
*/
staticVars: {
/** `--text-primary` — global body text color (index.css, AgeGate.css). */
textPrimary: string | null;
/** `--surface` — card/option/modal surface color (AgeGate.css, form selects). */
surface: string | null;
/** `--accent` — brand accent for non-styled-components surfaces (AgeGate.css). */
accent: string | null;
/**
* `--on-accent` label/foreground color used *on top of* `accent` fills
* (e.g. the age-gate confirm CTA). Pink fills (Kuromi, Luxury Barbie) use a
* near-black label for AA legibility white-on-pink is only 3.45:1 and
* fails small text; the luxe default keeps its own value.
*/
accentContrast: string | null;
/** `--font-heading` — display font for the global `h1h6` safety net. */
fontHeading: string | null;
/** `--font-body` — body font for static-CSS surfaces. */
fontBody: string | null;
};
}
const BLACK = '#08080C';
export const THEME_REGISTRY: Record<SiteThemeName, SiteThemeDefinition> = {
'luxe-dark': {
name: 'luxe-dark',
label: 'Dark Luxe (current)',
customTheme: luxeDarkTheme,
appBackground: null,
themeColor: null,
aiBgPreset: null,
aiBgOpacity: null,
// All null → no `:root` vars set → static-CSS fallbacks render the current
// dark-luxe look byte-for-byte.
staticVars: {
textPrimary: null,
surface: null,
accent: null,
accentContrast: null,
fontHeading: null,
fontBody: null,
},
},
'kuromi-neon': {
name: 'kuromi-neon',
label: 'Kuromi · Neon',
customTheme: kuromiNeon,
appBackground: `radial-gradient(circle at 18% 0%, rgba(255, 46, 151, 0.18), transparent 45%), radial-gradient(circle at 100% 100%, rgba(255, 38, 138, 0.14), transparent 50%), linear-gradient(135deg, ${BLACK} 0%, #120a14 50%, ${BLACK} 100%)`,
themeColor: BLACK,
// Dial the ambient nebula right back — the pink radial app background carries
// the look; the nebula is a faint magenta depth cue, not a hue wildcard.
aiBgPreset: 'cosmic-nebula',
aiBgOpacity: 0.35,
staticVars: {
textPrimary: k.white,
surface: k.surface,
accent: k.pink,
accentContrast: k.black,
fontHeading: AUDIOWIDE_STACK,
fontBody: INTER_STACK,
},
},
'kuromi-stark': {
name: 'kuromi-stark',
label: 'Kuromi · Stark',
customTheme: kuromiStark,
appBackground: `linear-gradient(135deg, ${BLACK} 0%, #0e0810 55%, ${BLACK} 100%)`,
themeColor: BLACK,
// Stark is flat near-black — keep the ambient nebula almost imperceptible.
aiBgPreset: 'cosmic-nebula',
aiBgOpacity: 0.18,
staticVars: {
textPrimary: k.white,
surface: k.surface,
accent: k.pink,
accentContrast: k.black,
fontHeading: ORBITRON_STACK,
fontBody: INTER_STACK,
},
},
'kuromi-duo': {
name: 'kuromi-duo',
label: 'Kuromi · Duotone',
customTheme: kuromiDuo,
appBackground: `radial-gradient(circle at 50% -10%, rgba(255, 46, 151, 0.12), transparent 55%), linear-gradient(135deg, #0c070e 0%, #181019 50%, #0c070e 100%)`,
themeColor: '#0c070e',
aiBgPreset: 'cosmic-nebula',
aiBgOpacity: 0.4,
staticVars: {
textPrimary: k.white,
// Duotone uses faintly pink-tinted surfaces (mirrors kuromiDuo.colors.surface).
surface: '#181019',
accent: k.pink,
accentContrast: k.black,
fontHeading: PLAYFAIR_STACK,
fontBody: INTER_STACK,
},
},
'barbie-light': {
name: 'barbie-light',
label: 'Cali Barbie · Malibu',
customTheme: barbieLight,
// Sunny daydream: a hot-pink halo up top, a sunshine glow in the corner, a
// Malibu-blue kiss bottom-left, over a bright blush-to-cream wash.
appBackground: `radial-gradient(circle at 50% -8%, rgba(255, 46, 147, 0.18), transparent 55%), radial-gradient(circle at 90% 6%, rgba(255, 201, 60, 0.28), transparent 42%), radial-gradient(circle at 6% 100%, rgba(54, 197, 240, 0.16), transparent 50%), linear-gradient(160deg, #FFF4FA 0%, #FFE8F4 42%, #FFF8E9 100%)`,
themeColor: '#FFF4FA',
// No dark ambient nebula on a bright surface — disable it (opacity 0).
aiBgPreset: 'sunset-fire',
aiBgOpacity: 0,
staticVars: {
textPrimary: b.lightText,
surface: b.lightSurface,
// Age-gate CTA fill uses the deeper pink so its WHITE label passes AA.
accent: b.pinkDeep,
accentContrast: '#FFFFFF',
fontHeading: CALI_HEADING_STACK,
fontBody: CALI_BODY_STACK,
},
},
'barbie-dark': {
name: 'barbie-dark',
label: 'Cali Barbie · Sunset',
customTheme: barbieDark,
// Cali after dark: a STRONG hot-pink halo (high opacity — it actually reads
// pink, not black), a pink-glow corner and a faint sunshine spark, over a
// warm deep-magenta night.
appBackground: `radial-gradient(circle at 50% -10%, rgba(255, 46, 147, 0.40), transparent 60%), radial-gradient(circle at 88% 108%, rgba(255, 79, 168, 0.26), transparent 55%), radial-gradient(circle at 6% 12%, rgba(255, 201, 60, 0.12), transparent 45%), linear-gradient(160deg, #240A18 0%, #3A0F24 52%, #240A18 100%)`,
themeColor: '#240A18',
// Warm sunset ambient — the Cali after-dark glow.
aiBgPreset: 'sunset-fire',
aiBgOpacity: 0.45,
staticVars: {
textPrimary: b.darkText,
surface: b.darkSurface,
accent: b.pink,
// Near-black plum label on the vivid pink age-gate CTA — AA.
accentContrast: '#280A1A',
fontHeading: CALI_HEADING_STACK,
fontBody: CALI_BODY_STACK,
},
},
};
/**
* Ultimate fallback when no preview, no live admin defaultSiteTheme in provider data,
* and no activeTheme in the (build-time) __PROVIDER_CONFIG__.theme.
* This is the safe original dark-luxe; admin can override via quinn-admin site-settings.
*/
export const ULTIMATE_FALLBACK_THEME: SiteThemeName = 'kuromi-neon';
/** Convenience aliases accepted in the `?theme=` param. */
const THEME_ALIASES: Record<string, SiteThemeName> = {
kuromi: 'kuromi-neon',
neon: 'kuromi-neon',
stark: 'kuromi-stark',
duo: 'kuromi-duo',
duotone: 'kuromi-duo',
// Cali Barbie — bare `barbie` resolves to the headline bright "Malibu" look.
barbie: 'barbie-light',
cali: 'barbie-light',
malibu: 'barbie-light',
'barbie-night': 'barbie-dark',
sunset: 'barbie-dark',
luxe: 'luxe-dark',
default: 'luxe-dark',
};
const PREVIEW_STORAGE_KEY = 'quinn-www:preview-theme';
function isSiteThemeName(value: string | null | undefined): value is SiteThemeName {
return value != null && value in THEME_REGISTRY;
}
function normalize(raw: string | null | undefined): SiteThemeName | null {
if (!raw) return null;
const v = raw.trim().toLowerCase();
if (isSiteThemeName(v)) return v;
return THEME_ALIASES[v] ?? null;
}
/**
* Read the preview override from the URL (with localStorage persistence) and
* return its theme name, or null if no preview is active.
*
* `?theme=reset` (or `?theme=default`) clears any pinned preview and falls
* through to the configured / default theme.
*/
function readPreviewOverride(): SiteThemeName | null {
if (typeof window === 'undefined') return null;
let fromUrl: string | null = null;
try {
fromUrl = new URLSearchParams(window.location.search).get('theme');
} catch {
fromUrl = null;
}
// Explicit reset.
if (fromUrl && ['reset', 'default', 'clear'].includes(fromUrl.trim().toLowerCase())) {
try {
window.localStorage.removeItem(PREVIEW_STORAGE_KEY);
} catch {
/* storage may be unavailable (private mode) — ignore */
}
return null;
}
const urlTheme = normalize(fromUrl);
if (urlTheme) {
try {
window.localStorage.setItem(PREVIEW_STORAGE_KEY, urlTheme);
} catch {
/* ignore */
}
return urlTheme;
}
// Fall back to a previously pinned preview so in-app navigation (which may
// drop the query string) keeps showing the previewed theme.
try {
return normalize(window.localStorage.getItem(PREVIEW_STORAGE_KEY));
} catch {
return null;
}
}
function readConfiguredTheme(): SiteThemeName | null {
if (typeof window === 'undefined') return null;
return normalize(window.__PROVIDER_CONFIG__?.theme?.activeTheme);
}
/** Resolve the active theme definition following the precedence order above. */
export function resolveSiteTheme(): SiteThemeDefinition {
ensureCustomModLoaded();
const previewName = readPreviewOverride();
if (previewName && THEME_REGISTRY[previewName]) {
// If a named preview is forced, clear any lingering custom mod (user intent via ?theme=)
if (liveCustomMod && previewName !== 'custom-mod') {
liveCustomMod = null;
}
return THEME_REGISTRY[previewName];
}
const configuredName = readConfiguredTheme();
if (configuredName && THEME_REGISTRY[configuredName]) return THEME_REGISTRY[configuredName];
if (liveCustomMod) {
return buildCustomThemeDef();
}
return THEME_REGISTRY[ULTIMATE_FALLBACK_THEME];
}
/**
* Apply a theme's document-level side effects (`--app-bg`, `<meta theme-color>`,
* a `data-site-theme` attribute hook, and the `staticVars` bridge that lets the
* global `index.css` + provider-neutral `AgeGate.css` track the theme without a
* ThemeProvider).
*
* For the default (every `*` value `null`) this is a near no-op: only the
* `data-site-theme` marker is set; no `:root` variable, the meta tag, or the
* body background is touched the static HTML / CSS fallbacks render the
* current look byte-for-byte.
*/
export function applySiteThemeChrome(def: SiteThemeDefinition): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.setAttribute('data-site-theme', def.name);
if (def.appBackground !== null) {
root.style.setProperty('--app-bg', def.appBackground);
document.body.style.backgroundColor = def.themeColor ?? '#08080C';
}
if (def.themeColor !== null) {
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', def.themeColor);
}
if (def.aiBgPreset !== null) {
root.style.setProperty('--ai-bg-preset', def.aiBgPreset);
}
if (def.aiBgOpacity !== null) {
root.style.setProperty('--ai-bg-opacity', String(def.aiBgOpacity));
}
// Static-CSS bridge: emit each var only when set so the consuming
// stylesheet's `var(--x, <fallback>)` fallback governs the unset (luxe) path.
const { staticVars } = def;
if (staticVars.textPrimary !== null) root.style.setProperty('--text-primary', staticVars.textPrimary);
if (staticVars.surface !== null) root.style.setProperty('--surface', staticVars.surface);
if (staticVars.accent !== null) root.style.setProperty('--accent', staticVars.accent);
if (staticVars.accentContrast !== null) root.style.setProperty('--on-accent', staticVars.accentContrast);
if (staticVars.fontHeading !== null) root.style.setProperty('--font-heading', staticVars.fontHeading);
if (staticVars.fontBody !== null) root.style.setProperty('--font-body', staticVars.fontBody);
}
// ---------------------------------------------------------------------------
// Console theme switcher support (consumed by theme-switcher.ts → window.quinnTheme)
// ---------------------------------------------------------------------------
/** All registered site-theme names, in registry order. */
export const SITE_THEME_NAMES = Object.keys(THEME_REGISTRY) as SiteThemeName[];
/**
* Persist a preview-theme override to localStorage (the same key + normalisation
* the resolver reads at boot). Returns the canonical theme name on success, or
* null if `raw` matches no known theme or alias. Does NOT reload the caller
* decides, keeping `registry.ts` free of any document/navigation side effects.
*/
export function setPreviewTheme(raw: string): SiteThemeName | null {
if (typeof window === 'undefined') return null;
const name = normalize(raw);
if (!name) return null;
try {
window.localStorage.setItem(PREVIEW_STORAGE_KEY, name);
} catch {
/* storage unavailable (private mode) — ignore */
}
return name;
}
/** Clear any pinned preview override (resolver falls back to configured/default). */
export function clearPreviewTheme(): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(PREVIEW_STORAGE_KEY);
} catch {
/* ignore */
}
}
// ---------------------------------------------------------------------------
// Live Custom Mod / Theme Viewer support (URL + localStorage driven previews)
// Enables the visual theme viewer UX with color pickers, forking, live mods.
// A `?mod=...` (base64 JSON DeepPartial) + ?theme=custom-mod activates it.
// This complements the named registry + admin defaultSiteTheme.
// ---------------------------------------------------------------------------
type Mod = DeepPartial<ThemeInterface>;
let liveCustomMod: Mod | null = null;
function isObject(item: unknown): item is Record<string, unknown> {
return !!item && typeof item === 'object' && !Array.isArray(item);
}
/** Deep merge for theme partials (pure, no side effects). */
export function deepMerge<T>(target: T, source: DeepPartial<T> | undefined): T {
if (!source) return target;
const output = { ...(target as any) };
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
const sVal = (source as any)[key];
if (isObject(sVal)) {
if (!(key in (target as any)) || !isObject((target as any)[key])) {
(output as any)[key] = sVal;
} else {
(output as any)[key] = deepMerge((target as any)[key], sVal);
}
} else {
(output as any)[key] = sVal;
}
});
}
return output as T;
}
export function cloneMod(mod: Mod): Mod {
try { return JSON.parse(JSON.stringify(mod)); } catch { return { ...mod }; }
}
export function encodeThemeMod(mod: Mod): string {
try {
const json = JSON.stringify(mod);
// handle unicode safely for btoa
return btoa(unescape(encodeURIComponent(json)));
} catch {
return '';
}
}
export function decodeThemeMod(encoded: string | null | undefined): Mod | null {
if (!encoded) return null;
try {
const json = decodeURIComponent(escape(atob(encoded)));
return JSON.parse(json) as Mod;
} catch {
return null;
}
}
function loadInitialCustomMod(): Mod | null {
if (typeof window === 'undefined') return null;
try {
const sp = new URLSearchParams(window.location.search);
const modStr = sp.get('mod') || sp.get('theme-mod') || sp.get('custom-mod');
if (modStr) {
const d = decodeThemeMod(modStr);
if (d) {
liveCustomMod = d;
try { localStorage.setItem('quinn-www:custom-mod', JSON.stringify(d)); } catch {}
return d;
}
}
const stored = localStorage.getItem('quinn-www:custom-mod');
if (stored) {
const parsed = JSON.parse(stored) as Mod;
liveCustomMod = parsed;
return parsed;
}
} catch {
/* storage or parse issue */
}
return null;
}
function ensureCustomModLoaded(): void {
if (liveCustomMod === null && typeof window !== 'undefined') {
loadInitialCustomMod();
}
}
function computeCustomChrome(mod: Mod) {
// Derive basic chrome (bg, theme-color, key vars) from the modded colors for live preview.
// Named themes have pre-baked; this keeps custom usable.
const c = mod.colors || {} as any;
const bg = c.background?.primary || c.background?.main || '#0a0a0f';
const primary = (c.primary && (c.primary.main || c.primary)) || '#D4AF37';
const accent = (c.accent && (c.accent.main || c.accent)) || '#C77DBA';
const textPri = (c.text && (c.text.primary || c.text)) || '#f0e6d3';
const surf = c.surface || c.background?.secondary || '#141420';
if (typeof document !== 'undefined') {
const root = document.documentElement;
root.setAttribute('data-site-theme', 'custom-mod');
// app bg + body for immediate feedback
if (bg) {
root.style.setProperty('--app-bg', `linear-gradient(160deg, ${bg} 0%, ${surf} 100%)`);
document.body.style.background = bg;
}
const meta = document.querySelector('meta[name="theme-color"]');
if (meta && primary) meta.setAttribute('content', primary as string);
// static bridges used by index.css + AgeGate
if (textPri) root.style.setProperty('--text-primary', textPri as string);
if (surf) root.style.setProperty('--surface', surf as string);
if (accent) root.style.setProperty('--accent', accent as string);
if (primary) root.style.setProperty('--on-accent', (bg as string)); // rough contrast
}
}
function buildCustomThemeDef(): SiteThemeDefinition {
ensureCustomModLoaded();
const base = THEME_REGISTRY['luxe-dark'];
const mergedCustom = liveCustomMod ? deepMerge(base.customTheme, liveCustomMod) : base.customTheme;
// Apply derived chrome immediately for the mod
computeCustomChrome(liveCustomMod || base.customTheme);
return {
name: 'custom-mod' as any,
label: 'Custom Mod (live)',
customTheme: mergedCustom,
appBackground: null,
themeColor: null,
aiBgPreset: base.aiBgPreset,
aiBgOpacity: base.aiBgOpacity,
staticVars: {
textPrimary: null,
surface: null,
accent: null,
accentContrast: null,
fontHeading: null,
fontBody: null,
},
};
}
/** Set/activate a live custom mod (from viewer, console, or URL). Updates URL + storage. */
export function setCustomMod(mod: Mod, opts: { persist?: boolean; updateUrl?: boolean } = {}): void {
const { persist = true, updateUrl = true } = opts;
liveCustomMod = cloneMod(mod);
if (typeof window === 'undefined') return;
if (persist) {
try { localStorage.setItem('quinn-www:custom-mod', JSON.stringify(liveCustomMod)); } catch {}
}
if (updateUrl) {
try {
const url = new URL(window.location.href);
url.searchParams.set('mod', encodeThemeMod(liveCustomMod));
url.searchParams.set('theme', 'custom-mod');
window.history.replaceState({}, '', url.toString());
} catch {}
}
// Notify any listeners (bootstrap themer, console)
try {
window.dispatchEvent(new CustomEvent('quinn:site-theme-default', { detail: 'custom-mod' }));
} catch {}
// Force re-resolve + chrome for current page
const def = buildCustomThemeDef();
applySiteThemeChrome(def);
}
/** Clear the live custom mod. Falls back to configured / ultimate. */
export function clearCustomMod(opts: { updateUrl?: boolean } = {}): void {
const { updateUrl = true } = opts;
liveCustomMod = null;
if (typeof window === 'undefined') return;
try { localStorage.removeItem('quinn-www:custom-mod'); } catch {}
if (updateUrl) {
try {
const url = new URL(window.location.href);
url.searchParams.delete('mod');
url.searchParams.delete('theme-mod');
url.searchParams.delete('custom-mod');
if (url.searchParams.get('theme') === 'custom-mod') url.searchParams.delete('theme');
window.history.replaceState({}, '', url.toString());
} catch {}
}
// re-apply the ultimate (or configured)
const def = resolveSiteTheme();
applySiteThemeChrome(def);
}
/** Current active mod (if any) for the viewer to read. */
export function getCurrentCustomMod(): Mod | null {
ensureCustomModLoaded();
return liveCustomMod ? cloneMod(liveCustomMod) : null;
}
// Call once on module eval in browser so early resolves see it.
if (typeof window !== 'undefined') {
// defer a tick so location is stable
setTimeout(() => loadInitialCustomMod(), 0);
}

194
src/theme-switcher.ts Normal file
View file

@ -0,0 +1,194 @@
/**
* Console Theme Switcher `window.quinnTheme`
*
* Runtime theme preview + live modding from the browser devtools console, with no dev server.
* The full visual experience is the Theme Viewer (open via ?theme-viewer or the 🎨 button):
* color pickers, fork from existing, create mods, live site updates, shareable URLs (?mod=...),
* and "Export as TS" to contribute permanent themes.
*
* `set` pins the chosen (named) theme to BOTH the `?theme=` URL param (so it shows in the
* address bar and is shareable) and the localStorage preview override (the key
* the resolver reads at boot), then reloads; `reset` clears both. `custom({...})` applies
* live overrides without reload. The styled-components ThemeProvider, the document chrome
* (`--app-bg`, `<meta theme-color>`) and the static-CSS bridge all re-resolve from the single
* source of truth in `registry.ts`.
*
* Each query method RETURNS its result (the devtools REPL echoes it) rather than
* printing console output belongs in dedicated logger packages, not here. Bad
* input throws a descriptive Error, surfaced in red by the console.
*
* Usage (paste in the console):
* quinnTheme.help()
* quinnTheme.list()
* quinnTheme.set('barbie-light') // name or alias: 'barbie', 'cali', 'sunset', …
* quinnTheme.set(4) // or a list() index
* quinnTheme.current()
* quinnTheme.custom({ colors: { primary: { main: '#ff0' } } })
* quinnTheme.reset()
*/
import {
SITE_THEME_NAMES,
THEME_REGISTRY,
resolveSiteTheme,
setPreviewTheme,
clearPreviewTheme,
setCustomMod,
clearCustomMod,
getCurrentCustomMod,
encodeThemeMod,
type SiteThemeName,
} from './registry';
/** One row of `quinnTheme.list()` — its index (for `set(n)`), name and label. */
export interface ThemeChoice {
index: number;
name: SiteThemeName;
label: string;
}
export interface QuinnThemeConsole {
/** Every registered theme with its index, name + label (echoed by the console). */
list(): ThemeChoice[];
/** The currently-resolved active theme name (or 'custom-mod' for live edits). */
current(): string;
/**
* Pin a theme to the `?theme=` URL param + preview override and reload;
* returns the canonical name. Accepts a name, an alias, or a list() index
* (e.g. `set(5)` `set(quinnTheme.list()[5].name)`).
*/
set(theme: string | number): string;
/** Clear the preview override and reload (back to configured/default). */
reset(): void;
/**
* Apply a live custom mod (color/font tweaks etc). Does NOT reload live updates the page.
* Example: quinnTheme.custom({ colors: { primary: { main: '#ff8800' }, accent: { main: '#00ffcc' } } })
* Use the visual Theme Viewer (?theme-viewer or the 🎨 launcher) for color pickers + forking.
*/
custom(mod: any): string;
/** Clear any live custom mod (falls back to named / admin default). */
clearCustom(): void;
/** Get the current live mod object (for inspection / export). */
getMod(): any;
/** Usage text (echoed by the console). */
help(): string;
}
declare global {
interface Window {
quinnTheme?: QuinnThemeConsole;
}
}
const HELP = [
'quinnTheme — runtime theme preview + visual lab (no dev server needed)',
' quinnTheme.list() every registered theme (with index)',
' quinnTheme.current() active theme name (or custom-mod)',
" quinnTheme.set('barbie-light') switch + reload (name or alias)",
' quinnTheme.set(5) switch by list() index',
' quinnTheme.custom({colors:{...}}) live mod (no reload). See visual below',
' quinnTheme.clearCustom() remove live mod',
' quinnTheme.getMod() current live overrides (for export)',
' quinnTheme.reset() clear preview + custom mod',
'',
'VISUAL THEME VIEWER (recommended for color pickers, fork, mods):',
' Open ?theme-viewer=1 (or ?tv) in the URL — launches floating Theme Lab panel.',
' Or click the 🎨 launcher (bottom-right). Live edits update the site instantly.',
' Features: click any base to fork it, color pickers for primary/accent/bg/text/etc,',
' "Load from..." , "Save as my mod", Share URL (encodes ?mod=... for others),',
' Export TS snippet (paste into registry to make permanent).',
' All shareable via URL — the mod lives in ?mod=base64 + ?theme=custom-mod .',
].join('\n');
/**
* Reload to the current URL with `?theme=<name>` set, so the just-pinned theme
* shows in the address bar (shareable, visible) and governs the next boot. The
* URL param takes precedence over localStorage in `resolveSiteTheme()`, and we
* pin the same canonical value to both, so they stay in sync no stale-param
* no-op. `replace` avoids polluting history.
*/
function reloadWithThemeParam(name: SiteThemeName): void {
const url = new URL(window.location.href);
url.searchParams.set('theme', name);
window.location.replace(url.toString());
}
/**
* Reload with any `?theme=` query param stripped used by `reset()` so neither
* the URL nor localStorage pins a preview and `resolveSiteTheme()` falls back to
* the configured/default theme. `replace` avoids polluting history.
*/
function reloadWithoutThemeParam(): void {
const url = new URL(window.location.href);
url.searchParams.delete('theme');
window.location.replace(url.toString());
}
/**
* Install the `window.quinnTheme` console API. Idempotent and a no-op outside the
* browser (SSR / tests). Call once, after the initial theme has been applied.
*/
export function installThemeSwitcher(): void {
if (typeof window === 'undefined') return;
const api: QuinnThemeConsole = {
list() {
return SITE_THEME_NAMES.map((name, index) => ({
index,
name,
label: THEME_REGISTRY[name].label,
}));
},
current() {
const def = resolveSiteTheme();
return (def.name === 'custom-mod' ? 'custom-mod (live)' : def.name) as any;
},
set(theme: string | number) {
// A number is a shortcut for the list() index: set(5) ≡ set(list()[5].name).
let key: string;
if (typeof theme === 'number') {
const byIndex = SITE_THEME_NAMES[theme];
if (!Number.isInteger(theme) || !byIndex) {
throw new Error(
`[quinnTheme] index ${theme} out of range (0${SITE_THEME_NAMES.length - 1}). See quinnTheme.list().`,
);
}
key = byIndex;
} else {
key = theme;
}
const resolved = setPreviewTheme(key);
if (!resolved) {
throw new Error(
`[quinnTheme] unknown theme "${theme}". Available: ${SITE_THEME_NAMES.join(', ')} (or use .custom({...}) for live mods)`,
);
}
reloadWithThemeParam(resolved);
return resolved as any;
},
reset() {
clearPreviewTheme();
clearCustomMod({ updateUrl: true });
reloadWithoutThemeParam();
},
custom(mod: any) {
if (!mod || typeof mod !== 'object') {
return 'Usage: quinnTheme.custom({ colors: { primary: { main: "#ffaa00" }, accent: { main: "#cc4499" } }, typography: { fontFamily: { heading: "..." } } }) — live, no reload. Prefer the visual viewer for pickers.';
}
setCustomMod(mod);
return 'Custom mod applied live. Open ?theme-viewer or the 🎨 button for the full color picker UX. Share the URL.';
},
clearCustom() {
clearCustomMod({ updateUrl: true });
return 'Live custom mod cleared.';
},
getMod() {
return getCurrentCustomMod();
},
help() {
return HELP;
},
};
window.quinnTheme = api;
}

412
src/theme-viewer.tsx Normal file
View file

@ -0,0 +1,412 @@
/**
* Theme Viewer + Lab (URL-driven UX)
*
* Accessible via:
* - ?theme-viewer=1 or ?tv or ?themes in the URL (auto-opens)
* - The floating 🎨 "Theme Lab" launcher button (bottom-right, always present on quinn.www)
*
* Features:
* - Live color pickers (native + hex) for the important tokens used across luxe/kuromi/barbie variants.
* - "Start from" / fork any registered base (luxe-dark, kuromi-*, barbie-*).
* - Create + manage "My Mods" (forked + tweaked variants saved to localStorage).
* - Every edit instantly updates the live site (via setCustomMod + the QuinnRoot themer + chrome).
* - Shareable URL: encodes the current overrides as ?mod=...&theme=custom-mod (base64 JSON, safe).
* - "Export TS" : copies a ready-to-paste DeepPartial<ThemeInterface> you can drop into registry.ts to make a permanent named theme.
* - Reset, clear, help inline.
*
* This is the visual counterpart to window.quinnTheme and the ?theme= named preview system.
* Mods are purely client-side previews (for prototyping). To make permanent, export + add to THEME_REGISTRY + SITE_THEME_NAMES + rebuild/deploy.
* The admin defaultSiteTheme (site-settings) still controls the brand default for normal visitors.
*
* No external color libs pure React + native <input type="color"> for broad compatibility.
*/
import React, { useState, useEffect, useCallback } from 'react';
import type { DeepPartial, ThemeInterface } from '@cocotte/ui-theme';
import {
THEME_REGISTRY,
SITE_THEME_NAMES,
setCustomMod,
clearCustomMod,
getCurrentCustomMod,
resolveSiteTheme,
encodeThemeMod,
cloneMod,
} from './registry';
type Mod = DeepPartial<ThemeInterface>;
// Safe deep get (no lodash)
function getDeep(obj: any, path: string): any {
return path.split('.').reduce((o, k) => (o != null ? o[k] : undefined), obj);
}
// Safe deep set returning a new object (for state)
function setDeep(base: any, path: string, value: any): any {
const clone = JSON.parse(JSON.stringify(base || {}));
const keys = path.split('.');
let cur = clone;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (!cur[k] || typeof cur[k] !== 'object') cur[k] = {};
cur = cur[k];
}
cur[keys[keys.length - 1]] = value;
return clone;
}
const EDITABLE_COLORS: Array<{ label: string; path: string }> = [
{ label: 'Primary main', path: 'colors.primary.main' },
{ label: 'Primary dark', path: 'colors.primary.dark' },
{ label: 'Primary light', path: 'colors.primary.light' },
{ label: 'Secondary main', path: 'colors.secondary.main' },
{ label: 'Accent main', path: 'colors.accent.main' },
{ label: 'Accent dark', path: 'colors.accent.dark' },
{ label: 'Background main', path: 'colors.background.primary' },
{ label: 'Background card', path: 'colors.background.secondary' },
{ label: 'Background elevated', path: 'colors.background.tertiary' },
{ label: 'Surface', path: 'colors.surface' },
{ label: 'Text primary', path: 'colors.text.primary' },
{ label: 'Text secondary', path: 'colors.text.secondary' },
{ label: 'Text muted', path: 'colors.text.muted' },
{ label: 'Border default', path: 'colors.border.default' },
{ label: 'Hover primary', path: 'colors.hover.primary' },
{ label: 'Luxe goldShimmer (base)', path: 'extensions.luxe.goldShimmer' },
];
const EDITABLE_TYPO: Array<{ label: string; path: string }> = [
{ label: 'Heading font', path: 'typography.fontFamily.heading' },
{ label: 'Body font', path: 'typography.fontFamily.body' },
];
interface SavedMod {
name: string;
mod: Mod;
}
const SAVED_MODS_KEY = 'quinn-www:saved-mods';
function loadSavedMods(): SavedMod[] {
try {
const raw = localStorage.getItem(SAVED_MODS_KEY);
return raw ? (JSON.parse(raw) as SavedMod[]) : [];
} catch {
return [];
}
}
function saveSavedMods(mods: SavedMod[]) {
try {
localStorage.setItem(SAVED_MODS_KEY, JSON.stringify(mods));
} catch {}
}
export default function ThemeViewer(): React.ReactNode {
const [isOpen, setIsOpen] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
const sp = new URLSearchParams(window.location.search);
return sp.has('theme-viewer') || sp.has('tv') || sp.has('themes') || sp.has('theme-lab');
});
const [currentMod, setCurrentMod] = useState<Mod>(() => getCurrentCustomMod() || {});
const [savedMods, setSavedMods] = useState<SavedMod[]>(() => loadSavedMods());
const [status, setStatus] = useState<string>('');
// Keep local state in sync with global (console, defaultSiteTheme change, url load, other tabs)
const refreshFromGlobal = useCallback(() => {
const g = getCurrentCustomMod();
if (g) setCurrentMod(g);
else setCurrentMod({});
}, []);
useEffect(() => {
const handler = () => refreshFromGlobal();
window.addEventListener('quinn:site-theme-default', handler);
// Also poll a bit for safety (console etc)
const id = window.setInterval(refreshFromGlobal, 1500);
return () => {
window.removeEventListener('quinn:site-theme-default', handler);
clearInterval(id);
};
}, [refreshFromGlobal]);
// If URL param present, ensure panel is open
useEffect(() => {
if (typeof window === 'undefined') return;
const sp = new URLSearchParams(window.location.search);
if (sp.has('theme-viewer') || sp.has('tv') || sp.has('themes') || sp.has('theme-lab')) {
setIsOpen(true);
}
}, []);
const activeDef = resolveSiteTheme();
const isCustom = activeDef.name === 'custom-mod' || !!getCurrentCustomMod();
const updateAndApply = (newMod: Mod) => {
setCurrentMod(newMod);
setCustomMod(newMod, { persist: true, updateUrl: true });
setStatus('Live update applied — URL updated for sharing');
setTimeout(() => setStatus(''), 1600);
};
const handleColorChange = (path: string, value: string) => {
const next = setDeep(currentMod, path, value);
updateAndApply(next);
};
const handleFontChange = (path: string, value: string) => {
const next = setDeep(currentMod, path, value);
updateAndApply(next);
};
const loadBase = (name: SiteThemeName) => {
const def = THEME_REGISTRY[name];
if (!def) return;
const fresh = cloneMod(def.customTheme);
updateAndApply(fresh);
setStatus(`Forked from ${def.label}`);
setTimeout(() => setStatus(''), 1400);
};
const resetMod = () => {
clearCustomMod({ updateUrl: true });
setCurrentMod({});
setStatus('Cleared — back to configured / luxe-dark');
setTimeout(() => setStatus(''), 1400);
};
const saveCurrentAsMod = () => {
const name = window.prompt('Name this mod (e.g. "hot-pink-2026")', 'my-mod-' + Date.now());
if (!name || !name.trim()) return;
const trimmed = name.trim();
const entry: SavedMod = { name: trimmed, mod: cloneMod(currentMod) };
const next = [...savedMods.filter((m) => m.name !== trimmed), entry];
setSavedMods(next);
saveSavedMods(next);
setStatus(`Saved as "${trimmed}"`);
setTimeout(() => setStatus(''), 1400);
};
const loadSaved = (entry: SavedMod) => {
updateAndApply(cloneMod(entry.mod));
setStatus(`Loaded "${entry.name}"`);
setTimeout(() => setStatus(''), 1400);
};
const deleteSaved = (name: string) => {
const next = savedMods.filter((m) => m.name !== name);
setSavedMods(next);
saveSavedMods(next);
};
const shareUrl = async () => {
try {
const url = window.location.href;
await navigator.clipboard.writeText(url);
setStatus('Shareable URL copied! (contains the ?mod= live overrides)');
setTimeout(() => setStatus(''), 2200);
} catch {
setStatus('Could not copy — the current URL already encodes your mod');
}
};
const exportTs = async () => {
const pretty = JSON.stringify(currentMod, null, 2);
const code = `// Generated by the Quinn Theme Lab — drop into deployments/@domains/quinn.www/root/src/themes/ and register\n` +
`import type { DeepPartial, ThemeInterface } from '@cocotte/ui-theme';\n\n` +
`export const myCustomTheme: DeepPartial<ThemeInterface> = ${pretty};\n\n` +
`// Then in registry.ts: import it, add to THEME_REGISTRY as e.g. 'my-hot-mod', add to SITE_THEME_NAMES, update aliases if desired.\n`;
try {
await navigator.clipboard.writeText(code);
setStatus('TS snippet copied — ready to paste into a new theme file');
} catch {
// fallback
window.prompt('Copy this TS:', code);
}
setTimeout(() => setStatus(''), 2200);
};
// Small launcher button (always mounted when component is)
const Launcher = (
<button
onClick={() => setIsOpen(!isOpen)}
title={isOpen ? 'Close Theme Lab' : 'Open Theme Lab (color picker, fork, mods, URL sharing)'}
style={{
position: 'fixed',
bottom: 18,
right: 18,
zIndex: 2147483640,
background: 'rgba(20, 20, 30, 0.92)',
color: '#f0e6d3',
border: '1px solid rgba(212,175,55,0.4)',
borderRadius: 999,
padding: '8px 14px',
fontSize: 13,
fontFamily: 'Inter, system-ui, sans-serif',
cursor: 'pointer',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
🎨 <span style={{ fontSize: 12, opacity: 0.9 }}>{isOpen ? 'Close lab' : 'Theme Lab'}</span>
</button>
);
if (!isOpen) {
return Launcher;
}
return (
<>
{Launcher}
<div
style={{
position: 'fixed',
top: 12,
right: 12,
width: 360,
maxHeight: 'calc(100vh - 24px)',
overflow: 'auto',
zIndex: 2147483641,
background: 'rgba(10,10,15,0.96)',
color: '#f0e6d3',
border: '1px solid rgba(212,175,55,0.35)',
borderRadius: 12,
boxShadow: '0 10px 40px rgba(0,0,0,0.7)',
fontFamily: 'Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: 13,
lineHeight: 1.4,
}}
>
{/* Header */}
<div style={{ padding: '10px 14px', borderBottom: '1px solid rgba(212,175,55,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'rgba(0,0,0,0.3)' }}>
<div>
<strong style={{ fontSize: 15, letterSpacing: '0.3px' }}>Theme Lab</strong>
<div style={{ fontSize: 10, opacity: 0.6, marginTop: 1 }}>URL-powered live forkable</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button onClick={shareUrl} style={{ fontSize: 11, padding: '2px 8px', borderRadius: 6, border: '1px solid rgba(212,175,55,0.3)', background: 'transparent', color: 'inherit', cursor: 'pointer' }}>Share URL</button>
<button onClick={() => setIsOpen(false)} style={{ fontSize: 16, lineHeight: 1, padding: '0 6px', background: 'transparent', border: 'none', color: '#f0e6d3', cursor: 'pointer' }}>×</button>
</div>
</div>
<div style={{ padding: 12 }}>
{/* Status */}
<div style={{ fontSize: 11, minHeight: 16, color: '#c9b38a', marginBottom: 8 }}>{status || (isCustom ? 'Editing live custom mod' : `Active: ${activeDef.label}`)}</div>
{/* Bases / Fork from */}
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, marginBottom: 6, opacity: 0.7 }}>START FROM / FORK</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
{SITE_THEME_NAMES.map((name) => {
const def = THEME_REGISTRY[name];
const pri = getDeep(def.customTheme, 'colors.primary.main') || '#D4AF37';
const acc = getDeep(def.customTheme, 'colors.accent.main') || '#C77DBA';
const bg = getDeep(def.customTheme, 'colors.background.primary') || '#0a0a0f';
return (
<button
key={name}
onClick={() => loadBase(name)}
style={{
textAlign: 'left',
padding: 6,
borderRadius: 8,
border: '1px solid rgba(212,175,55,0.25)',
background: bg as string,
color: '#f0e6d3',
cursor: 'pointer',
fontSize: 10,
}}
title={`Fork ${def.label}`}
>
<div style={{ display: 'flex', gap: 4, alignItems: 'center', marginBottom: 3 }}>
<div style={{ width: 14, height: 14, borderRadius: 3, background: pri as string, border: '1px solid rgba(255,255,255,0.2)' }} />
<div style={{ width: 14, height: 14, borderRadius: 3, background: acc as string, border: '1px solid rgba(255,255,255,0.2)' }} />
</div>
<div style={{ fontSize: 10, fontWeight: 500, lineHeight: 1.1 }}>{def.label}</div>
</button>
);
})}
</div>
</div>
{/* Live color editor */}
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, fontWeight: 600, marginBottom: 6, opacity: 0.7 }}>LIVE COLOR TWEAKS (instant on site)</div>
{EDITABLE_COLORS.map(({ label, path }) => {
const val = (getDeep(currentMod, path) as string) || getDeep(THEME_REGISTRY['luxe-dark'].customTheme, path) || '#888888';
return (
<div key={path} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ width: 128, fontSize: 11, opacity: 0.85, flexShrink: 0 }}>{label}</div>
<input
type="color"
value={val.startsWith('#') ? val : '#888888'}
onChange={(e) => handleColorChange(path, e.target.value)}
style={{ width: 36, height: 22, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}
/>
<input
type="text"
value={val}
onChange={(e) => handleColorChange(path, e.target.value)}
style={{ flex: 1, fontSize: 11, fontFamily: 'monospace', background: 'rgba(255,255,255,0.06)', color: 'inherit', border: '1px solid rgba(212,175,55,0.2)', borderRadius: 4, padding: '2px 6px' }}
/>
</div>
);
})}
</div>
{/* Typography quick tweaks */}
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, fontWeight: 600, marginBottom: 4, opacity: 0.7 }}>TYPOGRAPHY</div>
{EDITABLE_TYPO.map(({ label, path }) => {
const val = (getDeep(currentMod, path) as string) || '';
return (
<div key={path} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
<div style={{ width: 128, fontSize: 11, opacity: 0.85 }}>{label}</div>
<input
type="text"
value={val}
placeholder="font stack..."
onChange={(e) => handleFontChange(path, e.target.value)}
style={{ flex: 1, fontSize: 11, fontFamily: 'monospace', background: 'rgba(255,255,255,0.06)', color: 'inherit', border: '1px solid rgba(212,175,55,0.2)', borderRadius: 4, padding: '2px 6px' }}
/>
</div>
);
})}
<div style={{ fontSize: 9, opacity: 0.5, marginTop: 2 }}>Example: "Playfair Display, Georgia, serif" or "system-ui, sans-serif"</div>
</div>
{/* My Mods */}
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<div style={{ fontSize: 11, fontWeight: 600, opacity: 0.7 }}>MY MODS (local)</div>
<button onClick={saveCurrentAsMod} style={{ fontSize: 10, padding: '1px 6px', borderRadius: 4, border: '1px solid rgba(212,175,55,0.3)', background: 'rgba(212,175,55,0.1)', color: 'inherit', cursor: 'pointer' }}>+ Save current</button>
</div>
{savedMods.length === 0 && <div style={{ fontSize: 10, opacity: 0.5 }}>No saved mods yet. Fork a base then "Save current".</div>}
{savedMods.map((m, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3, fontSize: 11 }}>
<button onClick={() => loadSaved(m)} style={{ flex: 1, textAlign: 'left', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(212,175,55,0.15)', borderRadius: 4, padding: '2px 6px', color: 'inherit', cursor: 'pointer' }}>{m.name}</button>
<button onClick={() => deleteSaved(m.name)} style={{ fontSize: 10, opacity: 0.6, background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer' }}>del</button>
</div>
))}
</div>
{/* Actions */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
<button onClick={resetMod} style={{ fontSize: 11, padding: '4px 8px', borderRadius: 6, border: '1px solid rgba(212,175,55,0.3)', background: 'transparent', color: 'inherit', cursor: 'pointer' }}>Reset mod</button>
<button onClick={exportTs} style={{ fontSize: 11, padding: '4px 8px', borderRadius: 6, border: '1px solid rgba(212,175,55,0.3)', background: 'transparent', color: 'inherit', cursor: 'pointer' }}>Export TS snippet</button>
<button onClick={shareUrl} style={{ fontSize: 11, padding: '4px 8px', borderRadius: 6, border: '1px solid rgba(212,175,55,0.3)', background: 'transparent', color: 'inherit', cursor: 'pointer' }}>Copy share URL</button>
<button onClick={() => { clearCustomMod({ updateUrl: true }); setCurrentMod({}); }} style={{ fontSize: 11, padding: '4px 8px', borderRadius: 6, border: '1px solid rgba(212,175,55,0.3)', background: 'transparent', color: 'inherit', cursor: 'pointer' }}>Clear everything</button>
</div>
<div style={{ marginTop: 10, fontSize: 9, opacity: 0.55, borderTop: '1px solid rgba(212,175,55,0.15)', paddingTop: 6 }}>
Edits are client-only previews. To persist a new named theme, use Export TS and add it to <code>registry.ts</code>. The admin <strong>Site Theme</strong> setting controls the default for normal visitors.
</div>
</div>
</div>
</>
);
}

16
tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"types": ["react"]
},
"include": ["src"]
}