clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Paint the pages on paper: a new palette and a faint texture

cat committed e98033d
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

7 files changed, +98 -79
MREADME.md3+-
Mlib/assets.ts25+++--
Mlib/private-server.ts2+-
Msite/_includes/insights-body.njk16++--
Asite/assets/paper.webpbin
Asite/assets/plaster.webpbin
Msite/assets/style.css131++++++++++++++++--------------
M README.md +2 -1
M lib/assets.ts +15 -10
@@ -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);
M lib/private-server.ts +1 -1
M site/_includes/insights-body.njk +8 -8
A site/assets/paper.webp +0 -0

Binary file.

A site/assets/plaster.webp +0 -0

Binary file.

M site/assets/style.css +72 -59
@@ -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; }