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:
commit
56f518121d
13 changed files with 1879 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
23
README.md
Normal file
23
README.md
Normal 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
30
package.json
Normal 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
94
src/barbie-tokens.ts
Normal 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
158
src/barbie.ts
Normal 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
20
src/index.ts
Normal 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
56
src/kuromi-tokens.ts
Normal 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
181
src/kuromi.ts
Normal 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
75
src/luxe.ts
Normal 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
617
src/registry.ts
Normal 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 0–1 (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 `h1–h6` 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
194
src/theme-switcher.ts
Normal 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
412
src/theme-viewer.tsx
Normal 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
16
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue