ThemeScript
Code
ts
import ThemeScript from '@/components/layout/ThemeScript';src/components/layout/ThemeScript.tsx
AI prompt
text
Build a pre-paint theme script component for a Next.js (App Router) + React + TypeScript app.
A tiny component that applies the stored theme and density BEFORE first paint. Without it the page renders light, then the theme provider's effect flips it to dark a frame later — a white flash on every load for dark-mode users.
## Behaviour
- Renders a single `<script dangerouslySetInnerHTML={{ __html: SCRIPT }} />`. It must be inline and synchronous in `<head>`, which is why it is a raw script string rather than anything React-driven. No `'use client'`, no state.
- The script, wrapped entirely in `try { … } catch (e) {}` because `localStorage` throws outright in a locked-down browser:
1. `t = localStorage.getItem('app.theme')`; if absent, `'dark'` when `matchMedia('(prefers-color-scheme: dark)')` matches, else `'light'`.
2. If dark, add the `dark` class to `document.documentElement`.
3. `d = localStorage.getItem('app.density') || 'standard'`; set `data-density` on `<html>` and the root font-size — compact 13px, standard 14px, comfortable 16px.
- Keep the keys and sizes identical to the theme provider's, or the two fight.
- Written in plain ES5 (`var`) so it runs before any bundle.
## Usage
Render `<ThemeScript />` inside `<head>` in the root layout, and put `suppressHydrationWarning` on `<html>` — the script changes its class and style before React hydrates.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
/* Origin: bonus-adjustment (96S2), verbatim. */
/**
* Applies the stored theme and density BEFORE first paint.
*
* Without this the page renders light, then ThemeProvider's effect flips it to
* dark a frame later — a white flash on every navigation for dark-mode users.
* It has to be inline and synchronous in <head>, which is why it's a raw script
* tag rather than anything React-driven, and it is wrapped in try/catch because
* localStorage throws outright in a locked-down browser.
*/
const SCRIPT = `
try {
var t = localStorage.getItem('ba.theme');
if (!t) t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (t === 'dark') document.documentElement.classList.add('dark');
var d = localStorage.getItem('ba.density') || 'standard';
document.documentElement.setAttribute('data-density', d);
document.documentElement.style.fontSize =
d === 'compact' ? '13px' : d === 'comfortable' ? '16px' : '14px';
} catch (e) {}
`.trim();
export default function ThemeScript() {
return <script dangerouslySetInnerHTML={{ __html: SCRIPT }} />;
}