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

@@ -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;