The page is a light grey (#E9E9E9) in ink (#1C2127), or ink-dark in dark mode, with one accent, #3148F6, for links, the current tab, focus and primary actions, and a deep blue for hover. The status colours move to the same family: green, amber and red, readable on both backgrounds. Behind everything sits a texture from CC0 scans (ambientCG): paper, multiplied in light mode, and plaster, screened in dark. It is a fixed pseudo-element behind the content, so scrolling never repaints it, and it goes away for readers who ask for less transparency. The textures ship as hashed assets like the rest: lib/assets.ts takes .webp files, and the stylesheet's url(./paper.webp) becomes the image's hashed name, as a script's imports do. Code views and file headers sit on an opaque raised surface, so what scrolls under a sticky gutter or header stays hidden. The tabs' scroll shadows go: they painted the header's colour, which a texture can't match.
- commit
- e98033d5f013f26d8f8f979a667415607348549d
- parent
- f00bfd0eaef101e535cca5ca0f819f4c5bd0753e
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 81s, signed by cat
- kind
- feat risk score 2.1 of 3, Jev's estimate
- review
- #12 Square, hairline, textured: the site's design pass
ci attestation
forge-attestation 1 type ci commit e98033d5f013f26d8f8f979a667415607348549d tree fb101126272772484bd6fe0dc121c6ee1df792e6 script .forge/ci 38df37c0d5a6382f60569079a0dd821094e37e4a result pass exit 0 started 2026-10-19T01:49:10Z duration 81s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:1780d9a75433fb2c4949369f02b7e32d63bdf6eee317507c744557c94be870b4 253 lines |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |a60238e running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |a60238e pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |4cfa38f running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |4cfa38f fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |ad8cd87 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |ad8cd87 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 44487ef -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |3c2de98 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |3c2de98 pass in 0s, signed |pushed main |f68f5cd running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |f68f5cd pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: deployed 3c44761 -> /sites/dami/forge/guide/ |(node:1017) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.20IXQw/wt/demo/root/.forge/www |(node:1167) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.20IXQw/wt/demo/root/.forge/www |(node:1315) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.20IXQw/wt/demo/root/.forge/www |(node:1453) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.20IXQw/wt/demo/root/.forge/www |(node:2121) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.20IXQw/wt/demo/root/.forge/www |226 of 226 pages render the same without 11ty |guide: 4 files, links ok |ok
Signature: good, cat
| M | README.md | 3 | +- |
| M | lib/ | 25 | +++-- |
| M | lib/ | 2 | +- |
| M | site/ | 16 | ++-- |
| A | site/ | bin | |
| A | site/ | bin | |
| M | site/ | 131 | ++++++++++++++++-------------- |
@@ -1330,7 +1330,8 @@ rendered, `/assets/<hash>.css`, shared by pages of a kind. Import
13301330 `macros.njk` with context, or blocks in macros don't reach the bundle.13311331 13321332 Assets ship under content-hashed names (`/assets/style.<hash>.css`,1333-`ui.<hash>.js`, mermaid, sqlite-wasm), so browsers can cache them for good. The same1333+`ui.<hash>.js`, mermaid, sqlite-wasm), so browsers can cache them for good;1334+the stylesheet's `url(./paper.webp)` becomes the texture's hashed name. The same13341335 sources give the same names, and pages' fingerprints include them, so13351336 unchanged pages are still hard-linked. A build keeps the files of the last13361337 five code versions (listed in `assets/versions.json`), because pages built by@@ -1,5 +1,6 @@
11 // Assets under content-hashed names, /assets/style.<hash>.css, so browsers2-// can cache them forever: the stylesheet, the logo, the vendored scripts, our2+// can cache them forever: the stylesheet, the logo, the page textures, the3+// vendored scripts, our34 // TypeScript with its types stripped, mermaid, sqlite-wasm (its script and45 // its .wasm, for code search), and @simplewebauthn/browser (passkeys, on56 // the sign-in page). 11ty's bundle plugin@@ -42,15 +43,15 @@ export interface Asset {
4243 data: Buffer;4344 }4445 45-// By short name: style, logo, fixi, ssexi, diffs, keys, live, search, query,46-// codesearch, login, ui, mermaid, sqlite3, sqlite3wasm, webauthn.46+// By short name: style, logo, paper, plaster, fixi, ssexi, diffs, keys, live,47+// search, query, codesearch, login, ui, mermaid, sqlite3, sqlite3wasm, webauthn.4748 export type Assets = Record<string, Asset>;4849 4950 let memo: { key: string; assets: Assets } | null = null;5051 5152 export function assets(): Assets {5253 const files: [string, string, string][] = [53- ...fs.readdirSync(src).filter((f) => /\.(css|svg|js|ts)$/.test(f)).sort().map((f): [string, string, string] => [f.split('.')[0], f, path.join(src, f)]),54+ ...fs.readdirSync(src).filter((f) => /\.(css|svg|webp|js|ts)$/.test(f)).sort().map((f): [string, string, string] => [f.split('.')[0], f, path.join(src, f)]),5455 ...VENDOR,5556 ];5657 // Rebuilt only when a source changes (the dev server rebuilds in one process).@@ -58,7 +59,13 @@ export function assets(): Assets {
5859 if (memo?.key === key) return memo.assets;5960 // Our scripts import each other as './query.ts', which Node and tsc read6061 // as it is; shipped, that is the other file's hashed name, so a script's61- // name changes with what it imports.62+ // name changes with what it imports. The stylesheet's url(./paper.webp)63+ // becomes the image's hashed name the same way.64+ const local = (base: string, dep: string): Asset => {65+ const d = files.find(([, b, p]) => b === dep && path.dirname(p) === src);66+ if (!d) throw new Error(`${base} links ./${dep}, which is not in site/assets`);67+ return make(d);68+ };6269 const made = new Map<string, Asset>();6370 const make = ([short, base, f]: [string, string, string]): Asset => {6471 const done = made.get(short);@@ -67,13 +74,11 @@ export function assets(): Assets {
6774 let ext = path.extname(base);6875 const name = base.split('.')[0];6976 if (ext === '.ts') {70- const js = stripTypeScriptTypes(data.toString('utf8')).replace(/(\bfrom\s*)'\.\/([\w-]+\.ts)'/g, (_, from: string, dep: string) => {71- const d = files.find(([, b, p]) => b === dep && path.dirname(p) === src);72- if (!d) throw new Error(`${base} imports ./${dep}, which is not in site/assets`);73- return `${from}'./${make(d).file}'`;74- });77+ const js = stripTypeScriptTypes(data.toString('utf8')).replace(/(\bfrom\s*)'\.\/([\w-]+\.ts)'/g, (_, from: string, dep: string) => `${from}'./${local(base, dep).file}'`);7578 data = Buffer.from(js);7679 ext = '.js';80+ } else if (ext === '.css') {81+ data = Buffer.from(data.toString('utf8').replace(/\burl\(\.\/([\w-]+\.\w+)\)/g, (_, dep: string) => `url(./${local(base, dep).file})`));7782 }7883 const a = { file: `${name}.${hash(data)}${ext}`, data };7984 made.set(short, a);@@ -257,7 +257,7 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
257257 const css = renderer?.bundles.get(url.pathname);258258 const data = a?.data ?? (css != null ? Buffer.from(css) : null);259259 if (!data) return notFound(res, req.method === 'HEAD');260- const type = /\.css$/.test(name) ? 'text/css; charset=utf-8' : /\.js$/.test(name) ? 'text/javascript; charset=utf-8' : /\.wasm$/.test(name) ? 'application/wasm' : /\.svg$/.test(name) ? 'image/svg+xml' : 'application/octet-stream';260+ const type = /\.css$/.test(name) ? 'text/css; charset=utf-8' : /\.js$/.test(name) ? 'text/javascript; charset=utf-8' : /\.wasm$/.test(name) ? 'application/wasm' : /\.svg$/.test(name) ? 'image/svg+xml' : /\.webp$/.test(name) ? 'image/webp' : 'application/octet-stream';261261 res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'public, max-age=31536000, immutable', 'Content-Length': data.length });262262 res.end(req.method === 'HEAD' ? undefined : data);263263 }@@ -9,17 +9,17 @@
99 checked for color-blind readers against this page's light and dark1010 backgrounds; text keeps the page's own colors. */1111 .insights {12- --viz-1: #2a78d6;13- --viz-fail: #d03b3b;14- --viz-grid: #eaeef2;15- --viz-axis: #c3c2b7;12+ --viz-1: #3148f6;13+ --viz-fail: #c7162b;14+ --viz-grid: #d9d9d9;15+ --viz-axis: #a9aeb4;1616 }1717 @media (prefers-color-scheme: dark) {1818 .insights {19- --viz-1: #3987e5;20- --viz-fail: #d03b3b;21- --viz-grid: #21262d;22- --viz-axis: #3d444d;19+ --viz-1: #6f7fff;20+ --viz-fail: #f0606e;21+ --viz-grid: #2a3037;22+ --viz-axis: #4a525b;2323 }2424 }2525 .insights .ins-meta { margin: 0.4em 0 1em; }Binary file.
Binary file.
@@ -1,63 +1,84 @@
11 /* forge: the base stylesheet, light and dark, phone first: tokens, layout,22 lists, code views. CSS for one feature lives with its markup, in a33 template or include ({% css %}), and reaches only the pages that use it. */4+/* The palette: ink on a light grey page, one accent (#3148F6) for links,5+ the current tab, focus and primary actions, and a faint paper texture6+ behind it all (plaster in dark mode). */47 :root {5- --bg: #ffffff;6- --fg: #1f2328;7- --muted: #59636e;8- --faint: #8c959f;9- --border: #d1d9e0;10- --subtle: #f6f8fa;11- --hover: #eef1f4;12- --link: #0969da;13- --accent: #2ca9bc;14- --add-bg: #dafbe1;15- --add-fg: #116329;16- --add-ln: #aceebb;17- --del-bg: #ffebe9;18- --del-fg: #a40e26;19- --del-ln: #ffcecb;20- --hunk-bg: #ddf4ff;21- --hunk-fg: #0550ae;22- --pass: #1a7f37;23- --fail: #cf222e;24- --warn: #9a6700;25- --merged: #8250df;26- --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;27- --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;8+ --bg: #e9e9e9;9+ --fg: #1c2127;10+ --muted: #5b6169;11+ --faint: #8a9097;12+ --border: #d2d2d2;13+ /* A raised surface over the page, translucent, and the same as one opaque14+ colour, for what content scrolls under (sticky headers, code gutters). */15+ --subtle: rgba(255, 255, 255, 0.55);16+ --raised: #f5f5f5;17+ --hover: rgba(255, 255, 255, 0.7);18+ --link: #3148f6;19+ --accent: #3148f6;20+ --deep: #1c4b8f;21+ --add-bg: rgba(14, 132, 32, 0.1);22+ --add-fg: #0e8420;23+ --add-ln: rgba(14, 132, 32, 0.2);24+ --del-bg: rgba(199, 22, 43, 0.08);25+ --del-fg: #c7162b;26+ --del-ln: rgba(199, 22, 43, 0.17);27+ --hunk-bg: rgba(49, 72, 246, 0.07);28+ --hunk-fg: #1c4b8f;29+ --pass: #0e8420;30+ --fail: #c7162b;31+ --warn: #b46a00;32+ --merged: #3148f6;33+ --texture: url(./paper.webp);34+ --texture-size: 256px 150px;35+ --texture-blend: multiply;36+ --texture-opacity: 0.5;37+ --sans: "Ubuntu", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;38+ --mono: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;2839 --radius: 8px;2940 color-scheme: light dark;3041 }3142 @media (prefers-color-scheme: dark) {3243 :root {33- --bg: #0d1117;34- --fg: #e6edf3;35- --muted: #9198a1;36- --faint: #6e7681;37- --border: #30363d;38- --subtle: #151b23;39- --hover: #1c232c;40- --link: #4493f8;41- --add-bg: rgba(46, 160, 67, 0.16);42- --add-fg: #3fb950;43- --add-ln: rgba(46, 160, 67, 0.32);44- --del-bg: rgba(248, 81, 73, 0.16);45- --del-fg: #ff7b72;46- --del-ln: rgba(248, 81, 73, 0.32);47- --hunk-bg: rgba(56, 139, 253, 0.12);48- --hunk-fg: #79c0ff;49- --pass: #3fb950;50- --fail: #ff7b72;51- --warn: #d29922;52- --merged: #ab7df8;44+ --bg: #1c2127;45+ --fg: #e9e9e9;46+ --muted: #a3a9b0;47+ --faint: #737a82;48+ --border: #2f363e;49+ --subtle: rgba(255, 255, 255, 0.04);50+ --raised: #23282e;51+ --hover: rgba(255, 255, 255, 0.06);52+ --link: #8e9bff;53+ --deep: #b4bcff;54+ --add-bg: rgba(79, 191, 95, 0.13);55+ --add-fg: #4fbf5f;56+ --add-ln: rgba(79, 191, 95, 0.26);57+ --del-bg: rgba(240, 96, 110, 0.13);58+ --del-fg: #f0606e;59+ --del-ln: rgba(240, 96, 110, 0.26);60+ --hunk-bg: rgba(142, 155, 255, 0.1);61+ --hunk-fg: #8e9bff;62+ --pass: #4fbf5f;63+ --fail: #f0606e;64+ --warn: #e3a33b;65+ --merged: #8e9bff;66+ --texture: url(./plaster.webp);67+ --texture-size: 256px 256px;68+ --texture-blend: screen;69+ --texture-opacity: 0.3;5370 }5471 }5572 5673 * { box-sizing: border-box; }5774 html { -webkit-text-size-adjust: 100%; }5875 body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 var(--sans); }76+/* The texture: CC0 scans (ambientCG), fixed to the window behind the77+ content, so scrolling never repaints it. */78+body::before { content: ''; position: fixed; inset: 0; z-index: -1; pointer-events: none; background: var(--texture) 0 0 / var(--texture-size) repeat; mix-blend-mode: var(--texture-blend); opacity: var(--texture-opacity); }79+@media (prefers-reduced-transparency: reduce) { body::before { display: none; } }5980 a { color: var(--link); text-decoration: none; }60-a:hover { text-decoration: underline; }81+a:hover { color: var(--deep); text-decoration: underline; }6182 :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }6283 code, kbd, pre, .mono, .sha { font-family: var(--mono); }6384 .sha { font-size: 0.9em; }@@ -72,7 +93,7 @@ kbd { border: 1px solid var(--border); border-bottom-width: 2px; border-radius:
7293 hr { border: 0; border-top: 1px solid var(--border); margin: 1.5em 0; }7394 7495 /* header */75-header.site { background: var(--subtle); border-bottom: 1px solid var(--border); }96+header.site { border-bottom: 1px solid var(--border); }7697 .top { display: flex; align-items: center; gap: 10px; padding: 12px 0 8px; min-width: 0; }7798 .logo { display: block; color: var(--fg); line-height: 0; flex: none; }7899 .mark { display: block; width: 28px; height: 28px; }@@ -86,15 +107,7 @@ nav.tabs::-webkit-scrollbar { display: none; }
86107 nav.tabs a { color: var(--fg); padding: 8px 12px; border-bottom: 2px solid transparent; white-space: nowrap; }87108 nav.tabs a:hover { text-decoration: none; background: var(--hover); border-radius: 6px 6px 0 0; }88109 nav.tabs a.cur { border-bottom-color: var(--accent); font-weight: 600; }89-/* Tabs that don't fit scroll sideways; a shadow marks the edge that has more. */90-nav.tabs {91- --shade: color-mix(in srgb, var(--fg) 22%, transparent);92- background:93- linear-gradient(to right, var(--subtle) 40%, transparent) left / 28px 100% no-repeat local,94- linear-gradient(to left, var(--subtle) 40%, transparent) right / 28px 100% no-repeat local,95- radial-gradient(farthest-side at 0 50%, var(--shade), transparent) left / 10px 100% no-repeat scroll,96- radial-gradient(farthest-side at 100% 50%, var(--shade), transparent) right / 10px 100% no-repeat scroll;97-}110+/* Tabs that don't fit scroll sideways. */98111 @media (max-width: 480px) {99112 /* Six tabs fit a 360px phone: tighter, and sharing the width. */100113 nav.tabs { gap: 0; padding: 0 8px; }@@ -195,13 +208,13 @@ table.list tbody tr.sel td:first-child { box-shadow: inset 2px 0 0 var(--accent)
195208 /* code views */196209 /* The gutter (line numbers, and the +/- marker in diffs) is sticky, so code197210 scrolls under it: every gutter cell is opaque, its row's tint (translucent198- in dark mode) layered over the page background. --w is the widest line211+ in dark mode) layered over the code's background. --w is the widest line199212 number's digit count, set per file, so the columns never overlap. */200-.code { --lnw: max(3.5em, calc(var(--w, 3) * 1ch + 20px)); font: 12.5px/1.5 var(--mono); tab-size: 4; -moz-tab-size: 4; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); }213+.code { --lnw: max(3.5em, calc(var(--w, 3) * 1ch + 20px)); font: 12.5px/1.5 var(--mono); tab-size: 4; -moz-tab-size: 4; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--raised); }201214 .code .rows { display: inline-block; min-width: 100%; padding: 4px 0; }202215 .code .l { --gut: transparent; display: flex; color: var(--fg); text-decoration: none; }203216 .code a.l:hover { text-decoration: none; }204-.code .ln, .code .mk { position: sticky; background: linear-gradient(var(--gut), var(--gut)) var(--bg); user-select: none; -webkit-user-select: none; }217+.code .ln, .code .mk { position: sticky; background: linear-gradient(var(--gut), var(--gut)) var(--raised); user-select: none; -webkit-user-select: none; }205218 .code .ln { flex: none; width: var(--lnw); padding: 0 10px; text-align: right; color: var(--faint); left: 0; }206219 .code .ln:hover { color: var(--fg); }207220 .code .ln + .ln { left: var(--lnw); }@@ -226,10 +239,10 @@ body.wrap-lines .code .hunk { white-space: pre-wrap; overflow-wrap: anywhere; }
226239 body:not(.no-wrap) .code .lc, body:not(.no-wrap) .code .hunk { white-space: pre-wrap; overflow-wrap: anywhere; }227240 body:not(.no-wrap) .code .rows { display: block; }228241 }229-.file { margin: 16px 0; border: 1px solid var(--border); border-radius: var(--radius); }242+section.file { margin: 16px 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--raised); }230243 .file > .code { border: 0; border-radius: 0 0 var(--radius) var(--radius); }231244 .box > .code { border: 0; border-radius: 0; }232-.fh { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; padding: 8px 12px; background: var(--subtle); border-bottom: 1px solid var(--border); border-radius: var(--radius) var(--radius) 0 0; font-size: 0.9em; }245+.fh { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; padding: 8px 12px; background: var(--raised); border-bottom: 1px solid var(--border); border-radius: var(--radius) var(--radius) 0 0; font-size: 0.9em; }233246 /* A long path shares the line with the status and counts, wrapping at its234247 slashes, rather than wrapping onto a line of its own. */235248 .fh .fname { font-family: var(--mono); color: var(--fg); font-weight: 600; overflow-wrap: break-word; flex: 1 1 0; min-width: min(4em, 100%); max-width: max-content; }