prototype(#53): palette candidates as runtime token swaps
Throwaway. Tokenises the theme's literal colours so candidate palettes can be swapped in the browser, adds three candidates (Plum Noir one-metal, two-metal pewter, two-metal plus a champagne inset), a WCAG checker, a seeder and the screenshots they are judged on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -0,0 +1,21 @@
|
||||
# Throwaway prototype for #53 — palette candidates on a dense page
|
||||
|
||||
Not for merge. The theme SCSS on this branch is mechanically tokenised (literal
|
||||
hexes and `rgba()` triples turned into `var(--wg-*)`) so a candidate palette can
|
||||
be swapped at runtime instead of rebuilt per candidate.
|
||||
|
||||
- `contrast.py` — WCAG check for every candidate surface/ink pair. Run first.
|
||||
- `_base-plumnoir.css` + `candidate-{a,b,c}.css` — the candidates, as `:root` overrides.
|
||||
- `seed.js` — categories + one 13-post topic with a quote, over the REST API.
|
||||
- `shots.js` / `probe.js` — screenshots and a computed-style probe, both against the live stack.
|
||||
- `shots/` — the images the resolution is judged on.
|
||||
|
||||
Replay:
|
||||
|
||||
```bash
|
||||
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/shots.js
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
/* shared by all candidates: the Plum Noir surface lineage (#49 rule "re-tune, don't replace") */
|
||||
:root {
|
||||
--wg-bg: #150E13;
|
||||
--wg-bg-deep: #0C0810;
|
||||
--wg-bg-deep-rgb: 12, 8, 16;
|
||||
--wg-bg-deepest-rgb: 8, 6, 11;
|
||||
--wg-panel: #21161C;
|
||||
--wg-panel-2: #180F16;
|
||||
--wg-panel-3: #351E28;
|
||||
--wg-plum: #351E28;
|
||||
--wg-plum-rgb: 53, 30, 40;
|
||||
--wg-plum-soft: #4A2A38;
|
||||
--wg-velvet-panel: rgba(33, 22, 28, 0.97);
|
||||
--wg-velvet-panel-hover: rgba(45, 30, 38, 0.98);
|
||||
--wg-ledger-panel: rgba(24, 15, 22, 0.97);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/* A — Plum Noir Ledger. One metal. Surfaces re-tuned, gold does every job it does today. */
|
||||
@@ -0,0 +1,15 @@
|
||||
/* B — Two Metals. A + pewter #9EA7B3: pewter structures, gilt marks what matters or can be touched. */
|
||||
:root {
|
||||
--wg-pewter: #9EA7B3;
|
||||
--wg-pewter-rgb: 158, 167, 179;
|
||||
/* structure moves to pewter */
|
||||
--wg-border: rgba(var(--wg-pewter-rgb), 0.20);
|
||||
--wg-border-soft: rgba(var(--wg-pewter-rgb), 0.11);
|
||||
--wg-ledger-ruling: rgba(var(--wg-pewter-rgb), 0.075);
|
||||
--wg-ledger-rule-strong: rgba(var(--wg-pewter-rgb), 0.20);
|
||||
--wg-ledger-border: rgba(var(--wg-pewter-rgb), 0.24);
|
||||
/* metadata is structure, not value */
|
||||
--wg-text-muted: #9EA7B3;
|
||||
--wg-ledger-ink-muted: #9EA7B3;
|
||||
/* gilt keeps: links, ledger ink, buttons, focus ring, stamps */
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* C — Two Metals + plain paper. B + one champagne inset, to test whether a light surface earns a slot. */
|
||||
:root {
|
||||
--wg-paper: #EFE5D2;
|
||||
--wg-ink-on-light: #351E28;
|
||||
--wg-focus-ring-on-light: #09080B;
|
||||
}
|
||||
/* the inset: quoted material is paper pasted into the ledger. No metal on it (#50 finding 9). */
|
||||
blockquote, .wg-paper {
|
||||
background: var(--wg-paper) !important;
|
||||
color: var(--wg-ink-on-light) !important;
|
||||
border: 0 !important;
|
||||
border-left: 3px solid var(--wg-red) !important;
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,.5), 0 10px 22px rgba(0,0,0,.35) !important;
|
||||
}
|
||||
blockquote a, .wg-paper a { color: #6E1F2A !important; }
|
||||
blockquote *, .wg-paper * { color: inherit !important; }
|
||||
blockquote :focus-visible, .wg-paper :focus-visible { outline-color: var(--wg-focus-ring-on-light) !important; }
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Throwaway WCAG 2.x contrast check for the #53 palette candidates.
|
||||
|
||||
Same method as the #50 headroom research: relative luminance, (L1+.05)/(L2+.05),
|
||||
alpha colours flattened over their real backdrop first (source-over, sRGB).
|
||||
Self-checks against WCAG's published values before printing anything.
|
||||
"""
|
||||
|
||||
|
||||
def _srgb(c):
|
||||
c /= 255
|
||||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def rgb(h):
|
||||
h = h.lstrip("#")
|
||||
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def lum(h):
|
||||
r, g, b = rgb(h)
|
||||
return 0.2126 * _srgb(r) + 0.7152 * _srgb(g) + 0.0722 * _srgb(b)
|
||||
|
||||
|
||||
def flatten(fg, alpha, bg):
|
||||
f, b = rgb(fg), rgb(bg)
|
||||
return "#%02x%02x%02x" % tuple(round(f[i] * alpha + b[i] * (1 - alpha)) for i in range(3))
|
||||
|
||||
|
||||
def ratio(a, b):
|
||||
la, lb = lum(a), lum(b)
|
||||
hi, lo = max(la, lb), min(la, lb)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
def _selfcheck():
|
||||
assert round(ratio("#000000", "#ffffff"), 1) == 21.0
|
||||
assert round(ratio("#767676", "#ffffff"), 2) == 4.54
|
||||
assert round(ratio("#949494", "#ffffff"), 2) == 3.03
|
||||
assert ratio("#959595", "#ffffff") < 3.0
|
||||
assert flatten("#ffffff", 0.5, "#000000") == "#808080"
|
||||
|
||||
|
||||
_selfcheck()
|
||||
|
||||
# candidate surfaces (the invented ones need proving; the rest come from #50)
|
||||
SURFACES = {
|
||||
"bg #150E13": "#150E13",
|
||||
"bg-deep #0C0810": "#0C0810",
|
||||
"panel #21161C": "#21161C",
|
||||
"panel-2 #180F16": "#180F16",
|
||||
"panel-3 / Plum Noir #351E28": "#351E28",
|
||||
"plum-soft #4A2A38": "#4A2A38",
|
||||
"paper / champagne #EFE5D2": "#EFE5D2",
|
||||
}
|
||||
|
||||
INKS = {
|
||||
"Bone #E6E0D6": "#E6E0D6",
|
||||
"Ash #B9B2A6": "#B9B2A6",
|
||||
"Dust #9A9086": "#9A9086",
|
||||
"Ledger Ink #D8C28A": "#D8C28A",
|
||||
"Tarnished Gold #C2A35A": "#C2A35A",
|
||||
"Old Gilt #A8893F": "#A8893F",
|
||||
"Gold Lit #E0C878": "#E0C878",
|
||||
"Pewter #9EA7B3": "#9EA7B3",
|
||||
"Ink-on-light #351E28": "#351E28",
|
||||
"Red #8E3438": "#8E3438",
|
||||
"Stamp #A84A4E": "#A84A4E",
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
w = max(len(k) for k in INKS)
|
||||
for sname, s in SURFACES.items():
|
||||
print(f"\n== {sname}")
|
||||
for iname, i in INKS.items():
|
||||
r = ratio(i, s)
|
||||
tag = "AA" if r >= 4.5 else ("large/non-text" if r >= 3.0 else "FAIL")
|
||||
print(f" {iname:<{w}} {r:6.2f} {tag}")
|
||||
@@ -0,0 +1,48 @@
|
||||
// Did the candidate CSS actually reach the pixels? Computed-style probe for #53.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { chromium } = require('playwright');
|
||||
const BASE = process.env.BASE || 'http://localhost:4567';
|
||||
const read = (f) => fs.readFileSync(path.join(__dirname, f), 'utf8');
|
||||
const base = read('_base-plumnoir.css');
|
||||
const CANDIDATES = { baseline: '', a: base, b: base + read('candidate-b.css') };
|
||||
|
||||
const PROBES = [
|
||||
['body', 'backgroundColor'],
|
||||
['[component="post"]', 'backgroundColor'],
|
||||
['[component="post"] .post-header, [component="post"] .topic-item-header', 'color'],
|
||||
['.timeago', 'color'],
|
||||
['[component="post"]', 'borderTopColor'],
|
||||
['.topic-title, h1', 'color'],
|
||||
['a[href^="/user/"]', 'color'],
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||
await page.goto(BASE + '/login');
|
||||
await page.fill('#username', 'admin');
|
||||
await page.fill('#password', 'Admin12345!');
|
||||
await page.click('#login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const rows = {};
|
||||
for (const [name, css] of Object.entries(CANDIDATES)) {
|
||||
await page.goto(BASE + '/topic/2', { waitUntil: 'networkidle' });
|
||||
if (css) await page.addStyleTag({ content: css });
|
||||
rows[name] = await page.evaluate(
|
||||
(probes) =>
|
||||
probes.map(([sel, prop]) => {
|
||||
const el = document.querySelector(sel);
|
||||
return el ? getComputedStyle(el)[prop] : 'MISSING';
|
||||
}),
|
||||
PROBES,
|
||||
);
|
||||
}
|
||||
PROBES.forEach(([sel, prop], i) => {
|
||||
console.log(
|
||||
`${sel} ${prop}\n baseline ${rows.baseline[i]}\n a ${rows.a[i]}\n b ${rows.b[i]}`,
|
||||
);
|
||||
});
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,88 @@
|
||||
// Throwaway seeder for #53: categories + one dense topic, over the REST API.
|
||||
// node prototype/seed.js (needs the dev stack up on :4567)
|
||||
const BASE = process.env.BASE || 'http://localhost:4567';
|
||||
const USER = 'admin', PASS = 'Admin12345!';
|
||||
|
||||
let cookie = '';
|
||||
async function req(path, opts = {}) {
|
||||
const res = await fetch(BASE + path, {
|
||||
...opts,
|
||||
redirect: 'manual',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie, ...(opts.headers || {}) },
|
||||
});
|
||||
const set = res.headers.getSetCookie?.() || [];
|
||||
if (set.length) cookie = set.map((c) => c.split(';')[0]).join('; ');
|
||||
const text = await res.text();
|
||||
try { return { status: res.status, body: JSON.parse(text) }; } catch { return { status: res.status, body: text }; }
|
||||
}
|
||||
|
||||
const CATS = [
|
||||
['The Gate House', 'Announcements from the Warden of the Gate. Read before you post.'],
|
||||
['The Velvet Ledger', 'In-character correspondence, letters, and the record of the city.'],
|
||||
['The Undercroft', 'Rules lawyering, builds, and mechanics. Bring numbers.'],
|
||||
['Petitions', 'Support, bug reports, and requests to the staff.'],
|
||||
['The Long Table', 'Off-topic. Keep it civil, keep it quiet.'],
|
||||
];
|
||||
|
||||
const POSTS = [
|
||||
'The lamplighters have not come to the Ashgate quarter in nine nights. The Watch says this is a matter of budget. The Watch says a great many things.',
|
||||
'It is a matter of budget. The oil comes up the river and the river is being taxed twice, once by the Crown and once by whoever is currently pretending to be the Crown at the Narrows.',
|
||||
'Nine nights is long enough that people have started leaving their own lamps out. Which is charity, and also a list of which houses have oil.',
|
||||
'I walked it last week. There are chalk marks on seven doors between the cooperage and the old chapel. Not guild marks. Not any mark I know.',
|
||||
'Post the marks. If they are what I think they are then this is not a budget problem and the Watch is not being slow, it is being paid.',
|
||||
'I am not posting the marks in a public thread. Ask me in the Ledger and I will write it out properly.',
|
||||
'Seconding that. Last time someone posted a sigil here it was scrubbed off every door in the quarter inside a day and we learned nothing.',
|
||||
'For what it is worth the chapel bell has also stopped, and nobody has said a word about that either.',
|
||||
'The bell is a rope problem, not a conspiracy. I fixed a bell in Southcant last winter, same silence, same rumours, one frayed rope.',
|
||||
'Then the rope was cut, in the same nine nights the lamps went out, in the same quarter. You are describing a coincidence as if it were a comfort.',
|
||||
'Nobody here has said the word out loud so I will: the last time a quarter went dark and quiet, three families sold their houses in a week and none of them were seen again.',
|
||||
'They were seen. Just not by anyone willing to say where.',
|
||||
];
|
||||
|
||||
(async () => {
|
||||
await req('/login'); // prime csrf
|
||||
const cfg = await req('/api/config');
|
||||
const csrf = cfg.body.csrf_token;
|
||||
const h = { 'x-csrf-token': csrf };
|
||||
const login = await req('/login', { method: 'POST', headers: h, body: JSON.stringify({ username: USER, password: PASS }) });
|
||||
if (login.status >= 400) throw new Error('login failed: ' + login.status + ' ' + JSON.stringify(login.body));
|
||||
const cfg2 = await req('/api/config');
|
||||
const h2 = { 'x-csrf-token': cfg2.body.csrf_token };
|
||||
|
||||
const cids = [];
|
||||
for (const [name, description] of CATS) {
|
||||
const r = await req('/api/v3/categories', { method: 'POST', headers: h2, body: JSON.stringify({ name, description }) });
|
||||
if (!r.body?.response?.cid) throw new Error('category failed: ' + JSON.stringify(r.body));
|
||||
cids.push(r.body.response.cid);
|
||||
console.log('category', name, '->', r.body.response.cid);
|
||||
}
|
||||
|
||||
// a few short topics so the category list has recent activity
|
||||
const titles = [
|
||||
'The lamps in the Ashgate quarter',
|
||||
'Read this before you post in the Ledger',
|
||||
'Damage reduction stacking, one more time',
|
||||
'Cannot log in after the patch',
|
||||
'What is everyone drinking',
|
||||
];
|
||||
let denseTid = null;
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
const r = await req('/api/v3/topics', {
|
||||
method: 'POST', headers: h2,
|
||||
body: JSON.stringify({ cid: cids[i % cids.length], title: titles[i], content: POSTS[0] }),
|
||||
});
|
||||
const tid = r.body?.response?.tid;
|
||||
console.log('topic', titles[i], '->', tid);
|
||||
if (i === 0) denseTid = tid;
|
||||
}
|
||||
|
||||
for (const content of POSTS.slice(1)) {
|
||||
await req(`/api/v3/topics/${denseTid}`, { method: 'POST', headers: h2, body: JSON.stringify({ content }) });
|
||||
}
|
||||
// one quoted reply, so candidate C's paper inset has something to render
|
||||
await req(`/api/v3/topics/${denseTid}`, {
|
||||
method: 'POST', headers: h2,
|
||||
body: JSON.stringify({ content: '> They were seen. Just not by anyone willing to say where.\n\nThat is the whole city in one line, and I would like it on a plaque.' }),
|
||||
});
|
||||
console.log('dense topic:', BASE + '/topic/' + denseTid);
|
||||
})();
|
||||
@@ -0,0 +1,52 @@
|
||||
// Throwaway screenshotter for #53. Logs in, then for each candidate injects the
|
||||
// candidate's :root override at runtime and shoots the pages that matter.
|
||||
// PLAYWRIGHT_BROWSERS_PATH=... nix shell nixpkgs#playwright-test -c node prototype/shots.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:4567';
|
||||
const OUT = process.env.OUT || path.join(__dirname, 'shots');
|
||||
const read = (f) => fs.readFileSync(path.join(__dirname, f), 'utf8');
|
||||
const base = read('_base-plumnoir.css');
|
||||
|
||||
const CANDIDATES = {
|
||||
baseline: '',
|
||||
a: base + read('candidate-a.css'),
|
||||
b: base + read('candidate-b.css'),
|
||||
c: base + read('candidate-b.css') + read('candidate-c.css'),
|
||||
};
|
||||
|
||||
const PAGES = [
|
||||
['topic', '/topic/2'],
|
||||
['categories', '/categories'],
|
||||
['cards', process.env.CARDS_URL || '/recent'],
|
||||
];
|
||||
|
||||
(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(CANDIDATES)) {
|
||||
for (const [pname, url] of PAGES) {
|
||||
await page.goto(BASE + url, { waitUntil: 'networkidle' });
|
||||
if (css) await page.addStyleTag({ content: css });
|
||||
await page.waitForTimeout(400);
|
||||
const file = path.join(OUT, `${pname}-${name}.png`);
|
||||
await page.screenshot({ path: file, fullPage: true });
|
||||
// close crop: the same region every time, so candidates can be compared at real size
|
||||
const crop = path.join(OUT, `${pname}-${name}-crop.png`);
|
||||
await page.screenshot({ path: crop, clip: { x: 120, y: 60, width: 1060, height: 620 } });
|
||||
console.log(file);
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 728 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 728 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 728 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 629 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 492 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 964 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 967 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 959 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 970 KiB |