clone

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

A style guide at /design/, drawn from the real tokens and controls

cat committed 3688980
The page lists the token block of style.css as it is, and shows each control from ui.njk with the real CSS. design is a reserved name, like insights, so no owner's pages take its place.
commit
368898018eb6007fe240aec9e232e0f11be6107e
parent
f35ab42d61d748717d452199526596f9d93daaf9
author
cat <admin@dev.site>
ci
✓ passed in 73s, signed by cat
kind
feat risk score 1.2 of 3, Jev's estimate
review
#13 A design system: tokens, three fonts, no caps, real controls
ci attestation
forge-attestation 1
type ci
commit 368898018eb6007fe240aec9e232e0f11be6107e
tree 1e36cffaaa8d1cd35c045887dd5cd7e6e1024410
script .forge/ci ba20ec15392fe0c74a07ee735a5cb0194d5e5381
result pass
exit 0
started 2026-10-19T03:01:44Z
duration 73s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:ba619af4f169c545c19ed04dcfb3a8e2609041eb4fefc7edaa11e5dc4a57b90e 259 lines
|pushed wip/i18n
|80dcc66 running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|80dcc66 pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|a1a0bb4 running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|a1a0bb4 fail in 0s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|98d765c running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|98d765c 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 0cb419a -> /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
|38a0d2e running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|38a0d2e pass in 0s, signed
|pushed main
|b73dd2f running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|b73dd2f 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: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/
|site guide: deployed 6797a46 -> /sites/dami/forge/guide/
|(node:77661) 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.GYhE8n/wt/demo/root/.forge/www
|(node:77788) 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 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.GYhE8n/wt/demo/root/.forge/www
|(node:77928) 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 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.GYhE8n/wt/demo/root/.forge/www
|(node:78061) 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.GYhE8n/wt/demo/root/.forge/www
|(node:78727) 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 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.GYhE8n/wt/demo/root/.forge/www
|234 of 234 pages render the same without 11ty
|guide: 8 files, links ok
|ok

Signature: good, cat

6 files changed, +156 -5
Mbin/forge6+-
Mlib/render-page.ts8++-
Msite/assets/style.css2+
Msite/assets/ui.ts10+++
Asite/design.njk117++++++++++++++++++++++++++++++
Atest/scenarios/design-page.sh18+++++
M bin/forge +3 -3
M lib/render-page.ts +6 -2
@@ -19,6 +19,7 @@ import { createRequire } from 'node:module';
1919 import { fileURLToPath } from 'node:url';2020 import { filters, markupFilters, loadForge } from './forge.ts';2121 import { assetUrls, hash } from './assets.ts';22+import { forgeTokens } from './check-css.ts';2223 2324 const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');2425 const siteDir = path.join(root, 'site');
@@ -63,6 +64,8 @@ export function setup(env: Env): void {
6364 const f = fn as (...args: unknown[]) => unknown;6465 env.addFilter(name, markupFilters.has(name) ? (...args: unknown[]) => safe(String(f(...args))) : f);6566 }67+ // The style guide (design.njk) lists the tokens style.css has.68+ env.addGlobal('designTokens', forgeTokens());6669 }6770 6871 // Templates by name from site/_includes, then site/, as 11ty finds them. Front
@@ -158,11 +161,12 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import
158161 const forge = loadForge();159162 const render = pageRenderer();160163 const templates = listTemplates();161- // Every page, unless lists are named: the index, owners, the forge's insights, and forge.pages.<list>.164+ // Every page, unless lists are named: the index, owners, the forge's insights, the style guide, and forge.pages.<list>.162165 const jobs: { file: string; template: string; data: Record<string, unknown> }[] = [];163- for (const list of lists.length ? lists : ['index', 'owners', 'forgeInsights', ...templates.keys()]) {166+ for (const list of lists.length ? lists : ['index', 'owners', 'forgeInsights', 'design', ...templates.keys()]) {164167 if (list === 'index') jobs.push({ file: '/index.html', template: 'index.njk', data: { forge } });165168 else if (list === 'forgeInsights') jobs.push({ file: '/insights/index.html', template: 'insights.njk', data: { forge } });169+ else if (list === 'design') jobs.push({ file: '/design/index.html', template: 'design.njk', data: { forge } });166170 else if (list === 'owners') for (const o of forge.owners) jobs.push({ file: `/${o.owner}/index.html`, template: 'owner.njk', data: { forge, o } });167171 else {168172 const template = templates.get(list);
M site/assets/style.css +2 -0
M site/assets/ui.ts +10 -0
A site/design.njk +117 -0
@@ -0,0 +1,117 @@
1+---2+permalink: /design/index.html3+---4+{#- The style guide: the tokens style.css defines (designTokens, read from its5+ token block by lib/check-css.ts) and every control from ui.njk, drawn by6+ the real stylesheet and the real macros, so it can't drift from them.7+ The rules behind it are in DESIGN.md. -#}8+{% extends "base.njk" %}9+{% import "macros.njk" as m with context %}10+{% import "ui.njk" as ui %}11+{% block title %}Design - {{ forge.config.name }}{% endblock %}12+{% block head %}{{ m.meta('Design - ' + forge.config.name, 'The tokens and controls every page of ' + forge.config.name + ' is made of.', '/design/') }}{% endblock %}13+{% block heading %}<a href="/">{{ forge.config.name }}</a> / <b>design</b>14+{% endblock %}15+{% block content %}16+{% css %}17+/* the style guide (design.njk) */18+.dg-intro { max-width: 46em; margin: 0 0 var(--space-6); color: var(--muted); }19+.dg-intro p { margin: 0 0 var(--space-2); }20+.dg-rules { margin: 0; padding-left: var(--space-5); }21+.dg-rules li { margin: var(--space-1) 0; }22+.dg-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3) var(--space-4); padding: var(--space-4) 0; border-bottom: var(--hairline) solid var(--border); }23+.dg-row > .label { flex: 0 0 9em; }24+.dg-group { margin: var(--space-4) 0 var(--space-1); font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--muted); }25+.dg-swatch { display: inline-block; width: 28px; height: 20px; border: var(--hairline) solid var(--border-strong); vertical-align: middle; }26+table.dg-tokens { table-layout: fixed; }27+table.dg-tokens td { vertical-align: middle; overflow: hidden; text-overflow: ellipsis; }28+table.dg-tokens td:nth-child(1) { width: 34%; }29+table.dg-tokens td:nth-child(2) { width: 22%; }30+table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); white-space: nowrap; }31+.dg-sample { white-space: nowrap; }32+.dg-bar { display: inline-block; height: var(--space-3); background: var(--accent); vertical-align: middle; }33+.dg-fam { padding: var(--space-3) 0; border-bottom: var(--hairline) solid var(--border); }34+.dg-fam .big { font-size: var(--text-xl); line-height: 1.3; }35+.dg-fam .use { font-size: var(--text-sm); color: var(--muted); }36+.dg-legible { font: var(--fw-regular) var(--text-code)/1.6 var(--mono); }37+.dg-legible span + span { margin-left: var(--space-4); }38+@media (max-width: 720px) {39+ .dg-row > .label { flex-basis: 100%; }40+ table.dg-tokens td.v.dark { display: none; }41+ table.dg-tokens td:nth-child(1) { width: 30%; }42+ table.dg-tokens td:nth-child(2) { width: 40%; }43+}44+{% endcss %}45+<div class="dg-intro">46+<p>What every page here is made of: the tokens in <code>site/assets/style.css</code> and the controls in <code>site/_includes/ui.njk</code>, drawn below by that same CSS and those same macros. The rules are in <code>DESIGN.md</code>, and CI checks them (<code>lib/check-css.ts</code>).</p>47+<ul class="dg-rules">48+<li>Tokens only: no colour, size, weight or family is written anywhere else.</li>49+<li>Open Sans for the interface, Noto Sans Mono for what is copied or compared, Noto Serif for prose people wrote.</li>50+<li>Chrome is lowercase, written that way; no capitals by CSS, no letter-spacing. Hierarchy is weight, size and colour.</li>51+<li>Square corners, hairline rules between things rather than boxes around them.</li>52+<li>Tabs say where you are; checkboxes turn one thing on or off; segmented controls pick one of a few; buttons act; links go places. Words for where you are, symbols for how you look at it.</li>53+</ul>54+</div>55+56+{{ ui.heading('controls') }}57+<div class="dg-row"><span class="label">buttons</span>{{ ui.button('copy') }}{{ ui.button('comment', true) }}{{ ui.button('approve', false, 'disabled') }}</div>58+<div class="dg-row"><span class="label">icon toggle</span><span data-demo>{{ ui.iconToggle('globe', 'only repos with a site') }}</span><span data-demo>{{ ui.iconToggle('globe', 'only repos with a site, on', true) }}</span><span class="desc">a checkbox drawn as a symbol: aria-pressed, a label, a title</span></div>59+<div class="dg-row"><span class="label">segmented</span><span data-demo>{{ ui.segmented('sort', [{icon: 'clock', label: 'newest first', pressed: true}, {icon: 'az', label: 'by name, a to z'}]) }}</span><span data-demo>{{ ui.segmented('view', [{label: 'unified', pressed: true}, {label: 'split'}]) }}</span></div>60+<div class="dg-row"><span class="label">checkbox</span>{{ ui.checkbox('wrap lines') }}{{ ui.checkbox('viewed', true) }}</div>61+<div class="dg-row"><span class="label">text box</span><span style="flex: 1 1 16em">{{ ui.input('ssh clone url', 'git@forge.example:cat/forge', 'readonly', true) }}</span><span style="flex: 1 1 16em">{{ ui.input('search', '', 'placeholder="search commits"') }}</span></div>62+<div class="dg-row"><span class="label">text area</span><span style="flex: 1 1 24em">{{ ui.textarea('a comment', 'Comment on #3. Markdown is fine.') }}</span></div>63+<div class="dg-row"><span class="label">tabs</span><span style="flex: 1 1 20em">{{ ui.tabs('example', [{href: '#tabs', text: 'all', n: 12, cur: true}, {href: '#tabs', text: 'tools', n: 5}, {href: '#tabs', text: 'games', n: 4}, {href: '#tabs', text: 'other', n: 3}]) }}</span></div>64+<div class="dg-row"><span class="label">status labels</span>{{ ui.status('open') }}{{ ui.status('draft') }}{{ ui.status('merged') }}{{ ui.status('closed') }}</div>65+<div class="dg-row"><span class="label">ci and diffs</span><span class="ci pass">&#10003; 2/2</span><span class="ci fail">&#10007; 1/2</span><span class="ci partial">&ndash; 1/2</span><span class="mono"><span class="i">+12</span> <span class="d">-3</span></span><span class="st A">A</span><span class="st M">M</span><span class="st D">D</span><span class="st R">R</span></div>66+<div class="dg-row"><span class="label">icons</span>{% for i in ['globe', 'clock', 'az', 'branch', 'folder', 'file', 'link', 'up', 'search'] %}<span title="{{ i }}">{{ ui.icon(i, i) }}</span>{% endfor %}</div>67+68+{{ ui.heading('section heading', 4, 'a count, then a quieter note') }}69+<div class="box">70+{% call ui.listTable(['commit', 'author', {text: '+', cls: 'num'}, {text: '-', cls: 'num'}, 'ci']) %}71+<tr><td class="main"><a href="#list">Parse --name and --shout</a></td><td class="nowrap">dami</td><td class="num i">+12</td><td class="num d">-3</td><td><span class="ci pass">&#10003;</span></td></tr>72+<tr><td class="main"><a href="#list">Keep a range-diff's marker at the left edge</a></td><td class="nowrap">ana</td><td class="num i">+4</td><td class="num d">-1</td><td><span class="ci fail">&#10007;</span></td></tr>73+{% endcall %}74+</div>75+76+{{ ui.heading('code and prose') }}77+<div class="code diff" style="--w:2"><div class="rows">78+<div class="hunk"><span>@@ -1,3 +1,4 @@ greet()</span></div>79+<span class="l"><span class="ln">1</span><span class="ln">1</span><span class="mk"></span><span class="lc"><span class="hljs-meta">#!/bin/sh</span></span></span>80+<span class="l del"><span class="ln">2</span><span class="ln"></span><span class="mk">-</span><span class="lc"><span class="hljs-built_in">echo</span> <span class="hljs-string">"hello, world"</span></span></span>81+<span class="l add"><span class="ln"></span><span class="ln">2</span><span class="mk">+</span><span class="lc">name=<span class="hljs-variable">${1:-world}</span></span></span>82+<span class="l add"><span class="ln"></span><span class="ln">3</span><span class="mk">+</span><span class="lc"><span class="hljs-built_in">echo</span> <span class="hljs-string">"hello, <span class="hljs-variable">$name</span>"</span> <span class="hljs-comment"># 0O 1lI 5S 8B rn m</span></span></span>83+</div></div>84+<p class="dg-legible">legibility at the code size: <span>0O o</span><span>1lI| i</span><span>rn m</span><span>{} () []</span><span>-- =&gt; !=</span></p>85+<div class="box"><div class="markdown">86+<h2>A README in the serif</h2>87+<p>Prose people wrote is set in <em>Noto Serif</em>: READMEs, the wiki, release notes, a review's description and its comments. Code inside it, like <code>forge attest --stack</code>, is in the mono.</p>88+<blockquote><p>Interface chrome never uses it.</p></blockquote>89+</div></div>90+91+{{ ui.heading('type') }}92+<div class="dg-fam"><div class="big" style="font-family: var(--sans)">Open Sans: tabs, lists, subjects, buttons</div><div class="use"><code>--sans</code> all interface text</div></div>93+<div class="dg-fam"><div class="big" style="font-family: var(--mono)">Noto Sans Mono: 0f0215e dami/help +12</div><div class="use"><code>--mono</code> anything copied or compared: hashes, ids, branches, paths, counts, code, diffs, commands</div></div>94+<div class="dg-fam"><div class="big" style="font-family: var(--serif)">Noto Serif: what people wrote</div><div class="use"><code>--serif</code> rendered prose only</div></div>95+96+{% for g in designTokens %}97+<p class="dg-group">{{ g.title }}</p>98+<div class="box">99+<table class="list dg-tokens">100+<tbody>101+{% for t in g.tokens %}102+<tr>103+{% if t.name.startsWith('--text-') %}<td class="main dg-sample" style="font-size: var({{ t.name }})">the quick brown fox, 0123</td>104+{% elif t.name.startsWith('--fw-') %}<td class="main dg-sample" style="font-weight: var({{ t.name }})">the quick brown fox</td>105+{% elif t.name == '--sans' or t.name == '--mono' or t.name == '--serif' %}<td class="main dg-sample" style="font-family: var({{ t.name }})">the quick brown fox, 0123</td>106+{% elif t.name.startsWith('--space-') %}<td class="main"><span class="dg-bar" style="width: var({{ t.name }})"></span></td>107+{% elif t.light.startsWith('#') or t.light.startsWith('rgb') %}<td><span class="dg-swatch" style="background: var({{ t.name }})"></span></td>108+{% else %}<td></td>109+{% endif %}110+<td class="mono nowrap">{{ t.name }}</td><td class="v" title="{{ t.light }}">{{ t.light }}</td><td class="v dark"{% if t.dark %} title="{{ t.dark }}"{% endif %}>{{ t.dark or '' }}</td>111+</tr>112+{% endfor %}113+</tbody>114+</table>115+</div>116+{% endfor %}117+{% endblock %}
A test/scenarios/design-page.sh +18 -0