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. +
+
+ +