feat(theming): server-resolved theme engine and admin appearance UI (phases 3-4) #122

Merged
whitlocktech merged 1 commits from feat/theming-nav-phase-3-4 into edge 2026-08-08 00:22:33 +00:00
Member

What & why

Phases 3–4 of docs/website/THEMING_AND_NAV.md: three presets, the curated font shortlist, and /admin/appearance to drive them. Targets edge; docs pair: RunicGateway/docs#105.

An instance that never opens the new screen renders byte-for-byte as it does today — verified live, not just asserted.

The design's preset mechanism does not survive contact with the code

§6.2 put the presets in theme.css as [data-theme="…"] blocks. That cannot work: SiteContext.jsx:31 writes --accent as an inline style on <html>, and an inline property beats any attribute-selector block. An admin who picked Fantasy without also setting a custom accent would have had Fantasy's #c9973f painted over by BRAND_ACCENT_COLOR — while getPublic().brand.accent, the value the Android app themes its whole Material palette from, reported the other one. Two surfaces disagreeing about the accent, both "correct".

Resolved server-side instead, which removes the conflict rather than sequencing around it:

  • presets live in server/src/config/themePresets.js;
  • server/src/utils/themeResolve.js layers :root ← preset ← custom per field into a token map;
  • getPublic() returns it as theme;
  • client/src/lib/themeVars.js writes it onto <html>.

One authority for the merge, and brand.accent is by construction the accent the site paints. theme.css's :root is untouched — no row means no theme block, the client writes nothing, and the shipped stylesheet stands.

The client half's real logic is removal: inline properties are not cleared by writing a smaller object over them, so applyThemeTokens tracks what it set last time and removePropertys whatever the new payload no longer mentions. Without it, "Reset to defaults" looks broken until a reload.

Four smaller corrections

  • Presets carry the full 15-token palette. §6.2's eight colors would have left Fantasy's warm brown page with --line: #2a3544 and --blue: #13243c — blue-grey borders and a blue-grey active nav row. The admin form still exposes only §6.1's eight; the rest are supporting shades a preset gets right coherently. --mode-live/--mode-maint stay fixed (green means live) and --panel-grad stays derived, both locked by tests.
  • The option catalog is served, not duplicated. GET /settings/theme/options returns the presets with their full token maps (so a control shows what an unset field currently resolves to), the font shortlist, the shadow depths, and each editable field paired with the CSS variable it drives. Duplicating the lists client-side would let the form offer a font the server rejects — which surfaces as a save 400ing for no visible reason. A test asserts every offered option validates.
  • Validation is deliberately asymmetric: strict on write (400, naming the offending field) and forgiving on read (drop the bad field, keep its neighbours). Strict-on-write gives feedback; forgiving-on-read means a hand-edited row degrades to the shipped default instead of rendering a broken site.
  • One font added to §5.1's twelve — Georgia in the serif list. The shortlist gave the sans role a "today's default" option (Arial, byte-identical to --sans) but left serif with no way back to Georgia, "Times New Roman", serif short of resetting the whole theme. No new web family, so §5.2's combined URL is unchanged.

The Discord bot (§4.5's accentInt note was a no-op)

getPublic().brand never exposed accentIntpublicBrand.test.js:45 asserts it is undefined, deliberately — and the server-side brand.accentInt has no consumer at all. Embeds are colored by bot/src/brand.js, a separate process reading BRAND_ACCENT_COLOR at boot. So there was nothing per-request to recompute, and the drift was real but unfixable from the server.

The bot now reads brand.accent through the public-API client it already had, behind getters with a 10-minute TTL: brand.accentInt stays a plain property read at every existing call site, an embed never awaits a network call, at most one refresh is ever in flight, and any failure keeps the last known good value with env as the floor.

Fixed in passing

settings/nav.controller.js (Phase 0) imported the logger factory rather than calling it, so log.error was undefined: a DB fault would have thrown a TypeError inside the catch — no response sent, request left hanging — instead of returning a 500.

How it was tested

  • cd server && npm test732 pass, 0 fail, including 25 new in themeResolve.test.js and 6 new in publicBrand.test.js. Load-bearing negatives: red / rgb(1,2,3) / url(…) / var(--bg) / #ff0000; x all rejected as colors; a font offered for another role rejected; 1000px and 4em rejected; an invalid field dropped without discarding its neighbours; and a test that parses theme.css and asserts the runic-gateway preset still matches :root token for token, since that is the one place the server duplicates the stylesheet.
  • cd client && node --test75 pass, 0 fail, 8 new in themeVars.test.js covering the removal half: reset clears everything applied, a property someone else set is never removed, non-custom-property keys are ignored.
  • npm run swagger + npm run routes:manifest regenerated; manifest diff is exactly the one new route, and routes.guards.json confirms it carries noindex, requireAuth.
  • cd client && npm run build — clean.
  • Live smoke against the local MariaDB and a temporary admin (since deleted): baseline /public/settings has no theme block → saved Fantasy + a custom accent → brand.accent and theme['--accent'] both #123456, 23 tokens, --mode-live and --panel-grad absent → four malformed writes 400 with the field named and the stored theme untouched → the bot fetched #123456 / 0x123456 → reset returned theme: absent, brand.accent back to env, and zero rows in the settings table. In the browser: the admin panel and portal both repainted instantly on save, and after reset exactly one inline property remained (--accent, SiteContext's own line) with every token back to :root.

One thing this surfaced

Applying Fantasy on a live instance shows the §4.8 rgba() literals carry a hue, not just a light/dark assumption: .btn-ghost's rgba(11, 22, 48, 0.45) leaves the portal quick-links reading blue on a warm page. Nothing is broken — a visible seam, not a bug — but it means the dark presets need that promotion too, so Phase 9 is re-scoped in the docs from "light-mode port" to "make the hue-carrying literals follow the palette, then Parchment". Not fixed here: it is a Phase-2-style mechanical promotion and folding it into the phase that introduced the presets would hide it inside an unrelated diff.

Also deferred by decision: the theme arrives with the /public/settings fetch, so a themed instance paints the shipped palette for one frame before repainting. Phase 5 has to rewrite renderIndexHtml into a cached, invalidated shell anyway (§4.3); injecting a <style> block there removes the flash for free rather than solving it twice.

Not covered by tests: bot/ has no test harness (CI only installs it), so the accent refresh was verified by the live smoke above rather than by a unit test. Adding a runner to that package felt like scope for its own change.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why Phases 3–4 of [`docs/website/THEMING_AND_NAV.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/website/THEMING_AND_NAV.md): three presets, the curated font shortlist, and `/admin/appearance` to drive them. Targets **`edge`**; docs pair: RunicGateway/docs#105. An instance that never opens the new screen renders byte-for-byte as it does today — verified live, not just asserted. ### The design's preset mechanism does not survive contact with the code §6.2 put the presets in `theme.css` as `[data-theme="…"]` blocks. That cannot work: `SiteContext.jsx:31` writes `--accent` as an **inline style on `<html>`**, and an inline property beats any attribute-selector block. An admin who picked Fantasy without also setting a custom accent would have had Fantasy's `#c9973f` painted over by `BRAND_ACCENT_COLOR` — while `getPublic().brand.accent`, the value the Android app themes its whole Material palette from, reported the other one. Two surfaces disagreeing about the accent, both "correct". **Resolved server-side instead**, which removes the conflict rather than sequencing around it: - presets live in `server/src/config/themePresets.js`; - `server/src/utils/themeResolve.js` layers `:root` ← preset ← custom **per field** into a token map; - `getPublic()` returns it as `theme`; - `client/src/lib/themeVars.js` writes it onto `<html>`. One authority for the merge, and `brand.accent` is *by construction* the accent the site paints. `theme.css`'s `:root` is untouched — no row means no `theme` block, the client writes nothing, and the shipped stylesheet stands. The client half's real logic is **removal**: inline properties are not cleared by writing a smaller object over them, so `applyThemeTokens` tracks what it set last time and `removeProperty`s whatever the new payload no longer mentions. Without it, "Reset to defaults" looks broken until a reload. ### Four smaller corrections - **Presets carry the full 15-token palette.** §6.2's eight colors would have left Fantasy's warm brown page with `--line: #2a3544` and `--blue: #13243c` — blue-grey borders and a blue-grey active nav row. The admin *form* still exposes only §6.1's eight; the rest are supporting shades a preset gets right coherently. `--mode-live`/`--mode-maint` stay fixed (green means live) and `--panel-grad` stays derived, both locked by tests. - **The option catalog is served, not duplicated.** `GET /settings/theme/options` returns the presets *with their full token maps* (so a control shows what an unset field currently resolves to), the font shortlist, the shadow depths, and each editable field paired with the CSS variable it drives. Duplicating the lists client-side would let the form offer a font the server rejects — which surfaces as a save 400ing for no visible reason. A test asserts every offered option validates. - **Validation is deliberately asymmetric**: strict on write (400, naming the offending field) and forgiving on read (drop the bad field, keep its neighbours). Strict-on-write gives feedback; forgiving-on-read means a hand-edited row degrades to the shipped default instead of rendering a broken site. - **One font added to §5.1's twelve** — Georgia in the serif list. The shortlist gave the sans role a "today's default" option (Arial, byte-identical to `--sans`) but left serif with no way back to `Georgia, "Times New Roman", serif` short of resetting the whole theme. No new web family, so §5.2's combined URL is unchanged. ### The Discord bot (§4.5's `accentInt` note was a no-op) `getPublic().brand` never exposed `accentInt` — `publicBrand.test.js:45` asserts it is `undefined`, deliberately — and the server-side `brand.accentInt` has no consumer at all. Embeds are colored by `bot/src/brand.js`, **a separate process** reading `BRAND_ACCENT_COLOR` at boot. So there was nothing per-request to recompute, and the drift was real but unfixable from the server. The bot now reads `brand.accent` through the public-API client it already had, behind getters with a 10-minute TTL: `brand.accentInt` stays a plain property read at every existing call site, an embed never awaits a network call, at most one refresh is ever in flight, and any failure keeps the last known good value with env as the floor. ### Fixed in passing `settings/nav.controller.js` (Phase 0) imported the logger **factory** rather than calling it, so `log.error` was `undefined`: a DB fault would have thrown a `TypeError` inside the catch — no response sent, request left hanging — instead of returning a 500. ## How it was tested - `cd server && npm test` — **732 pass, 0 fail**, including 25 new in `themeResolve.test.js` and 6 new in `publicBrand.test.js`. Load-bearing negatives: `red` / `rgb(1,2,3)` / `url(…)` / `var(--bg)` / `#ff0000; x` all rejected as colors; a font offered for another role rejected; `1000px` and `4em` rejected; an invalid field dropped without discarding its neighbours; and a test that parses `theme.css` and asserts the `runic-gateway` preset still matches `:root` token for token, since that is the one place the server duplicates the stylesheet. - `cd client && node --test` — **75 pass, 0 fail**, 8 new in `themeVars.test.js` covering the removal half: reset clears everything applied, a property someone else set is never removed, non-custom-property keys are ignored. - `npm run swagger` + `npm run routes:manifest` regenerated; manifest diff is exactly the one new route, and `routes.guards.json` confirms it carries `noindex, requireAuth`. - `cd client && npm run build` — clean. - **Live smoke** against the local MariaDB and a temporary admin (since deleted): baseline `/public/settings` has no `theme` block → saved Fantasy + a custom accent → `brand.accent` and `theme['--accent']` both `#123456`, 23 tokens, `--mode-live` and `--panel-grad` absent → four malformed writes 400 with the field named and the stored theme untouched → the bot fetched `#123456` / `0x123456` → reset returned `theme: absent`, `brand.accent` back to env, and **zero rows** in the settings table. In the browser: the admin panel and portal both repainted instantly on save, and after reset exactly **one** inline property remained (`--accent`, SiteContext's own line) with every token back to `:root`. ## One thing this surfaced Applying Fantasy on a live instance shows the §4.8 `rgba()` literals carry a **hue**, not just a light/dark assumption: `.btn-ghost`'s `rgba(11, 22, 48, 0.45)` leaves the portal quick-links reading blue on a warm page. Nothing is broken — a visible seam, not a bug — but it means the dark presets need that promotion too, so Phase 9 is re-scoped in the docs from "light-mode port" to "make the hue-carrying literals follow the palette, then Parchment". Not fixed here: it is a Phase-2-style mechanical promotion and folding it into the phase that introduced the presets would hide it inside an unrelated diff. **Also deferred by decision:** the theme arrives with the `/public/settings` fetch, so a themed instance paints the shipped palette for one frame before repainting. Phase 5 has to rewrite `renderIndexHtml` into a cached, invalidated shell anyway (§4.3); injecting a `<style>` block there removes the flash for free rather than solving it twice. **Not covered by tests:** `bot/` has no test harness (CI only installs it), so the accent refresh was verified by the live smoke above rather than by a unit test. Adding a runner to that package felt like scope for its own change. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-08-08 00:17:53 +00:00
Phases 3-4 of docs/website/THEMING_AND_NAV.md. Three presets, the curated font
shortlist, and /admin/appearance to drive them.

The design put the presets in theme.css as [data-theme] blocks. That does not
work: SiteContext writes --accent as an inline style on <html>, which beats any
attribute-selector block, so a preset's accent would have been painted over by
BRAND_ACCENT_COLOR while getPublic().brand.accent -- the value the Android app
themes itself from -- reported the other one.

Presets now live in server/src/config/themePresets.js. themeResolve.js layers
:root <- preset <- custom per field into a token map, getPublic() returns it as
`theme`, and the client writes it onto <html>. One authority for the merge, and
brand.accent is by construction the accent the site paints. theme.css's :root is
untouched, so an instance with no row gets no theme block and renders as today.

Also: presets carry the full 15-token palette (eight would have left Fantasy
with blue-grey borders); the option catalog is served from
GET /settings/theme/options so the form cannot offer what the server rejects;
validation is strict on write and forgiving on read; and the Discord bot now
fetches the effective accent instead of its boot-time env copy.

Fixes a Phase 0 bug in passing: settings/nav.controller.js imported the logger
factory rather than calling it, so a DB fault would have thrown a TypeError
inside the catch instead of returning 500.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-08-08 00:22:21 +00:00
whitlocktech merged commit 02580ebda3 into edge 2026-08-08 00:22:33 +00:00
whitlocktech deleted branch feat/theming-nav-phase-3-4 2026-08-08 00:22:34 +00:00
Sign in to join this conversation.
No description provided.