Author SHA1 Message Date
archvillainette 7a29402d93 prototype(#54): variants and the settled ledger row
Adds the three category-list variants the set was judged against (roomy,
tabular, tabular+gold) and the fixes that came out of looking at them in a
browser:

- square everywhere, with enough specificity to beat the theme's own
  `border-radius: 8px !important`; only the status dot stays round
- a drop shadow under every bounded surface, so a square edge reads as an
  edge and not a cut
- Cinzel always uppercase
- the Well component: the three right-hand readings sit in recesses on a
  shared grid, inset on all four sides
- the clamped teaser clamps by line count, not by NodeBB's hard max-height,
  which was cutting the glyphs
2026-07-31 18:00:47 +02:00
archvillainetteandClaude Opus 5 1a479023d5 prototype(#54): ornament and frame component set as a specimen page
Throwaway. Names the closed component vocabulary (rail, panel, plaque,
band, ledger, four dividers, section header, icon, horizon, stamp) and
shows it at both densities with zero images, plus the same components
mapped onto the live category list and topic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:47:44 +02:00
25 changed files with 1064 additions and 0 deletions
+27
View File
@@ -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
```
+299
View File
@@ -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: 0;
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: 0;
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;
}
+180
View File
@@ -0,0 +1,180 @@
/* #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. */
/* ---- SQUARE. One shape, every surface. A cut or rounded corner is a second
shape language and the shell only gets one. ---- */
/* specificity has to beat the theme's own `border-radius: 8px !important` */
html body [class],
html body [class]::before,
html body [class]::after,
html body [class] [class],
html body [class] [class][class],
html body blockquote,
html body pre,
html body code,
html body img,
html body input,
html body textarea,
html body button {
border-radius: 0 !important;
}
/* Signets stay square like everything else — the theme's own signet plate is
the settled treatment and it is square. Only the status dot is round,
because it is a dot. */
html body [class] [class].status,
html body .status {
border-radius: 50% !important;
}
/* ---- PANEL: edges fade. No hairline runs corner to corner, but the edge is
present enough to hold the panel off the page. ---- */
li[component="categories/category"],
li[component="category/topic"],
.westgate-surface,
.card,
ul.categories-list,
ul.westgate-categories-list,
[component="post/content"],
.post-container {
border-color: transparent !important;
position: relative;
}
ul.categories-list::after,
ul.westgate-categories-list::after,
.westgate-surface::after,
.post-container::after,
body.template-category .category-header::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid rgba(var(--wg-pewter-rgb), 0.34);
-webkit-mask-image: linear-gradient(180deg, #000, rgba(0, 0, 0, 0.16));
mask-image: linear-gradient(180deg, #000, rgba(0, 0, 0, 0.16));
pointer-events: none;
z-index: 2;
}
/* ---- A square edge with no falloff reads as a cut, not an edge. Every
bounded surface sits on a wide, offset-down black shadow: no lift, no
stacking claim, just the corner catching the room (Candlelight). ---- */
html body ul.categories-list,
html body ul.westgate-categories-list,
html body .westgate-surface,
html body .post-container,
html body .topic-list-header,
html body [data-widget-area] .card,
html body.template-category .category-header {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.02),
0 10px 26px rgba(0, 0, 0, 0.34),
0 2px 4px rgba(0, 0, 0, 0.28) !important;
}
/* ---- 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);
}
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);
}
/* ---- CASE: Cinzel is always uppercase. One rule, no exceptions, because
its lowercase is a small-caps design and reads as a weaker version of the
same thing rather than a second voice. ---- */
html body [class] h1,
html body [class] h2,
html body [class] h3,
html body .topic-title,
html body .breadcrumb {
text-transform: uppercase !important;
}
/* ---- 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;
}
+47
View File
@@ -0,0 +1,47 @@
// 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'],
['category', '/category/8'],
['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();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 KiB

+272
View File
@@ -0,0 +1,272 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>#54 — ornament and frame specimen (zero images)</title>
<style>
@font-face {
font-family: Cinzel;
src: url("../../public/fonts/cinzel/cinzel-latin-600-normal.woff2") format("woff2");
font-weight: 600;
font-display: swap;
}
@font-face {
font-family: Jost;
src: url("../../public/fonts/jost/jost-latin-400-normal.woff2") format("woff2");
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: Jost;
src: url("../../public/fonts/jost/jost-latin-600-normal.woff2") format("woff2");
font-weight: 600;
font-display: swap;
}
</style>
<link rel="stylesheet" href="ornament.css" />
<style>
body {
margin: 0;
background: var(--wg-bg);
color: var(--wg-text-soft);
font-family: var(--wg-font-body);
line-height: 1.55;
}
h1,
h2 {
font-family: var(--wg-font-display);
color: var(--wg-text);
font-weight: 600;
}
section {
padding-block: 2.5rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.note {
color: var(--wg-text-muted);
font-size: 0.82rem;
}
a {
color: var(--wg-gold);
}
.swatchrow {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
</style>
</head>
<body>
<!-- SHELL: identical in both densities. Band + lintel + rail. -->
<div class="wg-band">
<div class="wg-rail" style="display: flex; gap: 1.5rem; align-items: center">
<span
style="
font-family: var(--wg-font-display);
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--wg-ledger-ink);
"
>Shadows Over Westgate</span
>
<span class="note">topbar — same band, same rail, every page</span>
</div>
</div>
<div class="wg-band wg-band--lintel wg-horizon" style="padding-block: 3rem">
<div class="wg-rail">
<h1 style="margin: 0 0 0.5rem; font-size: 2.6rem">The City and Its Ledger</h1>
<p class="note" style="margin: 0">
LINTEL + HORIZON — the threshold into the page. Zero images: the glow is a radial
gradient, the rule bleeds 32px of light downward.
</p>
</div>
</div>
<main class="wg-rail">
<!-- ================= SHOWCASE DENSITY ================= -->
<section class="wg-showcase">
<h2 class="wg-head wg-head--earned wg-head--ruled">Showcase density</h2>
<p class="note">
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.
</p>
<div class="grid" style="margin-top: 1.5rem">
<div class="wg-panel wg-panel--velvet">
<h3 class="wg-head wg-head--earned" style="font-size: 0.95rem">Panel</h3>
<p>
The bounded surface. Its edge is a masked overlay — full strength at the top, gone
by the bottom. No hairline runs corner to corner.
</p>
</div>
<div class="wg-panel wg-panel--plain">
<h3 class="wg-head" style="font-size: 0.95rem">Plain panel</h3>
<p>
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.
</p>
</div>
<div class="wg-panel wg-panel--velvet wg-legend">
<span class="wg-plaque">Plaque</span>
<p style="margin-top: 0.75rem">
A name plate, not a band — <code>inline-block</code> is the whole idea. As a
straddling legend it sits on its own panel edge.
</p>
</div>
</div>
<h2 class="wg-head wg-head--earned wg-head--ruled" style="margin-top: 2.5rem">
The divider inventory
</h2>
<p class="note">Four. There is no fifth. Anything else is a habit, not a divider.</p>
<div class="wg-panel wg-panel--plain" style="margin-top: 1rem">
<p><strong>1. Ruling</strong> — row separation inside a ledger, and nowhere else.</p>
<div class="wg-ledger" style="margin: 0.75rem 0 1.5rem">
<div class="wg-ledger__row wg-ledger__row--marked">
<span style="color: var(--wg-ledger-ink)">A marked row</span>
<span class="note" style="margin-left: auto">14 replies · 2h</span>
</div>
<div class="wg-ledger__row">
<span>An ordinary row</span>
<span class="note" style="margin-left: auto">3 replies · 5h</span>
</div>
<div class="wg-ledger__row">
<span>Another ordinary row</span>
<span class="note" style="margin-left: auto">no replies · 1d</span>
</div>
</div>
<p><strong>2. Fading rule</strong> — between sections. It ends by fading, not by stopping.</p>
<hr class="wg-rule" style="margin: 0.75rem 0 1.5rem" />
<p><strong>3. Lintel</strong> — a threshold. One per band, at the top of the page shell.</p>
<div class="wg-band wg-band--lintel" style="margin: 0.75rem 0 1.5rem; padding: 1rem">
<span class="note">the band below a lintel</span>
</div>
<p><strong>4. Spine</strong> — a vertical fade marking a sequence: a thread of posts.</p>
<div class="wg-spine" style="margin-top: 0.75rem">
<p style="margin: 0">First reply in the thread.</p>
<p style="margin: 0.5rem 0 0">Second reply, same spine.</p>
</div>
</div>
<h2 class="wg-head wg-head--earned wg-head--ruled" style="margin-top: 2.5rem">
Icons, stamps, and the two metals
</h2>
<div class="wg-panel wg-panel--velvet" style="margin-top: 1rem">
<div class="swatchrow">
<span
class="wg-icon"
style="--wg-icon-src: url('../../public/game-icons/scroll-unfurled.svg')"
></span>
<span
class="wg-icon"
style="--wg-icon-src: url('../../public/game-icons/tavern-sign.svg')"
></span>
<span
class="wg-icon"
style="--wg-icon-src: url('../../public/game-icons/duality-mask.svg')"
></span>
<span class="note">pewter: structure and identity</span>
<span
class="wg-icon wg-icon--gilt"
style="--wg-icon-src: url('../../public/game-icons/wax-seal.svg')"
></span>
<span class="note">gilt: this matters, or can be touched</span>
<span class="wg-stamp">Closed</span>
<span class="note">the only red</span>
</div>
<p class="note" style="margin-bottom: 0">
Every icon is a mask over a token, never a coloured file — one asset serves every
state and forced-colors mode.
</p>
</div>
</section>
<hr class="wg-rule" />
<!-- ================= APP DENSITY ================= -->
<section class="wg-app">
<h2 class="wg-head wg-head--earned wg-head--ruled">App density — same rail, same shell</h2>
<p class="note">
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.
</p>
<div style="display: flex; gap: 1rem; align-items: baseline; margin: 1rem 0">
<span class="wg-plaque">Roleplay</span>
<span class="note">the page title keeps its plaque; nothing else gets one</span>
</div>
<div class="wg-ledger">
<div class="wg-ledger__row wg-ledger__row--marked">
<span
class="wg-icon"
style="--wg-icon-src: url('../../public/game-icons/stabbed-note.svg')"
></span>
<span style="color: var(--wg-text)">Notices from the Night Market</span>
<span class="note" style="margin-left: auto">42 · 12m</span>
</div>
<div class="wg-ledger__row">
<span
class="wg-icon"
style="--wg-icon-src: url('../../public/game-icons/mute.svg')"
></span>
<span>Rumors heard near the docks</span>
<span class="note" style="margin-left: auto">9 · 1h</span>
</div>
<div class="wg-ledger__row">
<span
class="wg-icon"
style="--wg-icon-src: url('../../public/game-icons/sword-altar.svg')"
></span>
<span>Journal of a compromised clerk</span>
<span class="note" style="margin-left: auto">3 · 6h</span>
</div>
<div class="wg-ledger__row">
<span
class="wg-icon"
style="--wg-icon-src: url('../../public/game-icons/cowled.svg')"
></span>
<span>Faction standing, second quarter</span>
<span class="note" style="margin-left: auto">27 · 1d</span>
</div>
</div>
<h3 class="wg-head" style="margin-top: 1.5rem">A reduced header (has not earned ornament)</h3>
<div class="wg-panel wg-panel--plain">
<p style="margin: 0">
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.
</p>
</div>
</section>
<hr class="wg-rule" />
<section>
<h2 class="wg-head wg-head--earned">The standing test</h2>
<p class="note" style="max-width: 60ch">
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.
</p>
</section>
</main>
<div class="wg-band wg-band--lintel" style="margin-top: 3rem; padding-block: 2rem">
<div class="wg-rail">
<span class="note">Footer — the same band and lintel that opened the page.</span>
</div>
</div>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
/* Variant: gilt structures, pewter only speaks.
Two Metals inverted from #53's assignment — the metals keep separate jobs,
but the visible edge work goes back to gold and pewter is demoted to
metadata text only. Answers "there is too much silver now". */
:root {
--wg-border: rgba(var(--wg-gold-rgb), 0.2);
--wg-border-soft: rgba(var(--wg-gold-rgb), 0.11);
--wg-ledger-ruling: rgba(var(--wg-gold-rgb), 0.08);
--wg-ledger-rule-strong: rgba(var(--wg-gold-rgb), 0.2);
--wg-ledger-border: rgba(var(--wg-gold-rgb), 0.24);
}
/* panel edges, ledger edge, icon ring: gilt */
html body ul.categories-list::after,
html body ul.westgate-categories-list::after,
html body .westgate-surface::after,
html body .post-container::after,
html body.template-category .category-header::after {
border-color: rgba(var(--wg-gold-rgb), 0.28) !important;
}
html body 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-gold-rgb), 0.28) !important;
}
/* category names keep the ledger ink, one step brighter than body */
html body li[component="categories/category"] h2.title,
html body li[component="categories/category"] h2.title a {
color: var(--wg-ledger-ink) !important;
}
+30
View File
@@ -0,0 +1,30 @@
/* Variant: rows breathe. The category list goes back to separate panels with
air between them, instead of one ruled ledger. Same components, same square
shape, same fading edge — only the ledger reading is dropped. */
html body ul.categories-list,
html body ul.westgate-categories-list {
gap: var(--wg-space-sm, 0.75rem) !important;
background: none !important;
}
html body ul.categories-list::after,
html body ul.westgate-categories-list::after {
content: none;
}
html body ul.categories-list li[component="categories/category"],
html body ul.westgate-categories-list li[component="categories/category"] {
background: var(--wg-panel-2) !important;
background-image: none !important;
padding: var(--wg-space-md, 1rem) !important;
}
/* each row gets its own fading edge, since it is a panel again */
html body ul.categories-list li[component="categories/category"]::after,
html body ul.westgate-categories-list li[component="categories/category"]::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid rgba(var(--wg-pewter-rgb), 0.34);
-webkit-mask-image: linear-gradient(180deg, #000, rgba(0, 0, 0, 0.16));
mask-image: linear-gradient(180deg, #000, rgba(0, 0, 0, 0.16));
pointer-events: none;
z-index: 2;
}
+179
View File
@@ -0,0 +1,179 @@
/* Variant: the ledger row as a line in a record, not three blocks in a row.
The data columns move into WELLS; the left side stays prose. */
/* ---- WELL — a recess, the inverse of Panel. Panel is laid on the surface
and casts down; a well is cut into it and shadows inward. Three of them,
one per reading, bleeding to the row's right edge. The gap between two
wells is their divider: a well never carries a rule inside it. ---- */
html body li[component="categories/category"] > div.col-lg-5 {
background: none !important;
box-shadow: none !important;
align-items: stretch !important;
padding: 0 !important;
gap: 0.4rem !important;
/* the wells sit inside the row's margin on every side — a well cut flush to
the right edge reads as the row running off the page */
margin: 0 !important;
}
/* the stat pair: two equal columns of well, not two little cards */
html body li[component="categories/category"] .meta.stats,
html body li[component="categories/category"] .stats {
display: grid !important;
/* the counts need only their own width; the room goes to the teaser */
grid-template-columns: 6rem 6rem !important;
justify-content: end !important;
gap: 0.4rem !important;
padding: 0 !important;
border: 0 !important;
background: none !important;
box-shadow: none !important;
}
/* the well itself */
html body li[component="categories/category"] .stats .card,
html body li[component="categories/category"] .stats .card-header,
html body li[component="categories/category"] .teaser {
/* the basin sits below the row it is cut into, but not as dead as the
page floor: between panel-2 and bg-deep */
background: color-mix(in srgb, var(--wg-panel-2) 45%, var(--wg-bg-deep)) !important;
background-image: none !important;
border: 0 !important;
box-shadow:
inset 0 1px 3px rgba(0, 0, 0, 0.55),
inset 0 0 0 1px rgba(var(--wg-gold-rgb), 0.1) !important;
padding: 0.5rem 0.85rem !important;
}
/* the teaser is prose, so it gets the room and a proper margin of its own */
html body li[component="categories/category"] .teaser {
flex: 1 1 auto !important;
/* the basin's inset is even on all four sides — a wider left/right inset
reads as the text not filling the well */
padding: 0.55rem !important;
}
/* the teaser's inner blocks are flex items once the well is a flex box, so
they shrink to their text and stop filling. Make them fill it. */
html body li[component="categories/category"] .lastpost,
html body li[component="categories/category"] .westgate-lastpost {
flex: 1 1 auto !important;
width: 100% !important;
min-width: 0 !important;
height: auto !important;
}
html body li[component="categories/category"] [component="category/posts"] {
padding-left: 0 !important;
width: 100% !important;
min-width: 0 !important;
/* the byline and its line of prose are one block, not two rows */
gap: 0.1rem !important;
height: auto !important;
line-height: 1.35 !important;
}
html body li[component="categories/category"] .teaser .text-nowrap {
line-height: 1.2 !important;
/* the signet's ring is drawn outside its 18px box, and this row is
`text-truncate` (overflow: hidden), so at x=0 the left of the ring is cut.
Give it the ring's width back. */
padding-left: 2px !important;
}
html body li[component="categories/category"] .stats .card {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
}
/* the number carries; the label is engraved and small */
html body li[component="categories/category"] .stats .card > span:first-child {
font-family: var(--wg-font-display) !important;
color: var(--wg-text) !important;
}
html body li[component="categories/category"] .stats .card > span + span,
html body li[component="categories/category"] .stats small {
font-family: var(--wg-font-display) !important;
font-size: 0.78rem !important;
letter-spacing: 0.12em !important;
text-transform: uppercase !important;
color: var(--wg-text-muted) !important;
}
/* the teaser well reads as text, so it is set left and loses NodeBB's
inline pure-white rule — a ledger has one ruling weight, not two */
html body li[component="categories/category"] .teaser {
display: flex !important;
align-items: center !important;
}
html body li[component="categories/category"] .teaser *,
html body li[component="categories/category"] .lastpost,
html body li[component="categories/category"] .westgate-lastpost {
text-align: left !important;
border-left: 0 !important;
padding-left: 0 !important;
}
/* the byline row must stretch, or it shrinks to its own text and the
`text-truncate` on it starts clipping the signet */
html body li[component="categories/category"] [component="category/posts"],
html body li[component="categories/category"] .lastpost,
html body li[component="categories/category"] .westgate-lastpost {
align-items: stretch !important;
justify-content: center !important;
}
html body li[component="categories/category"] .teaser .text-nowrap,
html body li[component="categories/category"] .teaser .post-content {
width: 100% !important;
max-width: 100% !important;
}
html body li[component="categories/category"] .teaser p,
html body li[component="categories/category"] [component="category/posts"] p {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin: 0 !important;
color: var(--wg-text-muted) !important;
}
/* the left side is prose and stops at its own measure; the wells take the
room that was standing empty between the two */
html body li[component="categories/category"] > div.col-lg-7 {
flex: 0 0 46% !important;
max-width: 46% !important;
}
html body li[component="categories/category"] > div.col-lg-5 {
flex: 1 1 auto !important;
max-width: none !important;
}
/* one baseline for the whole row, so rows scan as lines rather than pages.
The columns stretch: a centred well cannot grow, so a two-line teaser
overflows it top and bottom and gets clipped. */
html body li[component="categories/category"] {
align-items: stretch !important;
}
html body li[component="categories/category"] .teaser,
html body li[component="categories/category"] .lastpost,
html body li[component="categories/category"] .westgate-lastpost,
html body li[component="categories/category"] [component="category/posts"] {
height: auto !important;
min-height: 0 !important;
overflow: visible !important;
}
/* the clamp keeps its own overflow — it is what puts the ellipsis on. But its
line box has to be taller than the glyphs, or `overflow: hidden` shaves the
ascenders off the first line and the descenders off the last. */
html body li[component="categories/category"] .post-content {
height: auto !important;
min-height: 0 !important;
line-height: 1.5 !important;
/* NodeBB's `line-clamp-sm-2` clamps by a hard `max-height` sized to its own
line-height. Ours is taller, so that max-height cuts the glyphs. Drop it
and clamp by line count instead. */
max-height: none !important;
display: -webkit-box !important;
-webkit-line-clamp: 2 !important;
-webkit-box-orient: vertical !important;
overflow: hidden !important;
}
html body li[component="categories/category"] h2.title {
margin-bottom: 0.15rem !important;
}