Status: proposal / awaiting go-ahead. No code changed yet — this is the blueprint.
Scope: targets/web/frontend/styles.css (the 4,515-line monolith) → a modern, modular,
layered, fully token-driven CSS system + a runtime theming framework (dark/light bases ×
neon accent presets).
Scope note (revised twice): This plan ships Rung 0 (System) + Rung 1 (Base × Accent presets) + Rung 3 (portable theme files: import/export a small JSON of token overrides). The only piece deferred is Rung 2 (an in-app custom color editor GUI) — deliberately skipped because the theme-file import (Rung 3) already provides customization for power users without building a color-picker cockpit. Adding the Rung 2 editor later is a pure addition (a UI that writes the same JSON the importer reads). Runtime lazy-loading of accents (former "Phase 6") is in.
1. Why we're doing this (goals & non-goals)
Goals
- Kill the CodeFactor
F.styles.csshas zero lint issues today — itsFis driven purely by size (5,268 counted lines). Splitting into ~30 files, each well under ~300 lines, removes the size penalty. Target: every CSS file gradesA, and the repo's overall grade rounds up (once the split lands onmain, which is the branch CodeFactor grades). - Modernize. Adopt current-standard CSS: cascade layers, a two-tier design-token system,
color-mix()/oklch()for derived colors,@propertytyped custom properties, logical properties,:where()zero-specificity resets, container queries where they beat media queries, andprefers-reduced-motion/forced-colorssupport. - Modularize. One monolith → a
styles/tree organized by concern (tokens, base, layout, components, themes, utilities), assembled through a singleindex.css. Each of the ~60 existing comment-delimited sections becomes (or joins) a file. - A real theming framework. Users pick a base (System / dark / light) and an accent preset (neon colors) from a header dropdown; power users can import a small theme-file. Light- mode accents read as pastel-neon; dark-mode accents as true neon. Fonts are not themeable (fixed to the current pairing) in this scope.
- Preserve behavior & accessibility. Pixel-identical rendering through the refactor phases (guarded by the existing Playwright visual baseline), WCAG AA maintained per theme.
- Clean, non-hacky CSS. Actively remove cruft as we go: dead/duplicate rules, magic numbers
(fold into tokens/scales), gratuitous
!important, over-specific selectors and specificity wars (cascade layers make most unnecessary), brittle workarounds, and copy-paste. The result should read as focused, well-named, intentional CSS — each rule earns its place. Load-bearing "hacks" that are actually required (e.g. the-webkit-appearancenumber-spinner reset) are kept but documented so they're not mistaken for cruft. Every cleanup is render-equivalent and proven so by the visual-regression diff — "clean" never means "changed the look."
Non-goals (explicitly out of scope for this effort)
- No redesign of layouts or components — this is a re-plumbing, not a re-skin (new themes are additive; the default keeps today's charcoal + mint look).
- No CSS framework/Tailwind/Sass adoption — stay plain CSS (Vite already bundles
@import). - No change to what data leaves the device (theming is local-only; see §12).
2. Current-state audit (what we're starting from)
- One file:
targets/web/frontend/styles.css, 4,515 lines, imported once inmain.jsx(import "./styles.css";). ~60 sections, each already introduced by a/* ---- name ---- */banner — a natural seam map for the split. - Already token-driven — big head start.
:rootdefines the palette as custom properties (--accent,--accent-strong,--accent-soft,--bg,--panel,--fg,--border,--radius*,--font-*, plus a--dpl-*set for CodeMirror syntax). Components mostly consume these, so retheming is largely "remap the variables," not "rewrite the rules." - Dark-first with a light fallback. A single
@media (prefers-color-scheme: light)block overrides the surface/text/DPL tokens. There is no runtime switch today — the OS decides. - Tooling present:
targets/web/stylelint.config.mjsexists and the CSS lint gate is green (commitc1482cc, "clear all lint issues", 2.35.3). Build is Vite 8 + React; no PostCSS, autoprefixer, or Sass in the tree. Fonts are self-hosted via@fontsource/*. - Settings/persistence pattern to mirror: settings flow through
targets/web/storage/(a synchronous in-memory cache hydrated at boot; a real file on disk locally,localStorageonly in the online build). There's already alocalepreference ("auto"or a code) — atheme/accentpreference follows the exact same shape. - i18n is the architectural template.
targets/web/frontend/i18n/(config.js,I18nProvider.jsx,loadMessages.js) + a language picker inLinksMenu.jsxis a working example of exactly the provider/registry/persisted-preference/menu-picker pattern the theme system needs. - Safety net exists:
tests/e2e/visual.spec.js(Playwright visual regression). This is the linchpin that lets us refactor 4,500 lines of CSS without fear.
Implication: the codebase is unusually well-positioned for this. The main work is (a) a physical split, (b) upgrading the token layer from one-tier to two-tier, and (c) building the theme-selection layer on top. The risk is mostly regression, which we neutralize with the visual baseline.
3. Target architecture
3.1 Cascade layers (the specificity backbone)
Wrap everything in an explicit @layer order declared once, up front:
@layer reset, tokens, base, layout, components, utilities, theme, overrides;
Why: layers make source order and file order irrelevant to precedence, so we can split freely
without specificity surprises, and a theme layer can cleanly override components without
!important or selector-weight games. This is the single most important modernization for
maintainability.
3.2 File tree (the modular split)
targets/web/frontend/styles/
index.css # @layer decl + @imports, in order. This is what main.jsx imports.
reset.css # box-sizing, margin reset, :where() normalizations
tokens/
primitives.css # raw palette + scales: --green-500, --gray-900, radii, spacing, z, fonts
semantic.css # role tokens mapped from primitives: --accent, --bg, --fg, --border …
dpl.css # --dpl-* syntax tokens (semantic, theme-aware)
base/
elements.css # html/body/#root, typography defaults, focus-visible, scrollbars
motion.css # transitions + prefers-reduced-motion guards
layout/
app-frame.css # .app, title bar, workspace two-pane, footer/status
responsive.css # container queries + remaining media queries
components/
buttons.css inputs.css cards.css composer.css dialogs.css
links-menu.css nsfw.css block-palette.css chips.css tooltip.css
settings.css provider.css prompt-result.css gallery.css manage.css
code-editor.css … (one file per existing section, ~25–30 files, each < ~300 lines)
themes/
_contract.css # documents the token contract a theme must satisfy
base-dark.css # [data-theme="dark"] → surfaces/text/lines
base-light.css # [data-theme="light"] → surfaces/text/lines (+ pastel accent mapping)
accents/
mint.css aurora.css magenta.css cyan.css amber.css violet.css … (one per accent)
utilities/
helpers.css # .flex, spacing helpers, a11y .visually-hidden, etc.
Vite inlines @imports at build, so this ships as one bundled stylesheet in production (no extra
HTTP requests) — modularity for authoring, single artifact for delivery. (Runtime lazy loading
of a selected theme is a separate, optional capability — see §3.6.)
3.3 Two-tier design tokens (the heart of theming)
Today's tokens are single-tier (semantic names holding literal hex). We split into:
- Primitive tokens (
tokens/primitives.css): the raw material — a full palette expressed inoklch()(perceptually uniform, so accent ramps are even and contrast is predictable), plus radius/spacing/z-index/font scales. These never appear directly in component CSS. - Semantic tokens (
tokens/semantic.css): the roles components actually use —--accent,--bg,--panel,--fg,--border,--ring, etc. — each mapped from primitives.
Themes only ever remap semantic tokens. A base theme sets surfaces/text/lines; an accent sets the accent ramp. Components stay untouched because they only ever read semantic tokens.
3.4 Derived colors via color-mix() / relative color (kills the shade sprawl)
Instead of hand-picking --accent, --accent-strong, --accent-soft, --accent-hover,
--accent-ink per theme, derive them from a single accent seed:
--accent-strong: oklch(from var(--accent) calc(l - 0.06) c h);
--accent-soft: color-mix(in oklch, var(--accent) 14%, transparent);
--accent-hover: color-mix(in oklch, var(--accent) 88%, var(--fg));
An accent preset then becomes basically one line (--accent: <seed>), and light-vs-dark
behavior (pastel vs true-neon) is handled by mixing the seed toward the base's background/foreground.
This is what makes "many accents" cheap and consistent.
3.5 Theme application model (data attributes on <html>)
<html data-theme="dark" data-accent="aurora">
data-theme∈ {dark,light} selects a base (surfaces/text).systemresolves to one of those at runtime viamatchMedia('(prefers-color-scheme: …)').data-accentselects an accent ramp.color-schemeis set to match so native form controls/scrollbars follow.- Selectors:
[data-theme="light"] { … },[data-accent="aurora"] { … }, all inside@layer theme.
3.6 The "browser downloads and imports it" piece — runtime theme delivery
The modern mechanism you gestured at is CSS module scripts / import attributes —
import sheet from "./themes/accents/aurora.css" with { type: "css" } returns a ready
CSSStyleSheet that we attach via document.adoptedStyleSheets. This is the literal "the browser
downloads and imports a stylesheet as a module" capability. We use it to lazy-load an accent
only when it's selected (via dynamic import(...)), so the initial bundle stays lean and extra
accents cost nothing until chosen.
Note: with Rung 2/3 deferred there's no runtime-generated custom sheet — every theme is a static, authored CSS file, so this is purely a lazy-loading optimization, not a correctness requirement.
Support & fallback: adoptedStyleSheets and CSS module scripts are supported in current
Chrome/Edge/Firefox/Safari, but we gate on feature-detection and fall back to injecting a
<link>/<style> element. oklch()/color-mix() are broadly supported now; we set a
.browserslistrc and provide static hex fallbacks in the primitives layer for the default themes
so nothing breaks on an old engine. Because it's only an accelerator, this whole piece can also be
dropped to a plain "all accents in the bundle" approach if support/complexity ever argues for it.
4. The theming UX
4.1 The rungs we ship
- Rung 0 — System. Base follows the OS (
prefers-color-scheme) and flips live when the OS flips — this is "the browser auto-selects dark/light." It's the current behavior, preserved. - Rung 1 — Base + Accent (the everyday UI). A System / Dark / Light segmented control plus a grid of accent swatches. Click a swatch → the whole app recolors instantly. This is the entire experience for essentially everyone.
- Rung 3 — Portable theme file (power users). Import a small JSON of token overrides to go beyond the presets, and Export the current theme to get a starting file to edit/share. No in-app color-editor needed — the file is the customization surface. (See §4.4.)
Deferred — Rung 2 (in-app custom color editor GUI). Not built now. Customization is available via the Rung 3 file. If we later want live sliders/color-pickers in the app, that editor just writes the same JSON the importer already reads — a pure addition, no token/component rework.
4.2 The header control (your "combo box that drops a complex form")
A compact Appearance button sits on the right of the top bar — next to the existing links menu
and NSFW toggle (the bar already has topbar-spacer → NsfwToggle → LinksMenu; the theme button
joins that cluster). It shows a small swatch/paint icon reflecting the current accent. Clicking
opens a popover form:
┌ Appearance ─────────────────────────┐
│ Mode [ System | Dark | Light ] │ ← segmented control
│ │
│ Accent │
│ ● ● ● ● ● (swatch grid) │ ← click to apply live
│ ● ● ● ● │
│ │
│ ⋯ Import theme… Export current │ ← Rung 3, understated at the bottom
└─────────────────────────────────────┘
It's one popover: simple at a glance (mode + swatches), with the file actions tucked at the bottom
for the few who want them. Built as its own components/ThemePicker.jsx, opened from a header
button, following the same popover pattern the app already uses for the provider gear / links menu.
4.3 Accent presets (Material common accent hues as a color reference only)
Using Google Material's common accent (A200/A400) hues purely as a familiar reference palette — not adopting Material Design itself. A curated, wheel-spanning set of ~9 that pairs with the mint default:
| Name | Reference hue (Material A200) | Notes |
|---|---|---|
| Mint (default) | ~#69F0AE / today's #34e2a0 |
current brand green |
| Teal | #64FFDA |
|
| Cyan | #18FFFF |
|
| Blue | #448AFF |
|
| Violet | #7C4DFF (deep-purple A200) |
|
| Magenta | #E040FB (purple A200) |
|
| Pink | #FF4081 |
|
| Coral | #FF6E40 (deep-orange A200) |
warm red-orange |
| Amber | #FFD740 |
These reference hexes are seeds, not final values: each is retuned in oklch() for an even
ramp and re-mixed per base so it renders as true neon on dark and pastel-neon on light
(§3.4). Every seed must pass the AA contrast test (§8) before it ships; any that can't is nudged in
lightness/chroma until it does. Final count/names are open (§11).
4.4 Theme-file format (refining your "CSS variables in JSON, only overrides")
Your instinct is right; here's a churned/expanded version that stays small but is safe and forward-compatible:
{
"format": "rap-theme", // identifies the file type
"version": 1, // schema version → lets us evolve without breaking old files
"name": "Midnight Pink", // shown in the picker after import
"base": "dark", // which base it builds on: "system" | "dark" | "light"
"tokens": { // ONLY semantic-token overrides — usually just an accent
"--accent": "#ff4081"
}
}
- Minimal by default. A typical theme is
base+ one--accentline; everything else is derived (§3.4), so files stay short exactly as you wanted. - Only semantic tokens are allowed (
--accent,--bg,--panel,--fg,--border,--radius, …) — never primitives, never arbitrary CSS. On import each key is checked against an allow-list and each value coerced/validated as a color or length. Unknown keys and anything that isn't a clean color/length are rejected with a clear message. This is why the file can't be an injection vector (§10). - Applied at runtime by generating a tiny
CSSStyleSheetfrom thetokensmap and attaching it viadocument.adoptedStyleSheetson top of the chosen base — the same adopted-sheet mechanism as §3.6. - Export simply serializes the current base + accent (+ any active overrides) into this shape, giving users a valid starting file to tweak or share.
4.5 Default theme — recommendation
Ship the default as base = System, accent = Mint. This gives you both things you asked about: it's mint (today's brand) and it auto-selects dark/light from the browser — which is also exactly today's behavior, so nobody's experience changes on upgrade. (If you'd rather force Dark + Mint regardless of OS, that's a one-line default change — say which and I'll set it. Flagged in §11.)
Accent preset starter set (dark = true neon / light = pastel-neon)
Proposed initial palette (final names/colors are an open question — §11): Mint (default, today's green), Aurora (green→cyan), Cyan, Magenta/Hot-Pink, Violet, Amber, Coral. Each defined once as a seed; the base theme decides neon-vs-pastel rendering.
5. ThemeProvider (JS architecture — mirrors i18n)
targets/web/frontend/theme/
config.js # registry: bases, accents, defaults, allow-listed token contract, storage keys
ThemeProvider.jsx # context; reads settings; sets <html> data-* ; lazy-adopts accent/custom sheet
useTheme.js # { mode, accent, resolvedMode, setMode, setAccent, importTheme, exportTheme }
applyTheme.js # pure: (state) -> data-attrs (+ adopted sheet) side-effects
presets.js # accent seed definitions (data, not CSS) for the picker swatches
themeFile.js # Rung 3: serialize (export) + parse/validate (import) the theme JSON
UI: targets/web/frontend/components/ThemePicker.jsx — the header button + popover form of §4.2.
- Persistence: add
themeMode(system|dark|light),accent(preset id), andcustomTokens(the last-imported override map, or null) todefaultSettingsinlib/settings.js; they ride the existing storage cache (file locally, localStorage online). No new storage system. - No-FOUC boot script: a tiny inline
<head>script (inindex.html) reads the persisted theme (orprefers-color-scheme) and setsdata-theme/data-accentbefore first paint, so there's no flash of the wrong theme. This is the one piece that can't wait for React. - UI surface: the
ThemePickerheader button + popover (§4.2), sitting in the top-bar's right cluster alongsideNsfwToggleandLinksMenu. Mode + accent swatches up top; Import/Export understated at the bottom.
6. Modern-CSS checklist (applied throughout)
@layerfor all authored CSS.oklch()palette +color-mix()/relative-color derivation.@propertytyped registration for animatable tokens (smooth cross-theme transitions, gated byprefers-reduced-motion).- Logical properties (
margin-inline,padding-block,inset) — future-proofs RTL (the app is already i18n'd). :where()for zero-specificity resets/base.- Container queries to replace layout media queries where a component should respond to its pane, not the viewport (the two-pane workspace is a prime candidate).
:focus-visiblefocus rings from a--ringtoken;forced-colors/high-contrast handling;prefers-reduced-motionand (optionally)prefers-contrast.color-schemekept in sync per theme.
7. Tooling & lint
- Vite bundles
@importnatively — no PostCSS needed for the split. Add autoprefixer only if the browserslist target warrants it (likely not; document the decision either way). - Stylelint: extend
stylelint.config.mjs— addstylelint-order(predictable property order), enforce no raw hex outsidetokens/(a custom rule /declaration-property-valueguard so components must use semantic tokens), and layer-/import-awareness. Keep the gate green innpm test. .browserslistrcadded to pin the support target that justifiesoklch/color-mix/adopted sheets and drives fallbacks.- Keep everything inside
npm run lint/npm testso CI stays the source of truth.
8. Testing strategy (regression is the real risk)
- Freeze a visual baseline first. Run
tests/e2e/visual.spec.jsondevHEAD and commit the baselines before touching CSS. Every subsequent phase must diff clean (or the diff must be an intended, reviewed change →npm run test:e2e:update). - Token & split phases target zero visual change — the visual suite is the pass/fail oracle.
- Theme-matrix visual tests: parametrize screenshots over {dark,light} × {each accent} for a few representative screens (Generate, Manage, a dialog).
- axe a11y pass per theme (extend the existing
@axe-corespecs) — contrast especially. - Unit tests:
applyTheme— state → correctdata-*attributes;systemresolves via a mockedmatchMediaand updates live when the OS preference changes.- Contrast test — assert WCAG AA for every shipped base×accent pair (the guardrail that a
new accent can't ship illegible) plus token-derivation sanity for the
color-mix/oklchramps. themeFile.js(Rung 3) — export→import round-trips losslessly; import accepts a valid minimal file; import rejects unknown token keys, non-color/length values, wrongformat/version, and any attempt to smuggle raw CSS — with a clear error each time.- Provider/
useTheme— default, mode switch, accent switch, and persistence round-trip through the storage cache; online-vs-local parity (file-backed vs localStorage-backed).
npm run smoke+npm testgreen at every commit;npm run test:e2ebefore each release.
9. Phased delivery (each phase independently shippable & verifiable)
| Phase | Deliverable | Risk | Ships as |
|---|---|---|---|
| 0. Guardrails | Commit visual baseline; add .browserslistrc; stylelint rules; declare @layer order (no moves yet) |
none | PATCH |
| 1. Token upgrade | Two-tier tokens (primitives+semantic) + color-mix derivation; zero visual change |
low | MINOR |
| 2. Physical split + cleanup | Monolith → styles/ tree under layers; remove hacky CSS (dead/duplicate rules, magic numbers, gratuitous !important, specificity hacks) while keeping render identical; index.css import |
low–med | MINOR |
| 3. Theme engine | ThemeProvider, data-* attrs, no-FOUC script; dark & light as real switchable themes (System still default) |
med | MINOR |
| 4. Accent presets | Neon accent set (seed-based); base×accent matrix | low | MINOR |
| 5. Picker UI (Rung 1) | ThemePicker header button + popover: System/Dark/Light + accent swatches |
low | MINOR |
| 6. Runtime delivery | Lazy-load accents via CSS module scripts + adopted stylesheets (with <link> fallback) |
med | MINOR |
| 7. Theme files (Rung 3) | themeFile.js import/export + the popover's Import/Export actions |
med | MINOR |
(Deferred, not in this plan: Rung 2 — an in-app custom color editor GUI.)
Phases 0–2 already fully retire the CodeFactor F (the split does it) — so the original ask is
satisfied early, and the theming (3–7) builds on a clean foundation. Rung-1 theming is complete at
Phase 5; Phases 6–7 add lazy loading and portable theme files. Each phase follows the repo's
default loop: feature/* branch → lint+format → smoke/tests → visual diff → commit with changelog
entry → merge --no-ff → release per SemVer. Ship whenever green with your go-ahead.
10. Risks & mitigations
- Visual regression across 4,500 lines → the Playwright visual baseline (Phase 0) is a hard gate; split in small, diff-clean commits.
- Specificity breakage → cascade layers make order-independence explicit; keep the same rule order within a layer during the split.
- FOUC on load / theme change → inline pre-paint boot script + adopted stylesheets.
- Browser support (
oklch,color-mix, adopted sheets, CSS module scripts) →.browserslistrc+ feature detection + hex fallbacks for default themes; runtime lazy-load degrades to<link>injection. - High-churn file (
styles.cssshows churn 13) → land the split fast to stop new work piling into the monolith; communicate a freeze window. - Online vs local parity → theme prefs use the existing storage layer that already abstracts file-vs-localStorage; test both editions.
- Theme-file import as an attack surface (Rung 3) → never inject arbitrary CSS text: parse to a known allow-list of semantic tokens, coerce each value as a color/length, reject everything else. Covered by the import rejection tests (§8).
- Scope creep into redesign → non-goals in §1 are the guardrail; default look is preserved. (Deferring the Rung 2 editor keeps the UI surface small — the theme file carries customization instead.)
11. Open questions for you
Resolved from your notes: Phase 6 (lazy accent loading) → in. Fonts → not themeable. System/auto option → yes. Header dropdown (§4.2) → yes. Theme-file = JSON of semantic-token overrides (§4.4) → yes. Accent palette → Material A200 hues as reference (§4.3).
Still open:
- Default base: ship System + Mint (recommended — mint brand and auto dark/light, = today's behavior) or force Dark + Mint?
- Accent set: the §4.3 nine (Mint, Teal, Cyan, Blue, Violet, Magenta, Pink, Coral, Amber) — good as-is, or add/drop/rename any?
- Rung 2 (in-app custom color editor): confirmed deferred (customize via theme file for now) — agreed, or do you want the live editor in this pass too?
12. Housekeeping the refactor must carry (per repo standing instructions)
- Notes: session log entries per working day; a
decisions/architecture.mdentry for cascade layers + two-tier tokens + theming model; updatesystems/gui.md; new note pages auto-wire into the doc-site. - Legal: theming is local-only —
themeMode/accent/customTokenslive in the existing on-device storage, fonts stay self-hosted, and the Rung 3 import reads a user-picked local file (no network fetch, no remote themes). No new third-party data flow, so the privacy/terms/cookies pages need no change; re-read them once at the theme-file phase to confirm and note "no change" in the session log. - Credits: if we adopt any external palette/inspiration or tool, add it to
list-credits.md. - Versioning: bump
VERSION+package.jsontogether per phase (MINOR for feature phases, PATCH for Phase 0).
13. Bottom line
The repo is already ~70% of the way to themeable — it's variable-driven and cleanly sectioned. The
work is: (a) split the monolith under cascade layers (this alone fixes CodeFactor), (b)
upgrade one-tier tokens to a two-tier primitive→semantic system with color-mix/oklch
derivation, and (c) layer a header Appearance dropdown (System/Dark/Light × Material-
referenced accent swatches) plus a small importable theme file on top, modeled on the existing
i18n provider. Phases 0–2 retire the F; phases 3–7 deliver the theming, safely, behind a
visual-regression net. Only the in-app custom editor GUI (Rung 2) is deferred — and the theme-
file format is built so that editor, if ever wanted, is a pure addition that writes the same JSON
the importer already reads.