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
| M | bin/ | 6 | +- |
| M | lib/ | 8 | ++- |
| M | site/ | 2 | + |
| M | site/ | 10 | +++ |
| A | site/ | 117 | ++++++++++++++++++++++++++++++ |
| A | test/ | 18 | +++++ |
@@ -1013,7 +1013,7 @@ load_config() {
10131013 10141014 valid_name() {10151015 case $1 in1016- '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | git-shell-commands) return 1 ;;1016+ '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | design | git-shell-commands) return 1 ;;10171017 esac10181018 }10191019 @@ -2501,7 +2501,7 @@ build_all() {
25012501 [ -d "$w" ] || continue25022502 slug=${w#"$FORGE_WWW"/}25032503 slug=${slug%/}2504- case $slug in assets/* | sites/* | previews/* | hosts/* | insights/*) continue ;; esac2504+ case $slug in assets/* | sites/* | previews/* | hosts/* | insights/* | design/*) continue ;; esac25052505 [ -f "$FORGE_ROOT/$slug.git/HEAD" ] && [ -f "$FORGE_ROOT/$slug.git/git-daemon-export-ok" ] || rm -rf "$w"25062506 done25072507 for w in "$FORGE_WWW"/sites/*/*/ "$FORGE_WWW"/previews/*/*/; do@@ -2512,7 +2512,7 @@ build_all() {
25122512 done25132513 for w in "$FORGE_WWW"/*/; do25142514 b=$(basename "$w")2515- case $b in assets | sites | previews | hosts | insights) continue ;; esac2515+ case $b in assets | sites | previews | hosts | insights | design) continue ;; esac25162516 [ -d "$FORGE_ROOT/$b" ] || rm -rf "$w"25172517 done25182518 if [ -d "$FORGE_WWW/hosts" ]; then node "$FORGE_HOME/lib/hosts.ts" prune; fi@@ -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);@@ -86,6 +86,7 @@
8686 --text-2xl: 28px;8787 /* mono inline in sans text runs large; this brings it level */8888 --text-inline-mono: 0.9em;89+ /* weights: body, links and controls, emphasis and headings */8990 --fw-regular: 400;9091 --fw-medium: 500;9192 --fw-strong: 650;@@ -100,6 +101,7 @@
100101 --space-6: 24px;101102 --space-8: 32px;102103 --space-10: 40px;104+ /* lines, corners and controls: rules are hairlines, the current tab's bar is thicker, nothing is rounded */103105 --hairline: 1px;104106 --bar: 3px;105107 --radius: 0;@@ -117,6 +117,16 @@
117117 shelves();118118 });119119 120+ // The style guide's examples (design.njk): a toggle flips, a segment is121+ // picked from its group.122+ document.addEventListener('click', (e) => {123+ const b = (e.target as Element | null)?.closest<HTMLElement>('[data-demo] button[aria-pressed]');124+ if (!b) return;125+ const group = b.closest('.segmented');126+ if (group) for (const o of group.querySelectorAll('button')) o.setAttribute('aria-pressed', String(o === b));127+ else b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));128+ });129+120130 const content = (): void => {121131 wrap(wrapped());122132 times();@@ -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">✓ 2/2</span><span class="ci fail">✗ 1/2</span><span class="ci partial">– 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">✓</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">✗</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>-- => !=</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 %}@@ -0,0 +1,18 @@
1+# /design/, the style guide: built with the index, from the real tokens2+# (every one style.css defines is listed, with its dark value) and the3+# real controls from ui.njk.4+. "$(dirname "$0")/../lib.sh"5+export FORGE_STRICT=16+build -7+p=$FORGE_WWW/design/index.html8+has "$p" '<b>design</b>'9+for t in $(sed -n '/== tokens ==/,/== end tokens ==/p' "$T_HOME/site/assets/style.css" | grep -oE '^ --[a-z0-9-]+' | sort -u); do10+ has "$p" "<td class=\"mono nowrap\">$t</td>"11+done12+has "$p" '<td class="mono nowrap">--bg</td><td class="v" title="#e9e9e9">#e9e9e9</td><td class="v dark" title="#08090b">#08090b</td>'13+has "$p" 'class="icon-btn" aria-pressed="false" aria-label="only repos with a site"'14+has "$p" '<label class="check"><input type="checkbox">wrap lines</label>'15+has "$p" '<span class="status merged">merged</span>'16+# A full build keeps it: it isn't an owner's directory to prune.17+build18+exists "$FORGE_WWW/design/index.html"