diff --git a/prototype/ornament/README.md b/prototype/ornament/README.md
new file mode 100644
index 0000000..c3df4f5
--- /dev/null
+++ b/prototype/ornament/README.md
@@ -0,0 +1,27 @@
+# Throwaway prototype for #54 — the ornament and frame component set
+
+Not for merge. Nothing here is theme code; it is the named component set made
+concrete so it can be looked at.
+
+- `ornament.css` — the component set itself, standalone, zero images.
+- `specimen.html` — every named component, at both densities, on one page.
+- `real-surface.css` — the same components mapped onto real NodeBB markup.
+- `shots.js` — before/after on the live category list and a 13-post topic.
+- `shots/` — the images the resolution is judged on.
+
+The palette is candidate B from #53, injected at runtime (`../_base-plumnoir.css`
+plus `../candidate-b.css`), so no rebuild is needed to see it.
+
+Replay:
+
+```bash
+# specimen page — needs http, not file://: mask-image on an SVG is blocked by CORS
+python3 -m http.server 8931 # then open /prototype/ornament/specimen.html
+
+# real surface
+cd ../sow-nodebb && nix develop --command make dev-reset && nix develop --command make dev
+cd - && node prototype/seed.js
+export PLAYWRIGHT_BROWSERS_PATH=$(nix build --print-out-paths --no-link nixpkgs#playwright-driver.browsers)
+P=$(nix build --print-out-paths --no-link nixpkgs#playwright-test)
+NODE_PATH=$P/lib/node_modules nix shell nixpkgs#playwright-test -c node prototype/ornament/shots.js
+```
diff --git a/prototype/ornament/ornament.css b/prototype/ornament/ornament.css
new file mode 100644
index 0000000..77092fe
--- /dev/null
+++ b/prototype/ornament/ornament.css
@@ -0,0 +1,299 @@
+/* #54 — the ornament and frame component set, as a closed vocabulary.
+ Throwaway. Zero images: every mark here is CSS. Palette = candidate B (#53). */
+
+/* ---- palette B (from prototype/_base-plumnoir.css + candidate-b.css) ---- */
+:root {
+ --wg-bg: #150e13;
+ --wg-bg-deep: #0c0810;
+ --wg-panel: #21161c;
+ --wg-panel-2: #180f16;
+ --wg-panel-3: #351e28;
+ --wg-plum: #351e28;
+ --wg-plum-soft: #4a2a38;
+
+ --wg-gold: #c2a35a;
+ --wg-gold-rgb: 194, 163, 90;
+ --wg-gold-lit: #e0c878;
+ --wg-old-gilt: #a8893f;
+ --wg-ledger-ink: #d8c28a;
+ --wg-red: #8e3438;
+ --wg-stamp: #a84a4e;
+
+ --wg-pewter: #9ea7b3;
+ --wg-pewter-rgb: 158, 167, 179;
+
+ --wg-text: #e6e0d6;
+ --wg-text-soft: #b9b2a6;
+ --wg-text-muted: #9ea7b3;
+
+ /* the four dividers, as tokens — this list is closed */
+ --wg-ruling: rgba(var(--wg-pewter-rgb), 0.075);
+ --wg-edge: rgba(var(--wg-pewter-rgb), 0.2);
+ --wg-edge-soft: rgba(var(--wg-pewter-rgb), 0.11);
+
+ --wg-page-width: 1420px;
+ --wg-ledger-line: 1.78rem;
+ --wg-font-display: Cinzel, Georgia, serif;
+ --wg-font-body: Jost, system-ui, sans-serif;
+}
+
+/* ======================================================================
+ 1. RAIL — not decoration, a constraint. One left edge, one right edge,
+ every page, every density (#49 One Rail).
+ ====================================================================== */
+.wg-rail {
+ max-width: var(--wg-page-width);
+ margin-inline: auto;
+ padding-inline: 1.5rem;
+}
+
+/* ======================================================================
+ 2. PANEL — the bounded surface. Two weights, nothing between.
+ Edges Fade (#49): the edge is a masked overlay, strongest at the top,
+ gone by the bottom. No hairline runs corner to corner.
+ ====================================================================== */
+.wg-panel {
+ position: relative;
+ background: var(--wg-panel);
+ border-radius: 8px;
+ padding: var(--wg-pad, 1rem);
+}
+.wg-panel::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ border: 1px solid var(--wg-edge);
+ -webkit-mask-image: linear-gradient(180deg, #000, rgba(0, 0, 0, 0.12));
+ mask-image: linear-gradient(180deg, #000, rgba(0, 0, 0, 0.12));
+ pointer-events: none;
+}
+/* cheap panel: the same shape one step down. No sheen, no fade — flat. */
+.wg-panel--plain {
+ background: var(--wg-panel-2);
+}
+.wg-panel--plain::before {
+ border-color: var(--wg-edge-soft);
+ -webkit-mask-image: none;
+ mask-image: none;
+}
+/* velvet sheen — texture #1. Inset only, never an asset. */
+.wg-panel--velvet {
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.025),
+ inset 0 0 34px rgba(96, 32, 68, 0.11),
+ 0 10px 26px rgba(0, 0, 0, 0.24);
+}
+.wg-panel--velvet:hover {
+ background: color-mix(in srgb, var(--wg-panel) 88%, var(--wg-plum-soft));
+}
+
+/* ======================================================================
+ 3. PLAQUE — a name plate, not a band. inline-block is the whole idea.
+ Page titles and standing labels. Suppressed where the logo does the job.
+ ====================================================================== */
+.wg-plaque {
+ display: inline-block;
+ background: var(--wg-bg-deep);
+ border: 1px solid var(--wg-edge);
+ padding: 0.35rem 1rem;
+ font-family: var(--wg-font-display);
+ font-weight: 600;
+ font-size: 0.86rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--wg-ledger-ink);
+}
+.wg-plaque--gilt {
+ border-color: rgba(var(--wg-gold-rgb), 0.34);
+}
+/* the straddling legend: a title sitting on its own panel edge */
+.wg-legend {
+ position: relative;
+}
+.wg-legend > .wg-plaque {
+ position: absolute;
+ top: -1.05rem;
+ left: 1.25rem;
+}
+
+/* ======================================================================
+ 4. BAND — a strip that spans the rail, edge to edge. Never inset.
+ Shell chrome only: topbar, lintel, footer, thresholds.
+ ====================================================================== */
+.wg-band {
+ position: relative;
+ background: var(--wg-panel-2);
+ padding-block: 0.75rem;
+}
+/* LINTEL — divider #3. Gilt rule that bleeds light downward (#48 Warframe). */
+.wg-band--lintel {
+ border-top: 1px solid rgba(var(--wg-gold-rgb), 0.5);
+ background-image: linear-gradient(
+ 180deg,
+ rgba(var(--wg-gold-rgb), 0.1),
+ transparent 32px
+ );
+}
+
+/* ======================================================================
+ 5. LEDGER — the archive interior. Ruled rows, one line height.
+ ====================================================================== */
+.wg-ledger {
+ background: var(--wg-panel-2);
+ border-radius: 8px;
+ overflow: hidden;
+}
+.wg-ledger__row {
+ display: flex;
+ gap: 1rem;
+ align-items: baseline;
+ min-height: calc(var(--wg-ledger-line) * 1.6);
+ padding: 0.6rem 1rem;
+ /* RULING — divider #1. Row separation, nothing else. */
+ background: linear-gradient(
+ to bottom,
+ transparent calc(100% - 1px),
+ var(--wg-ruling) calc(100% - 1px)
+ );
+ /* gilt left mark: this row has something at stake (unread, owned) */
+ border-left: 1px solid transparent;
+}
+.wg-ledger__row--marked {
+ border-left-color: rgba(var(--wg-gold-rgb), 0.34);
+}
+.wg-ledger__row:last-child {
+ background: none;
+}
+
+/* ======================================================================
+ DIVIDERS — the closed list. Four, and no fifth.
+ 1. ruling (above, .wg-ledger__row)
+ 2. fading rule
+ 3. lintel (above, .wg-band--lintel)
+ 4. spine
+ ====================================================================== */
+.wg-rule {
+ height: 1px;
+ border: 0;
+ background: linear-gradient(90deg, var(--wg-edge), transparent);
+}
+.wg-spine {
+ position: relative;
+ padding-left: 1.25rem;
+}
+.wg-spine::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0.2rem;
+ bottom: 0.2rem;
+ width: 1px;
+ background: linear-gradient(
+ to bottom,
+ rgba(var(--wg-gold-rgb), 0.18),
+ rgba(var(--wg-gold-rgb), 0.07)
+ );
+}
+
+/* ======================================================================
+ SECTION HEADER — one component, two forms.
+ A header earns ornament by switching face and case, not by gaining a
+ background or a flourish (#48 Warframe).
+ ====================================================================== */
+.wg-head {
+ font-family: var(--wg-font-body);
+ font-weight: 600;
+ font-size: 1rem;
+ letter-spacing: 0;
+ text-transform: none;
+ color: var(--wg-text-soft);
+ margin: 0 0 0.5rem;
+}
+.wg-head--earned {
+ font-family: var(--wg-font-display);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--wg-ledger-ink);
+}
+/* the one permitted flourish, and only on an earned header: a rule that
+ starts at the text and fades out to the rail. */
+.wg-head--earned.wg-head--ruled {
+ display: flex;
+ align-items: center;
+ gap: 0.9rem;
+}
+.wg-head--earned.wg-head--ruled::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: linear-gradient(90deg, rgba(var(--wg-gold-rgb), 0.34), transparent);
+}
+
+/* ======================================================================
+ ICON — mask over a token, never a coloured image.
+ Pewter structures, gilt marks value (#49 Two Metals).
+ ====================================================================== */
+.wg-icon {
+ display: inline-block;
+ width: var(--wg-icon-size, 20px);
+ height: var(--wg-icon-size, 20px);
+ background-color: var(--wg-pewter);
+ -webkit-mask: var(--wg-icon-src) center / contain no-repeat;
+ mask: var(--wg-icon-src) center / contain no-repeat;
+ vertical-align: -0.2em;
+}
+.wg-icon--gilt {
+ background-color: var(--wg-gold);
+}
+
+/* ======================================================================
+ HORIZON — texture #2. The city at night instead of hero art.
+ Showcase only. Radial, warm, never cyan (#49 No Machines).
+ ====================================================================== */
+.wg-horizon {
+ background:
+ radial-gradient(
+ 120% 80% at 50% 100%,
+ rgba(var(--wg-gold-rgb), 0.12),
+ transparent 62%
+ ),
+ linear-gradient(180deg, var(--wg-bg-deep), var(--wg-bg));
+}
+
+/* ======================================================================
+ DENSITY — one shell, two densities. Ornament weight and row spacing
+ only. Width never changes (One Rail).
+ ====================================================================== */
+.wg-showcase {
+ --wg-pad: 1.5rem;
+ --wg-icon-size: 28px;
+}
+.wg-app {
+ --wg-pad: 0.75rem;
+ --wg-icon-size: 20px;
+}
+/* app density: sheen off, plaques off except the page title, headers reduced */
+.wg-app .wg-panel--velvet {
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.42);
+}
+.wg-app .wg-band--lintel {
+ background-image: linear-gradient(
+ 180deg,
+ rgba(var(--wg-gold-rgb), 0.06),
+ transparent 18px
+ );
+}
+
+/* ======================================================================
+ STAMP — the only red. A state or a mark of loss, never decoration.
+ ====================================================================== */
+.wg-stamp {
+ font-family: var(--wg-font-display);
+ font-size: 0.78rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--wg-stamp);
+ border: 1px solid rgba(168, 74, 78, 0.45);
+ padding: 0.1rem 0.5rem;
+}
diff --git a/prototype/ornament/real-surface.css b/prototype/ornament/real-surface.css
new file mode 100644
index 0000000..2fe2473
--- /dev/null
+++ b/prototype/ornament/real-surface.css
@@ -0,0 +1,122 @@
+/* #54 — the component set applied to one real surface: the category list and
+ a topic, on the live stack. Injected at runtime over palette B. Throwaway.
+
+ Every rule below is one of the named components doing its job on real
+ NodeBB markup. Nothing new is invented here. */
+
+/* ---- PANEL: edges fade. No hairline runs corner to corner. ---- */
+li[component="categories/category"],
+li[component="category/topic"],
+.westgate-surface,
+.card {
+ border-color: transparent !important;
+ position: relative;
+}
+li[component="categories/category"]::after,
+.westgate-surface::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ border: 1px solid var(--wg-border);
+ -webkit-mask-image: linear-gradient(180deg, #000, rgba(0, 0, 0, 0.12));
+ mask-image: linear-gradient(180deg, #000, rgba(0, 0, 0, 0.12));
+ pointer-events: none;
+}
+
+/* ---- LEDGER: rows are ruled, not boxed. The category list is a ledger:
+ one surface, ruled rows, no gaps and no per-row card. ---- */
+ul.categories-list,
+ul.westgate-categories-list {
+ gap: 0 !important;
+ background: var(--wg-panel-2);
+ border-radius: 8px;
+ overflow: hidden;
+}
+ul.categories-list li[component="categories/category"],
+ul.westgate-categories-list li[component="categories/category"] {
+ border-radius: 0 !important;
+ background: none !important;
+ /* RULING — the only separator inside a ledger */
+ background-image: linear-gradient(
+ to bottom,
+ transparent calc(100% - 1px),
+ var(--wg-ledger-ruling) calc(100% - 1px)
+ ) !important;
+}
+ul.categories-list li[component="categories/category"]::after,
+ul.westgate-categories-list li[component="categories/category"]::after {
+ content: none;
+}
+
+/* ---- SECTION HEADER: earned form = display face + uppercase + one colour
+ token + the single permitted flourish, a gilt rule fading to the rail. ---- */
+li[component="categories/category"] h2.title,
+.category-header h1,
+[data-widget-area] .card-header {
+ display: flex !important;
+ align-items: center;
+ gap: 0.9rem;
+ font-family: var(--wg-font-display) !important;
+ text-transform: uppercase !important;
+ letter-spacing: 0.08em !important;
+ color: var(--wg-ledger-ink) !important;
+ background: none !important;
+}
+li[component="categories/category"] h2.title::after,
+.category-header h1::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: linear-gradient(90deg, rgba(var(--wg-gold-rgb), 0.34), transparent);
+}
+
+/* ---- PLAQUE: the page title becomes a name plate, not a band. ---- */
+body.template-category .category-header .category-title,
+body.template-topic .topic-title h1 {
+ display: inline-block;
+ background: var(--wg-bg-deep);
+ border: 1px solid var(--wg-border);
+ padding: 0.35rem 1rem;
+ font-family: var(--wg-font-display);
+ font-size: 0.95rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--wg-ledger-ink);
+}
+
+/* ---- ICON: pewter is structure. Gilt is reserved for what can be touched. ---- */
+li[component="categories/category"] .icon [class*="wg-icon-"],
+li[component="categories/category"] [class*="wg-icon-"] {
+ background-color: var(--wg-pewter) !important;
+}
+/* The category icon plate keeps its plum wash and desaturation: ACP colours
+ arrive candy-bright and the existing treatment is what pulls them into the
+ register. Only its ring moves from gilt to pewter — the plate structures,
+ it does not mark value. */
+li[component="categories/category"] .icon {
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.12),
+ inset 0 -3px 7px rgba(0, 0, 0, 0.52),
+ 0 0 0 1px rgba(var(--wg-pewter-rgb), 0.24) !important;
+}
+
+/* ---- LINTEL: the topbar is a threshold, and says so. ---- */
+.wg-topbar,
+#header-menu {
+ border-bottom: 0 !important;
+}
+.wg-topbar {
+ border-top: 1px solid rgba(var(--wg-gold-rgb), 0.5) !important;
+ background-image: linear-gradient(
+ 180deg,
+ rgba(var(--wg-gold-rgb), 0.1),
+ transparent 32px
+ ) !important;
+}
+
+/* ---- APP DENSITY: sheen off. Hierarchy is tone plus the two metals. ---- */
+li[component="categories/category"],
+li[component="category/topic"] {
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.42) !important;
+}
diff --git a/prototype/ornament/shots.js b/prototype/ornament/shots.js
new file mode 100644
index 0000000..88b3b98
--- /dev/null
+++ b/prototype/ornament/shots.js
@@ -0,0 +1,46 @@
+// Throwaway screenshotter for #54. Palette B is the ground; the only variable
+// is whether the ornament component set is applied.
+// PLAYWRIGHT_BROWSERS_PATH=... nix shell nixpkgs#playwright-test -c node prototype/ornament/shots.js
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE || 'http://localhost:4567';
+const OUT = path.join(__dirname, 'shots');
+const read = (f) => fs.readFileSync(path.join(__dirname, f), 'utf8');
+const paletteB = read('../_base-plumnoir.css') + read('../candidate-b.css');
+const ornament = read('real-surface.css');
+
+const VARIANTS = { before: paletteB, after: paletteB + ornament };
+const PAGES = [
+ ['categories', '/categories'],
+ ['topic', '/topic/2'],
+];
+
+(async () => {
+ fs.mkdirSync(OUT, { recursive: true });
+ const browser = await chromium.launch();
+ const ctx = await browser.newContext({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 2 });
+ const page = await ctx.newPage();
+
+ await page.goto(BASE + '/login');
+ await page.fill('#username', 'admin');
+ await page.fill('#password', 'Admin12345!');
+ await page.click('#login');
+ await page.waitForLoadState('networkidle');
+
+ for (const [name, css] of Object.entries(VARIANTS)) {
+ for (const [pname, url] of PAGES) {
+ await page.goto(BASE + url, { waitUntil: 'networkidle' });
+ await page.addStyleTag({ content: css });
+ await page.waitForTimeout(500);
+ await page.screenshot({ path: path.join(OUT, `${pname}-${name}.png`), fullPage: true });
+ await page.screenshot({
+ path: path.join(OUT, `${pname}-${name}-crop.png`),
+ clip: { x: 120, y: 60, width: 1060, height: 620 },
+ });
+ console.log(pname, name);
+ }
+ }
+ await browser.close();
+})();
diff --git a/prototype/ornament/shots/categories-after-crop.png b/prototype/ornament/shots/categories-after-crop.png
new file mode 100644
index 0000000..dd11d6a
Binary files /dev/null and b/prototype/ornament/shots/categories-after-crop.png differ
diff --git a/prototype/ornament/shots/categories-after.png b/prototype/ornament/shots/categories-after.png
new file mode 100644
index 0000000..12bf4cd
Binary files /dev/null and b/prototype/ornament/shots/categories-after.png differ
diff --git a/prototype/ornament/shots/categories-before-crop.png b/prototype/ornament/shots/categories-before-crop.png
new file mode 100644
index 0000000..0af9be5
Binary files /dev/null and b/prototype/ornament/shots/categories-before-crop.png differ
diff --git a/prototype/ornament/shots/categories-before.png b/prototype/ornament/shots/categories-before.png
new file mode 100644
index 0000000..0d925ba
Binary files /dev/null and b/prototype/ornament/shots/categories-before.png differ
diff --git a/prototype/ornament/shots/specimen-full.png b/prototype/ornament/shots/specimen-full.png
new file mode 100644
index 0000000..4c57855
Binary files /dev/null and b/prototype/ornament/shots/specimen-full.png differ
diff --git a/prototype/ornament/shots/topic-after-crop.png b/prototype/ornament/shots/topic-after-crop.png
new file mode 100644
index 0000000..28e993c
Binary files /dev/null and b/prototype/ornament/shots/topic-after-crop.png differ
diff --git a/prototype/ornament/shots/topic-after.png b/prototype/ornament/shots/topic-after.png
new file mode 100644
index 0000000..b5b35a9
Binary files /dev/null and b/prototype/ornament/shots/topic-after.png differ
diff --git a/prototype/ornament/shots/topic-before-crop.png b/prototype/ornament/shots/topic-before-crop.png
new file mode 100644
index 0000000..5b5d4bc
Binary files /dev/null and b/prototype/ornament/shots/topic-before-crop.png differ
diff --git a/prototype/ornament/shots/topic-before.png b/prototype/ornament/shots/topic-before.png
new file mode 100644
index 0000000..f01efae
Binary files /dev/null and b/prototype/ornament/shots/topic-before.png differ
diff --git a/prototype/ornament/specimen.html b/prototype/ornament/specimen.html
new file mode 100644
index 0000000..933f1a4
--- /dev/null
+++ b/prototype/ornament/specimen.html
@@ -0,0 +1,272 @@
+
+
+
+
+
+ #54 — ornament and frame specimen (zero images)
+
+
+
+
+
+
+
+
+ Shadows Over Westgate
+ topbar — same band, same rail, every page
+
+
+
+
+
The City and Its Ledger
+
+ LINTEL + HORIZON — the threshold into the page. Zero images: the glow is a radial
+ gradient, the rule bleeds 32px of light downward.
+
+
+
+
+
+
+
+ Showcase density
+
+
+ Earned header: display face, uppercase, one colour token, and the single permitted
+ flourish — a gilt rule that starts at the text and fades to the rail.
+
+
+
+
+
Panel
+
+ The bounded surface. Its edge is a masked overlay — full strength at the top, gone
+ by the bottom. No hairline runs corner to corner.
+
+
+
+
Plain panel
+
+ The cheap panel. One tone down, softer edge, no sheen, no fade. This is what a
+ panel looks like when it has nothing to say.
+
+
+
+
Plaque
+
+ A name plate, not a band — inline-block is the whole idea. As a
+ straddling legend it sits on its own panel edge.
+
+
+
+
+
+ The divider inventory
+
+ Four. There is no fifth. Anything else is a habit, not a divider.
+
+
+
1. Ruling — row separation inside a ledger, and nowhere else.
+
+
+ A marked row
+ 14 replies · 2h
+
+
+ An ordinary row
+ 3 replies · 5h
+
+
+ Another ordinary row
+ no replies · 1d
+
+
+
+
2. Fading rule — between sections. It ends by fading, not by stopping.
+
+
+
3. Lintel — a threshold. One per band, at the top of the page shell.
+
+ the band below a lintel
+
+
+
4. Spine — a vertical fade marking a sequence: a thread of posts.
+
+
First reply in the thread.
+
Second reply, same spine.
+
+
+
+
+ Icons, stamps, and the two metals
+
+
+
+
+
+
+ pewter: structure and identity
+
+ gilt: this matters, or can be touched
+ Closed
+ the only red
+
+
+ Every icon is a mask over a token, never a coloured file — one asset serves every
+ state and forced-colors mode.
+
+
+
+
+
+
+
+
+ App density — same rail, same shell
+
+ Identical width, identical components. Sheen off, rows tighter, headers reduced, icons
+ one step smaller. Nothing here is a different component; it is the same set turned down.
+
+
+
+ Roleplay
+ the page title keeps its plaque; nothing else gets one
+
+
+
+
+
+ Notices from the Night Market
+ 42 · 12m
+
+
+
+ Rumors heard near the docks
+ 9 · 1h
+
+
+
+ Journal of a compromised clerk
+ 3 · 6h
+
+
+
+ Faction standing, second quarter
+ 27 · 1d
+
+
+
+ A reduced header (has not earned ornament)
+
+
+ Body face, no case switch, no rule, no background. The reduced header is the default;
+ ornament is the exception that has to be argued for.
+
+
+
+
+
+
+
+ The standing test
+
+ This page contains no images of any kind — every frame, rule, plaque, glow, and icon
+ above is CSS or a mask over a token. If this page looks unfinished, the component set
+ is not done.
+
+
+
+
+
+
+ Footer — the same band and lintel that opened the page.
+
+
+
+