feat(theming): server-resolved theme engine and admin appearance UI (phases 3-4) #122
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/theming-nav-phase-3-4"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Phases 3–4 of
docs/website/THEMING_AND_NAV.md: three presets, the curated font shortlist, and/admin/appearanceto drive them. Targetsedge; 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.cssas[data-theme="…"]blocks. That cannot work:SiteContext.jsx:31writes--accentas 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#c9973fpainted over byBRAND_ACCENT_COLOR— whilegetPublic().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:
server/src/config/themePresets.js;server/src/utils/themeResolve.jslayers:root← preset ← custom per field into a token map;getPublic()returns it astheme;client/src/lib/themeVars.jswrites it onto<html>.One authority for the merge, and
brand.accentis by construction the accent the site paints.theme.css's:rootis untouched — no row means nothemeblock, 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
applyThemeTokenstracks what it set last time andremovePropertys whatever the new payload no longer mentions. Without it, "Reset to defaults" looks broken until a reload.Four smaller corrections
--line: #2a3544and--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-maintstay fixed (green means live) and--panel-gradstays derived, both locked by tests.GET /settings/theme/optionsreturns 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.--sans) but left serif with no way back toGeorgia, "Times New Roman", serifshort of resetting the whole theme. No new web family, so §5.2's combined URL is unchanged.The Discord bot (§4.5's
accentIntnote was a no-op)getPublic().brandnever exposedaccentInt—publicBrand.test.js:45asserts it isundefined, deliberately — and the server-sidebrand.accentInthas no consumer at all. Embeds are colored bybot/src/brand.js, a separate process readingBRAND_ACCENT_COLORat boot. So there was nothing per-request to recompute, and the drift was real but unfixable from the server.The bot now reads
brand.accentthrough the public-API client it already had, behind getters with a 10-minute TTL:brand.accentIntstays 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, solog.errorwasundefined: a DB fault would have thrown aTypeErrorinside 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 inthemeResolve.test.jsand 6 new inpublicBrand.test.js. Load-bearing negatives:red/rgb(1,2,3)/url(…)/var(--bg)/#ff0000; xall rejected as colors; a font offered for another role rejected;1000pxand4emrejected; an invalid field dropped without discarding its neighbours; and a test that parsestheme.cssand asserts therunic-gatewaypreset still matches:roottoken for token, since that is the one place the server duplicates the stylesheet.cd client && node --test— 75 pass, 0 fail, 8 new inthemeVars.test.jscovering 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:manifestregenerated; manifest diff is exactly the one new route, androutes.guards.jsonconfirms it carriesnoindex, requireAuth.cd client && npm run build— clean./public/settingshas nothemeblock → saved Fantasy + a custom accent →brand.accentandtheme['--accent']both#123456, 23 tokens,--mode-liveand--panel-gradabsent → four malformed writes 400 with the field named and the stored theme untouched → the bot fetched#123456/0x123456→ reset returnedtheme: absent,brand.accentback 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'srgba(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/settingsfetch, so a themed instance paints the shipped palette for one frame before repainting. Phase 5 has to rewriterenderIndexHtmlinto 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
AI-assisted contributions (required)
Claude Code (Opus 5). I have reviewed and understandevery change, and take responsibility for it. AI-authored commits are
marked with a
Co-Authored-By/Assisted-Bytrailer.License
(GNU GPL v3.0 or later), and I have the right to contribute it.