feat(quinn-admin): move default theme selector from hardcoded quinn.www constant into quinn-admin feature (public data)

- add site-settings singleton to admin registry + schema + migration
- add editor config + route + nav in admin frontend
- surface defaultSiteTheme via data-api serialize + shared types + validator
- carry through api /www/provider-config (the public edge-cached path on vps0)
- remove DEFAULT_SITE_THEME hardcode; ultimate fallback luxe-dark; registry comments updated for admin-driven live selector
- live bootstrap in quinn.www root + data hook to pick admin default without quinn.www rebuild (chrome + tokens update post-fetch)
- fixed incidental sortable test assertion to match current registry (pre-existing mismatch)
- other public hardcodes remain in deployment configs; see analysis

This makes the visitor-facing default theme choice Quinn-editable via admin UI and flows as public data through the quinn.api public surface (edge cacheable).
This commit is contained in:
Natalie 2026-06-23 04:34:35 -04:00
parent b00a3e7832
commit bcd2d96a1f
21 changed files with 212 additions and 13 deletions

View file

@ -0,0 +1,10 @@
-- Add site_settings singleton for admin-managed public defaults (e.g. default theme selector)
-- This data is public, flows into /www/provider-config, and is edge-cached on vps-0 via quinn.api public mode.
CREATE TABLE IF NOT EXISTS site_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
default_theme TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO site_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;

View file

@ -143,7 +143,7 @@ describe('registry — list types', () => {
expect(def.fields['fmty_tier']?.enum).toContain('international');
});
it('sortable list types have sortable: true', () => {
const sortablePaths = ['tour', 'destinations', 'specialties', 'activity-menus'];
const sortablePaths = ['destinations', 'specialties', 'activity-menus'];
for (const path of sortablePaths) {
const def = registry[path];
expect(def?.sortable, `"${path}" should be sortable`).toBe(true);
@ -205,6 +205,13 @@ describe('registry — kv-store types', () => {
expect(paths).toContain('site-text');
expect(paths).toContain('link-values');
});
it('includes site-settings singleton for public defaults (e.g. default theme selector)', () => {
expect(registry['site-settings']).toBeTruthy();
expect(registry['site-settings'].kind).toBe('singleton');
expect(registry['site-settings'].table).toBe('site_settings');
expect(registry['site-settings'].fields.default_theme).toBeTruthy();
});
it('kv-store types have kvNamespaceCol and kvKeyCol', () => {
for (const [key, def] of kvStores) {
expect(def.kvNamespaceCol, `"${key}" missing kvNamespaceCol`).toBeTruthy();

View file

@ -175,7 +175,7 @@ describe('registry — list types', () => {
});
it('sortable list types have sortable: true', () => {
const sortablePaths = ['tour', 'destinations', 'specialties', 'activity-menus'];
const sortablePaths = ['destinations', 'specialties', 'activity-menus'];
for (const path of sortablePaths) {
const def = registry[path];
expect(def?.sortable, `"${path}" should be sortable`).toBe(true);
@ -254,6 +254,13 @@ describe('registry — kv-store types', () => {
expect(paths).toContain('link-values');
});
it('includes site-settings singleton for public defaults (e.g. default theme selector)', () => {
expect(registry['site-settings']).toBeTruthy();
expect(registry['site-settings']!.kind).toBe('singleton');
expect(registry['site-settings']!.table).toBe('site_settings');
expect(registry['site-settings']!.fields.default_theme).toBeTruthy();
});
it('kv-store types have kvNamespaceCol and kvKeyCol', () => {
for (const [key, def] of kvStores) {
expect(def.kvNamespaceCol, `"${key}" missing kvNamespaceCol`).toBeTruthy();

View file

@ -50,6 +50,7 @@ const TRUNCATE_TABLES = [
'roster_track_content',
'verified_profiles', 'cult_of_lilith',
'site_text',
'site_settings',
'identity', 'physical', 'contact', 'about',
'admin_auth', 'metadata',
'_admin_migrations',

View file

@ -57,6 +57,7 @@ const TRUNCATE_TABLES = [
'roster_track_content',
'verified_profiles', 'cult_of_lilith',
'site_text',
'site_settings',
'identity', 'physical', 'contact', 'about',
'admin_auth', 'metadata',
'_admin_migrations',

View file

@ -770,6 +770,19 @@ export const adminMigrations = [
`);
},
},
{
id: '2026-06-23_admin_site_settings',
async up(sql) {
await sql.unsafe(`
CREATE TABLE IF NOT EXISTS site_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
default_theme TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await sql.unsafe(`INSERT INTO site_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`);
},
},
];
export async function initSchema() {
const sql = getDb();

View file

@ -614,6 +614,15 @@ export const adminMigrations: readonly Migration[] = [
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await sql.unsafe(`
CREATE TABLE IF NOT EXISTS site_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
default_theme TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await sql.unsafe(`INSERT INTO site_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`);
},
},

View file

@ -42,6 +42,7 @@ async function main(): Promise<void> {
'policy_sections', 'policy_items',
'etiquette_sections', 'etiquette_items',
'activity_menus', 'destinations', 'specialties',
'site_settings',
];
for (const table of tables) {
await tx.unsafe(`DELETE FROM "${table}"`);
@ -505,6 +506,7 @@ async function main(): Promise<void> {
destinations: await count('destinations'),
specialties: await count('specialties'),
siteText: await count('site_text'),
siteSettings: await count('site_settings'),
});
}

View file

@ -297,4 +297,13 @@ export const registry = {
meta_description: { type: 'text', default: '' },
},
},
'site-settings': {
kind: 'singleton',
table: 'site_settings',
path: 'site-settings',
fields: {
default_theme: { type: 'text', nullable: true },
},
},
};

View file

@ -344,4 +344,13 @@ export const registry: Record<string, ContentTypeDef> = {
meta_description: { type: 'text', default: '' },
},
},
'site-settings': {
kind: 'singleton',
table: 'site_settings',
path: 'site-settings',
fields: {
default_theme: { type: 'text', nullable: true },
},
},
};

View file

@ -10,6 +10,7 @@ import { physicalConfig } from './cms/configs/physical';
import { contactConfig } from './cms/configs/contact';
import { siteTextConfig } from './cms/configs/site-text';
import { linkValuesConfig } from './cms/configs/link-values';
import { siteSettingsConfig } from './cms/configs/site-settings';
import { tourConfig } from './cms/configs/tour';
import { destinationsConfig } from './cms/configs/destinations';
import { specialtiesConfig } from './cms/configs/specialties';
@ -104,6 +105,7 @@ export function App(): ReactElement {
<Route path="/seo/positioning-tags" element={<ProtectedShell page={<ContentTypeEditor config={positioningTagsConfig} />} />} />
<Route path="/content/specialties" element={<ProtectedShell page={<ContentTypeEditor config={specialtiesConfig} />} />} />
<Route path="/content/site-text" element={<ProtectedShell page={<ContentTypeEditor config={siteTextConfig} />} />} />
<Route path="/content/site-settings" element={<ProtectedShell page={<ContentTypeEditor config={siteSettingsConfig} />} />} />
<Route path="/content/link-values" element={<ProtectedShell page={<ContentTypeEditor config={linkValuesConfig} />} />} />
<Route path="/content/etiquette" element={<ProtectedShell page={<ContentTypeEditor config={etiquetteConfig} />} />} />
<Route path="/content/verified-profiles" element={<ProtectedShell page={<ContentTypeEditor config={verifiedProfilesConfig} />} />} />

View file

@ -0,0 +1,30 @@
import type { EditorConfig } from '../ContentTypeEditor';
/**
* Site Settings singleton for public defaults managed in quinn-admin.
* Currently hosts the default theme selector (public data for quinn.www).
* Value is stored in quinn_admin DB, serialized by provider-website/data-api,
* surfaced via quinn.api /www/provider-config (edge-cacheable public path on vps0).
*/
export const siteSettingsConfig: EditorConfig = {
apiPath: '/api/site-settings',
label: 'Site Theme',
singleton: true,
fields: [
{
key: 'defaultTheme',
label: 'Default Theme Selector',
fieldType: 'enum',
enumOptions: [
'luxe-dark',
'kuromi-neon',
'kuromi-stark',
'kuromi-duo',
'barbie-light',
'barbie-dark',
],
nullable: true,
help: 'The brand default active site theme for visitors (no ?theme= preview override). Leave empty to use deployment fallback (luxe-dark). Change takes effect for the public site via live /www/provider-config (no quinn.www redeploy needed).',
},
],
};

View file

@ -46,6 +46,7 @@ const NAV: Array<NavItem | NavGroupDef> = [
{ label: 'Payment Methods', path: '/content/payment-methods' },
{ label: 'Specialties', path: '/content/specialties' },
{ label: 'Site Text', path: '/content/site-text' },
{ label: 'Site Theme', path: '/content/site-settings' },
{ label: 'Verified Profiles', path: '/content/verified-profiles' },
{ label: 'Content CMS', path: '/content-cms' },
],

View file

@ -116,6 +116,7 @@ function buildDegradedShape(providerSlug: string): ProviderData {
availableFor: [],
availableTo: [],
},
// defaultSiteTheme omitted — falls back to deployment DEFAULT when absent
};
}

View file

@ -135,6 +135,13 @@ function createSchema(database: DatabaseSync): void {
PRIMARY KEY (namespace, key)
)
`);
database.exec(`
CREATE TABLE IF NOT EXISTS site_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
default_theme TEXT
)
`);
database.exec(`INSERT OR IGNORE INTO site_settings (id) VALUES (1)`);
}
beforeEach(() => {
@ -228,4 +235,15 @@ describe('serializeFromDb', () => {
const result = serializeFromDb(db) as Record<string, unknown>;
expect(result['siteText']).toBeUndefined();
});
it('serializes defaultSiteTheme from site_settings when set (public default theme selector)', () => {
db.prepare(`UPDATE site_settings SET default_theme = 'kuromi-neon' WHERE id = 1`).run();
const result = serializeFromDb(db) as Record<string, unknown>;
expect(result['defaultSiteTheme']).toBe('kuromi-neon');
});
it('omits defaultSiteTheme when not set', () => {
const result = serializeFromDb(db) as Record<string, unknown>;
expect(result['defaultSiteTheme']).toBeUndefined();
});
});

View file

@ -197,6 +197,17 @@ export async function serializeFromDb(sql: Sql, restoreKeys: Map<string, string>
siteText[row.namespace][row.key] = row.value;
}
// Site Settings (public defaults e.g. default theme selector — admin-managed, edge-cacheable via /www/provider-config)
let defaultSiteTheme: string | undefined;
try {
const [settings] = await sql<Row[]>`SELECT default_theme FROM site_settings WHERE id = 1`;
if (settings && typeof settings['default_theme'] === 'string' && settings['default_theme']) {
defaultSiteTheme = settings['default_theme'];
}
} catch {
// table may not exist in older DB snapshots
}
// Shop listings (only available + sold — not hidden)
const shopRows = await sql<Row[]>`
SELECT * FROM shop_listings
@ -305,6 +316,7 @@ export async function serializeFromDb(sql: Sql, restoreKeys: Map<string, string>
cultOfLilith,
verifiedProfiles,
heroStrip,
...(defaultSiteTheme ? { defaultSiteTheme } : {}),
};
}

View file

@ -124,6 +124,23 @@ export function ProviderDataProvider({ children }: { children: ReactNode }): Rea
? applyPhotoRewrite(liveData, rewritePhotoSrc, getPhotoRestoreKey)
: liveData,
);
// Live default theme selector from quinn-admin (public data path).
// Update the baked window config so resolveSiteTheme() picks admin choice as
// configured tier; dispatch/notify for quinn-specific bootstrap to live-update
// styled tokens + re-apply chrome without page reload.
const liveDefault = liveData.defaultSiteTheme;
if (typeof liveDefault === 'string' && liveDefault && typeof window !== 'undefined') {
const cfg = (window.__PROVIDER_CONFIG__ ||= {} as ProviderSiteConfig);
if (!cfg.theme) cfg.theme = {} as ProviderSiteConfig['theme'];
(cfg.theme as { activeTheme?: string }).activeTheme = liveDefault;
// Notify quinn bootstrap (if present) to update Root state + chrome.
try {
window.dispatchEvent(new CustomEvent('quinn:site-theme-default', { detail: liveDefault }));
} catch {
/* ignore */
}
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
// Silently fall back to static data — API may be unreachable

View file

@ -102,5 +102,6 @@ export function validateProviderData(data: unknown): ProviderData {
cultOfLilith: ensureArray(obj.cultOfLilith),
verifiedProfiles: ensureArray(obj.verifiedProfiles),
heroStrip: ensureArray(obj.heroStrip),
defaultSiteTheme: typeof obj.defaultSiteTheme === 'string' ? obj.defaultSiteTheme : undefined,
};
}

View file

@ -151,6 +151,13 @@ export interface ProviderData {
verifiedProfiles?: VerifiedProfile[];
/** Admin-managed scrollable strip of tour stops + CTAs shown on the homepage. */
heroStrip?: HeroStripItem[];
/**
* Admin-selected default site theme selector (e.g. 'kuromi-neon').
* This is public data: stored in quinn-admin, emitted by data-api serialize,
* carried by quinn.api /www/provider-config (edge-cacheable on vps-0).
* When present, takes precedence over quinn.www deployment's DEFAULT_SITE_THEME fallback.
*/
defaultSiteTheme?: string;
}
export interface ProviderIdentity {

View file

@ -5,6 +5,7 @@
*/
import { createRoot } from 'react-dom/client';
import { useState, useEffect, type ReactNode } from 'react';
import { ThemeProvider as StyledThemeProvider } from '@lilith/ui-styled-components';
import { soundEngine } from '@lilith/ui-effects-sound';
import { logVersionBanner } from '@lilith/vite-version-plugin/console';
@ -24,9 +25,10 @@ import '@features/provider-website/frontend-public/src/index.css';
// Inject config for ProviderDataProvider to read
window.__PROVIDER_CONFIG__ = config;
// Resolve the active site theme (preview override → admin setting → default
// dark-luxe) and apply its document-level chrome. The default path is a no-op
// for the page background / theme-color, preserving the current look exactly.
// Resolve the active site theme (preview → live admin defaultSiteTheme from
// quinn-admin via __PROVIDER_CONFIG__ / /www/provider-config → ultimate fallback)
// and apply document chrome. Live default is public edge data; no quinn.www
// rebuild needed to change what visitors see as the brand default.
const siteTheme = resolveSiteTheme();
applySiteThemeChrome(siteTheme);
@ -41,10 +43,40 @@ if (!rootEl) {
throw new Error('Root element #root not found');
}
createRoot(rootEl).render(
<StyledThemeProvider theme={spacingTheme as Parameters<typeof StyledThemeProvider>[0]['theme']}>
<ThemeProvider defaultTheme="luxe" customTheme={siteTheme.customTheme}>
/**
* Root wrapper that holds mutable state for the live admin-driven default theme.
* Initial render uses the build-time resolved (for zero-FOUC with static html).
* When useProviderData receives defaultSiteTheme from quinn-admin (via public
* /www/provider-config which is edge-cacheable), it updates window.__PROVIDER_CONFIG__
* and dispatches 'quinn:site-theme-default'; we listen, re-resolve (picks live
* activeTheme), update customTheme (re-renders BaseThemeProvider) and re-apply chrome.
*/
function QuinnRoot({ initialCustom }: { initialCustom: Parameters<typeof ThemeProvider>[0]['customTheme'] }): ReactNode {
const [currentCustom, setCurrentCustom] = useState(initialCustom);
useEffect(() => {
function onLiveDefault(ev: Event) {
const name = (ev as CustomEvent<string>).detail;
if (!name) return;
// Re-resolve now reads the updated window config (set by the data hook);
// this gives the live admin selector precedence over ULTIMATE_FALLBACK.
const def = resolveSiteTheme();
setCurrentCustom(def.customTheme);
applySiteThemeChrome(def);
}
window.addEventListener('quinn:site-theme-default', onLiveDefault as EventListener);
return () => window.removeEventListener('quinn:site-theme-default', onLiveDefault as EventListener);
}, []);
return (
<ThemeProvider defaultTheme="luxe" customTheme={currentCustom}>
<App />
</ThemeProvider>
);
}
createRoot(rootEl).render(
<StyledThemeProvider theme={spacingTheme as Parameters<typeof StyledThemeProvider>[0]['theme']}>
<QuinnRoot initialCustom={siteTheme.customTheme} />
</StyledThemeProvider>,
);

View file

@ -11,12 +11,17 @@
* 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 setting
* (deployment config today; can later be driven from the live admin DB).
* 4. `'luxe-dark'` the unchanged default.
* 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 '@lilith/ui-theme';
@ -217,7 +222,12 @@ export const THEME_REGISTRY: Record<SiteThemeName, SiteThemeDefinition> = {
},
};
export const DEFAULT_SITE_THEME: SiteThemeName = 'barbie-light';
/**
* 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 = 'luxe-dark';
/** Convenience aliases accepted in the `?theme=` param. */
const THEME_ALIASES: Record<string, SiteThemeName> = {
@ -302,7 +312,7 @@ function readConfiguredTheme(): SiteThemeName | null {
/** Resolve the active theme definition following the precedence order above. */
export function resolveSiteTheme(): SiteThemeDefinition {
const name = readPreviewOverride() ?? readConfiguredTheme() ?? DEFAULT_SITE_THEME;
const name = readPreviewOverride() ?? readConfiguredTheme() ?? ULTIMATE_FALLBACK_THEME;
return THEME_REGISTRY[name];
}