feat(theming): settings-store, nav merge util and radius tokens

Phases 0-2 of docs/website/THEMING_AND_NAV.md. Groundwork only: no admin UI,
no consumer wiring, and an instance that never touches the new settings keys
renders exactly as it does today.

Phase 0 - settings store:
- settingsDb.remove() and DELETE /api/v1/admin/settings/:key, the "reset to
  default" primitive. Defaults for these keys live in BRAND_* env, theme.css
  and the hardcoded NAV arrays, so reset has to delete the row rather than
  store a copy of the default. Allowlisted to the five theming/nav keys plus
  hero_layout_draft, admin-only, idempotent.
- GET /api/v1/settings/nav behind requireAuth with no role gate. AdminLayout
  renders for editors and moderators and PlayerPortalLayout for players, and
  none of them can read GET /admin/settings, so without this their nav
  override would silently never apply.
- A fifth router group for it: /public is anonymous, /admin/settings is
  adminOnly, /player is self-scoped data. This is configuration that needs a
  login.
- parseJsonSetting() in utils/settingsJson.js. settings.value is TEXT, so
  every JSON key arrives as a string; malformed or wrong-shaped reads as
  absent, never as an error and never half-applied.
- theme_visual / brand_assets / nav_public join PUBLIC_KEYS; nav_admin and
  nav_player deliberately do not.

Phase 1 - client/src/lib/navOverrides.js, the pure merge util. Presentation
only: it can set label/order/hidden and (grouped navs) group, and nothing
else. It cannot introduce a `to`, cannot touch roles/feature, and hidden:false
cannot un-hide anything - the existing filters run afterward, unchanged, and
remain the boundary.

Phase 2 - promoted 23 border-radius literals in theme.css to four tokens at
today's values (14x8px, 4x999px, 4x10px, 1x12px). The 7px/6px editor chrome
and the two 50% circles stay literal. --shadow-card and --panel-grad were
already tokens.

Tests: 16 new server tests, 20 new client tests. The route-manifest guard now
also asserts /settings/** sits behind requireAuth. Swagger and both route
artifacts regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-07 18:15:29 -05:00
parent d765280e28
commit ec0036ce6d
18 changed files with 1042 additions and 28 deletions

View File

@@ -0,0 +1,138 @@
// Apply an admin's stored navigation overrides to a hardcoded NAV array.
//
// The three navs (public header, admin sidebar, player portal) stay declared in
// code; this layer only reorders, relabels and hides what is already there.
// See docs/website/THEMING_AND_NAV.md §7.
//
// **This is presentation, never authorization.** The override can carry
// `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else.
// It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or
// `end`, so the existing role/feature filters in SiteHeader and AdminLayout run
// *after* this merge, unchanged, and remain the actual boundary. An override
// saying `hidden: false` on a role-gated item still shows nothing to a viewer
// whose role check fails: hiding is subtractive here, never additive.
//
// Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string
// label, a group that does not exist — is ignored rather than rejected, so a
// stale or hand-edited settings row degrades to the code default instead of
// rendering a broken nav.
// Two shapes are supported, because two exist:
// flat [{ to, label, ... }] — public header, player portal
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
function isGrouped(nav) {
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
}
// A stored override entry is usable only field by field: a bad `label` must not
// discard a good `order` alongside it.
function cleanEntry(raw, groupTitles) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
const out = {}
if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim()
if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order
if (raw.hidden === true) out.hidden = true
// `group` may only name a section the base nav already declares. Anything else
// — a renamed group, a typo, an invented category — is dropped, so an item can
// never land in a header that does not exist.
if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group
return out
}
// Sort by effective order, where an item the admin never reordered keeps its
// index in the base array as its key. Two tie-breaks, in order: an explicit
// order beats a coincidental index (the admin said "first", so first), and two
// explicit orders stay in code order (the sort is stable).
//
// In practice the editor writes an order for every item in a list, the way
// drag-and-drop reordering does, so ties are the stale-row case rather than the
// normal one. They still have to resolve predictably.
function byOrder(items) {
return items
.map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined }))
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
.map(({ item }) => {
const { __order, ...rest } = item
return rest
})
}
// Apply label/hidden/order to one flat list. Returns visible items only, with
// the sort key parked on `__order` for byOrder to consume.
function mergeItems(items, entries) {
const out = []
for (const item of items) {
const o = entries.get(item.to)
if (o?.hidden) continue
// Spread the base item first so `to`, `roles`, `feature`, `icon` and `end`
// survive verbatim — the override only ever lands on `label`.
out.push({ ...item, ...(o?.label ? { label: o.label } : {}), __order: o?.order })
}
return out
}
/**
* @param {Array} baseNav the hardcoded nav — the source of truth for `to`,
* `roles`, `feature`, `icon` and `end`
* @param {object|null} overrides the parsed settings JSON, keyed by `to`, or
* null when the admin never touched this nav
* @returns {Array} a new array of the same shape, or `baseNav` itself when there
* is nothing to apply
*/
export function applyNavOverrides(baseNav, overrides) {
if (!Array.isArray(baseNav)) return []
// The untouched path, and the one that matters most: no row, a malformed row,
// or a row with nothing usable in it all render the nav exactly as coded.
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav
const grouped = isGrouped(baseNav)
const groupTitles = new Set(
grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [],
)
// Keyed by `to`, and only for a `to` the base nav actually declares. An
// override for a route that no longer exists is dropped here, so deleting a
// route in code can never leave a dangling override that does something
// unexpected later.
const known = new Set(
grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to),
)
const entries = new Map()
for (const [to, raw] of Object.entries(overrides)) {
if (!known.has(to)) continue
const entry = cleanEntry(raw, groupTitles)
if (entry && Object.keys(entry).length > 0) entries.set(to, entry)
}
if (entries.size === 0) return baseNav
if (!grouped) return byOrder(mergeItems(baseNav, entries))
// Grouped: an item may also be moved into another *existing* titled section.
// Groups keep their coded order — only membership and within-group order move.
const moved = new Map() // destination title → items pulled in from elsewhere
const kept = baseNav.map((g) => {
const items = []
for (const item of g.items) {
const o = entries.get(item.to)
if (o?.group && o.group !== g.title) {
if (!moved.has(o.group)) moved.set(o.group, [])
moved.get(o.group).push(item)
continue
}
items.push(item)
}
return { ...g, items }
})
return kept
.map((g) => ({
...g,
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)),
}))
// A group whose every item was hidden must not leave an orphaned header.
// AdminLayout drops empty groups again after its own role filter; doing it
// here too keeps the util correct on its own.
.filter((g) => g.items.length > 0)
}
export default applyNavOverrides

View File

@@ -24,6 +24,22 @@
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3);
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
/* Corner radius, by the kind of surface rather than by the pixel value, so a
theme preset can restyle all of them at once (see
docs/website/THEMING_AND_NAV.md §4.7). Seeded at the values already in use
— this promotion is a no-op, and every existing instance must keep looking
exactly as it does today.
Deliberately four tokens, not three: .card/.panel are 10px and .panel-flat
is 12px, so collapsing them would have restyled every card on every
install. The 7px (.rte-btn) and 6px (.rte-linkmenu-item) values stay
literals — interior editor chrome, not brand surface — as do the 50%
circles, which are shapes rather than radii. */
--radius-pill: 999px;
--radius-panel: 12px;
--radius-card: 10px;
--radius-input: 8px;
}
* {
@@ -99,7 +115,7 @@ a {
flex-direction: column;
padding: 24px;
border: 1px solid var(--line);
border-radius: 10px;
border-radius: var(--radius-card);
text-decoration: none;
color: var(--ink);
background: var(--panel-grad);
@@ -123,19 +139,19 @@ a.card:focus-visible {
}
.panel {
border: 1px solid var(--line);
border-radius: 10px;
border-radius: var(--radius-card);
background: var(--panel-grad);
}
.panel-flat {
border: 1px solid var(--line);
border-radius: 12px;
border-radius: var(--radius-panel);
overflow: hidden;
background: var(--panel-flat);
}
.note {
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: 8px;
border-radius: var(--radius-input);
background: rgba(19, 36, 60, 0.4);
padding: 18px 22px;
color: var(--muted);
@@ -168,7 +184,7 @@ a.card:focus-visible {
/* ===== Pills / buttons ===== */
.pill {
border: 1px solid var(--line);
border-radius: 999px;
border-radius: var(--radius-pill);
padding: 7px 14px;
color: var(--muted);
background: rgba(11, 22, 48, 0.5);
@@ -186,7 +202,7 @@ a.card:focus-visible {
outline: none;
}
.btn {
border-radius: 999px;
border-radius: var(--radius-pill);
padding: 12px 26px;
font-family: var(--sans);
font-size: 0.92rem;
@@ -214,7 +230,7 @@ a.card:focus-visible {
background: var(--blue);
}
.btn-sq {
border-radius: 8px;
border-radius: var(--radius-input);
padding: 10px 18px;
font-size: 0.85rem;
}
@@ -230,7 +246,7 @@ button[disabled] {
.select {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-input);
padding: 11px 14px;
background: var(--bg);
color: var(--ink);
@@ -312,7 +328,7 @@ button[disabled] {
}
.prose img {
max-width: 100%;
border-radius: 8px;
border-radius: var(--radius-input);
border: 1px solid var(--line);
}
@@ -320,7 +336,7 @@ button[disabled] {
.rte {
position: relative;
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-input);
background: var(--bg);
}
.rte:focus-within {
@@ -407,7 +423,7 @@ button[disabled] {
width: min(360px, calc(100% - 20px));
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-input);
background: var(--panel-a);
box-shadow: var(--shadow-card);
}
@@ -449,7 +465,7 @@ button[disabled] {
display: inline-block;
padding: 3px 10px;
border: 1px solid var(--line);
border-radius: 999px;
border-radius: var(--radius-pill);
background: rgba(127, 153, 189, 0.1);
color: var(--accent);
font-family: var(--sans);
@@ -494,7 +510,7 @@ button[disabled] {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-input);
background: var(--panel-flat);
color: var(--text);
text-align: left;
@@ -532,7 +548,7 @@ button[disabled] {
overflow-y: auto;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-input);
background: var(--bg);
}
.diff-add {
@@ -603,7 +619,7 @@ button[disabled] {
vertical-align: middle;
}
.badge {
border-radius: 999px;
border-radius: var(--radius-pill);
padding: 3px 11px;
font-size: 0.72rem;
font-weight: 700;
@@ -780,7 +796,7 @@ button[disabled] {
}
.page-image img {
max-width: 100%;
border-radius: 8px;
border-radius: var(--radius-input);
border: 1px solid var(--line);
display: block;
}
@@ -863,7 +879,7 @@ button[disabled] {
}
.pb-column-editor {
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-input);
padding: 12px;
background: var(--panel-flat, transparent);
}
@@ -881,7 +897,7 @@ button[disabled] {
}
.pb-subblock {
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius-input);
padding: 10px;
margin-top: 10px;
background: var(--bg);
@@ -919,7 +935,7 @@ button[disabled] {
border: 1px solid #6e3b38;
background: rgba(110, 59, 56, 0.16);
color: #e6a9a3;
border-radius: 8px;
border-radius: var(--radius-input);
padding: 10px 14px;
margin-top: 14px;
font-size: 0.86rem;
@@ -928,7 +944,7 @@ button[disabled] {
border: 1px solid var(--accent);
background: var(--blue);
color: var(--accent-bright);
border-radius: 8px;
border-radius: var(--radius-input);
padding: 8px 14px;
margin-top: 14px;
font-size: 0.86rem;
@@ -960,7 +976,7 @@ button[disabled] {
gap: 8px;
padding: 12px;
border: 1px dashed var(--line);
border-radius: 10px;
border-radius: var(--radius-card);
margin-bottom: 16px;
}
.pb-canvas {
@@ -970,7 +986,7 @@ button[disabled] {
}
.pb-block-card {
border: 1px solid var(--line);
border-radius: 10px;
border-radius: var(--radius-card);
background: var(--panel-flat, transparent);
}
.pb-block-card.is-dragging {
@@ -1082,7 +1098,7 @@ button[disabled] {
border: 1px solid var(--accent);
background: var(--blue);
color: var(--accent-bright);
border-radius: 8px;
border-radius: var(--radius-input);
padding: 8px 14px;
margin-bottom: 20px;
font-size: 0.85rem;

View File

@@ -0,0 +1,203 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { applyNavOverrides } from '../src/lib/navOverrides.js'
// The nav-override merge (docs/website/THEMING_AND_NAV.md §7.1) — the one piece
// of this feature with real correctness risk, so it is tested in isolation from
// React. Two properties matter above all others:
//
// 1. No override, or a useless one, renders the coded nav untouched.
// 2. The override cannot add a route, cannot touch a role/feature gate, and
// cannot un-hide anything. It is presentation only.
const FLAT = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'Wiki', to: '/wiki' },
{ label: 'Shard', to: '/site/shard', feature: 'status' },
]
const GROUPED = [
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
{
title: 'Content',
items: [
{ to: '/admin/posts', label: 'Posts', roles: ['admin', 'editor'] },
{ to: '/admin/wiki', label: 'Wiki', roles: ['admin', 'editor'] },
],
},
{
title: 'System',
items: [
{ to: '/admin/settings', label: 'Settings', roles: ['admin'] },
{ to: '/admin/users', label: 'Users', roles: ['admin'] },
],
},
]
const labels = (nav) => nav.map((i) => i.label)
const groupLabels = (nav) => nav.map((g) => [g.title ?? null, g.items.map((i) => i.label)])
// ── The untouched path ────────────────────────────────────────────────────
// Most instances will never set these keys. Absence must be a true no-op, and
// cheap: the same array reference back means no needless re-render either.
test('no override returns the base nav unchanged', () => {
for (const overrides of [null, undefined, '', 0, [], 'not an object']) {
assert.equal(applyNavOverrides(FLAT, overrides), FLAT)
}
})
test('an override with nothing usable in it returns the base nav unchanged', () => {
assert.equal(applyNavOverrides(FLAT, {}), FLAT)
// Every field here is unusable: unknown route, blank label, non-numeric order,
// hidden as a string rather than the boolean true.
assert.equal(
applyNavOverrides(FLAT, {
'/does/not/exist': { label: 'Ghost', hidden: true },
'/wiki': { label: ' ', order: 'first', hidden: 'yes' },
}),
FLAT,
)
})
// ── The security boundary ─────────────────────────────────────────────────
// The single most important negative case: the override layer must never be a
// way to introduce a route into a nav.
test('an unknown `to` is ignored, never added', () => {
const out = applyNavOverrides(FLAT, { '/admin/secret': { label: 'Secret', order: 0 } })
assert.equal(out.length, FLAT.length)
assert.ok(!out.some((i) => i.to === '/admin/secret'))
})
test('roles, feature, icon, end and to survive the merge verbatim', () => {
const out = applyNavOverrides(FLAT, {
'/site/shard': { label: 'Server Status', roles: ['player'], feature: null, to: '/evil' },
})
const shard = out.find((i) => i.to === '/site/shard')
assert.equal(shard.label, 'Server Status') // the one thing an override may set
assert.equal(shard.feature, 'status') // gate untouched
assert.equal(shard.roles, undefined) // and not invented
assert.ok(!out.some((i) => i.to === '/evil'))
})
test('hidden:false cannot un-hide anything — hiding is subtractive only', () => {
// The item is still present after the merge; whether it renders is decided by
// the caller's own role/feature filter, which this layer cannot reach.
const out = applyNavOverrides(GROUPED, { '/admin/settings': { hidden: false } })
assert.equal(out, GROUPED, 'a no-op override leaves the base nav alone')
})
// ── Flat navs: label, order, hidden ───────────────────────────────────────
test('label overrides only the labelled item', () => {
const out = applyNavOverrides(FLAT, { '/site/news': { label: 'Announcements' } })
assert.deepEqual(labels(out), ['Home', 'Announcements', 'Wiki', 'Shard'])
})
test('hidden drops the item', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { hidden: true } })
assert.deepEqual(labels(out), ['Home', 'News', 'Shard'])
})
// An item the admin never reordered keeps its position in the coded array, so
// setting one order does not scramble the rest.
test('order moves one item and leaves the others in code order', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { order: -1 } })
assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
})
test('two items given the same order keep their code order (stable sort)', () => {
const out = applyNavOverrides(FLAT, { '/site/news': { order: 0 }, '/wiki': { order: 0 } })
// News before Wiki — the tie resolves to the coded order, not to insertion
// order in the settings JSON. Both precede Home, whose 0 is only its index.
assert.deepEqual(labels(out), ['News', 'Wiki', 'Home', 'Shard'])
})
// An explicit order and an untouched item's index share one number line, so
// they can collide. "Put this first" has to actually mean first.
test('an explicit order beats an untouched item that merely sits at that index', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { order: 0 } })
assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
})
test('the merge does not mutate the base nav', () => {
const before = JSON.stringify(FLAT)
applyNavOverrides(FLAT, { '/wiki': { label: 'Library', order: 0, hidden: false } })
assert.equal(JSON.stringify(FLAT), before)
})
test('no internal sort key leaks into the returned items', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { order: 1 } })
for (const item of out) assert.ok(!('__order' in item), 'sort key must not be rendered')
})
// ── Grouped (admin) navs ──────────────────────────────────────────────────
test('label and order apply within a group', () => {
const out = applyNavOverrides(GROUPED, {
'/admin/wiki': { label: 'Knowledge Base', order: 0 },
})
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Knowledge Base', 'Posts']],
['System', ['Settings', 'Users']],
])
})
test('group moves an item into another existing section', () => {
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content' } })
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Posts', 'Wiki', 'Users']],
['System', ['Settings']],
])
})
// A group that does not exist must not conjure a header. Groups are chosen from
// a dropdown of existing titles in the editor; this is the stale-row guard.
test('a group that is not an existing title is ignored', () => {
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Danger Zone' } })
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Posts', 'Wiki']],
['System', ['Settings', 'Users']],
])
})
test('a moved item can be ordered in its new group', () => {
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content', order: -1 } })
assert.deepEqual(groupLabels(out)[1], ['Content', ['Users', 'Posts', 'Wiki']])
})
test('hiding every item in a group leaves no orphaned header', () => {
const out = applyNavOverrides(GROUPED, {
'/admin/settings': { hidden: true },
'/admin/users': { hidden: true },
})
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Posts', 'Wiki']],
])
})
test('group ordering itself is not overridable — sections stay in code order', () => {
const out = applyNavOverrides(GROUPED, { '/admin/settings': { order: -99 } })
assert.deepEqual(
out.map((g) => g.title ?? null),
[null, 'Content', 'System'],
)
})
// ── Degenerate input ──────────────────────────────────────────────────────
test('a non-array base nav yields an empty nav rather than throwing', () => {
assert.deepEqual(applyNavOverrides(null, { '/': { hidden: true } }), [])
assert.deepEqual(applyNavOverrides(undefined, null), [])
})
test('an empty base nav stays empty', () => {
assert.deepEqual(applyNavOverrides([], { '/': { label: 'Home' } }), [])
})