clone

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

A design system: tokens, three fonts, no caps, real controls #13

cat description

No texture; one token block in style.css (a darker, near-black dark mode); Open Sans for the interface, Noto Sans Mono for what is copied or compared, Noto Serif for prose, self-hosted; no capitals or letter-spacing; tabs, checkboxes, icon toggles and segmented controls from ui.njk; a style guide at /design/; lib/check-css.ts holds the CSS to DESIGN.md in CI. The guide follows the same rules.

commits7oldest firstcombined diff 70 files, +1959 -808
discussion0 comments

No comments yet: forge comment 13 adds one.

1/7 Take the paper texture off the pages

4 files changed, +7 -20
Mlib/assets.ts11+++++------
Dsite/assets/paper-dark.webpbin
Dsite/assets/paper.webpbin
Msite/assets/style.css16++--------------
M lib/assets.ts +5 -6
@@ -1,7 +1,6 @@
11 // Assets under content-hashed names, /assets/style.<hash>.css, so browsers2-// can cache them forever: the stylesheet, the logo, the page textures, the3-// vendored scripts, our4-// TypeScript with its types stripped, mermaid, sqlite-wasm (its script and2+// can cache them forever: the stylesheet, the logo, the vendored scripts,3+// our TypeScript with its types stripped, mermaid, sqlite-wasm (its script and54 // its .wasm, for code search), and @simplewebauthn/browser (passkeys, on65 // the sign-in page). 11ty's bundle plugin76 // writes the templates' own CSS next to them, also named by a hash.
@@ -43,7 +42,7 @@ export interface Asset {
4342 data: Buffer;4443 }4544 46-// By short name: style, logo, paper, paper-dark, fixi, ssexi, diffs, keys, live,45+// By short name: style, logo, fixi, ssexi, diffs, keys, live,4746 // search, query, codesearch, login, ui, mermaid, sqlite3, sqlite3wasm, webauthn.4847 export type Assets = Record<string, Asset>;4948
@@ -59,8 +58,8 @@ export function assets(): Assets {
5958 if (memo?.key === key) return memo.assets;6059 // Our scripts import each other as './query.ts', which Node and tsc read6160 // as it is; shipped, that is the other file's hashed name, so a script's62- // name changes with what it imports. The stylesheet's url(./paper.webp)63- // becomes the image's hashed name the same way.61+ // name changes with what it imports. A url(./x.webp) in the stylesheet62+ // becomes that file's hashed name the same way.6463 const local = (base: string, dep: string): Asset => {6564 const d = files.find(([, b, p]) => b === dep && path.dirname(p) === src);6665 if (!d) throw new Error(`${base} links ./${dep}, which is not in site/assets`);
D site/assets/paper-dark.webp +0 -0

Binary file.

D site/assets/paper.webp +0 -0

Binary file.

M site/assets/style.css +2 -14
@@ -3,8 +3,7 @@
33 template or include ({% css %}), and reaches only the pages that use it. */44 /* The look: square corners, content on the page between 1px rules, labels55 in small uppercase monospace, one accent (#3148F6) for links, the current6- tab's bar, focus and primary actions, and a faint paper texture behind it7- all, the same paper in dark mode. */6+ tab's bar, focus and primary actions, on a plain page. */87 :root {98 --bg: #e9e9e9;109 --fg: #1c2127;
@@ -31,10 +30,6 @@
3130 --fail: #c7162b;3231 --warn: #b46a00;3332 --merged: #3148f6;34- --texture: url(./paper.webp);35- --texture-size: 448px 300px;36- --texture-blend: multiply;37- --texture-opacity: 0.35;3833 --sans: "Ubuntu", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;3934 --mono: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;4035 color-scheme: light dark;
@@ -63,19 +58,12 @@
6358 --fail: #f0606e;6459 --warn: #e3a33b;6560 --merged: #8e9bff;66- --texture: url(./paper-dark.webp);67- --texture-blend: screen;68- --texture-opacity: 0.32;69- }61+ }7062 }7163 7264 *, ::before, ::after { box-sizing: border-box; border-radius: 0; }7365 html { -webkit-text-size-adjust: 100%; }7466 body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 var(--sans); }75-/* The texture: CC0 scans (ambientCG), fixed to the window behind the76- content, so scrolling never repaints it. */77-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); }78-@media (prefers-reduced-transparency: reduce) { body::before { display: none; } }7967 a { color: var(--link); text-decoration: none; }8068 a:hover { color: var(--deep); text-decoration: underline; }8169 :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

2/7 Serve Open Sans, Noto Sans Mono and Noto Serif from the forge

13 files changed, +335 -10
Mlib/assets.ts18++++--
Mlib/private-server.ts2+-
Mlib/static.ts1+
Msite/_includes/base.njk1+
Asite/assets/fonts/OFL-noto-sans-mono.txt93++++++++++++++++++++++++++++++
Asite/assets/fonts/OFL-noto-serif.txt93++++++++++++++++++++++++++++++
Asite/assets/fonts/OFL-open-sans.txt92++++++++++++++++++++++++++++++
Asite/assets/fonts/make-fonts.sh34+++++++++++
Asite/assets/fonts/noto-sans-mono.woff2bin
Asite/assets/fonts/noto-serif-italic.woff2bin
Asite/assets/fonts/noto-serif.woff2bin
Asite/assets/fonts/open-sans.woff2bin
Msite/assets/style.css11+++-
M lib/assets.ts +11 -7
@@ -1,6 +1,7 @@
11 // Assets under content-hashed names, /assets/style.<hash>.css, so browsers2-// can cache them forever: the stylesheet, the logo, the vendored scripts,3-// our TypeScript with its types stripped, mermaid, sqlite-wasm (its script and2+// can cache them forever: the stylesheet, the logo, the fonts (in fonts/,3+// shipped next to the rest), the vendored scripts, our TypeScript with its4+// 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 plugin67 // writes the templates' own CSS next to them, also named by a hash.
@@ -17,6 +18,7 @@ import { fileURLToPath } from 'node:url';
1718 1819 const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');1920 const src = path.join(root, 'site', 'assets');21+const fonts = path.join(src, 'fonts');2022 const MERMAID = path.join(root, 'node_modules', 'mermaid', 'dist', 'mermaid.min.js');2123 const SQLITE = path.join(root, 'node_modules', '@sqlite.org', 'sqlite-wasm', 'dist');2224 const WEBAUTHN = path.join(root, 'node_modules', '@simplewebauthn', 'browser', 'dist', 'bundle', 'index.umd.min.js');
@@ -42,7 +44,8 @@ export interface Asset {
4244 data: Buffer;4345 }4446 45-// By short name: style, logo, fixi, ssexi, diffs, keys, live,47+// By short name: style, logo, open-sans, noto-sans-mono, noto-serif,48+// noto-serif-italic, fixi, ssexi, diffs, keys, live,4649 // search, query, codesearch, login, ui, mermaid, sqlite3, sqlite3wasm, webauthn.4750 export type Assets = Record<string, Asset>;4851
@@ -51,6 +54,7 @@ let memo: { key: string; assets: Assets } | null = null;
5154 export function assets(): Assets {5255 const files: [string, string, string][] = [5356 ...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)]),57+ ...fs.readdirSync(fonts).filter((f) => f.endsWith('.woff2')).sort().map((f): [string, string, string] => [f.split('.')[0], f, path.join(fonts, f)]),5458 ...VENDOR,5559 ];5660 // Rebuilt only when a source changes (the dev server rebuilds in one process).
@@ -58,10 +62,10 @@ export function assets(): Assets {
5862 if (memo?.key === key) return memo.assets;5963 // Our scripts import each other as './query.ts', which Node and tsc read6064 // as it is; shipped, that is the other file's hashed name, so a script's61- // name changes with what it imports. A url(./x.webp) in the stylesheet62- // becomes that file's hashed name the same way.65+ // name changes with what it imports. A url(./fonts/x.woff2) in the66+ // stylesheet becomes that file's hashed name the same way, beside it.6367 const local = (base: string, dep: string): Asset => {64- const d = files.find(([, b, p]) => b === dep && path.dirname(p) === src);68+ const d = files.find(([, , p]) => path.relative(src, p) === dep);6569 if (!d) throw new Error(`${base} links ./${dep}, which is not in site/assets`);6670 return make(d);6771 };
@@ -77,7 +81,7 @@ export function assets(): Assets {
7781 data = Buffer.from(js);7882 ext = '.js';7983 } else if (ext === '.css') {80- data = Buffer.from(data.toString('utf8').replace(/\burl\(\.\/([\w-]+\.\w+)\)/g, (_, dep: string) => `url(./${local(base, dep).file})`));84+ data = Buffer.from(data.toString('utf8').replace(/\burl\(\.\/((?:fonts\/)?[\w-]+\.\w+)\)/g, (_, dep: string) => `url(./${local(base, dep).file})`));8185 }8286 const a = { file: `${name}.${hash(data)}${ext}`, data };8387 made.set(short, a);
M lib/private-server.ts +1 -1
M lib/static.ts +1 -0
M site/_includes/base.njk +1 -0
A site/assets/fonts/OFL-noto-sans-mono.txt +93 -0 mechanical
@@ -0,0 +1,93 @@
1+Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+https://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.
A site/assets/fonts/OFL-noto-serif.txt +93 -0 mechanical
@@ -0,0 +1,93 @@
1+Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+https://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.
A site/assets/fonts/OFL-open-sans.txt +92 -0 mechanical
@@ -0,0 +1,92 @@
1+Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+https://scripts.sil.org/OFL6+7+-----------------------------------------------------------8+SIL OPEN FONT LICENSE Version 1.1 - 26 February 20079+-----------------------------------------------------------10+11+PREAMBLE12+The goals of the Open Font License (OFL) are to stimulate worldwide13+development of collaborative font projects, to support the font14+creation efforts of academic and linguistic communities, and to15+provide a free and open framework in which fonts may be shared and16+improved in partnership with others.17+18+The OFL allows the licensed fonts to be used, studied, modified and19+redistributed freely as long as they are not sold by themselves. The20+fonts, including any derivative works, can be bundled, embedded,21+redistributed and/or sold with any software provided that any reserved22+names are not used by derivative works. The fonts and derivatives,23+however, cannot be released under any other type of license. The24+requirement for fonts to remain under this license does not apply to25+any document created using the fonts or their derivatives.26+27+DEFINITIONS28+"Font Software" refers to the set of files released by the Copyright29+Holder(s) under this license and clearly marked as such. This may30+include source files, build scripts and documentation.31+32+"Reserved Font Name" refers to any names specified as such after the33+copyright statement(s).34+35+"Original Version" refers to the collection of Font Software36+components as distributed by the Copyright Holder(s).37+38+"Modified Version" refers to any derivative made by adding to,39+deleting, or substituting -- in part or in whole -- any of the40+components of the Original Version, by changing formats or by porting41+the Font Software to a new environment.42+43+"Author" refers to any designer, engineer, programmer, technical44+writer or other person who contributed to the Font Software.45+46+PERMISSION & CONDITIONS47+Permission is hereby granted, free of charge, to any person obtaining48+a copy of the Font Software, to use, study, copy, merge, embed,49+modify, redistribute, and sell modified and unmodified copies of the50+Font Software, subject to the following conditions:51+52+1) Neither the Font Software nor any of its individual components, in53+Original or Modified Versions, may be sold by itself.54+55+2) Original or Modified Versions of the Font Software may be bundled,56+redistributed and/or sold with any software, provided that each copy57+contains the above copyright notice and this license. These can be58+included either as stand-alone text files, human-readable headers or59+in the appropriate machine-readable metadata fields within text or60+binary files as long as those fields can be easily viewed by the user.61+62+3) No Modified Version of the Font Software may use the Reserved Font63+Name(s) unless explicit written permission is granted by the64+corresponding Copyright Holder. This restriction only applies to the65+primary font name as presented to the users.66+67+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font68+Software shall not be used to promote, endorse or advertise any69+Modified Version, except to acknowledge the contribution(s) of the70+Copyright Holder(s) and the Author(s) or with their explicit written71+permission.72+73+5) The Font Software, modified or unmodified, in part or in whole,74+must be distributed entirely under this license, and must not be75+distributed under any other license. The requirement for fonts to76+remain under this license does not apply to any document created using77+the Font Software.78+79+TERMINATION80+This license becomes null and void if any of the above conditions are81+not met.82+83+DISCLAIMER84+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,85+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF86+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT87+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE88+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,89+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL90+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING91+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM92+OTHER DEALINGS IN THE FONT SOFTWARE.
A site/assets/fonts/make-fonts.sh +34 -0
@@ -0,0 +1,34 @@
1+#!/bin/sh2+# How the fonts here were made; the forge never runs this, it serves the3+# outputs. Sources: the variable fonts in google/fonts (ofl/), under the SIL4+# Open Font License (each family's OFL-*.txt is next to its fonts).5+#6+# python3 -m venv /tmp/fonts-venv && /tmp/fonts-venv/bin/pip install fonttools brotli7+# PATH=/tmp/fonts-venv/bin:$PATH sh site/assets/fonts/make-fonts.sh <dir of sources>8+#9+# <dir> holds OpenSans[wdth,wght].ttf, NotoSansMono[wdth,wght].ttf,10+# NotoSerif[wdth,wght].ttf and NotoSerif-Italic[wdth,wght].ttf. Each is11+# pinned to its normal width (the pages never change it) and keeps a weight12+# range, then is cut down to Latin, the punctuation, and the arrows, ticks,13+# triangles and box drawing the templates print; a glyph a font lacks14+# comes from the system's fonts.15+set -eu16+src=${1:?usage: make-fonts.sh <dir of source .ttf files>}17+here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)18+tmp=$(mktemp -d)19+trap 'rm -rf "$tmp"' EXIT20+# Basic Latin, Latin-1, the Latin Extended-A letters of European languages,21+# general punctuation, the euro and trade marks, arrows, minus and the22+# comparison signs, the branch sign, box drawing (stack indents), small23+# triangles (folds) and ticks and crosses (CI).24+uni='U+0020-007E,U+00A0-00FF,U+0100-017F,U+0192,U+02C6,U+02DA,U+02DC,U+2000-206F,U+20AC,U+2122,U+2190-21FF,U+2212,U+2260,U+2264-2265,U+2387,U+2500-257F,U+25B4-25BF,U+2713-2717'25+make() { # make <source> <out> <weights>26+ fonttools varLib.instancer -q -o "$tmp/$2.ttf" "$src/$1" wdth=100 "wght=$3"27+ pyftsubset "$tmp/$2.ttf" --unicodes="$uni" --layout-features+=tnum,zero,case --flavor=woff2 \28+ --no-hinting --desubroutinize --output-file="$here/$2.woff2"29+}30+make 'OpenSans[wdth,wght].ttf' open-sans 300:80031+make 'NotoSansMono[wdth,wght].ttf' noto-sans-mono 400:70032+make 'NotoSerif[wdth,wght].ttf' noto-serif 400:70033+make 'NotoSerif-Italic[wdth,wght].ttf' noto-serif-italic 400:70034+ls -l "$here"/*.woff2
A site/assets/fonts/noto-sans-mono.woff2 +0 -0

Binary file.

A site/assets/fonts/noto-serif-italic.woff2 +0 -0

Binary file.

A site/assets/fonts/noto-serif.woff2 +0 -0

Binary file.

A site/assets/fonts/open-sans.woff2 +0 -0

Binary file.

M site/assets/style.css +9 -2
@@ -4,6 +4,12 @@
44 /* The look: square corners, content on the page between 1px rules, labels55 in small uppercase monospace, one accent (#3148F6) for links, the current66 tab's bar, focus and primary actions, on a plain page. */7+/* The fonts, self-hosted, variable in weight, cut to Latin and the symbols8+ the pages print (fonts/make-fonts.sh). The sans is preloaded (base.njk). */9+@font-face { font-family: "Open Sans"; src: url(./fonts/open-sans.woff2) format("woff2"); font-weight: 300 800; font-style: normal; font-display: swap; }10+@font-face { font-family: "Noto Sans Mono"; src: url(./fonts/noto-sans-mono.woff2) format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }11+@font-face { font-family: "Noto Serif"; src: url(./fonts/noto-serif.woff2) format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }12+@font-face { font-family: "Noto Serif"; src: url(./fonts/noto-serif-italic.woff2) format("woff2"); font-weight: 400 700; font-style: italic; font-display: swap; }713 :root {814 --bg: #e9e9e9;915 --fg: #1c2127;
@@ -30,8 +36,9 @@
3036 --fail: #c7162b;3137 --warn: #b46a00;3238 --merged: #3148f6;33- --sans: "Ubuntu", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;34- --mono: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;39+ --sans: "Open Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;40+ --mono: "Noto Sans Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;41+ --serif: "Noto Serif", Georgia, Cambria, "Times New Roman", serif;3542 color-scheme: light dark;3643 }3744 @media (prefers-color-scheme: dark) {

3/7 Tokens, a darker dark mode, lowercase chrome and real controls

Every colour, size, weight and family is a token at the top of
style.css, and the rest of it and every template's CSS use only those.
Dark mode is near black. No capitals or letter-spacing anywhere:
headings, tabs, buttons and labels are written lowercase and get their
weight from the type. Prose people wrote is in the serif.

Controls come from ui.njk: tabs for where you are (now also the
shelves, with an "all" tab), a globe icon toggle for repos with a site,
a segmented control for the sort, a checkbox for wrapping lines,
buttons that do things.
40 files changed, +732 -517
Mlib/jev-commits.ts2+-
Msite/_includes/base.njk29+-
Msite/_includes/discussion.njk43+-
Msite/_includes/help.njk22+-
Msite/_includes/insights-body.njk86++---
Msite/_includes/login.njk12+-
Msite/_includes/macros.njk101++++---
Msite/_includes/private.njk9+-
Msite/_includes/search.njk45+-
Asite/_includes/ui.njk93++++++
Msite/assets/diffs.ts10+-
Msite/assets/discuss.ts12+-
Msite/assets/insights.ts22+-
Msite/assets/keys.ts2+-
Msite/assets/logo.svg2+-
Msite/assets/search.ts22+-
Msite/assets/style.css483++++++++++++++++++------------
Msite/assets/ui.ts45++-
Msite/index.njk1+
Msite/insights.njk1+
Msite/owner.njk1+
Msite/repo/blob.njk7+-
Msite/repo/commit.njk21+-
Msite/repo/home.njk3+-
Msite/repo/insights.njk3+-
Msite/repo/log.njk1+
Msite/repo/refs.njk18+-
Msite/repo/releases.njk15+-
Msite/repo/review-diff.njk34+-
Msite/repo/review.njk38+-
Msite/repo/reviews.njk10+-
Msite/repo/revision.njk21+-
Msite/repo/sites.njk3+-
Msite/repo/tree.njk3+-
Msite/repo/wiki.njk17+-
Mtest/scenarios/insights-pages.sh2+-
Mtest/scenarios/log-columns.sh2+-
Mtest/scenarios/release-page.sh4+-
Mtest/scenarios/release-tab.sh2+-
Mtest/scenarios/web-comment-page.sh2+-
M lib/jev-commits.ts +1 -1
M site/_includes/base.njk +15 -14
@@ -2,6 +2,7 @@
22 replace head (its description and link preview), scripts (any script only33 it needs) and heading (the title in the header). -#}44 {% import "macros.njk" as m with context %}5+{% import "ui.njk" as ui %}56 {% set r = p.repo if p else null %}67 {% set br = p.branch.name if p and p.branch else (r.defaultBranch if r else '') %}78 <!DOCTYPE html>
@@ -34,7 +35,7 @@
3435 <a class="logo" href="/" data-key="g" title="{{ forge.config.name }}: all repositories" aria-label="{{ forge.config.name }}">{% include "logo.njk" %}</a>3536 <div class="title">3637 {% block heading %}37-{% if r %}<a href="/{{ r.owner }}/">{{ r.owner }}</a> / <a href="{{ r.urls.home.href }}" data-key="h"><b>{{ r.name }}</b></a>{% if r.private %} <a class="private-mark" href="/private/" title="A private repo: only its readers see its pages">private</a>{% endif %}38+{% if r %}<a href="/{{ r.owner }}/">{{ r.owner }}</a> / <a href="{{ r.urls.home.href }}" data-key="h"><b>{{ r.name }}</b></a>{% if r.private %} <a class="private-mark" href="/private/" title="a private repo: only its readers see its pages">private</a>{% endif %}3839 {% else %}<b>{{ forge.config.name }}</b>{% endif %}3940 {% endblock %}4041 </div>
@@ -43,10 +44,10 @@
4344 {% include "search.njk" %}4445 {% if r %}4546 <details class="clone">46-<summary>Clone</summary>47+<summary>clone</summary>4748 <div class="panel">48-<div class="copyrow"><label>SSH</label><input readonly value="{{ r.urls.cloneSsh }}" aria-label="ssh clone url"><button type="button" data-copy="{{ r.urls.cloneSsh }}">Copy</button></div>49-{% if r.urls.cloneHttp %}<div class="copyrow"><label>HTTPS</label><input readonly value="{{ r.urls.cloneHttp }}" aria-label="https clone url"><button type="button" data-copy="{{ r.urls.cloneHttp }}">Copy</button></div>{% endif %}49+<div class="copyrow"><label>ssh</label>{{ ui.input('ssh clone url', r.urls.cloneSsh, 'readonly', true) }}{{ ui.button('copy', false, 'data-copy="' + r.urls.cloneSsh + '"') }}</div>50+{% if r.urls.cloneHttp %}<div class="copyrow"><label>https</label>{{ ui.input('https clone url', r.urls.cloneHttp, 'readonly', true) }}{{ ui.button('copy', false, 'data-copy="' + r.urls.cloneHttp + '"') }}</div>{% endif %}5051 {% if r.private %}<p class="clone-note">HTTPS takes a token: <code>ssh {{ forge.config.ssh }} token --repo {{ r.slug }}</code></p>{% endif %}5152 </div>5253 </details>
@@ -54,16 +55,16 @@
5455 </div>5556 {% if r and r.description %}<p class="repo-desc">{{ r.description }}</p>{% endif %}5657 {% if r %}57-<nav class="tabs" aria-label="repository">58-<a href="{{ r.urls.home.href }}" data-key="f"{% if p.nav == 'files' or p.nav == 'home' %} class="cur"{% endif %}>Code</a>59-<a href="{{ r.urls.log(br).href }}" data-key="l"{% if p.nav == 'log' %} class="cur"{% endif %}>Commits</a>60-<a href="{{ r.urls.refs.href }}" data-key="b"{% if p.nav == 'refs' %} class="cur"{% endif %}>Branches</a>61-<a href="{{ r.urls.reviews.href }}" data-key="r"{% if p.nav == 'reviews' %} class="cur"{% endif %}>Reviews</a>62-{% if r.counts.wiki %}<a href="{{ r.urls.wiki().href }}" data-key="w"{% if p.nav == 'wiki' %} class="cur"{% endif %}>Wiki</a>{% endif %}63-{% if r.counts.sites %}<a href="{{ r.urls.sites.href }}" data-key="s"{% if p.nav == 'sites' %} class="cur"{% endif %}>Sites</a>{% endif %}64-{% if r.counts.releases %}<a href="{{ r.urls.releases.href }}" data-key="e"{% if p.nav == 'releases' %} class="cur"{% endif %}>Releases</a>{% endif %}65-<a href="{{ r.urls.insights.href }}" data-key="i"{% if p.nav == 'insights' %} class="cur"{% endif %}>Insights</a>66-</nav>58+{{ ui.tabs('repository', [59+ {href: r.urls.home.href, text: 'code', key: 'f', cur: p.nav == 'files' or p.nav == 'home'},60+ {href: r.urls.log(br).href, text: 'commits', key: 'l', cur: p.nav == 'log'},61+ {href: r.urls.refs.href, text: 'branches', key: 'b', cur: p.nav == 'refs'},62+ {href: r.urls.reviews.href, text: 'reviews', key: 'r', cur: p.nav == 'reviews'},63+ {href: r.urls.wiki().href if r.counts.wiki else '', text: 'wiki', key: 'w', cur: p.nav == 'wiki', off: not r.counts.wiki},64+ {href: r.urls.sites.href, text: 'sites', key: 's', cur: p.nav == 'sites', off: not r.counts.sites},65+ {href: r.urls.releases.href, text: 'releases', key: 'e', cur: p.nav == 'releases', off: not r.counts.releases},66+ {href: r.urls.insights.href, text: 'insights', key: 'i', cur: p.nav == 'insights'}67+]) }}6768 {% endif %}6869 </div>6970 </header>
M site/_includes/discussion.njk +21 -22
@@ -3,40 +3,39 @@
33 revision are outdated. Written with forge comment and forge approve, or44 from this page signed in: the form at the end stays hidden until55 discuss.ts finds who is signed in, and posts the tip the page shows. #}6+{% import "ui.njk" as ui %}67 {% set u = p.repo.urls %}78 {% css %}8-ul.approvals { list-style: none; margin: 0; padding: 10px 0; border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 0.92em; }9+ul.approvals { list-style: none; margin: 0; padding: var(--space-3) 0; border-bottom: var(--hairline) solid var(--border); display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-5); font-size: var(--text-sm); }910 ul.approvals .tick { color: var(--pass); }1011 ul.approvals .stale { color: var(--muted); }1112 ul.approvals .stale .tick { color: var(--warn); }12-ul.approvals .date { font: 12px var(--mono); color: var(--muted); }13-ul.approvals .date time { font-size: 1em; }13+ul.approvals .date { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }14+ul.approvals .date time { font-size: inherit; }1415 ol.comments { list-style: none; margin: 0; padding: 0; }15-.comment { border-bottom: 1px solid var(--border); padding: 0 0 4px; }16-.comment:target { box-shadow: inset 3px 0 0 var(--accent); }17-.comment-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; padding: 10px 12px 0; font-size: 0.9em; }18-.comment-head .date { font: 12px var(--mono); color: var(--muted); }19-.comment-head .date time { font-size: 1em; }20-.comment-head .rev { font: 12px var(--mono); color: var(--muted); }21-.comment .tag { font: 600 10.5px var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--warn); }22-.comment .quote { margin: 8px 12px 0; border: 1px solid var(--border); background: var(--raised); overflow: hidden; }23-.comment .quote .where { padding: 3px 10px; font: 12px/1.6 var(--mono); border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }24-.comment .quote pre { margin: 0; padding: 4px 10px; font: 12.5px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 4; }16+.comment { border-bottom: var(--hairline) solid var(--border); padding: 0 0 var(--space-1); }17+.comment:target { box-shadow: inset var(--bar) 0 0 var(--accent); }18+.comment-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-half) var(--space-3); padding: var(--space-3) var(--space-3) 0; font-size: var(--text-sm); }19+.comment-head .date { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }20+.comment-head .date time { font-size: inherit; }21+.comment-head .rev { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }22+.comment .tag { font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--warn); }23+.comment .quote { margin: var(--space-2) var(--space-3) 0; border: var(--hairline) solid var(--border); background: var(--raised); overflow: hidden; }24+.comment .quote .where { padding: var(--space-1) var(--space-3); font: var(--fw-regular) var(--text-xs)/1.6 var(--mono); border-bottom: var(--hairline) solid var(--border); overflow-wrap: anywhere; }25+.comment .quote pre { margin: 0; padding: var(--space-1) var(--space-3); font: var(--fw-regular) var(--text-code)/1.55 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 4; }2526 .comment.outdated .quote pre { color: var(--muted); }26-.comment .markdown { padding: 6px 12px 8px; font-size: 0.95rem; line-height: 1.55; }27+.comment .markdown { padding: var(--space-2) var(--space-3); font-size: var(--text-md); line-height: 1.6; }2728 .comment .markdown > :last-child { margin-bottom: 0; }2829 /* Sent from this page, until the rebuilt page shows it. */2930 .comment.pending { border-bottom-style: dashed; }30-.comment.pending .text { padding: 8px 12px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); }31-.reply { margin: 12px 0 0; }32-.reply textarea { display: block; width: 100%; min-height: 6em; resize: vertical; font: 14px/1.5 var(--sans); color: var(--fg); background: var(--raised); border: 1px solid var(--border); padding: 8px 10px; }33-.reply textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }34-.reply-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin-top: 8px; font-size: 0.92em; }31+.comment.pending .text { padding: var(--space-2) var(--space-3); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); }32+.reply { margin: var(--space-3) 0 0; }33+.reply-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-2); font-size: var(--text-sm); }3534 .reply-bar .grow { flex: 1; }3635 .reply-status.bad { color: var(--fail); white-space: pre-line; }3736 {% endcss %}3837 <section class="discussion" id="discussion">39-<h2>Discussion <span class="desc">{{ rv.comments.length | plural('comment') }}{% if rv.approvals.length %}, {{ rv.approvals.length | plural('approval') }}{% endif %}</span></h2>38+{{ ui.heading('discussion', null, (rv.comments.length | plural('comment')) + ((', ' + (rv.approvals.length | plural('approval'))) if rv.approvals.length else '')) }}4039 {% if rv.approvals.length %}4140 <ul class="approvals">4241 {% for a in rv.approvals %}
@@ -61,7 +60,7 @@ ol.comments { list-style: none; margin: 0; padding: 0; }
6160 <p class="desc" id="no-comments">No comments yet: <code>forge comment {{ rv.id }}</code> adds one.</p>6261 {% endif %}6362 <form class="reply" id="reply" data-post="{{ u.review(rv.id).href }}" data-at="{{ rv.tip }}" data-author="{{ rv.author or '' }}" hidden>64-<textarea name="body" maxlength="32768" aria-label="Comment on #{{ rv.id }}" placeholder="Comment on #{{ rv.id }}. Markdown is fine."></textarea>65-<div class="reply-bar"><span class="desc who"></span><span class="grow"></span><span class="reply-status" role="status"></span>{% if rv.live %}<button type="button" data-approve title="Approve this tree of #{{ rv.id }}, as forge approve {{ rv.id }} does">Approve</button>{% endif %}<button type="submit" class="go">Comment</button></div>63+{{ ui.textarea('comment on #' + rv.id, 'Comment on #' + rv.id + '. Markdown is fine.', 'name="body" maxlength="32768"') }}64+<div class="reply-bar"><span class="desc who"></span><span class="grow"></span><span class="reply-status" role="status"></span>{% if rv.live %}{{ ui.button('approve', false, 'data-approve title="approve this tree of #' + rv.id + ', as forge approve ' + rv.id + ' does"') }}{% endif %}{{ ui.button('comment', true, '', 'submit') }}</div>6665 </form>6766 </section>
M site/_includes/help.njk +11 -11
@@ -1,18 +1,18 @@
11 {# The footer, with the button that lists the keyboard shortcuts in a dialog (keys.ts). -#}22 {% css %}3-footer.site .keyhelp { background: none; border: 0; padding: 0; color: var(--muted); }3+footer.site .keyhelp { min-height: 0; background: none; border: 0; padding: 0; color: var(--muted); }44 footer.site .keyhelp:hover { color: var(--fg); background: none; }55 @media (pointer: coarse) and (hover: none) { footer.site .keyhelp { display: none; } }66 /* keyboard shortcuts (keys.ts) */7-#help { width: min(440px, 94vw); padding: 0; background: var(--raised); color: var(--fg); border: 1px solid var(--border); box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2); }7+#help { width: min(440px, 94vw); padding: 0; background: var(--raised); color: var(--fg); border: var(--hairline) solid var(--border); box-shadow: var(--shadow); }88 #help:focus { outline: none; }9-#help::backdrop { background: rgba(0, 0, 0, 0.3); }10-#help > div { padding: 16px 20px; }11-#help h2 { margin: 0 0 4px; }12-#help table { width: 100%; border-collapse: collapse; font-size: 0.92em; }13-#help td { padding: 5px 0; vertical-align: top; border-bottom: 1px solid var(--border); }14-#help td:first-child { width: 1%; padding-right: 16px; white-space: nowrap; }15-#help kbd { color: var(--fg); font-size: 0.85em; }16-#help p { margin: 12px 0 0; font-size: 0.85em; }9+#help::backdrop { background: var(--scrim); }10+#help > div { padding: var(--space-4) var(--space-5); }11+#help h2 { margin: 0 0 var(--space-1); }12+#help table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }13+#help td { padding: var(--space-1) 0; vertical-align: top; border-bottom: var(--hairline) solid var(--border); }14+#help td:first-child { width: 1%; padding-right: var(--space-4); white-space: nowrap; }15+#help kbd { color: var(--fg); }16+#help p { margin: var(--space-3) 0 0; font-size: var(--text-sm); }1717 {% endcss %}18-<footer class="site wrap"><button type="button" class="keyhelp" title="Keyboard shortcuts"><kbd>?</kbd> shortcuts</button></footer>18+<footer class="site wrap"><button type="button" class="keyhelp" title="keyboard shortcuts"><kbd>?</kbd> shortcuts</button></footer>
M site/_includes/insights-body.njk +36 -50
@@ -5,37 +5,22 @@
55 {% set forgeWide = not p %}66 {% set base = '/insights/' if forgeWide else p.repo.urls.insights.href %}77 {% css %}8-/* insights (insights.ts): the chart colors are the data-viz palette's,9- checked for color-blind readers against this page's light and dark10- backgrounds; text keeps the page's own colors. */11-.insights {12- --viz-1: #3148f6;13- --viz-fail: #c7162b;14- --viz-grid: #d9d9d9;15- --viz-axis: #a9aeb4;16-}17-@media (prefers-color-scheme: dark) {18- .insights {19- --viz-1: #6f7fff;20- --viz-fail: #f0606e;21- --viz-grid: #2a3037;22- --viz-axis: #4a525b;23- }24-}25-.insights .ins-meta { margin: 0.4em 0 1em; }26-.insights > h3 { margin: 28px 0 0; padding-bottom: 6px; border-bottom: 1px solid var(--border); font: 600 11px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }27-.insights .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0 20px; margin: 0 0 8px; }28-.insights .kpi { border-top: 1px solid var(--border); padding: 10px 0; }29-.insights .kpi .label { font: 600 10.5px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--faint); }30-.insights .kpi .value { font: 600 1.5rem/1.2 var(--mono); margin-top: 4px; font-variant-numeric: tabular-nums; }31-.insights .kpi .sub { color: var(--muted); font-size: 0.85em; }32-.insights .charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: 12px 20px; }8+/* insights (insights.ts): the chart colours are tokens (--viz-*, checked9+ for colour-blind readers against both pages); text keeps the page's own. */10+.insights .ins-meta { margin: var(--space-1) 0 var(--space-4); }11+.insights > h3 { margin: var(--space-8) 0 0; padding-bottom: var(--space-2); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-md); }12+.insights .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0 var(--space-5); margin: 0 0 var(--space-2); }13+.insights .kpi { border-top: var(--hairline) solid var(--border); padding: var(--space-3) 0; }14+.insights .kpi .label { font-size: var(--text-sm); color: var(--muted); }15+.insights .kpi .value { font: var(--fw-medium) var(--text-xl)/1.2 var(--mono); margin-top: var(--space-1); font-variant-numeric: tabular-nums; }16+.insights .kpi .sub { color: var(--muted); font-size: var(--text-sm); }17+.insights .charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: var(--space-3) var(--space-5); }3318 .insights figure { margin: 0; min-width: 0; position: relative; }34-.insights figcaption h3 { margin: 1.6em 0 2px; font: 600 11px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }35-.insights figcaption .desc { font-size: 0.85em; }36-.insights .plot { position: relative; margin-top: 6px; }19+.insights figcaption h3 { margin: var(--space-6) 0 var(--space-half); font-size: var(--text-md); }20+.insights figcaption .desc { font-size: var(--text-sm); }21+.insights .plot { position: relative; margin-top: var(--space-2); }3722 .insights .plot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }38-.insights svg { display: block; width: 100%; overflow: visible; font: 10.5px var(--mono); font-variant-numeric: tabular-nums; }23+.insights svg { display: block; width: 100%; overflow: visible; font: var(--fw-regular) var(--text-xs) var(--mono); font-variant-numeric: tabular-nums; }3924 .insights svg .grid { stroke: var(--viz-grid); stroke-width: 1; shape-rendering: crispEdges; }4025 .insights svg .base { stroke: var(--viz-axis); stroke-width: 1; shape-rendering: crispEdges; }4126 .insights svg .tick { fill: var(--muted); }
@@ -45,20 +30,21 @@
4530 .insights svg .hair { stroke: var(--muted); stroke-width: 1; shape-rendering: crispEdges; }4631 .insights svg .on { filter: brightness(1.15); }4732 .insights svg .mark-label { fill: var(--fg); paint-order: stroke; stroke: var(--bg); stroke-width: 3px; stroke-linejoin: round; }48-.insights .empty-chart { color: var(--muted); font-size: 0.9em; padding: 28px 0; text-align: center; border-block: 1px solid var(--border); }49-.insights .legend { display: flex; gap: 14px; font-size: 0.85em; color: var(--muted); margin-top: 4px; }50-.insights .legend i { display: inline-block; width: 10px; height: 10px; margin-right: 5px; vertical-align: -1px; }51-.insights .tip { position: absolute; z-index: 2; pointer-events: none; background: var(--raised); border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); padding: 6px 9px; font-size: 0.85em; white-space: nowrap; }52-.insights .tip b { font-size: 1.05em; }53-.insights .tip .k { display: inline-block; width: 10px; height: 2px; margin-right: 6px; vertical-align: middle; }54-.insights details.table { margin-top: 4px; font-size: 0.85em; }55-.insights details.table summary { color: var(--muted); cursor: pointer; font: 600 10.5px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; }56-.insights details.table table { border-collapse: collapse; margin-top: 4px; font-variant-numeric: tabular-nums; }57-.insights details.table td, .insights details.table th { padding: 2px 10px 2px 0; text-align: left; border-bottom: 1px solid var(--border); }33+.insights .empty-chart { color: var(--muted); font-size: var(--text-sm); padding: var(--space-8) 0; text-align: center; border-block: var(--hairline) solid var(--border); }34+.insights .legend { display: flex; gap: var(--space-4); font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-1); }35+.insights .legend i { display: inline-block; width: 10px; height: 10px; margin-right: var(--space-1); vertical-align: -1px; }36+.insights .tip { position: absolute; z-index: 2; pointer-events: none; background: var(--raised); border: var(--hairline) solid var(--border); box-shadow: var(--shadow); padding: var(--space-1) var(--space-2); font-size: var(--text-sm); white-space: nowrap; }37+.insights .tip b { font-size: var(--text-md); }38+.insights .tip .k { display: inline-block; width: 10px; height: 2px; margin-right: var(--space-2); vertical-align: middle; }39+.insights details.table { margin-top: var(--space-1); font-size: var(--text-sm); }40+.insights details.table summary { color: var(--muted); cursor: pointer; font-weight: var(--fw-medium); }41+.insights details.table table { border-collapse: collapse; margin-top: var(--space-1); font-variant-numeric: tabular-nums; }42+.insights details.table td, .insights details.table th { padding: var(--space-half) var(--space-3) var(--space-half) 0; text-align: left; border-bottom: var(--hairline) solid var(--border); }43+.insights details.table th { font-weight: var(--fw-medium); color: var(--muted); }5844 .insights details.table .scroll { max-height: 240px; overflow: auto; }59-.insights .files td { vertical-align: top; padding: 1px 12px 1px 0; }45+.insights .files td { vertical-align: top; padding: var(--space-half) var(--space-3) var(--space-half) 0; }6046 .insights .files td:first-child { white-space: nowrap; }61-.insights pre.cmd { overflow-x: auto; background: var(--subtle); border: 1px solid var(--border); padding: 8px 12px; font-size: 0.85em; }47+.insights pre.cmd { overflow-x: auto; background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-2) var(--space-3); font-size: var(--text-sm); }6248 @media (forced-colors: active) {6349 .insights svg .s1, .insights svg .fail { fill: CanvasText; }6450 }
@@ -67,23 +53,23 @@
6753 <p class="desc ins-meta" id="ins-meta">From the forge's lake (<code>forge lake</code>).<noscript> The charts need JavaScript; the numbers are in the files below.</noscript></p>6854 <div class="kpis" id="ins-kpis"></div>6955 <div class="charts">70-<figure data-chart="builds"><figcaption><h3>Build time</h3><span class="desc">seconds per build of {{ 'the pages' if forgeWide else "this repo's pages" }}</span></figcaption><div class="plot"></div><details class="table"><summary>Table</summary></details></figure>71-<figure data-chart="pushes"><figcaption><h3>Pushes</h3><span class="desc">pushes a <span class="ins-bucket">day</span></span></figcaption><div class="plot"></div><details class="table"><summary>Table</summary></details></figure>72-<figure data-chart="land"><figcaption><h3>Time to land</h3><span class="desc">hours from a review's first push to landing, by when it landed</span></figcaption><div class="plot"></div><details class="table"><summary>Table</summary></details></figure>73-<figure data-chart="revisions"><figcaption><h3>Revisions per review</h3><span class="desc">reviews by how many revisions they were pushed in</span></figcaption><div class="plot"></div><details class="table"><summary>Table</summary></details></figure>74-<figure data-chart="ci"><figcaption><h3>CI results</h3><span class="desc">attestations a <span class="ins-bucket">day</span>, passed and failed</span></figcaption><div class="plot"></div><details class="table"><summary>Table</summary></details></figure>75-<figure data-chart="ciTime"><figcaption><h3>CI duration</h3><span class="desc">seconds per run of <code>.forge/ci</code></span></figcaption><div class="plot"></div><details class="table"><summary>Table</summary></details></figure>56+<figure data-chart="builds"><figcaption><h3>build time</h3><span class="desc">seconds per build of {{ 'the pages' if forgeWide else "this repo's pages" }}</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>57+<figure data-chart="pushes"><figcaption><h3>pushes</h3><span class="desc">pushes a <span class="ins-bucket">day</span></span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>58+<figure data-chart="land"><figcaption><h3>time to land</h3><span class="desc">hours from a review's first push to landing, by when it landed</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>59+<figure data-chart="revisions"><figcaption><h3>revisions per review</h3><span class="desc">reviews by how many revisions they were pushed in</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>60+<figure data-chart="ci"><figcaption><h3>ci results</h3><span class="desc">attestations a <span class="ins-bucket">day</span>, passed and failed</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>61+<figure data-chart="ciTime"><figcaption><h3>ci duration</h3><span class="desc">seconds per run of <code>.forge/ci</code></span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>7662 </div>7763 {% if forgeWide %}78-<h3>By repo</h3>64+<h3>by repo</h3>7965 <div class="box">8066 <table class="list nav" id="ins-repos">81-<thead><tr><th>Repository</th><th class="num">Pushes</th><th class="num">Builds</th><th class="num">Median build</th><th class="num">Landed</th><th class="num">CI passed</th><th>Last push</th></tr></thead>67+<thead><tr><th>repository</th><th class="num">pushes</th><th class="num">builds</th><th class="num">median build</th><th class="num">landed</th><th class="num">ci passed</th><th>last push</th></tr></thead>8268 <tbody></tbody>8369 </table>8470 </div>8571 {% endif %}86-<h3>Data</h3>72+<h3>data</h3>8773 <p class="desc">The rows behind the charts{{ ', for public repos only' if forgeWide else '' }}, as Parquet. DuckDB reads them over HTTP in byte ranges, so a query fetches only what it needs:</p>8874 <table class="files desc">8975 <tr><td><a href="{{ base }}insights.json">insights.json</a></td><td>what the charts draw</td></tr>
M site/_includes/login.njk +6 -6
@@ -4,6 +4,7 @@
44 out. Data: forge, p (title), user (or ''), csrf, signal (JSON or ''),55 next, ready (sign-in is set up: FORGE_BASE_URL), host. -#}66 {% extends "base.njk" %}7+{% import "ui.njk" as ui %}78 {% block scripts %}89 <script src="{{ assets.webauthn }}" defer></script>910 <script src="{{ assets.login }}" defer></script>
@@ -13,9 +14,8 @@
1314 {% block content %}1415 {% css %}1516 /* the sign-in page */16-.login { padding: 0 0 16px; max-width: 40em; }17-.login h1 { font-size: 1.3rem; margin: 4px 0 12px; }18-.login button.go { padding: 7px 14px; }17+.login { padding: 0 0 var(--space-4); max-width: 40em; }18+.login h1 { font-size: var(--text-xl); margin: var(--space-1) 0 var(--space-3); }1919 .login-status { min-height: 1.4em; }2020 .login-status.bad { color: var(--fail); }2121 .login form { display: inline; }
@@ -24,7 +24,7 @@
2424 {% if user %}2525 <h1>Signed in as {{ user }}</h1>2626 <p>You see the pages of the private repos you can read: <a href="/private/">your private repos</a>.</p>27-<form method="post" action="/login/logout"><input type="hidden" name="csrf" value="{{ csrf }}"><button type="submit" id="signout">Sign out</button></form>27+<form method="post" action="/login/logout"><input type="hidden" name="csrf" value="{{ csrf }}">{{ ui.button('sign out', false, 'id="signout"', 'submit') }}</form>2828 {% elif not ready %}2929 <h1>Sign in</h1>3030 <p>Sign-in isn't set up here: the forge needs <code>FORGE_BASE_URL</code>, the origin passkeys belong to.</p>
@@ -32,12 +32,12 @@
3232 <div id="signin-part">3333 <h1>Sign in</h1>3434 <p>Private repos show their pages to their readers. Sign in with a passkey you have added to see them.</p>35-<p><button type="button" class="go" id="signin">Sign in with a passkey</button></p>35+<p>{{ ui.button('sign in with a passkey', true, 'id="signin"') }}</p>3636 </div>3737 <div id="enroll-part" hidden>3838 <h1>Add a passkey</h1>3939 <p>For <b id="enroll-user">you</b> on {{ host }}. Your browser, phone or password manager keeps it, and signs you in with it from now on.</p>40-<p><button type="button" class="go" id="enroll">Add a passkey</button></p>40+<p>{{ ui.button('add a passkey', true, 'id="enroll"') }}</p>4141 </div>4242 <p class="login-status" id="login-status" role="status"></p>4343 <p class="desc">No passkey yet, or lost them all? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link that adds one: your ssh key is how you get in.</p>
M site/_includes/macros.njk +58 -43
@@ -1,5 +1,7 @@
11 {# Shared markup. Import it with context, so the CSS blocks in these macros2- reach the page's bundle: {% import "macros.njk" as m with context %} #}2+ reach the page's bundle: {% import "macros.njk" as m with context %}3+ The controls themselves are in ui.njk. #}4+{% import "ui.njk" as ui %}35 46 {# A page's description and its link preview in chat apps (OpenGraph).57 og:url needs the forge's address, FORGE_BASE_URL. #}
@@ -40,7 +42,7 @@
4042 {% macro log(commits, repo) %}4143 {% set jev = commits | selectattr('jev') | length %}4244 <table class="list stack nav log">43-<thead><tr>{% if jev %}<th class="kind">Type</th>{% endif %}<th>Commit</th><th>Author</th><th>Date</th><th class="num">+</th><th class="num">-</th>{% if jev %}<th class="num" title="Jev's estimate of how likely a change is to break something, 0 to 3">Risk</th>{% endif %}<th>CI</th></tr></thead>45+<thead><tr>{% if jev %}<th class="kind">type</th>{% endif %}<th>commit</th><th>author</th><th>date</th><th class="num">+</th><th class="num">-</th>{% if jev %}<th class="num" title="Jev's estimate of how likely a change is to break something, 0 to 3">risk</th>{% endif %}<th>ci</th></tr></thead>4446 <tbody>4547 {% for c in commits %}4648 <tr>{% if jev %}<td class="kind">{{ jevKind(c) }}</td>{% endif %}<td class="main"><a href="{{ repo.urls.commit(c.sha).href }}">{{ c.subject }}</a>{{ jevMismatch(c) }}</td><td class="nowrap">{{ c.author.name }}</td><td class="date">{{ c.author.date | date }}</td><td class="num i">+{{ c.stats.add }}</td><td class="num d">-{{ c.stats.del }}</td>{% if jev %}<td class="num risk">{{ jevRisk(c) }}</td>{% endif %}<td>{{ c.ci | ciHtml | safe }}</td></tr>
@@ -53,7 +55,7 @@
5355 new branch doesn't change every page. Without JS it links to the branches page. #}5456 {% macro branches(repo, current, kind) %}5557 <details class="picker" fx-action="{{ repo.urls.branchMenu(kind).href }}" fx-trigger="toggle" fx-target="#branch-menu" fx-swap="innerHTML">56-<summary>&#9095; <b>{{ current }}</b></summary>58+<summary>{{ ui.icon('branch') }}<b>{{ current }}</b></summary>5759 <div id="branch-menu"><a href="{{ repo.urls.refs.href }}">all branches</a></div>5860 </details>5961 {% endmacro %}
@@ -62,7 +64,7 @@
6264 <span class="crumbs"><a href="{{ repo.urls.tree(branch).href }}">{{ repo.name }}</a>{% for c in list %} / <a href="{{ c.href }}">{{ c.name }}</a>{% endfor %}</span>6365 {% endmacro %}6466 65-{% macro status(s) %}<span class="status {{ s }}">{{ s }}</span>{% endmacro %}67+{% macro status(s) %}{{ ui.status(s) }}{% endmacro %}6668 6769 {% macro reviewci(ci) %}6870 {% if ci %}<span class="ci {{ ci.state }}" title="{{ ci.pass }} of {{ ci.total }} commits passed">{{ '✓' if ci.state == 'pass' else ('✗' if ci.state == 'fail' else '–') }} {{ ci.pass }}/{{ ci.total }}</span>{% else %}<span class="desc">no CI</span>{% endif %}
@@ -80,29 +82,31 @@
8082 {% macro reviewItem(r, repo, depth = 0) %}8183 {% css %}8284 .rv-list { list-style: none; margin: 0; padding: 0; }83-.rv { position: relative; display: grid; grid-template-columns: auto 1fr auto; column-gap: 10px; row-gap: 3px; align-items: baseline; padding: 10px 8px; border-bottom: 1px solid var(--border); }85+.rv { position: relative; display: grid; grid-template-columns: auto 1fr auto; column-gap: var(--space-3); row-gap: var(--space-1); align-items: baseline; padding: var(--space-3) var(--space-2); border-bottom: var(--hairline) solid var(--border); }8486 .rv:hover, .rv.sel { background: var(--hover); }8587 .rv.sel { box-shadow: inset 2px 0 0 var(--accent); }8688 .rv > .status { grid-row: 1; }87-.rv-title { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }89+.rv-title { min-width: 0; font-weight: var(--fw-strong); overflow-wrap: anywhere; }90+.rv-title a { color: var(--fg); }91+.rv-title a:hover { color: var(--deep); }8892 .rv-title a::after { content: ''; position: absolute; inset: 0; }89-.rv-side { display: flex; gap: 12px; align-items: baseline; justify-content: flex-end; white-space: nowrap; font: 12.5px var(--mono); }90-.rv-side .desc { font-family: var(--sans); font-size: 13px; }91-.rv-meta { grid-column: 2 / 4; display: flex; flex-wrap: wrap; column-gap: 14px; row-gap: 2px; font: 12px/1.5 var(--mono); color: var(--muted); }92-.rv-meta time { font-size: 1em; }93+.rv-side { display: flex; gap: var(--space-3); align-items: baseline; justify-content: flex-end; white-space: nowrap; font: var(--fw-regular) var(--text-xs) var(--mono); }94+.rv-side .desc { font-family: var(--sans); font-size: var(--text-sm); }95+.rv-meta { grid-column: 2 / 4; display: flex; flex-wrap: wrap; column-gap: var(--space-4); row-gap: var(--space-half); font: var(--fw-regular) var(--text-xs)/1.5 var(--mono); color: var(--muted); }96+.rv-meta time, .rv-meta .mono { font-size: inherit; }9397 .rv-meta .num { font-variant-numeric: tabular-nums; }94-.rv-meta .add { color: var(--pass); }95-.rv-meta .del { color: var(--fail); }98+.rv-meta .add { color: var(--add-fg); }99+.rv-meta .del { color: var(--del-fg); }96100 .rv-meta a, .rv-side a { position: relative; z-index: 1; }97-.rv.layer { margin-left: calc(var(--depth) * 22px); border-left: 1px solid var(--border); }98-.appr { white-space: nowrap; font-weight: 600; color: var(--pass); }99-.appr + .appr { margin-left: 8px; }100-.appr.stale { color: var(--warn); font-weight: normal; }101+.rv.layer { margin-left: calc(var(--depth) * var(--space-6)); border-left: var(--hairline) solid var(--border); }102+.appr { white-space: nowrap; font-weight: var(--fw-strong); color: var(--pass); }103+.appr + .appr { margin-left: var(--space-2); }104+.appr.stale { color: var(--warn); font-weight: var(--fw-regular); }101105 @media (max-width: 720px) {102106 .rv { grid-template-columns: auto 1fr; }103107 .rv-side { grid-column: 2; justify-content: flex-start; }104108 .rv-meta { grid-column: 1 / 3; }105- .rv.layer { margin-left: calc(var(--depth) * 12px); }109+ .rv.layer { margin-left: calc(var(--depth) * var(--space-3)); }106110 }107111 {% endcss %}108112 {% set last = r.revisions[r.revisions.length - 1] %}
@@ -125,54 +129,65 @@
125129 {% macro files(entries, parentHref) %}126130 <table class="list files nav">127131 <tbody>128-{% if parentHref %}<tr><td class="icon">&#8617;</td><td class="main"><a href="{{ parentHref }}" data-key="u">..</a></td><td></td></tr>{% endif %}132+{% if parentHref %}<tr><td class="icon">{{ ui.icon('up') }}</td><td class="main"><a href="{{ parentHref }}" data-key="u">..</a></td><td></td></tr>{% endif %}129133 {% for e in entries %}130-<tr><td class="icon">{{ '&#128193;' | safe if e.type == 'tree' else ('&#128279;' | safe if e.type == 'commit' else '&#128196;' | safe) }}</td><td class="main">{% if e.href %}<a href="{{ e.href }}">{{ e.name }}</a>{% else %}{{ e.name }} <span class="desc">@ {{ e.oid | short }}</span>{% endif %}</td><td class="num size">{{ e.size | size if e.type == 'blob' else '' }}</td></tr>134+<tr><td class="icon">{{ ui.icon('folder' if e.type == 'tree' else ('link' if e.type == 'commit' else 'file')) }}</td><td class="main">{% if e.href %}<a href="{{ e.href }}">{{ e.name }}</a>{% else %}{{ e.name }} <span class="desc">@ {{ e.oid | short }}</span>{% endif %}</td><td class="num size">{{ e.size | size if e.type == 'blob' else '' }}</td></tr>131135 {% endfor %}132136 </tbody>133137 </table>134138 {% endmacro %}135139 136-{# The index and owner pages: repos as rows on shelves by kind (the shelves137- filter), with links (.chip) that jump to a shelf. ui.ts makes them filters and138- adds "has a site" and the sort; keys.ts walks the cards with j and k. #}140+{# The index and owner pages: repos as rows on shelves by kind. Tabs above141+ them name the shelves, "all" first: without JS they jump to a shelf,142+ with it (ui.ts) one shows that shelf alone. Beside them, how to look at143+ the list, which needs JS and shows with it: the globe keeps only repos144+ with a site, and the sort is newest first or by name. keys.ts walks the145+ rows with j and k. #}139146 {% macro shelves(repos, owners = false) %}140147 {% css %}141-.shelf-bar { display: flex; flex-wrap: wrap; gap: 4px 18px; align-items: baseline; margin: 0 0 4px; }148+.shelf-bar { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-6); align-items: center; margin: 0 0 var(--space-2); border-bottom: var(--hairline) solid var(--border); }149+.shelf-bar nav.tabs { flex: 0 1 auto; min-width: 0; }150+.shelf-bar.plain { border-bottom: 0; margin-bottom: calc(-1 * var(--control-h) - var(--space-2)); }142151 .shelf-bar .grow { flex: 1; }143-.chip { font: inherit; font-size: 0.9em; letter-spacing: normal; text-transform: none; color: var(--link); background: none; border: 0; padding: 4px 0; cursor: pointer; white-space: nowrap; }144-.chip:hover { color: var(--deep); background: none; text-decoration: none; }145-.chip[aria-pressed="true"] { color: var(--fg); font-weight: 600; box-shadow: inset 0 -2px 0 var(--accent); }146-.chip .n { font: 11px var(--mono); color: var(--faint); margin-left: 5px; font-variant-numeric: tabular-nums; }147-.shelf-head { display: flex; align-items: baseline; gap: 8px; margin: 26px 0 0; padding-bottom: 6px; border-bottom: 1px solid var(--border); scroll-margin-top: 12px; }152+.shelf-tools { display: flex; gap: var(--space-2); align-items: center; padding: var(--space-1) 0; }153+.shelf-tools[hidden] { display: none; }154+.shelf-head { display: flex; align-items: baseline; gap: var(--space-2); margin: var(--space-6) 0 0; padding-bottom: var(--space-2); border-bottom: var(--hairline) solid var(--border); scroll-margin-top: var(--space-3); }148155 .shelf-head h2 { margin: 0; padding: 0; border: 0; }149-.shelf-head .n { font: 600 11px var(--mono); color: var(--faint); }156+.shelf-head .n { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); }150157 /* Repos as rows between hairlines: name, what it is, then its sites, reviews and last push. */151158 .cards { list-style: none; margin: 0; padding: 0; }152-.card { position: relative; display: grid; grid-template-columns: minmax(8em, 15em) 1fr auto; column-gap: 16px; align-items: baseline; min-width: 0; padding: 9px 8px; border-bottom: 1px solid var(--border); }159+.card { position: relative; display: grid; grid-template-columns: minmax(8em, 15em) 1fr auto; column-gap: var(--space-4); align-items: baseline; min-width: 0; padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); }153160 .card:hover, .card:focus-within, .card.sel { background: var(--hover); }154161 .card.sel { box-shadow: inset 2px 0 0 var(--accent); }155162 .card-top { min-width: 0; }156-.card-name { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }163+.card-name { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: var(--fw-strong); color: var(--fg); }164+.card-name:hover { color: var(--deep); }157165 .card-name::after { content: ''; position: absolute; inset: 0; }158-.card-name .desc { font-weight: normal; }159-.card-desc { grid-column: 2; margin: 0; color: var(--muted); font-size: 0.92em; overflow-wrap: anywhere; }160-.card-foot { grid-column: 3; display: flex; align-items: baseline; gap: 14px; font: 12px var(--mono); color: var(--faint); white-space: nowrap; }166+.card-name .desc { font-weight: var(--fw-regular); }167+.card-desc { grid-column: 2; margin: 0; color: var(--muted); font-size: var(--text-sm); overflow-wrap: anywhere; }168+.card-foot { grid-column: 3; display: flex; align-items: baseline; gap: var(--space-4); font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); white-space: nowrap; }161169 .card-foot a { position: relative; z-index: 1; }170+.card-foot .site { display: inline-flex; align-items: center; gap: var(--space-1); align-self: center; }162171 .card-foot .grow { display: none; }163-.card-foot time { font-size: 1em; }172+.card-foot time { font-size: inherit; }164173 @media (max-width: 720px) {165- .card { grid-template-columns: 1fr; row-gap: 3px; }174+ .card { grid-template-columns: 1fr; row-gap: var(--space-1); }166175 .card-desc, .card-foot { grid-column: 1; }167176 }168-.shelves-empty { color: var(--muted); padding: 24px 0; text-align: center; }177+.shelves-empty { color: var(--muted); padding: var(--space-6) 0; text-align: center; }169178 {% endcss %}170179 {% set list = repos | shelves %}171-<div class="shelf-bar" data-shelves>172-{% if list.length > 1 %}{% for sh in list %}<a class="chip" href="#{{ sh.id }}" data-shelf="{{ sh.id }}">{{ sh.title }}<span class="n">{{ sh.repos.length }}</span></a>{% endfor %}{% endif %}173-<button type="button" class="chip" data-has-site hidden aria-pressed="false">has a site</button>180+<div class="shelf-bar{% if list.length < 2 %} plain{% endif %}" id="shelves" data-shelves>181+{% if list.length > 1 %}182+{% set tabs = [{href: '#shelves', text: 'all', n: repos.length, cur: true, attrs: 'data-shelf=""'}] %}183+{% for sh in list %}{% set _ = tabs.push({href: '#' + sh.id, text: sh.title, n: sh.repos.length, attrs: 'data-shelf="' + sh.id + '"'}) %}{% endfor %}184+{{ ui.tabs('shelves', tabs) }}185+{% endif %}174186 <span class="grow"></span>175-<button type="button" class="chip" data-sort hidden title="Sort by name or by last push">newest first</button>187+<span class="shelf-tools" hidden>188+{{ ui.iconToggle('globe', 'only repos with a site', false, 'data-has-site') }}189+{{ ui.segmented('sort', [{icon: 'clock', label: 'newest first', pressed: true, attrs: 'data-sort="date"'}, {icon: 'az', label: 'by name, a to z', attrs: 'data-sort="name"'}]) }}190+</span>176191 </div>177192 {% for sh in list %}178193 <section class="shelf" data-shelf="{{ sh.id }}">
@@ -192,6 +207,6 @@
192207 {% endmacro %}193208 194209 {% macro sitesLink(s) %}195-{% if s.counts.sites == 1 and s.sites.length %}<a href="{{ s.sites[0].url }}">{{ s.sites[0].name }}</a>196-{% elif s.counts.sites %}<a href="{{ s.urls.sites.href }}">sites</a>{% endif %}210+{% if s.counts.sites == 1 and s.sites.length %}<a class="site" href="{{ s.sites[0].url }}" title="its site">{{ ui.icon('globe') }}{{ s.sites[0].name }}</a>211+{% elif s.counts.sites %}<a class="site" href="{{ s.urls.sites.href }}" title="its sites">{{ ui.icon('globe') }}sites</a>{% endif %}197212 {% endmacro %}
M site/_includes/private.njk +5 -4
@@ -3,26 +3,27 @@
33 Data: forge, p (title), user, csrf, signal (JSON or ''), repos (owner,44 name, description, lastCommit, href, built). -#}55 {% extends "base.njk" %}6+{% import "ui.njk" as ui %}67 {% block scripts %}78 <script src="{{ assets.webauthn }}" defer></script>89 <script src="{{ assets.login }}" defer></script>910 {% endblock %}1011 {% block head %}<meta name="robots" content="noindex">1112 {% endblock %}12-{% block heading %}<b>Private repos</b>13+{% block heading %}<b>private repos</b>1314 {% endblock %}1415 {% block content %}1516 {% css %}16-.signed-in { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; margin: 0 0 12px; color: var(--muted); font-size: 0.92em; }17+.signed-in { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-3); align-items: center; margin: 0 0 var(--space-3); color: var(--muted); font-size: var(--text-sm); }1718 .signed-in form { display: inline; }1819 {% endcss %}1920 <div class="signed-in" id="login"{% if signal %} data-signal="{{ signal }}"{% endif %}>2021 <span>Signed in as <b>{{ user }}</b>. These are the private repos you can read; only their readers see them.</span>21-<form method="post" action="/login/logout"><input type="hidden" name="csrf" value="{{ csrf }}"><button type="submit" id="signout">Sign out</button></form>22+<form method="post" action="/login/logout"><input type="hidden" name="csrf" value="{{ csrf }}">{{ ui.button('sign out', false, 'id="signout"', 'submit') }}</form>2223 </div>2324 <div class="box">2425 <table class="list stack nav">25-<thead><tr><th>Repository</th><th>Description</th><th>Updated</th></tr></thead>26+<thead><tr><th>repository</th><th>description</th><th>updated</th></tr></thead>2627 <tbody>2728 {% for s in repos %}2829 <tr><td class="nowrap">{% if s.built %}<a href="{{ s.href }}">{% endif %}<span class="desc">{{ s.owner }} /</span> <b>{{ s.name }}</b>{% if s.built %}</a>{% endif %}</td><td class="main">{{ s.description }}{% if not s.built %} <span class="desc">(no pages yet)</span>{% endif %}</td><td class="date">{{ s.lastCommit | date }}</td></tr>
M site/_includes/search.njk +23 -22
@@ -4,34 +4,35 @@
44 someone searches file contents. -#}55 {% css %}66 /* search: the header button, and the overlay it and "/" open */7-.search-open { display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 4px 9px; }7+.search-open { flex: none; gap: var(--space-2); color: var(--muted); }88 .search-open[hidden] { display: none; }9-.search-open kbd { line-height: 1.3; color: inherit; border-color: currentColor; background: none; }10-#search { position: fixed; inset: 0; z-index: 20; background: rgba(0, 0, 0, 0.3); }11-#search .search-box { position: absolute; top: max(8px, 8vh); left: 50%; transform: translateX(-50%); width: min(680px, calc(100vw - 16px)); background: var(--raised); border: 1px solid var(--border); padding: 8px; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2); }12-#search input { width: 100%; font: 16px var(--sans); padding: 8px 10px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); }13-#search input:focus, #finder input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }14-#search ol { list-style: none; margin: 4px 0 0; padding: 0; max-height: 65vh; overflow: auto; }15-#search li.group { margin: 0 0 2px; padding: 12px 8px 4px; font: 600 10.5px/1.4 var(--mono); color: var(--faint); text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid var(--border); }16-#search li.group span { font-weight: normal; margin-left: 4px; }17-#search li a { display: block; padding: 5px 8px; color: var(--fg); }9+.search-open kbd { border: 0; padding: 0; background: none; color: var(--faint); }10+#search { position: fixed; inset: 0; z-index: 20; background: var(--scrim); }11+#search .search-box { position: absolute; top: max(8px, 8vh); left: 50%; transform: translateX(-50%); width: min(680px, calc(100vw - 16px)); background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-2); box-shadow: var(--shadow); }12+#search input { width: 100%; font: var(--fw-regular) var(--text-lg) var(--sans); padding: var(--space-2) var(--space-3); border: var(--hairline) solid var(--border-strong); background: var(--bg); color: var(--fg); }13+#search input:focus, #finder input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 var(--hairline) var(--accent); }14+#search ol { list-style: none; margin: var(--space-1) 0 0; padding: 0; max-height: 65vh; overflow: auto; }15+#search li.group { margin: 0 0 var(--space-half); padding: var(--space-3) var(--space-2) var(--space-1); font-size: var(--text-sm); font-weight: var(--fw-strong); color: var(--muted); border-bottom: var(--hairline) solid var(--border); }16+#search li.group span { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); margin-left: var(--space-1); }17+#search li a { display: block; padding: var(--space-1) var(--space-2); color: var(--fg); }1818 #search li a:hover { background: var(--hover); text-decoration: none; }1919 #search li.sel a, #search li.sel button { background: var(--hover); box-shadow: inset 2px 0 0 var(--accent); }2020 #search .t, #search .m { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }21-#search .t.mono { font: 0.9em var(--mono); }22-#search .m.mono { font-family: var(--mono); white-space: pre; }23-#search .m { font-size: 0.82em; color: var(--muted); }24-#search b { color: var(--link); font-weight: 600; }25-#search li.more button { border: 0; background: none; padding: 5px 8px; width: 100%; text-align: left; }26-#search li.note { padding: 5px 8px; font-size: 0.85em; color: var(--muted); }27-#search .hint { margin: 8px 8px 2px; font-size: 0.85em; color: var(--muted); }21+#search .t.mono { font-size: var(--text-sm); }22+#search .m.mono { white-space: pre; }23+#search .m { font-size: var(--text-xs); color: var(--muted); }24+#search b { color: var(--link); font-weight: var(--fw-strong); }25+#search li.more button { min-height: 0; border: 0; background: none; padding: var(--space-1) var(--space-2); width: 100%; justify-content: flex-start; color: var(--link); }26+#search li.note { padding: var(--space-1) var(--space-2); font-size: var(--text-sm); color: var(--muted); }27+#search .hint { margin: var(--space-2) var(--space-2) var(--space-half); font-size: var(--text-sm); color: var(--muted); }2828 #search .hint:empty { display: none; }2929 /* file finder */30-#finder { position: fixed; top: 10%; left: 50%; transform: translateX(-50%); z-index: 20; width: min(640px, 94vw); background: var(--raised); border: 1px solid var(--border); padding: 8px; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2); }31-#finder input { width: 100%; font: 15px var(--mono); padding: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); }32-#finder ol { list-style: none; margin: 6px 0 0; padding: 0; max-height: 60vh; overflow: auto; }33-#finder li a { display: block; padding: 4px 8px; font-family: var(--mono); font-size: 0.9em; color: var(--fg); }30+#finder { position: fixed; top: 10%; left: 50%; transform: translateX(-50%); z-index: 20; width: min(640px, 94vw); background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-2); box-shadow: var(--shadow); }31+#finder input { width: 100%; font: var(--fw-regular) var(--text-md) var(--mono); padding: var(--space-2); border: var(--hairline) solid var(--border-strong); background: var(--bg); color: var(--fg); }32+#finder ol { list-style: none; margin: var(--space-2) 0 0; padding: 0; max-height: 60vh; overflow: auto; }33+#finder li a { display: block; padding: var(--space-1) var(--space-2); font-family: var(--mono); font-size: var(--text-sm); color: var(--fg); }3434 #finder li.sel a { background: var(--hover); box-shadow: inset 2px 0 0 var(--accent); }3535 #finder li b { color: var(--link); }3636 {% endcss %}37-<button type="button" class="search-open" hidden data-search="{{ r.urls.search.href if r else '/repos.json' }}"{% if o and o.owner %} data-owner="{{ o.owner }}"{% endif %}{% if r %} data-code="{{ assets.codesearch }}" data-sqlite="{{ assets.sqlite3 }}" data-wasm="{{ assets.sqlite3wasm }}"{% endif %} title="Search (/)" aria-label="Search"><svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><circle cx="6.8" cy="6.8" r="4.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10.3 10.3 14 14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg><kbd class="hide-sm">/</kbd></button>37+{% import "ui.njk" as ui %}38+<button type="button" class="search-open" hidden data-search="{{ r.urls.search.href if r else '/repos.json' }}"{% if o and o.owner %} data-owner="{{ o.owner }}"{% endif %}{% if r %} data-code="{{ assets.codesearch }}" data-sqlite="{{ assets.sqlite3 }}" data-wasm="{{ assets.sqlite3wasm }}"{% endif %} title="search (/)" aria-label="search">{{ ui.icon('search') }}<kbd class="hide-sm">/</kbd></button>
A site/_includes/ui.njk +93 -0
@@ -0,0 +1,93 @@
1+{#- The forge's controls, one macro each; their CSS is in style.css under2+ "controls". Pages use these rather than writing the markup again, so a3+ control looks and behaves the same everywhere; /design/ shows each one.4+ Import it with: {% import "ui.njk" as ui %}5+6+ The rule: tabs say where you are; a checkbox turns one thing on or off;7+ a segmented control picks one of a few; an icon toggle is a checkbox8+ drawn as a symbol (with aria-pressed, a label and a title); buttons do9+ things; links only go places. Chrome is written in lowercase here and10+ in templates; nothing is case-transformed by CSS.11+12+ attrs, where a macro takes it, is extra attributes as written, e.g.13+ 'data-wrap' or 'id="signin"'. -#}14+15+{# A 16px line icon in the text's colour. With a label it is an image with16+ that name; without, decoration beside words that say the same. #}17+{% macro icon(name, label = '') -%}18+<svg class="icon" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"{% if label %} role="img" aria-label="{{ label }}"{% else %} aria-hidden="true"{% endif %}>19+{%- if name == 'globe' %}<circle cx="8" cy="8" r="6.3"/><ellipse cx="8" cy="8" rx="2.7" ry="6.3"/><path d="M1.9 6h12.2M1.9 10h12.2"/>20+{%- elif name == 'clock' %}<circle cx="8" cy="8" r="6.3"/><path d="M8 4.4V8l2.6 1.6"/>21+{%- elif name == 'az' %}<path d="M1.8 13 4.6 3.5 7.4 13M2.8 10h3.6M9.4 3.5h4.4l-4.4 9.5h4.6"/>22+{%- elif name == 'folder' %}<path d="M1.8 4.2c0-.6.4-1 1-1h3.4l1.5 1.7h5.5c.6 0 1 .4 1 1v6.9c0 .6-.4 1-1 1H2.8c-.6 0-1-.4-1-1z"/>23+{%- elif name == 'file' %}<path d="M3.5 1.8h5.6l3.4 3.4v9H3.5zM9 1.8v3.5h3.5"/>24+{%- elif name == 'link' %}<path d="M6.7 9.3a2.8 2.8 0 0 0 4 0l2.1-2.1a2.8 2.8 0 0 0-4-4l-.8.8M9.3 6.7a2.8 2.8 0 0 0-4 0L3.2 8.8a2.8 2.8 0 0 0 4 4l.8-.8"/>25+{%- elif name == 'up' %}<path d="M6.5 3.2 3 6.7l3.5 3.5M3.2 6.7h6.3a3.5 3.5 0 0 1 0 7H8"/>26+{%- elif name == 'branch' %}<circle cx="4.5" cy="3.5" r="1.6"/><circle cx="4.5" cy="12.5" r="1.6"/><circle cx="11.5" cy="5" r="1.6"/><path d="M4.5 5.1v5.8M11.5 6.6c0 2.6-2.2 3.4-5.4 4.3"/>27+{%- elif name == 'search' %}<circle cx="6.8" cy="6.8" r="4.6"/><path d="M10.3 10.3 14 14"/>28+{%- endif %}</svg>29+{%- endmacro %}30+31+{# A button. primary: the one main action of its place, filled with the32+ accent; otherwise secondary. #}33+{% macro button(text, primary = false, attrs = '', type = 'button') -%}34+<button type="{{ type }}"{% if primary %} class="primary"{% endif %}{% if attrs %} {{ attrs | safe }}{% endif %}>{{ text }}</button>35+{%- endmacro %}36+37+{# An icon toggle: on or off, drawn as a symbol; filled with ink when on.38+ label names what it does when on; it is the button's name and title. #}39+{% macro iconToggle(name, label, pressed = false, attrs = '') -%}40+<button type="button" class="icon-btn" aria-pressed="{{ 'true' if pressed else 'false' }}" aria-label="{{ label }}" title="{{ label }}"{% if attrs %} {{ attrs | safe }}{% endif %}>{{ icon(name) }}</button>41+{%- endmacro %}42+43+{# A segmented control: one of a few, as joined buttons, the chosen one44+ filled with ink. items: {label, icon (optional: the label becomes the45+ name and title), pressed, attrs}. #}46+{% macro segmented(label, items, attrs = '') -%}47+<span class="segmented" role="group" aria-label="{{ label }}"{% if attrs %} {{ attrs | safe }}{% endif %}>48+{%- for it in items %}<button type="button"{% if it.icon %} class="icon-btn" aria-label="{{ it.label }}" title="{{ it.label }}"{% endif %} aria-pressed="{{ 'true' if it.pressed else 'false' }}"{% if it.attrs %} {{ it.attrs | safe }}{% endif %}>{{ icon(it.icon) if it.icon else it.label }}</button>{% endfor -%}49+</span>50+{%- endmacro %}51+52+{# A checkbox with its words: on or off. #}53+{% macro checkbox(text, checked = false, attrs = '') -%}54+<label class="check"><input type="checkbox"{% if checked %} checked{% endif %}{% if attrs %} {{ attrs | safe }}{% endif %}>{{ text }}</label>55+{%- endmacro %}56+57+{# Tabs: where you are. items: {href, text, cur, key (a keys.ts shortcut),58+ n (a count after the words), off (leave it out), attrs}. #}59+{% macro tabs(label, items, cls = '', attrs = '') -%}60+<nav class="tabs{% if cls %} {{ cls }}{% endif %}" aria-label="{{ label }}"{% if attrs %} {{ attrs | safe }}{% endif %}>61+{% for t in items %}{% if not t.off %}<a href="{{ t.href }}"{% if t.key %} data-key="{{ t.key }}"{% endif %}{% if t.cur %} class="cur" aria-current="page"{% endif %}{% if t.attrs %} {{ t.attrs | safe }}{% endif %}>{{ t.text }}{% if t.n != null %}<span class="n">{{ t.n }}</span>{% endif %}</a>62+{% endif %}{% endfor -%}63+</nav>64+{%- endmacro %}65+66+{# A text box. mono: for what is copied, like a URL or a command. #}67+{% macro input(label, value = '', attrs = '', mono = false) -%}68+<input class="input{% if mono %} mono{% endif %}" aria-label="{{ label }}"{% if value %} value="{{ value }}"{% endif %}{% if attrs %} {{ attrs | safe }}{% endif %}>69+{%- endmacro %}70+71+{% macro textarea(label, placeholder = '', attrs = '') -%}72+<textarea class="input" aria-label="{{ label }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if attrs %} {{ attrs | safe }}{% endif %}></textarea>73+{%- endmacro %}74+75+{# A list: a table with rows between hairlines that restacks on phones76+ (stack). heads: column names, or {text, cls, title}. The rows are the77+ call's body: {% call ui.listTable(['branch', 'updated']) %}<tr>...{% endcall %} #}78+{% macro listTable(heads = [], cls = 'stack nav', attrs = '') -%}79+<table class="list{% if cls %} {{ cls }}{% endif %}"{% if attrs %} {{ attrs | safe }}{% endif %}>80+{% if heads.length %}<thead><tr>{% for h in heads %}<th{% if h.cls %} class="{{ h.cls }}"{% endif %}{% if h.title %} title="{{ h.title }}"{% endif %}>{{ h.text if h.text != null else h }}</th>{% endfor %}</tr></thead>81+{% endif %}<tbody>82+{{ caller() }}</tbody>83+</table>84+{%- endmacro %}85+86+{# A review's status: the one filled label, white on its colour. #}87+{% macro status(s) %}<span class="status {{ s }}">{{ s }}</span>{% endmacro %}88+89+{# A section heading on a rule: its words, then a count (n) and a quieter90+ note (desc). #}91+{% macro heading(text, n = null, desc = '', id = '') -%}92+<h2{% if id %} id="{{ id }}"{% endif %}>{{ text }}{% if n != null %} <span class="n">{{ n }}</span>{% endif %}{% if desc %} <span class="desc">{{ desc }}</span>{% endif %}</h2>93+{%- endmacro %}
M site/assets/diffs.ts +5 -5
M site/assets/discuss.ts +6 -6
@@ -112,11 +112,11 @@
112112 const box = document.createElement('div');113113 box.className = 'cmts';114114 box.dataset.lineForm = where;115- box.innerHTML = '<form class="linecmt"><div class="where"></div><textarea name="body" maxlength="32768" placeholder="Comment on this line. Markdown is fine."></textarea>' +116- '<div class="reply-bar"><span class="reply-status" role="status"></span><span class="grow"></span><button type="button" data-cancel>Cancel</button><button type="submit" class="go">Comment</button></div></form>';115+ box.innerHTML = '<form class="linecmt"><div class="where"></div><textarea class="input" name="body" maxlength="32768" placeholder="Comment on this line. Markdown is fine."></textarea>' +116+ '<div class="reply-bar"><span class="reply-status" role="status"></span><span class="grow"></span><button type="button" data-cancel>cancel</button><button type="submit" class="primary">comment</button></div></form>';117117 box.querySelector('.where')!.textContent = `${where}, as ${me.user}`;118118 const text = box.querySelector('textarea')!;119- text.setAttribute('aria-label', `Comment on ${where}`);119+ text.setAttribute('aria-label', `comment on ${where}`);120120 text.value = draft(`line ${where}`);121121 below(row, box);122122 if (focus) text.focus();
@@ -202,8 +202,8 @@
202202 if (approve && me.user === form.dataset.author) approve.hidden = true;203203 else if (approve && shown({ kind: 'approve' })) {204204 approve.disabled = true;205- approve.textContent = 'Approved';206- approve.title = 'You approved this tree';205+ approve.textContent = 'approved';206+ approve.title = 'you approved this tree';207207 }208208 }209209 const hint = lines();
@@ -250,7 +250,7 @@
250250 watch();251251 }252252 busy(form, false);253- b.textContent = 'Approved';253+ b.textContent = 'approved';254254 b.disabled = true;255255 } catch (err) {256256 say(form, failed(err), true);
M site/assets/insights.ts +11 -11 mechanical
@@ -368,11 +368,11 @@
368368 box.appendChild(k);369369 };370370 const n = (k: string): number => t[k] ?? 0;371- tile('Pushes', count(n('pushes')), d.from ? `since ${dayLong(d.from)}` : 'none yet');372- tile('Builds', count(n('builds')), t.build_ms_median != null ? `median ${secs(t.build_ms_median / 1000)}` : 'none yet');373- tile('Reviews landed', `${count(n('landed'))} of ${count(n('reviews'))}`, t.land_hours_median != null ? `median ${hours(t.land_hours_median)} to land` : `${count(n('closed'))} closed`);374- tile('Revisions', t.revisions_median != null ? r1(t.revisions_median) : '-', 'per review, median');375- tile('CI passed', n('ci') ? `${Math.round((100 * n('ci_pass')) / n('ci'))}%` : '-', n('ci') ? `${n('ci_pass')} of ${plural(n('ci'), 'run')}${t.ci_seconds_median != null ? `, median ${secs(t.ci_seconds_median)}` : ''}` : 'no attestations yet');371+ tile('pushes', count(n('pushes')), d.from ? `since ${dayLong(d.from)}` : 'none yet');372+ tile('builds', count(n('builds')), t.build_ms_median != null ? `median ${secs(t.build_ms_median / 1000)}` : 'none yet');373+ tile('reviews landed', `${count(n('landed'))} of ${count(n('reviews'))}`, t.land_hours_median != null ? `median ${hours(t.land_hours_median)} to land` : `${count(n('closed'))} closed`);374+ tile('revisions', t.revisions_median != null ? r1(t.revisions_median) : '-', 'per review, median');375+ tile('ci passed', n('ci') ? `${Math.round((100 * n('ci_pass')) / n('ci'))}%` : '-', n('ci') ? `${n('ci_pass')} of ${plural(n('ci'), 'run')}${t.ci_seconds_median != null ? `, median ${secs(t.ci_seconds_median)}` : ''}` : 'no attestations yet');376376 }377377 378378 function draw(d: Data): void {
@@ -383,7 +383,7 @@
383383 chart('builds', (fig, plot) => {384384 if (!d.builds.length) return empty(fig, 'No builds yet.');385385 dots(plot, d.builds.map(([t, ms]) => ({ t, v: ms / 1000, head: when(t), rows: [[secs(ms / 1000), 'to build']] })), (v) => secs(v));386- table(fig, ['Built', 'Took'], () => [...d.builds].reverse().map(([t, ms]) => [when(t), secs(ms / 1000)]));386+ table(fig, ['built', 'took'], () => [...d.builds].reverse().map(([t, ms]) => [when(t), secs(ms / 1000)]));387387 });388388 389389 chart('pushes', (fig, plot) => {
@@ -395,7 +395,7 @@
395395 for (const s of fig.querySelectorAll('.ins-bucket')) s.textContent = bk.unit;396396 const every = Math.ceil(bk.keys.length / Math.max(2, Math.floor((plot.clientWidth || 440) / 70)));397397 columns(plot, bk.keys.map((k) => ({ label: bucketLabel(bk.unit, k), head: `${bk.unit === 'week' ? 'week of ' : ''}${bk.unit === 'month' ? bucketLabel('month', k) : dayLong(k)}`, segs: [{ v: sums.get(k) ?? 0, cls: 's1', name: 'pushes' }] })), (v) => count(v), every);398- table(fig, [bk.unit === 'week' ? 'Week of' : bk.unit === 'month' ? 'Month' : 'Day', 'Pushes'], () => bk.keys.filter((k) => sums.get(k)).reverse().map((k) => [dayLong(k), String(sums.get(k))]));398+ table(fig, [bk.unit === 'week' ? 'week of' : bk.unit === 'month' ? 'month' : 'day', 'pushes'], () => bk.keys.filter((k) => sums.get(k)).reverse().map((k) => [dayLong(k), String(sums.get(k))]));399399 });400400 401401 chart('land', (fig, plot) => {
@@ -403,7 +403,7 @@
403403 if (!landed.length) return empty(fig, 'No review has landed yet.');404404 const h = (r: ReviewRow): number => Math.max(0, (r[3]! - r[2]!) / 3600e3);405405 dots(plot, landed.map((r) => ({ t: r[3]!, v: h(r), head: `${reviewName(r)}, landed ${when(r[3]!)}`, rows: [[hours(h(r)), 'to land'], [String(r[5]), r[5] === 1 ? 'revision' : 'revisions']] })), (v) => hours(v));406- table(fig, ['Review', 'Landed', 'Took', 'Revisions'], () => [...landed].reverse().map((r) => [reviewName(r), when(r[3]!), hours(h(r)), String(r[5])]));406+ table(fig, ['review', 'landed', 'took', 'revisions'], () => [...landed].reverse().map((r) => [reviewName(r), when(r[3]!), hours(h(r)), String(r[5])]));407407 });408408 409409 chart('revisions', (fig, plot) => {
@@ -415,7 +415,7 @@
415415 for (const r of revs) n[Math.min(r[5], cap)]++;416416 const name = (i: number): string => (i === cap && top > cap ? `${i}+` : String(i));417417 columns(plot, n.slice(1).map((v, i) => ({ label: name(i + 1), head: `${name(i + 1)} ${i === 0 ? 'revision' : 'revisions'}`, segs: [{ v, cls: 's1', name: v === 1 ? 'review' : 'reviews' }] })), (v) => count(v));418- table(fig, ['Review', 'Revisions', 'Approvals', 'Ended'], () => [...revs].reverse().map((r) => [reviewName(r), String(r[5]), String(r[6]), r[4] ?? 'open']));418+ table(fig, ['review', 'revisions', 'approvals', 'ended'], () => [...revs].reverse().map((r) => [reviewName(r), String(r[5]), String(r[6]), r[4] ?? 'open']));419419 });420420 421421 chart('ci', (fig, plot) => {
@@ -428,7 +428,7 @@
428428 const every = Math.ceil(bk.keys.length / Math.max(2, Math.floor((plot.clientWidth || 440) / 70)));429429 columns(plot, bk.keys.map((k) => ({ label: bucketLabel(bk.unit, k), head: `${bk.unit === 'week' ? 'week of ' : ''}${dayLong(k)}`, segs: [{ v: pass.get(k) ?? 0, cls: 's1', name: 'passed' }, { v: fail.get(k) ?? 0, cls: 'fail', name: 'failed' }] })), (v) => count(v), every);430430 if (!fig.querySelector('.legend')) legend(fig, [['s1', 'passed'], ['fail', 'failed']]);431- table(fig, [bk.unit === 'week' ? 'Week of' : bk.unit === 'month' ? 'Month' : 'Day', 'Passed', 'Failed'], () => bk.keys.filter((k) => pass.get(k) || fail.get(k)).reverse().map((k) => [dayLong(k), String(pass.get(k) ?? 0), String(fail.get(k) ?? 0)]));431+ table(fig, [bk.unit === 'week' ? 'week of' : bk.unit === 'month' ? 'month' : 'day', 'passed', 'failed'], () => bk.keys.filter((k) => pass.get(k) || fail.get(k)).reverse().map((k) => [dayLong(k), String(pass.get(k) ?? 0), String(fail.get(k) ?? 0)]));432432 });433433 434434 chart('ciTime', (fig, plot) => {
@@ -436,7 +436,7 @@
436436 if (!runs.length) return empty(fig, 'No CI attestations yet (forge attest).');437437 dots(plot, runs.map(([t, s, p]) => ({ t, v: s!, fail: !p, head: when(t), rows: [[secs(s!), p ? 'passed' : 'failed', p ? 's1' : 'fail']] })), (v) => secs(v));438438 if (!fig.querySelector('.legend')) legend(fig, [['s1', 'passed'], ['fail', 'failed']]);439- table(fig, ['Started', 'Took', 'Result'], () => [...runs].reverse().map(([t, s, p]) => [when(t), secs(s!), p ? 'passed' : 'failed']));439+ table(fig, ['started', 'took', 'result'], () => [...runs].reverse().map(([t, s, p]) => [when(t), secs(s!), p ? 'passed' : 'failed']));440440 });441441 442442 const repos = root.querySelector('#ins-repos tbody');
M site/assets/keys.ts +1 -1
M site/assets/logo.svg +1 -1
M site/assets/search.ts +11 -11 mechanical
@@ -46,7 +46,7 @@ import { hasAll, marks, narrow, score as match, snippet, terms } from './query.t
4646 inText: boolean;4747 }4848 49- const KINDS = ['Repositories', 'Reviews', 'Commits', 'Wiki', 'Files'];49+ const KINDS = ['repositories', 'reviews', 'commits', 'wiki', 'files'];5050 const PER_KIND = 6;5151 5252 // Code search: lines from the worker, for the query they answer.
@@ -107,21 +107,21 @@ import { hasAll, marks, narrow, score as match, snippet, terms } from './query.t
107107 const owner = button!.dataset.owner;108108 const list: Rec[] = [];109109 for (const [slug, desc, href] of ix.repos ?? []) {110- if (!owner || String(slug).startsWith(owner + '/')) list.push(rec('Repositories', String(href), String(slug), '', String(desc)));110+ if (!owner || String(slug).startsWith(owner + '/')) list.push(rec('repositories', String(href), String(slug), '', String(desc)));111111 }112112 const home = ix.home ?? '/';113113 for (const [id, title, branch, author, status] of ix.reviews ?? []) {114- list.push(rec('Reviews', `${home}reviews/${id}/`, `#${id} ${title}`, `${branch}, ${author ? author + ', ' : ''}${status}`));114+ list.push(rec('reviews', `${home}reviews/${id}/`, `#${id} ${title}`, `${branch}, ${author ? author + ', ' : ''}${status}`));115115 }116116 for (const [sha, subject, author, date, body] of ix.commits ?? []) {117- list.push(rec('Commits', `${home}commit/${sha}/`, String(subject), `${String(sha).slice(0, 7)} ${author}, ${date}`, String(body), String(sha)));117+ list.push(rec('commits', `${home}commit/${sha}/`, String(subject), `${String(sha).slice(0, 7)} ${author}, ${date}`, String(body), String(sha)));118118 }119- for (const [href, title, text] of ix.wiki ?? []) list.push(rec('Wiki', String(href), String(title), '', String(text)));119+ for (const [href, title, text] of ix.wiki ?? []) list.push(rec('wiki', String(href), String(title), '', String(text)));120120 codeUrl = ix.code ?? null;121121 blobBase = ix.blob ?? '';122122 if (ix.files) {123123 const paths = (await (await fetch(ix.files)).text()).split('\n').filter(Boolean);124- for (const p of paths) list.push(rec('Files', `${ix.blob}${enc(p)}/`, p, '', '', '', true));124+ for (const p of paths) list.push(rec('files', `${ix.blob}${enc(p)}/`, p, '', '', '', true));125125 }126126 return list;127127 }
@@ -228,27 +228,27 @@ import { hasAll, marks, narrow, score as match, snippet, terms } from './query.t
228228 const ws = codeWords(q);229229 const fresh = code?.q === q;230230 if (fresh && code!.error) {231- heading('Code', '');231+ heading('code', '');232232 note(code!.error);233233 return true;234234 }235235 const hits = !code ? [] : fresh ? code.hits : code.hits.filter((h) => hasAll(h.text, ws));236236 if (!hits.length) {237237 if (fresh) return false;238- heading('Code', '');238+ heading('code', '');239239 note('Searching file contents');240240 return true;241241 }242242 const more = fresh && code!.more;243- heading('Code', `${hits.length}${more ? '+' : ''}`);244- const shown = expanded.has('Code') ? hits : hits.slice(0, PER_KIND);243+ heading('code', `${hits.length}${more ? '+' : ''}`);244+ const shown = expanded.has('code') ? hits : hits.slice(0, PER_KIND);245245 for (const h of shown) {246246 link(`${blobBase}${enc(h.path)}/${h.source ? 'source/' : ''}#l${h.line}`, [document.createTextNode(`${h.path}:${h.line}`)], true, marked(h.text, ws), true);247247 }248248 const rest = hits.length - shown.length;249249 if (rest || (more && code!.limit < CODE_MORE)) {250250 moreRow(rest ? `${rest}${more ? '+' : ''} more` : 'more', () => {251- expanded.add('Code');251+ expanded.add('code');252252 if (more && code!.limit < CODE_MORE) runCode(q, CODE_MORE);253253 });254254 } else if (more) note('More lines match than are shown here; a longer query narrows them.');
M site/assets/style.css +290 -193
@@ -1,9 +1,17 @@
1-/* forge: the base stylesheet, light and dark, phone first: tokens, layout,2- lists, code views. CSS for one feature lives with its markup, in a3- template or include ({% css %}), and reaches only the pages that use it. */4-/* The look: square corners, content on the page between 1px rules, labels5- in small uppercase monospace, one accent (#3148F6) for links, the current6- tab's bar, focus and primary actions, on a plain page. */1+/* forge: the base stylesheet, light and dark, phone first: tokens, then2+ layout, controls, lists and code views. CSS for one feature lives with3+ its markup, in a template or include ({% css %}), and reaches only the4+ pages that use it. The rules for all of it are in DESIGN.md, and5+ lib/check-css.ts holds every stylesheet to them in CI.6+7+ The look: a plain page, square corners, content between 1px rules rather8+ than in boxes; Open Sans for the interface, Noto Sans Mono for what is9+ copied or compared, Noto Serif for prose people wrote; hierarchy from10+ weight, size and colour, never capitals or tracking; one accent11+ (#3148F6) for links, the current tab's bar, focus and primary actions. */12+13+/* == tokens == Every colour, size, weight and family is here; nothing14+ below this block names one. The style guide (/design/) shows them. */715 /* The fonts, self-hosted, variable in weight, cut to Latin and the symbols816 the pages print (fonts/make-fonts.sh). The sans is preloaded (base.njk). */917 @font-face { font-family: "Open Sans"; src: url(./fonts/open-sans.woff2) format("woff2"); font-weight: 300 800; font-style: normal; font-display: swap; }
@@ -11,189 +19,292 @@
1119 @font-face { font-family: "Noto Serif"; src: url(./fonts/noto-serif.woff2) format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }1220 @font-face { font-family: "Noto Serif"; src: url(./fonts/noto-serif-italic.woff2) format("woff2"); font-weight: 400 700; font-style: italic; font-display: swap; }1321 :root {22+ /* colour: the page, surfaces raised over it, ink in three strengths23+ (muted keeps 4.5:1 on the page), rules, and the accent */1424 --bg: #e9e9e9;25+ --raised: #ffffff;26+ --hover: #f4f4f4;1527 --fg: #1c2127;1628 --muted: #5b6169;17- --faint: #8a9097;18- --border: #d2d2d2;19- /* A raised surface over the page, translucent, and the same as one opaque20- colour, for what content scrolls under (sticky headers, code gutters). */21- --subtle: rgba(255, 255, 255, 0.55);22- --raised: #f5f5f5;23- --hover: rgba(255, 255, 255, 0.7);29+ --faint: #7c828a;30+ --border: #d2d4d7;31+ --border-strong: #a9aeb4;2432 --link: #3148f6;2533 --accent: #3148f6;2634 --deep: #1c4b8f;35+ --accent-hover: #1c4b8f;36+ --on-accent: #ffffff;37+ /* meaning: diffs, CI, warnings */2738 --add-bg: rgba(14, 132, 32, 0.1);28- --add-fg: #0e8420;39+ --add-fg: #0b7a1d;2940 --add-ln: rgba(14, 132, 32, 0.2);3041 --del-bg: rgba(199, 22, 43, 0.08);3142 --del-fg: #c7162b;3243 --del-ln: rgba(199, 22, 43, 0.17);3344 --hunk-bg: rgba(49, 72, 246, 0.07);3445 --hunk-fg: #1c4b8f;35- --pass: #0e8420;46+ --pass: #0b7a1d;3647 --fail: #c7162b;37- --warn: #b46a00;48+ --warn: #965800;3849 --merged: #3148f6;50+ /* status labels: white on a fill, the same in both modes */51+ --status-open: #0e8420;52+ --status-merged: #3148f6;53+ --status-closed: #c7162b;54+ --status-other: #5b6169;55+ /* a linked line, floating panels, dialogs' backdrop, masks */56+ --target-bg: rgba(255, 213, 79, 0.25);57+ --target-gut: rgba(255, 213, 79, 0.4);58+ --shadow: 0 8px 24px rgba(0, 0, 0, 0.12);59+ --scrim: rgba(0, 0, 0, 0.3);60+ --mask: #000;61+ /* syntax highlighting (highlight.js classes) */62+ --tok-comment: #59636e;63+ --tok-keyword: #cf222e;64+ --tok-string: #0a3069;65+ --tok-const: #0550ae;66+ --tok-title: #8250df;67+ --tok-builtin: #953800;68+ --tok-tag: #116329;69+ /* charts (insights.ts): checked for colour-blind readers on both pages */70+ --viz-1: #3148f6;71+ --viz-fail: #c7162b;72+ --viz-grid: #d9d9d9;73+ --viz-axis: #a9aeb4;74+75+ /* type: the interface, what is copied or compared, and prose */3976 --sans: "Open Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;4077 --mono: "Noto Sans Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;4178 --serif: "Noto Serif", Georgia, Cambria, "Times New Roman", serif;79+ --text-xs: 12px;80+ --text-code: 12.5px;81+ --text-sm: 13.5px;82+ --text-md: 15px;83+ --text-prose: 16px;84+ --text-lg: 18px;85+ --text-xl: 22px;86+ --text-2xl: 28px;87+ /* mono inline in sans text runs large; this brings it level */88+ --text-inline-mono: 0.9em;89+ --fw-regular: 400;90+ --fw-medium: 500;91+ --fw-strong: 650;92+93+ /* space: a 4px scale */94+ --space-half: 2px;95+ --space-1: 4px;96+ --space-2: 8px;97+ --space-3: 12px;98+ --space-4: 16px;99+ --space-5: 20px;100+ --space-6: 24px;101+ --space-8: 32px;102+ --space-10: 40px;103+ --hairline: 1px;104+ --bar: 3px;105+ --radius: 0;106+ --control-h: 30px;42107 color-scheme: light dark;43108 }44109 @media (prefers-color-scheme: dark) {45110 :root {46- --bg: #1c2127;47- --fg: #e9e9e9;48- --muted: #a3a9b0;49- --faint: #737a82;50- --border: #2f363e;51- --subtle: rgba(255, 255, 255, 0.04);52- --raised: #23282e;53- --hover: rgba(255, 255, 255, 0.06);111+ --bg: #08090b;112+ --raised: #111418;113+ --hover: #15181d;114+ --fg: #e2e4e7;115+ --muted: #8f959d;116+ --faint: #6b7178;117+ --border: #1f2329;118+ --border-strong: #3a4048;54119 --link: #8e9bff;55120 --deep: #b4bcff;56- --add-bg: rgba(79, 191, 95, 0.13);121+ --accent-hover: #2a3ee0;122+ --add-bg: rgba(79, 191, 95, 0.12);57123 --add-fg: #4fbf5f;58- --add-ln: rgba(79, 191, 95, 0.26);59- --del-bg: rgba(240, 96, 110, 0.13);124+ --add-ln: rgba(79, 191, 95, 0.24);125+ --del-bg: rgba(240, 96, 110, 0.12);60126 --del-fg: #f0606e;61- --del-ln: rgba(240, 96, 110, 0.26);62- --hunk-bg: rgba(142, 155, 255, 0.1);127+ --del-ln: rgba(240, 96, 110, 0.24);128+ --hunk-bg: rgba(142, 155, 255, 0.09);63129 --hunk-fg: #8e9bff;64130 --pass: #4fbf5f;65131 --fail: #f0606e;66132 --warn: #e3a33b;67133 --merged: #8e9bff;68- }134+ --target-bg: rgba(255, 213, 79, 0.12);135+ --target-gut: rgba(255, 213, 79, 0.22);136+ --shadow: 0 8px 28px rgba(0, 0, 0, 0.7);137+ --scrim: rgba(0, 0, 0, 0.6);138+ --tok-comment: #8b929b;139+ --tok-keyword: #ff7b72;140+ --tok-string: #a5d6ff;141+ --tok-const: #79c0ff;142+ --tok-title: #d2a8ff;143+ --tok-builtin: #ffa657;144+ --tok-tag: #7ee787;145+ --viz-1: #6f7fff;146+ --viz-fail: #f0606e;147+ --viz-grid: #1a1e23;148+ --viz-axis: #3a4048;149+ }69150 }151+/* == end tokens == */70152 71-*, ::before, ::after { box-sizing: border-box; border-radius: 0; }153+*, ::before, ::after { box-sizing: border-box; border-radius: var(--radius); }72154 html { -webkit-text-size-adjust: 100%; }73-body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 var(--sans); }155+/* hidden wins over a display a control's class gives it */156+[hidden] { display: none !important; }157+body { margin: 0; background: var(--bg); color: var(--fg); font: var(--fw-regular) var(--text-md)/1.5 var(--sans); }74158 a { color: var(--link); text-decoration: none; }75159 a:hover { color: var(--deep); text-decoration: underline; }76160 :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }161+b, strong, th { font-weight: var(--fw-strong); }77162 code, kbd, pre, time, .mono, .sha { font-family: var(--mono); }78-time { font-size: 0.92em; }79-.sha { font-size: 0.9em; }80-h1, h2, h3 { line-height: 1.25; }81-/* Section headings: small uppercase monospace on a rule. */82-h2, .label { font: 600 11px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }83-h2 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin: 28px 0 0; padding-bottom: 6px; border-bottom: 1px solid var(--border); }84-h2 .desc, h2 a { font-weight: 600; }85-h2 .desc { color: var(--faint); }86-h3 { font-size: 1rem; margin: 1.4em 0 0.5em; }163+code, kbd, time, .sha, .mono { font-size: var(--text-inline-mono); }164+.mono .sha, .mono time, .mono code, pre code { font-size: inherit; }165+h1, h2, h3 { line-height: 1.25; font-weight: var(--fw-strong); }166+/* Section headings: sans, strong, on a rule; a count or note after them is quieter. */167+h2 { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-2); margin: var(--space-8) 0 0; padding-bottom: var(--space-2); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-md); color: var(--fg); }168+h2 .desc, h2 .n { font-weight: var(--fw-regular); color: var(--faint); }169+h2 .n { font: var(--fw-regular) var(--text-xs) var(--mono); font-variant-numeric: tabular-nums; }170+h2 a { font-weight: var(--fw-medium); }171+h3 { font-size: var(--text-md); margin: var(--space-6) 0 var(--space-2); }172+.label { font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--muted); }87173 .desc, .muted { color: var(--muted); }88174 .i { color: var(--add-fg); }89175 .d { color: var(--del-fg); }90-.wrap { max-width: 1280px; margin: 0 auto; padding: 0 16px; }91-kbd { border: 1px solid var(--border); padding: 0 0.35em; font-size: 0.8em; color: var(--muted); background: var(--subtle); }92-hr { border: 0; border-top: 1px solid var(--border); margin: 1.5em 0; }176+.wrap { max-width: 1280px; margin: 0 auto; padding: 0 var(--space-4); }177+kbd { border: var(--hairline) solid var(--border-strong); padding: 0 var(--space-1); color: var(--muted); background: var(--raised); }178+hr { border: 0; border-top: var(--hairline) solid var(--border); margin: var(--space-6) 0; }179+.icon { display: inline-block; width: 16px; height: 16px; flex: none; vertical-align: -3px; }93180 94181 /* header: on the page, over a rule that the current tab's bar sits on */95-header.site { border-bottom: 1px solid var(--border); }96-.top { display: flex; align-items: center; gap: 10px; padding: 12px 0 8px; min-width: 0; }182+header.site { border-bottom: var(--hairline) solid var(--border); }183+.top { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) 0 var(--space-2); min-width: 0; }97184 .logo { display: block; color: var(--fg); line-height: 0; flex: none; }98185 .mark { display: block; width: 28px; height: 28px; }99-.title { font: 1.05rem var(--mono); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }100-.title a { color: var(--fg); }186+.title { font-size: var(--text-lg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }187+.title a { color: var(--muted); }101188 .title a:hover { color: var(--deep); }102-.title b { font-weight: 650; }189+.title b, .title b a { font-weight: var(--fw-strong); color: var(--fg); }103190 .top .spacer { flex: 1; }104-.repo-desc { margin: 0 0 10px; color: var(--muted); font-size: 0.93em; }105-nav.tabs { display: flex; gap: 22px; overflow-x: auto; scrollbar-width: none; font: 600 11px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; }191+.repo-desc { margin: 0 0 var(--space-2); color: var(--muted); font-size: var(--text-sm); }192+193+/* tabs: where you are. Words, the current one in ink on the accent bar. */194+nav.tabs { display: flex; gap: var(--space-6); overflow-x: auto; scrollbar-width: none; font-size: var(--text-md); font-weight: var(--fw-medium); }106195 nav.tabs::-webkit-scrollbar { display: none; }107-nav.tabs a { color: var(--muted); padding: 9px 0 10px; white-space: nowrap; }196+nav.tabs a { color: var(--muted); padding: var(--space-2) 0 var(--space-2); white-space: nowrap; }108197 nav.tabs a:hover { color: var(--fg); text-decoration: none; }109-nav.tabs a.cur { color: var(--fg); box-shadow: inset 0 -3px 0 var(--accent); }198+nav.tabs a.cur, nav.tabs a[aria-current="page"], nav.tabs a[aria-current="true"] { color: var(--fg); box-shadow: inset 0 calc(-1 * var(--bar)) 0 var(--accent); }199+nav.tabs .n { margin-left: var(--space-1); font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); font-variant-numeric: tabular-nums; }110200 @media (max-width: 480px) {111- nav.tabs { gap: 16px; }201+ nav.tabs { gap: var(--space-4); }112202 }113203 /* Tabs that don't fit scroll sideways; the edge that has more fades out. */114204 @property --tabs-l { syntax: "<length>"; inherits: false; initial-value: 0px; }115205 @property --tabs-r { syntax: "<length>"; inherits: false; initial-value: 0px; }116206 @supports (animation-timeline: scroll()) {117- nav.tabs { mask-image: linear-gradient(to right, transparent, #000 var(--tabs-l), #000 calc(100% - var(--tabs-r)), transparent); animation: tabs-edge linear both; animation-timeline: scroll(self inline); }207+ nav.tabs { mask-image: linear-gradient(to right, transparent, var(--mask) var(--tabs-l), var(--mask) calc(100% - var(--tabs-r)), transparent); animation: tabs-edge linear both; animation-timeline: scroll(self inline); }118208 @keyframes tabs-edge { from { --tabs-l: 0px; --tabs-r: 32px; } to { --tabs-l: 32px; --tabs-r: 0px; } }119209 }120210 121-/* buttons: square, small uppercase monospace; secondary is the page's122- surface with accent text, primary (.go) is filled with ink */123-button, .btn, details.clone > summary { font: 600 11px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--link); background: var(--subtle); border: 1px solid var(--border); padding: 5px 10px; cursor: pointer; }124-button:hover, .btn:hover { color: var(--deep); background: var(--hover); border-color: var(--faint); text-decoration: none; }125-button[aria-pressed="true"] { color: var(--fg); border-color: var(--fg); }126-button.go, details.clone > summary { color: var(--bg); background: var(--fg); border-color: var(--fg); }127-button.go:hover, details.clone > summary:hover { color: var(--bg); background: var(--muted); border-color: var(--muted); }128-button:disabled { opacity: 0.6; cursor: default; }129-button kbd { font-size: 1em; padding: 0 0.3em; text-transform: none; }211+/* controls (ui.njk). Buttons do things: secondary is a raised surface,212+ primary is filled with the accent. A toggle that is on is filled with213+ ink: an icon toggle, or one segment of a segmented control. A checkbox214+ is on/off with words. Links only navigate. */215+button, .btn, details.clone > summary { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-1); min-height: var(--control-h); font: var(--fw-medium) var(--text-sm)/1.2 var(--sans); color: var(--fg); background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: var(--space-1) var(--space-3); cursor: pointer; white-space: nowrap; }216+button:hover, .btn:hover { color: var(--fg); border-color: var(--muted); text-decoration: none; }217+button.primary, details.clone > summary { color: var(--on-accent); background: var(--accent); border-color: var(--accent); }218+button.primary:hover, details.clone > summary:hover, details.clone[open] > summary { color: var(--on-accent); background: var(--accent-hover); border-color: var(--accent-hover); }219+button:disabled { opacity: 0.55; cursor: default; }220+button kbd { padding: 0 var(--space-1); }221+.icon-btn { width: var(--control-h); padding: 0; color: var(--muted); }222+.icon-btn:hover { color: var(--fg); }223+.icon-btn[aria-pressed="true"], .segmented > button[aria-pressed="true"] { color: var(--bg); background: var(--fg); border-color: var(--fg); }224+.segmented { display: inline-flex; }225+.segmented > button + button { margin-left: calc(-1 * var(--hairline)); }226+.segmented > button[aria-pressed="true"] { position: relative; }227+.check { display: inline-flex; align-items: center; gap: var(--space-2); min-height: var(--control-h); font-size: var(--text-sm); color: var(--muted); cursor: pointer; user-select: none; -webkit-user-select: none; white-space: nowrap; }228+.check:has(input:checked) { color: var(--fg); }229+.check input { width: 15px; height: 15px; margin: 0; accent-color: var(--accent); }230+.input, textarea.input { width: 100%; font: var(--fw-regular) var(--text-md)/1.5 var(--sans); color: var(--fg); background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: var(--space-1) var(--space-2); }231+.input.mono { font-family: var(--mono); font-size: var(--text-sm); }232+textarea.input { display: block; min-height: 6em; resize: vertical; padding: var(--space-2) var(--space-3); }233+.input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 var(--hairline) var(--accent); }130234 131235 /* clone */132236 details.clone { position: relative; flex: none; }133237 details.clone > summary { list-style: none; }134238 details.clone > summary::-webkit-details-marker { display: none; }135-details.clone .panel { position: absolute; right: 0; top: calc(100% + 6px); z-index: 10; width: min(440px, 92vw); background: var(--raised); border: 1px solid var(--border); padding: 10px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }136-.copyrow { display: flex; gap: 6px; align-items: center; margin: 4px 0; }137-.clone-note { margin: 6px 0 0; font-size: 0.85em; color: var(--muted); }138-.private-mark { font: 600 10.5px var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--warn); vertical-align: 2px; }239+details.clone .panel { position: absolute; right: 0; top: calc(100% + var(--space-2)); z-index: 10; width: min(440px, 92vw); background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-3); box-shadow: var(--shadow); }240+.copyrow { display: flex; gap: var(--space-2); align-items: center; margin: var(--space-1) 0; }241+.copyrow label { width: 44px; font-size: var(--text-sm); color: var(--muted); }242+.copyrow .input { flex: 1; min-width: 0; }243+.clone-note { margin: var(--space-2) 0 0; font-size: var(--text-sm); color: var(--muted); }244+.private-mark { font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--warn); }139245 .private-mark:hover { color: var(--warn); }140-.copyrow label { width: 44px; font: 600 10.5px var(--mono); letter-spacing: 0.08em; color: var(--faint); }141-.copyrow input { flex: 1; min-width: 0; font: 13px var(--mono); padding: 4px 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); }142246 143247 /* live status: words, no dot */144-#live { font: 11px var(--mono); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 40vw; }248+#live { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 40vw; }145249 #live .live-on { color: var(--pass); }146250 #live .live-off { color: var(--faint); }147251 #live .live-busy { color: var(--warn); animation: live-pulse 1s ease-in-out infinite alternate; }148252 @keyframes live-pulse { to { opacity: 0.4; } }149253 150-main.wrap { padding-top: 20px; padding-bottom: 40px; }254+main.wrap { padding-top: var(--space-5); padding-bottom: var(--space-10); }151255 main.wrap > :first-child { margin-top: 0; }152-footer.site { color: var(--muted); padding-bottom: 24px; font-size: 0.85em; }256+footer.site { color: var(--muted); padding-bottom: var(--space-6); font-size: var(--text-sm); }153257 154258 /* toolbars above lists and code */155-.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 12px; }259+.bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-3); margin: 0 0 var(--space-3); }156260 .bar .grow { flex: 1; }157-.crumbs { font-family: var(--mono); font-size: 0.95em; overflow-wrap: anywhere; }261+.crumbs { font-family: var(--mono); font-size: var(--text-sm); overflow-wrap: anywhere; }158262 details.picker { position: relative; }159-details.picker > summary { list-style: none; cursor: pointer; border: 1px solid var(--border); padding: 3px 10px; background: var(--subtle); font: 12.5px/1.5 var(--mono); }160-details.picker > summary:hover { background: var(--hover); border-color: var(--faint); }263+details.picker > summary { display: inline-flex; align-items: center; gap: var(--space-2); min-height: var(--control-h); list-style: none; cursor: pointer; border: var(--hairline) solid var(--border-strong); padding: var(--space-1) var(--space-3); background: var(--raised); font: var(--fw-regular) var(--text-sm)/1.2 var(--mono); }264+details.picker > summary b { font-weight: var(--fw-medium); }265+details.picker > summary:hover { border-color: var(--muted); }161266 details.picker > summary::-webkit-details-marker { display: none; }162-details.picker #branch-menu { position: absolute; z-index: 10; top: calc(100% + 4px); left: 0; min-width: 220px; max-height: 60vh; overflow: auto; background: var(--raised); border: 1px solid var(--border); padding: 4px 0; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); display: flex; flex-direction: column; }163-details.picker #branch-menu a { padding: 4px 12px; font-family: var(--mono); font-size: 0.9em; }267+details.picker #branch-menu { position: absolute; z-index: 10; top: calc(100% + var(--space-1)); left: 0; min-width: 220px; max-height: 60vh; overflow: auto; background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-1) 0; box-shadow: var(--shadow); display: flex; flex-direction: column; }268+details.picker #branch-menu a { padding: var(--space-1) var(--space-3); font-family: var(--mono); font-size: var(--text-sm); }164269 details.picker #branch-menu a:hover { background: var(--hover); text-decoration: none; }165270 166271 /* lists: tables without outer borders, rows between hairlines, headers in167- small uppercase monospace; they restack on phones */272+ small quiet words; they restack on phones */168273 table.list { width: 100%; border-collapse: collapse; }169-table.list th, table.list td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--border); vertical-align: baseline; }170-table.list thead th { font: 600 10.5px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--faint); padding-top: 10px; padding-bottom: 6px; }274+table.list th, table.list td { text-align: left; padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); vertical-align: baseline; }275+table.list thead th { font-size: var(--text-xs); font-weight: var(--fw-medium); color: var(--muted); padding-top: var(--space-3); padding-bottom: var(--space-1); }171276 table.list .num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }172-table.list td.num, table.list td.date { font: 12.5px/1.6 var(--mono); }277+table.list td.num, table.list td.date { font: var(--fw-regular) var(--text-xs)/1.6 var(--mono); }173278 table.list td.date { color: var(--muted); }174-table.list td.date time { font-size: 1em; }279+table.list td.date time { font-size: inherit; }175280 table.list .nowrap, table.list .date, table.list .sha, table.list td.mono { white-space: nowrap; }281+table.list td.mono { font-size: var(--text-sm); }176282 table.list td.main { width: 100%; overflow-wrap: anywhere; }283+/* A row's own link (a subject, a file, a branch) is in ink; other links stay blue. */284+table.list td.main > a:first-of-type, .files td.main a { color: var(--fg); font-weight: var(--fw-medium); }285+table.list td.main > a:first-of-type:hover, .files td.main a:hover { color: var(--deep); }177286 table.list tbody tr:hover td, table.list tbody tr:focus-within td, table.list tbody tr.sel td { background: var(--hover); }178287 table.list tbody tr.sel td:first-child { box-shadow: inset 2px 0 0 var(--accent); }179288 /* A box holds a list or prose under a heading row; only a file viewer180289 (.viewer) is a bordered region. */181-.box + .box { margin-top: 24px; }182-.box-head { border-bottom: 1px solid var(--border); padding: 8px; display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; font-size: 0.92em; }290+.box + .box { margin-top: var(--space-6); }291+.box-head { border-bottom: var(--hairline) solid var(--border); padding: var(--space-2); display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-4); align-items: baseline; font-size: var(--text-sm); }183292 .box-head .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }184-.box-head .label { margin-right: auto; }185-.box.viewer { border: 1px solid var(--border); background: var(--raised); }186-.box.viewer > .box-head { padding: 6px 12px; align-items: center; }293+.box-head .label { margin-right: auto; color: var(--fg); font-weight: var(--fw-strong); }294+.box.viewer { border: var(--hairline) solid var(--border); background: var(--raised); }295+.box.viewer > .box-head { padding: var(--space-1) var(--space-3); align-items: center; }187296 .box.viewer > table.list tr:last-child td { border-bottom: 0; }188-.files td.icon { width: 1.5em; color: var(--muted); padding-right: 0; }297+.files td { padding-top: var(--space-1); padding-bottom: var(--space-1); }298+.files td.icon { width: 1.75em; color: var(--muted); padding-right: 0; }299+.files td.icon .icon { vertical-align: -3px; }189300 .files td.size { color: var(--muted); }190-.empty { color: var(--muted); padding: 24px; text-align: center; }301+.empty { color: var(--muted); padding: var(--space-6); text-align: center; }191302 192303 @media (max-width: 720px) {193304 table.list thead { display: none; }194- table.list.stack tr { display: flex; flex-wrap: wrap; gap: 2px 10px; padding: 9px 8px; border-bottom: 1px solid var(--border); }195- table.list.stack td { border: 0; padding: 0; font-size: 0.85em; color: var(--muted); }196- table.list.stack td.main { flex: 0 0 100%; font-size: 1rem; color: var(--fg); }305+ table.list.stack tr { display: flex; flex-wrap: wrap; gap: var(--space-half) var(--space-3); padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); }306+ table.list.stack td { border: 0; padding: 0; font-size: var(--text-sm); color: var(--muted); }307+ table.list.stack td.main { flex: 0 0 100%; font-size: var(--text-md); color: var(--fg); }197308 /* a stacked row is one block: paint the row, not each cell, so the gaps between cells don't show */198309 table.list.stack tbody tr:hover, table.list.stack tbody tr:focus-within, table.list.stack tbody tr.sel { background: var(--hover); }199310 table.list.stack tbody tr td { background: none; }
@@ -209,16 +320,16 @@ table.list tbody tr.sel td:first-child { box-shadow: inset 2px 0 0 var(--accent)
209320 /* statuses and CI. A review's status is the one filled shape: a small210321 square label, white on the status colour (the same in both modes, so the211322 white stays readable). */212-.status { display: inline-block; font: 600 10px/1.5 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: #fff; background: #5b6169; padding: 0 6px; white-space: nowrap; vertical-align: 1px; }213-.status.open { background: #0e8420; }214-.status.merged { background: #3148f6; }215-.status.closed { background: #c7162b; }216-.ci { white-space: nowrap; font-weight: 600; }323+.status { display: inline-block; font: var(--fw-strong) var(--text-xs)/1.6 var(--sans); color: var(--on-accent); background: var(--status-other); padding: 0 var(--space-2); white-space: nowrap; vertical-align: 1px; }324+.status.open { background: var(--status-open); }325+.status.merged { background: var(--status-merged); }326+.status.closed { background: var(--status-closed); }327+.ci { white-space: nowrap; font-weight: var(--fw-strong); }217328 .ci[title] { cursor: help; }218329 .ci.pass { color: var(--pass); }219330 .ci.fail { color: var(--fail); }220331 .ci.partial, .ci.unverified { color: var(--warn); }221-.st { font-family: var(--mono); font-weight: 600; }332+.st { font-family: var(--mono); font-weight: var(--fw-strong); }222333 .st.A { color: var(--add-fg); }223334 .st.D { color: var(--del-fg); }224335 .st.R, .st.C { color: var(--warn); }
@@ -229,15 +340,15 @@ table.list tbody tr.sel td:first-child { box-shadow: inset 2px 0 0 var(--accent)
229340 scrolls under it: every gutter cell is opaque, its row's tint (translucent230341 in dark mode) layered over the code's background. --w is the widest line231342 number's digit count, set per file, so the columns never overlap. */232-.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); background: var(--raised); }233-.code .rows { display: inline-block; min-width: 100%; padding: 4px 0; }343+.code { --lnw: max(3.5em, calc(var(--w, 3) * 1ch + 20px)); font: var(--fw-regular) var(--text-code)/1.55 var(--mono); tab-size: 4; -moz-tab-size: 4; overflow-x: auto; border: var(--hairline) solid var(--border); background: var(--raised); }344+.code .rows { display: inline-block; min-width: 100%; padding: var(--space-1) 0; }234345 .code .l { --gut: transparent; display: flex; color: var(--fg); text-decoration: none; }235346 .code a.l:hover { text-decoration: none; }236347 .code .ln, .code .mk { position: sticky; background: linear-gradient(var(--gut), var(--gut)) var(--raised); user-select: none; -webkit-user-select: none; }237-.code .ln { flex: none; width: var(--lnw); padding: 0 10px; text-align: right; color: var(--faint); left: 0; }348+.code .ln { flex: none; width: var(--lnw); padding: 0 var(--space-3); text-align: right; color: var(--faint); left: 0; }238349 .code .ln:hover { color: var(--fg); }239350 .code .ln + .ln { left: var(--lnw); }240-.code .lc { white-space: pre; padding: 0 16px 0 6px; flex: 1; }351+.code .lc { white-space: pre; padding: 0 var(--space-4) 0 var(--space-2); flex: 1; }241352 .code .mk { flex: none; width: 1.2em; left: calc(2 * var(--lnw)); text-align: center; color: var(--faint); }242353 .code .l.add { background: var(--add-bg); }243354 .code .l.add .ln { --gut: var(--add-ln); }
@@ -245,11 +356,11 @@ table.list tbody tr.sel td:first-child { box-shadow: inset 2px 0 0 var(--accent)
245356 .code .l.del { background: var(--del-bg); }246357 .code .l.del .ln { --gut: var(--del-ln); }247358 .code .l.del .mk { --gut: var(--del-bg); color: var(--del-fg); }248-.code .l:target { --gut: rgba(255, 213, 79, 0.25); background: var(--gut); }249-.code .l:target .ln, .code .l:target .mk { --gut: rgba(255, 213, 79, 0.35); }359+.code .l:target { --gut: var(--target-bg); background: var(--gut); }360+.code .l:target .ln, .code .l:target .mk { --gut: var(--target-gut); }250361 .code .l.meta { color: var(--muted); font-style: italic; }251-.code .hunk { background: var(--hunk-bg); color: var(--hunk-fg); padding: 2px 12px; white-space: pre; }252-.code .hunk > span { display: inline-block; position: sticky; left: 12px; }362+.code .hunk { background: var(--hunk-bg); color: var(--hunk-fg); padding: var(--space-half) var(--space-3); white-space: pre; }363+.code .hunk > span { display: inline-block; position: sticky; left: var(--space-3); }253364 body.wrap-lines .code .lc { white-space: pre-wrap; overflow-wrap: anywhere; }254365 body.wrap-lines .code .rows { display: block; }255366 body.wrap-lines .code .hunk { white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -258,51 +369,31 @@ body.wrap-lines .code .hunk { white-space: pre-wrap; overflow-wrap: anywhere; }
258369 body:not(.no-wrap) .code .lc, body:not(.no-wrap) .code .hunk { white-space: pre-wrap; overflow-wrap: anywhere; }259370 body:not(.no-wrap) .code .rows { display: block; }260371 }261-section.file { margin: 16px 0; border: 1px solid var(--border); background: var(--raised); }372+section.file { margin: var(--space-4) 0; border: var(--hairline) solid var(--border); background: var(--raised); }262373 .file > .code { border: 0; }263374 .box > .code { border: 0; }264-.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); font-size: 0.9em; }375+.fh { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-2); padding: var(--space-2) var(--space-3); background: var(--raised); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-sm); }265376 /* A long path shares the line with the status and counts, wrapping at its266377 slashes, rather than wrapping onto a line of its own. */267-.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; }268-.fh .counts { font-family: var(--mono); font-size: 0.9em; }269-.fh .st { font-size: 0.9em; }270-.fh .view { margin-left: auto; font: 600 10.5px var(--mono); letter-spacing: 0.08em; text-transform: uppercase; }271-.file .note { margin: 0; padding: 10px 12px; color: var(--muted); }272-table.diffstat td { padding: 3px 8px; }273-table.diffstat td.num { font-size: 12px; color: var(--muted); }274-table.diffstat td.graph { font-family: var(--mono); white-space: nowrap; letter-spacing: -1px; }378+.fh .fname { font-family: var(--mono); color: var(--fg); font-weight: var(--fw-medium); overflow-wrap: break-word; flex: 1 1 0; min-width: min(4em, 100%); max-width: max-content; }379+.fh .counts { font: var(--fw-regular) var(--text-xs) var(--mono); }380+.fh .st { font-size: var(--text-xs); }381+.fh .view { margin-left: auto; font-size: var(--text-sm); }382+.file .note { margin: 0; padding: var(--space-3); color: var(--muted); }383+table.diffstat td { padding: var(--space-half) var(--space-2); }384+table.diffstat td.num { font-size: var(--text-xs); color: var(--muted); }385+table.diffstat td.graph { font: var(--fw-regular) var(--text-xs) var(--mono); white-space: nowrap; }275386 table.diffstat td.main { overflow-wrap: break-word; }276-table.diffstat td.main a { font-family: var(--mono); font-size: 0.92em; }387+table.diffstat td.main a { font-family: var(--mono); font-size: var(--text-sm); }277388 /* Phones: the path takes the row and wraps at slashes; the count and graph278389 follow on the same line if they fit, else right-aligned below. */279390 @media (max-width: 720px) {280- table.diffstat tr { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: baseline; gap: 0 8px; padding: 3px 8px; border-bottom: 1px solid var(--border); }391+ table.diffstat tr { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: baseline; gap: 0 var(--space-2); padding: var(--space-1) var(--space-2); border-bottom: var(--hairline) solid var(--border); }281392 table.diffstat td { padding: 0; border: 0; }282393 table.diffstat td.main { flex: 1 1 0; width: auto; min-width: min(10em, 100%); }283394 }284395 285-/* syntax highlighting: highlight.js classes, colors per theme */286-:root {287- --tok-comment: #59636e;288- --tok-keyword: #cf222e;289- --tok-string: #0a3069;290- --tok-const: #0550ae;291- --tok-title: #8250df;292- --tok-builtin: #953800;293- --tok-tag: #116329;294-}295-@media (prefers-color-scheme: dark) {296- :root {297- --tok-comment: #9198a1;298- --tok-keyword: #ff7b72;299- --tok-string: #a5d6ff;300- --tok-const: #79c0ff;301- --tok-title: #d2a8ff;302- --tok-builtin: #ffa657;303- --tok-tag: #7ee787;304- }305-}396+/* syntax highlighting: highlight.js classes, colours from the tokens */306397 .hljs-comment, .hljs-code, .hljs-formula { color: var(--tok-comment); }307398 .hljs-keyword, .hljs-doctag, .hljs-template-tag, .hljs-template-variable, .hljs-type, .hljs-variable.language_, .hljs-meta .hljs-keyword { color: var(--tok-keyword); }308399 .hljs-string, .hljs-regexp, .hljs-meta .hljs-string { color: var(--tok-string); }
@@ -311,82 +402,88 @@ table.diffstat td.main a { font-family: var(--mono); font-size: 0.92em; }
311402 .hljs-built_in, .hljs-symbol, .hljs-bullet { color: var(--tok-builtin); }312403 .hljs-name, .hljs-quote, .hljs-selector-tag, .hljs-selector-pseudo { color: var(--tok-tag); }313404 .hljs-subst, .hljs-params { color: var(--fg); }314-.hljs-section, .hljs-strong { font-weight: 600; }405+.hljs-section, .hljs-strong { font-weight: var(--fw-strong); }315406 .hljs-emphasis { font-style: italic; }316407 .hljs-addition { color: var(--add-fg); }317408 .hljs-deletion { color: var(--del-fg); }318409 319410 /* commit and review pages */320-.meta-head { margin: 0 0 16px; }321-.meta-head h1 { font-size: 1.3rem; margin: 0 0 6px; overflow-wrap: anywhere; }322-.meta-head .byline { color: var(--muted); font-size: 0.92em; }323-pre.msg { white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.5 var(--mono); background: var(--subtle); border: 1px solid var(--border); padding: 12px; margin: 12px 0; }324-details.att > summary, details.howto > summary { cursor: pointer; color: var(--muted); font: 600 11px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; padding: 8px 0; }411+.meta-head { margin: 0 0 var(--space-4); }412+.meta-head h1 { font-size: var(--text-xl); margin: 0 0 var(--space-2); overflow-wrap: anywhere; }413+.meta-head h1 a { color: var(--fg); }414+.meta-head h1 a:hover { color: var(--deep); }415+.meta-head h1 .desc { font: var(--fw-regular) var(--text-lg) var(--mono); color: var(--faint); }416+.meta-head .byline { color: var(--muted); font-size: var(--text-sm); }417+pre.msg { white-space: pre-wrap; overflow-wrap: anywhere; font: var(--fw-regular) var(--text-sm)/1.55 var(--mono); background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-3); margin: var(--space-3) 0; }418+details.att > summary, details.howto > summary { cursor: pointer; color: var(--muted); font-size: var(--text-sm); font-weight: var(--fw-medium); padding: var(--space-2) 0; }325419 details.att > summary:hover, details.howto > summary:hover { color: var(--fg); }326-details.howto > summary .jev-sum { text-transform: none; letter-spacing: 0; font: 13px var(--sans); }327-details.att pre, details.howto pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.5 var(--mono); background: var(--subtle); border: 1px solid var(--border); padding: 10px; }420+details.att pre, details.howto pre { white-space: pre-wrap; overflow-wrap: anywhere; font: var(--fw-regular) var(--text-xs)/1.55 var(--mono); background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-3); }328421 .depth { color: var(--faint); white-space: pre; }329422 330423 /* Above a diff: the diffstat, with the page's tools on its summary line. */331-.changes { display: grid; grid-template-columns: 1fr auto; align-items: center; column-gap: 12px; }424+.changes { display: grid; grid-template-columns: 1fr auto; align-items: center; column-gap: var(--space-3); }332425 .changes > * { grid-column: 1 / -1; }333-.changes > .tools { grid-column: 2; grid-row: 1; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 4px 12px; }426+.changes > .tools { grid-column: 2; grid-row: 1; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: var(--space-1) var(--space-3); }334427 .changes > .tools + * { grid-column: 1; grid-row: 1; }335-.count { color: var(--muted); font-size: 0.9em; white-space: nowrap; }428+.count { color: var(--muted); font-size: var(--text-sm); white-space: nowrap; }336429 @media (max-width: 480px) { .count .wide { display: none; } }337430 .file .code .l { scroll-margin-top: 2.75rem; }338431 339-/* Diff files fold (diffs.ts); review pages add a Viewed box. */432+/* Diff files fold (diffs.ts); review pages add a viewed checkbox. */340433 .fh.foldable { cursor: pointer; }341-.fh .fold { border: 0; background: none; padding: 0; width: 1.1em; color: var(--muted); font: 1.2em/1 var(--sans); letter-spacing: 0; }434+.fh .fold { min-height: 0; border: 0; background: none; padding: 0; width: 1.1em; color: var(--muted); font: var(--fw-regular) var(--text-md)/1 var(--sans); }342435 .fh .fold:hover { background: none; color: var(--fg); }343436 .fh .fold::before { content: "\25BE"; display: inline-block; transition: transform 0.1s; }344437 .file.folded .fold::before { transform: rotate(-90deg); }345438 .file.folded > :not(.fh) { display: none; }346439 .file.folded > .fh { border-bottom: 0; }347-.file.folded:has(.viewed input:checked) .fname { color: var(--muted); font-weight: normal; }348-.fh .viewed { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border: 1px solid var(--border); background: var(--bg); color: var(--muted); font: 600 10.5px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; user-select: none; -webkit-user-select: none; }440+.file.folded:has(.viewed input:checked) .fname { color: var(--muted); font-weight: var(--fw-regular); }441+.fh .viewed { margin-left: auto; min-height: 0; }349442 .fh .view ~ .viewed { margin-left: 0; }350-.fh .viewed:has(input:checked) { color: var(--fg); }351-.fh .viewed input { margin: 0; accent-color: var(--accent); }352443 353-/* prose: README, wiki, rendered markdown */354-.markdown { font-size: 1rem; line-height: 1.6; overflow-wrap: break-word; padding: 16px 8px; }444+/* prose: README, wiki, release notes, review descriptions, comments: the445+ serif, for what people wrote */446+.markdown { font: var(--fw-regular) var(--text-prose)/1.65 var(--serif); overflow-wrap: break-word; padding: var(--space-4) var(--space-2); }355447 .markdown > :first-child { margin-top: 0; }356-.markdown h1, .markdown h2 { border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }357-.markdown h1 { font-size: 1.8em; margin: 0.8em 0 0.5em; }358-.markdown h2 { display: block; font: 600 1.4em/1.25 var(--sans); letter-spacing: normal; text-transform: none; color: var(--fg); margin: 1.4em 0 0.6em; }359-.markdown h3 { font-size: 1.15em; }448+.markdown h1, .markdown h2, .markdown h3, .markdown h4 { font-family: var(--sans); font-weight: var(--fw-strong); color: var(--fg); }449+.markdown h1, .markdown h2 { display: block; border-bottom: var(--hairline) solid var(--border); padding-bottom: var(--space-1); }450+.markdown h1 { font-size: var(--text-2xl); margin: var(--space-6) 0 var(--space-3); }451+.markdown h2 { font-size: var(--text-xl); margin: var(--space-6) 0 var(--space-3); }452+.markdown h3 { font-size: var(--text-lg); }453+.markdown h4 { font-size: var(--text-md); }360454 .markdown a:visited { color: var(--deep); }361455 .markdown img { max-width: 100%; height: auto; }362456 .markdown p[align="center"], .markdown div[align="center"] { text-align: center; }363-.markdown code { font-size: 0.875em; background: var(--subtle); border: 1px solid var(--border); padding: 0.05em 0.3em; }364-.markdown pre { font: 13px/1.5 var(--mono); background: var(--subtle); border: 1px solid var(--border); padding: 12px 14px; overflow-x: auto; tab-size: 4; }365-.markdown pre code { background: none; border: 0; padding: 0; font-size: 1em; }457+.markdown code { background: var(--raised); border: var(--hairline) solid var(--border); padding: 0 var(--space-1); }458+.markdown pre { font: var(--fw-regular) var(--text-code)/1.55 var(--mono); background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-3) var(--space-4); overflow-x: auto; tab-size: 4; }459+.markdown pre code { background: none; border: 0; padding: 0; }460+/* In the raised file viewer, code sits on the page's colour, not in a second box. */461+.viewer .markdown pre, .viewer .markdown code { background: var(--bg); border-color: var(--bg); }462+.viewer .markdown pre code { background: none; }366463 .markdown pre.mermaid { background: none; border: 0; text-align: center; }367-.markdown blockquote { margin: 1em 0; padding: 0 1em; color: var(--muted); border-left: 3px solid var(--border); }368-.markdown table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 1em 0; }369-.markdown th, .markdown td { border-bottom: 1px solid var(--border); padding: 6px 12px 6px 0; text-align: left; vertical-align: top; }370-.markdown th { font: 600 10.5px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--faint); padding-top: 8px; }371-.markdown hr { border-top: 1px solid var(--border); }372-.markdown :target { scroll-margin-top: 16px; }464+.markdown blockquote { margin: var(--space-4) 0; padding: 0 var(--space-4); color: var(--muted); border-left: var(--bar) solid var(--border); }465+.markdown table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: var(--space-4) 0; font-size: var(--text-md); }466+.markdown th, .markdown td { border-bottom: var(--hairline) solid var(--border); padding: var(--space-2) var(--space-3) var(--space-2) 0; text-align: left; vertical-align: top; }467+.markdown th { font-family: var(--sans); font-weight: var(--fw-strong); }468+.markdown hr { border-top: var(--hairline) solid var(--border); }469+.markdown :target { scroll-margin-top: var(--space-4); }373470 @media (max-width: 720px) {374- .markdown { padding: 12px 4px; font-size: 0.97rem; }375- .markdown h1 { font-size: 1.5em; }376- .markdown h2 { font-size: 1.25em; }471+ .markdown { padding: var(--space-3) var(--space-1); }472+ .markdown h1 { font-size: var(--text-xl); }473+ .markdown h2 { font-size: var(--text-lg); }377474 }378475 /* jev: commits and repos */379476 /* What Jev says a commit or a repo is: plain words, quiet. */380-.jev-kind { font: 12px var(--mono); color: var(--muted); white-space: nowrap; }477+.jev-kind { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); white-space: nowrap; }381478 .jev-topic { color: var(--muted); white-space: nowrap; }382-/* Jev's risk score, 0 to 3: a small number, colored once it is moderate. */383-.jev-score { font: 12px var(--mono); font-variant-numeric: tabular-nums; color: var(--faint); white-space: nowrap; }479+/* Jev's risk score, 0 to 3: a small number, coloured once it is moderate. */480+.jev-score { font: var(--fw-regular) var(--text-xs) var(--mono); font-variant-numeric: tabular-nums; color: var(--faint); white-space: nowrap; }384481 .jev-score.moderate { color: var(--warn); }385-.jev-score.high { color: var(--fail); font-weight: 600; }386-.jev-mismatch { font-size: 0.85em; color: var(--faint); cursor: help; }387-.jev-note { font-size: 0.8rem; font-style: italic; color: var(--faint); }482+.jev-score.high { color: var(--fail); font-weight: var(--fw-strong); }483+.jev-mismatch { font-size: var(--text-sm); color: var(--faint); cursor: help; }484+.jev-note { font-size: var(--text-sm); font-style: italic; color: var(--faint); }388485 /* The log: Jev's type in a narrow column on the left. */389-table.log th.kind, table.log td.kind { width: 1%; white-space: nowrap; padding-right: 4px; }486+table.log th.kind, table.log td.kind { width: 1%; white-space: nowrap; padding-right: var(--space-1); }390487 table.log td.risk { width: 1%; }391488 @media (max-width: 720px) {392489 /* Stacked: the type leads the line under the subject. */
@@ -394,18 +491,18 @@ table.log td.risk { width: 1%; }
394491 table.log td.kind { order: 1; }395492 table.log td.kind:empty { display: none; }396493 }397-.releases-link { margin-left: 4px; }398-.notes { padding: 2px 8px 12px; font-size: 0.95em; }399-.notes h3 { margin: 14px 0 4px; font: 600 10.5px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--faint); }400-.notes ul { margin: 0; padding-left: 20px; }401-.notes li { margin: 2px 0; overflow-wrap: anywhere; }402-.notes details { margin-top: 12px; }403-.notes summary { cursor: pointer; font: 600 10.5px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--faint); }494+.releases-link { margin-left: var(--space-1); }495+.notes { padding: var(--space-half) var(--space-2) var(--space-3); font: var(--fw-regular) var(--text-md)/1.6 var(--serif); }496+.notes h3 { margin: var(--space-4) 0 var(--space-1); font: var(--fw-strong) var(--text-sm)/1.4 var(--sans); color: var(--muted); }497+.notes ul { margin: 0; padding-left: var(--space-5); }498+.notes li { margin: var(--space-half) 0; overflow-wrap: anywhere; }499+.notes details { margin-top: var(--space-3); }500+.notes summary { cursor: pointer; font: var(--fw-strong) var(--text-sm)/1.4 var(--sans); color: var(--muted); }404501 .notes summary:hover { color: var(--fg); }405502 406503 /* jev: review pages */407-.jev-mech, .jev-change { font: 12px var(--mono); color: var(--muted); white-space: nowrap; }504+.jev-mech, .jev-change { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); white-space: nowrap; }408505 .jev-change.behavior, .jev-sum.behavior { color: var(--warn); }409-.jev-sum { font-size: 0.9em; color: var(--muted); }410-summary .jev-sum { margin-left: 6px; font-weight: normal; }506+.jev-sum { font-size: var(--text-sm); font-weight: var(--fw-regular); color: var(--muted); }507+summary .jev-sum { margin-left: var(--space-2); }411508 .comment .tag.jev-addressed { color: var(--pass); }
M site/assets/ui.ts +27 -18
@@ -1,4 +1,4 @@
1-// Small page behaviors: copy buttons, the line-wrap toggle, the find-file1+// Small page behaviors: copy buttons, the wrap-lines checkbox, the find-file22 // button for touch screens, relative times, and diagrams (mermaid, loaded33 // only when a page has one).44 (() => {
@@ -7,33 +7,37 @@
77 const copy = async (text: string, button: HTMLElement): Promise<void> => {88 await navigator.clipboard.writeText(text);99 const was = button.textContent;10- button.textContent = 'Copied';10+ button.textContent = 'copied';1111 setTimeout(() => (button.textContent = was), 1200);1212 };1313 1414 document.addEventListener('click', (e) => {15- const el = (e.target as Element | null)?.closest<HTMLElement>('[data-copy], [data-copy-code], [data-wrap], [data-finder]');15+ const el = (e.target as Element | null)?.closest<HTMLElement>('[data-copy], [data-copy-code], [data-finder]');1616 if (!el) return;1717 if (el.dataset.copy !== undefined) void copy(el.dataset.copy, el);1818 else if (el.hasAttribute('data-copy-code')) {1919 const lines = [...document.querySelectorAll('.code.blob .lc')].map((l) => l.textContent ?? '');2020 void copy(lines.join('\n') + '\n', el);21- } else if (el.hasAttribute('data-wrap')) {22- const on = !document.body.classList.contains('wrap-lines');23- localStorage.setItem('forge.wrap', on ? '1' : '0');24- wrap(on);2521 } else if (el.hasAttribute('data-finder')) {2622 document.dispatchEvent(new KeyboardEvent('keydown', { key: 't' }));2723 }2824 });2925 26+ // The wrap-lines checkbox (ui.checkbox, with data-wrap).27+ document.addEventListener('change', (e) => {28+ const el = e.target as HTMLInputElement;29+ if (!el.matches?.('input[data-wrap]')) return;30+ localStorage.setItem('forge.wrap', el.checked ? '1' : '0');31+ wrap(el.checked);32+ });33+3034 // Long lines wrap on narrow screens unless the reader turned it off, and on3135 // wider ones if they turned it on. The CSS has the same default for pages3236 // without JS; no-wrap marks an explicit off.3337 const wrap = (on: boolean): void => {3438 document.body.classList.toggle('wrap-lines', on);3539 document.body.classList.toggle('no-wrap', !on);36- for (const b of document.querySelectorAll('[data-wrap]')) b.setAttribute('aria-pressed', String(on));40+ for (const b of document.querySelectorAll<HTMLInputElement>('input[data-wrap]')) b.checked = on;3741 };3842 const wrapped = (): boolean => {3943 const v = localStorage.getItem('forge.wrap');
@@ -71,19 +75,24 @@
7175 if (over > 0) tabs.scrollLeft += over + 24;7276 }7377 74- // The index and owner pages' shelves (shelves in macros.njk). Without JS the75- // chips jump to a shelf; here one shows that shelf alone, "has a site"76- // hides repos without one, and the sort switches between newest and a-z.77- // The choices outlive live.ts swapping in a rebuilt page.78+ // The index and owner pages' shelves (shelves in macros.njk). Without JS79+ // the tabs jump to a shelf; here one shows that shelf alone ("all" shows80+ // every one), the globe hides repos without a site, and the sort is81+ // newest first or by name. The choices outlive live.ts swapping in a82+ // rebuilt page.7883 const pick = { shelf: '', site: false, az: false };7984 const shelves = (): void => {8085 const bar = document.querySelector<HTMLElement>('[data-shelves]');8186 if (!bar) return;8287 for (const b of bar.querySelectorAll<HTMLElement>('[hidden]')) b.hidden = false;83- for (const c of bar.querySelectorAll<HTMLElement>('[data-shelf]')) c.setAttribute('aria-pressed', String(c.dataset.shelf === pick.shelf));88+ for (const t of bar.querySelectorAll<HTMLElement>('a[data-shelf]')) {89+ const cur = t.dataset.shelf === pick.shelf;90+ t.classList.toggle('cur', cur);91+ if (cur) t.setAttribute('aria-current', 'page');92+ else t.removeAttribute('aria-current');93+ }8494 bar.querySelector('[data-has-site]')?.setAttribute('aria-pressed', String(pick.site));85- const sort = bar.querySelector<HTMLElement>('[data-sort]');86- if (sort) sort.textContent = pick.az ? 'a-z' : 'newest first';95+ for (const b of bar.querySelectorAll<HTMLElement>('[data-sort]')) b.setAttribute('aria-pressed', String((b.dataset.sort === 'name') === pick.az));8796 let shown = 0;8897 for (const sec of document.querySelectorAll<HTMLElement>('section.shelf')) {8998 const ul = sec.querySelector('ul')!;
@@ -99,12 +108,12 @@
99108 if (empty) empty.hidden = shown > 0;100109 };101110 document.addEventListener('click', (e) => {102- const el = (e.target as Element | null)?.closest<HTMLElement>('[data-shelves] .chip');111+ const el = (e.target as Element | null)?.closest<HTMLElement>('[data-shelves] [data-shelf], [data-shelves] [data-has-site], [data-shelves] [data-sort]');103112 if (!el) return;104113 e.preventDefault();105- if (el.dataset.shelf !== undefined) pick.shelf = pick.shelf === el.dataset.shelf ? '' : el.dataset.shelf;114+ if (el.dataset.shelf !== undefined) pick.shelf = el.dataset.shelf;106115 else if (el.hasAttribute('data-has-site')) pick.site = !pick.site;107- else if (el.hasAttribute('data-sort')) pick.az = !pick.az;116+ else if (el.dataset.sort) pick.az = el.dataset.sort === 'name';108117 shelves();109118 });110119
M site/index.njk +1 -0
M site/insights.njk +1 -0 mechanical
M site/owner.njk +1 -0 mechanical
M site/repo/blob.njk +4 -3
M site/repo/commit.njk +11 -10
@@ -4,6 +4,7 @@ pagination:
44 ---55 {% extends "base.njk" %}66 {% import "macros.njk" as m with context %}7+{% import "ui.njk" as ui %}78 {% set c = p.commit %}89 {% set u = p.repo.urls %}910 {% set about = c.author.name ~ ' committed ' ~ c.short ~ ' to ' ~ p.repo.slug ~ ((': ' ~ (c.body | excerpt)) if c.body else (', +' ~ c.stats.add ~ ' -' ~ c.stats.del)) %}
@@ -12,8 +13,8 @@ pagination:
1213 {% endblock %}1314 {% block content %}1415 {% css %}15-dl.facts { display: grid; grid-template-columns: max-content 1fr; align-items: baseline; gap: 4px 16px; margin: 12px 0; font-size: 0.92em; }16-dl.facts dt { font: 600 10.5px/2.1 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--faint); }16+dl.facts { display: grid; grid-template-columns: max-content 1fr; align-items: baseline; gap: var(--space-1) var(--space-4); margin: var(--space-3) 0; font-size: var(--text-sm); }17+dl.facts dt { color: var(--muted); }1718 dl.facts dd { margin: 0; overflow-wrap: anywhere; }1819 {% endcss %}1920 <div class="meta-head">
@@ -22,20 +23,20 @@ dl.facts dd { margin: 0; overflow-wrap: anywhere; }
2223 </div>2324 {% if c.body %}<pre class="msg">{{ c.body }}</pre>{% endif %}2425 <dl class="facts">25-<dt>Commit</dt><dd class="sha">{{ c.sha }}</dd>26-{% for par in c.parents %}<dt>Parent</dt><dd class="sha">{{ par | commitLink(p.repo, par) | safe }}</dd>{% endfor %}27-<dt>Author</dt><dd>{{ c.author.name }} &lt;{{ c.author.email }}&gt;</dd>28-{% if c.ci %}<dt>CI</dt><dd>{{ c.ci | ciHtml(true) | safe }}{% if c.ci.latest.via %} <span class="desc">(same tree as {{ c.ci.latest.via | commitLink(p.repo) | safe }})</span>{% endif %}</dd>{% endif %}29-{% if c.jev %}<dt>Kind</dt><dd>{{ m.jevLabel(c) }}</dd>{% endif %}30-{% for rv in c.reviews %}<dt>Review</dt><dd><a href="{{ u.review(rv.id).href }}">#{{ rv.id }} {{ rv.title }}</a></dd>{% endfor %}26+<dt>commit</dt><dd class="sha">{{ c.sha }}</dd>27+{% for par in c.parents %}<dt>parent</dt><dd class="sha">{{ par | commitLink(p.repo, par) | safe }}</dd>{% endfor %}28+<dt>author</dt><dd>{{ c.author.name }} &lt;{{ c.author.email }}&gt;</dd>29+{% if c.ci %}<dt>ci</dt><dd>{{ c.ci | ciHtml(true) | safe }}{% if c.ci.latest.via %} <span class="desc">(same tree as {{ c.ci.latest.via | commitLink(p.repo) | safe }})</span>{% endif %}</dd>{% endif %}30+{% if c.jev %}<dt>kind</dt><dd>{{ m.jevLabel(c) }}</dd>{% endif %}31+{% for rv in c.reviews %}<dt>review</dt><dd><a href="{{ u.review(rv.id).href }}">#{{ rv.id }} {{ rv.title }}</a></dd>{% endfor %}3132 </dl>3233 {% if c.ci %}33-<details class="att"><summary>CI attestation</summary>34+<details class="att"><summary>ci attestation</summary>3435 <pre>{{ c.ci.latest.payload | statement }}</pre>3536 <p class="desc">Signature: {% if c.ci.latest.verification.ok %}good, {{ c.ci.latest.verification.signer }}{% if c.ci.latest.verification.key %} {{ c.ci.latest.verification.key }}{% endif %}{% else %}{{ c.ci.latest.verification.reason }}{% endif %}</p>3637 </details>3738 {% endif %}38-<div class="changes"><span class="tools"><button type="button" data-wrap>Wrap lines</button></span>39+<div class="changes"><span class="tools">{{ ui.checkbox('wrap lines', false, 'data-wrap') }}</span>3940 {{ c.files | diffstatHtml | safe }}4041 </div>4142 {{ c.files | diffHtml(p.repo) | safe }}
M site/repo/home.njk +2 -1 mechanical
M site/repo/insights.njk +2 -1
M site/repo/log.njk +1 -0 mechanical
M site/repo/refs.njk +7 -11
@@ -4,29 +4,25 @@ pagination:
44 ---55 {% extends "base.njk" %}66 {% import "macros.njk" as m with context %}7+{% import "ui.njk" as ui %}78 {% set u = p.repo.urls %}89 {% block content %}9-<h2>Branches</h2>10+{{ ui.heading('branches', p.branches.length) }}1011 <div class="box">11-<table class="list stack nav">12-<thead><tr><th>Branch</th><th>Updated</th><th>Author</th><th class="num">Behind</th><th class="num">Ahead</th><th>Review</th></tr></thead>13-<tbody>12+{% call ui.listTable(['branch', 'updated', 'author', {text: 'behind', cls: 'num'}, {text: 'ahead', cls: 'num'}, 'review']) %}1413 {% for b in p.branches %}1514 <tr><td class="main mono"><a href="{{ u.tree(b.name).href }}">{{ b.name }}</a>{% if b.isDefault %} <span class="desc">default</span>{% endif %}</td><td class="date">{{ b.date | date }}</td><td class="nowrap hide-sm">{{ b.author }}</td><td class="num">{{ ('' ~ b.behind + ' behind') if not b.isDefault else '' }}</td><td class="num">{{ ('' ~ b.ahead + ' ahead') if not b.isDefault else '' }}</td><td>{% if b.review %}<a href="{{ u.review(b.review.id).href }}">#{{ b.review.id }}</a> {{ m.status(b.review.status) }}{% endif %}</td></tr>1615 {% endfor %}17-</tbody>18-</table>16+{% endcall %}1917 </div>2018 {% if p.tags.length %}21-<h2>Tags <a class="releases-link" href="{{ u.releases.href }}">releases</a></h2>19+<h2>tags <span class="n">{{ p.tags.length }}</span> <a class="releases-link" href="{{ u.releases.href }}">releases</a></h2>2220 <div class="box">23-<table class="list stack nav">24-<tbody>21+{% call ui.listTable() %}2522 {% for t in p.tags %}2623 <tr><td class="main mono">{{ t.sha | commitLink(p.repo, t.name) | safe }}</td><td class="date">{{ t.date | date }}</td><td class="hide-sm">{{ t.subject }}</td></tr>2724 {% endfor %}28-</tbody>29-</table>25+{% endcall %}3026 </div>3127 {% endif %}3228 {% endblock %}
M site/repo/releases.njk +8 -7
@@ -4,23 +4,24 @@ pagination:
44 ---55 {% extends "base.njk" %}66 {% import "macros.njk" as m with context %}7+{% import "ui.njk" as ui %}78 {% block content %}89 {% css %}910 /* A release's files: name, size, sha256; then who signed SHA256SUMS. */10-.downloads { padding: 8px 8px 4px; border-bottom: 1px solid var(--border); }11+.downloads { padding: var(--space-2) var(--space-2) var(--space-1); border-bottom: var(--hairline) solid var(--border); }1112 .downloads table { width: 100%; border-collapse: collapse; }12-.downloads td { padding: 3px 12px 3px 0; vertical-align: top; }13-.downloads td.file { white-space: nowrap; font-weight: 600; }14-.downloads td.size { white-space: nowrap; text-align: right; font: 12px var(--mono); }15-.downloads td.sum { font-size: 0.8em; overflow-wrap: anywhere; width: 100%; }16-.downloads p { margin: 6px 0 4px; font-size: 0.9em; }13+.downloads td { padding: var(--space-1) var(--space-3) var(--space-1) 0; vertical-align: top; }14+.downloads td.file { white-space: nowrap; font-weight: var(--fw-medium); }15+.downloads td.size { white-space: nowrap; text-align: right; font: var(--fw-regular) var(--text-xs) var(--mono); }16+.downloads td.sum { font-size: var(--text-xs); overflow-wrap: anywhere; width: 100%; }17+.downloads p { margin: var(--space-2) 0 var(--space-1); font-size: var(--text-sm); }1718 {% endcss %}1819 {# Release notes: each tag's commits since the tag before it, under the1920 section Jev sorted them into (lib/jev-commits.ts). The wording is the2021 commit subjects; commits without an answer are under Other. A tag with2122 a release (forge release publish, lib/releases.ts) lists its files2223 first. #}23-<h2>Releases</h2>24+{{ ui.heading('releases', p.releases.length) }}2425 {% for r in p.releases %}2526 <div class="box" id="{{ r.tag.name }}">2627 <div class="box-head"><b class="mono">{{ r.tag.sha | commitLink(p.repo, r.tag.name) | safe }}</b><span class="grow desc">{{ ('since ' ~ r.prev) if r.prev else 'first release' }}, {{ r.count | plural('commit') }}{{ ' or more' if r.more }}</span><span class="date">{{ r.tag.date | date }}</span></div>
M site/repo/review-diff.njk +17 -17
@@ -4,6 +4,7 @@ pagination:
44 ---55 {% extends "base.njk" %}66 {% import "macros.njk" as m with context %}7+{% import "ui.njk" as ui %}78 {% set rv = p.review %}89 {% block head %}{{ m.reviewMeta(rv, p.repo, p.title, p.repo.urls.reviewDiff(rv.id).href) }}{% endblock %}910 {% block scripts %}<script src="{{ assets.diffs }}" defer></script>
@@ -12,40 +13,39 @@ pagination:
1213 {% block content %}1314 {% css %}1415 /* Line comments under their lines; they look like the discussion's (discussion.njk). */15-.cmt { border-top: 1px solid var(--border); }16-.cmt:target { box-shadow: inset 3px 0 0 var(--accent); }17-.cmt-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; padding: 8px 12px 0; font-size: 0.9em; }18-.cmt-head a, .cmt-head .date { font: 12px var(--mono); }19-.cmt-head time { font-size: 1em; }20-.cmt-head .tag { font: 600 10.5px var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--warn); }16+.cmt { border-top: var(--hairline) solid var(--border); }17+.cmt:target { box-shadow: inset var(--bar) 0 0 var(--accent); }18+.cmt-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-half) var(--space-3); padding: var(--space-2) var(--space-3) 0; font-size: var(--text-sm); }19+.cmt-head a, .cmt-head .date { font: var(--fw-regular) var(--text-xs) var(--mono); }20+.cmt-head time { font-size: inherit; }21+.cmt-head .tag { font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--warn); }2122 .cmt-head a { color: var(--muted); }22-.cmt .markdown { padding: 4px 12px 8px; font-size: 0.95rem; line-height: 1.55; }23+.cmt .markdown { padding: var(--space-1) var(--space-3) var(--space-2); font-size: var(--text-md); line-height: 1.6; }2324 .cmt .markdown > :last-child { margin-bottom: 0; }2425 /* In a diff, a comment is as wide as the code box, not its longest line. */2526 .code:has(.cmts) { container-type: inline-size; }26-.code .cmts { position: sticky; left: 0; width: 100cqw; padding: 0 10px 8px; background: var(--bg); border-block: 1px solid var(--border); font: 15px/1.5 var(--sans); white-space: normal; }27+.code .cmts { position: sticky; left: 0; width: 100cqw; padding: 0 var(--space-3) var(--space-2); background: var(--bg); border-block: var(--hairline) solid var(--border); font: var(--fw-regular) var(--text-md)/1.5 var(--sans); white-space: normal; }2728 .code .cmts > .cmt:first-child { border-top: 0; }28-.code .cmts > form { padding-top: 8px; }29+.code .cmts > form { padding-top: var(--space-2); }2930 /* A new line comment, signed in (discuss.ts): the + on the line under the3031 pointer (or the line last clicked) opens a form under it. */3132 .commenting .code.diff .rows { position: relative; }32-.addline { position: absolute; z-index: 2; width: 20px; height: 17px; padding: 0; font: 600 13px/15px var(--sans); letter-spacing: 0; text-align: center; color: #fff; background: var(--accent); border-color: var(--accent); }33-.addline:hover { color: #fff; background: var(--deep); border-color: var(--deep); }34-.cmts .where { font: 12px/1.6 var(--mono); color: var(--muted); margin-bottom: 4px; overflow-wrap: anywhere; }35-.cmts textarea { display: block; width: 100%; min-height: 5em; resize: vertical; font: 14px/1.5 var(--sans); color: var(--fg); background: var(--raised); border: 1px solid var(--border); padding: 8px 10px; }36-.cmts textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }37-.cmts .reply-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin-top: 8px; font-size: 0.92em; }33+.addline { position: absolute; z-index: 2; width: 20px; height: 17px; min-height: 0; padding: 0; font: var(--fw-strong) var(--text-sm)/15px var(--sans); text-align: center; color: var(--on-accent); background: var(--accent); border-color: var(--accent); }34+.addline:hover { color: var(--on-accent); background: var(--accent-hover); border-color: var(--accent-hover); }35+.cmts .where { font: var(--fw-regular) var(--text-xs)/1.6 var(--mono); color: var(--muted); margin-bottom: var(--space-1); overflow-wrap: anywhere; }36+.cmts textarea { min-height: 5em; }37+.cmts .reply-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-2); font-size: var(--text-sm); }3838 .cmts .reply-bar .grow { flex: 1; }3939 .cmts .reply-status.bad { color: var(--fail); white-space: pre-line; }4040 .cmt.pending { border-top-style: dashed; }41-.cmt.pending .text { padding: 8px 12px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); }41+.cmt.pending .text { padding: var(--space-2) var(--space-3); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); }4242 {% endcss %}4343 <div class="meta-head">4444 <h1><a href="{{ p.repo.urls.review(rv.id).href }}" data-key="u">{{ rv.title }}</a> <span class="desc">#{{ rv.id }}</span></h1>4545 <div class="byline">Combined diff of {{ rv.layer.length | plural('commit') }} on <span class="mono">{{ rv.branch }}</span></div>4646 </div>4747 <p class="desc" id="line-comments" data-post="{{ p.repo.urls.review(rv.id).href }}" data-at="{{ rv.tip }}" hidden></p>48-<div class="changes"><span class="tools"><span class="count" data-viewed="{{ p.repo.slug }}" hidden></span><button type="button" data-wrap>Wrap lines</button></span>48+<div class="changes"><span class="tools"><span class="count" data-viewed="{{ p.repo.slug }}" hidden></span>{{ ui.checkbox('wrap lines', false, 'data-wrap') }}</span>4949 {{ rv.combined | diffstatHtml | safe }}5050 </div>5151 {{ rv.combined | diffHtml(p.repo, '', rv.branch if rv.live else null, rv, true) | safe }}
M site/repo/review.njk +20 -18
@@ -4,6 +4,7 @@ pagination:
44 ---55 {% extends "base.njk" %}66 {% import "macros.njk" as m with context %}7+{% import "ui.njk" as ui %}78 {% set rv = p.review %}89 {% set u = p.repo.urls %}910 {% block head %}{{ m.reviewMeta(rv, p.repo, p.title, u.review(rv.id).href) }}{% endblock %}
@@ -12,24 +13,25 @@ pagination:
1213 {% endblock %}1314 {% block content %}1415 {% css %}15-.review-desc { margin: 0 0 16px; border-block: 1px solid var(--border); }16+.review-desc { margin: 0 0 var(--space-4); border-block: var(--hairline) solid var(--border); }1617 .review-desc .markdown > :last-child { margin-bottom: 0; }17-.stacked { margin-top: 28px; }18-.stacked > h3 { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; margin: 0 0 4px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 1rem; }19-.stacked > h3 .step { color: var(--faint); font: 600 11px var(--mono); letter-spacing: 0.08em; }20-#stack tr.this td { font-weight: 600; }18+.stacked { margin-top: var(--space-8); }19+.stacked > h3 { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); align-items: baseline; margin: 0 0 var(--space-1); padding-top: var(--space-3); border-top: var(--hairline) solid var(--border); font-size: var(--text-lg); }20+.stacked > h3 .step { color: var(--faint); font: var(--fw-regular) var(--text-xs) var(--mono); }21+#stack tr.this td { font-weight: var(--fw-strong); }22+.review-tools { margin-top: var(--space-3); }2123 /* The sticky bar: layers of the stack, commits of the layer. It has a fixed2224 height so file headers can stick right below it. */23-.revnav { position: sticky; top: 0; z-index: 3; height: 2.75rem; display: flex; align-items: center; gap: 12px; margin: 28px 0 0; background: var(--bg); border-bottom: 1px solid var(--border); white-space: nowrap; overflow-x: auto; scrollbar-width: none; font: 12px var(--mono); }25+.revnav { position: sticky; top: 0; z-index: 3; height: 2.75rem; display: flex; align-items: center; gap: var(--space-3); margin: var(--space-8) 0 0; background: var(--bg); border-bottom: var(--hairline) solid var(--border); white-space: nowrap; overflow-x: auto; scrollbar-width: none; font: var(--fw-regular) var(--text-xs) var(--mono); }2426 .revnav::-webkit-scrollbar { display: none; }2527 @media (max-width: 480px) {26- .revnav { gap: 8px; }27- .revnav .seg > * { padding: 3px 8px; }28+ .revnav { gap: var(--space-2); }29+ .revnav .seg > * { padding: var(--space-1) var(--space-2); }2830 .revnav .n { display: none; }2931 }30-.revnav .seg { display: inline-flex; align-items: stretch; border: 1px solid var(--border); background: var(--subtle); overflow: hidden; }31-.revnav .seg > * { padding: 3px 10px; }32-.revnav .seg > .at { color: var(--fg); border-inline: 1px solid var(--border); font-variant-numeric: tabular-nums; }32+.revnav .seg { display: inline-flex; align-items: stretch; border: var(--hairline) solid var(--border-strong); background: var(--raised); overflow: hidden; }33+.revnav .seg > * { padding: var(--space-1) var(--space-3); }34+.revnav .seg > .at { color: var(--fg); border-inline: var(--hairline) solid var(--border); font-variant-numeric: tabular-nums; }3335 .revnav .seg > a:hover { background: var(--hover); text-decoration: none; }3436 .revnav .seg > a:not([href]) { color: var(--faint); }3537 .revnav .grow { flex: 1; }
@@ -45,7 +47,7 @@ pagination:
4547 {% if rv.opts.description %}<div class="box review-desc"><div class="markdown">{{ rv.opts.description | reviewMarkdown(p.repo, rv, true) | safe }}</div></div>{% endif %}4648 4749 {% if p.stack.length > 1 %}48-<h2>Stack</h2>50+{{ ui.heading('stack', p.stack.length) }}4951 <div class="box">5052 <table class="list stack" id="stack">5153 <tbody>
@@ -58,7 +60,7 @@ pagination:
5860 </div>5961 {% endif %}6062 61-<h2>Commits <span class="desc">oldest first</span></h2>63+{{ ui.heading('commits', rv.layer.length, 'oldest first') }}6264 <div class="box">6365 <table class="list stack nav" id="layer">6466 <tbody>
@@ -69,10 +71,10 @@ pagination:
6971 </table>7072 </div>7173 72-<div class="bar" style="margin-top:12px">73-<a href="{{ u.reviewDiff(rv.id).href }}">Combined diff</a> <span class="desc">{{ rv.combinedStats.files | plural('file') }}, <span class="i">+{{ rv.combinedStats.add }}</span> <span class="d">-{{ rv.combinedStats.del }}</span></span>74-{% for s in rv.previews %}<span>Preview: <a href="{{ s.url }}">{{ s.name }}</a>{% if s.history[0].source != rv.tip %} <span class="ci partial" title="the branch has moved since this preview was built">stale</span>{% endif %}</span>{% endfor %}75-<span class="grow"></span>{% if p.stack.length < 2 and rv.layer.length < 2 %}<span class="count" data-viewed="{{ p.repo.slug }}" hidden></span>{% endif %}<button type="button" data-wrap>Wrap lines</button>74+<div class="bar review-tools">75+<a href="{{ u.reviewDiff(rv.id).href }}">combined diff</a> <span class="desc">{{ rv.combinedStats.files | plural('file') }}, <span class="i">+{{ rv.combinedStats.add }}</span> <span class="d">-{{ rv.combinedStats.del }}</span></span>76+{% for s in rv.previews %}<span>preview <a href="{{ s.url }}">{{ s.name }}</a>{% if s.history[0].source != rv.tip %} <span class="ci partial" title="the branch has moved since this preview was built">stale</span>{% endif %}</span>{% endfor %}77+<span class="grow"></span>{% if p.stack.length < 2 and rv.layer.length < 2 %}<span class="count" data-viewed="{{ p.repo.slug }}" hidden></span>{% endif %}{{ ui.checkbox('wrap lines', false, 'data-wrap') }}7678 </div>7779 7880 {% if rv.revisions.length > 1 %}
@@ -88,7 +90,7 @@ pagination:
8890 {% endif %}8991 9092 {% if rv.status == 'open' or rv.status == 'draft' %}91-<details class="howto"><summary>Check out or land</summary>93+<details class="howto"><summary>check out or land</summary>9294 <pre>git fetch {{ u.cloneSsh }} {{ rv.branch }} &amp;&amp; git switch --detach FETCH_HEAD9395 forge checkout {{ rv.id }} # the same, on a local tracking branch9496 forge attest --stack # CI for each commit, on your machine
M site/repo/reviews.njk +6 -4
M site/repo/revision.njk +11 -10
@@ -4,6 +4,7 @@ pagination:
44 ---55 {% extends "base.njk" %}66 {% import "macros.njk" as m with context %}7+{% import "ui.njk" as ui %}78 {% set rv = p.review %}89 {% block head %}{{ m.reviewMeta(rv, p.repo, p.title, p.repo.urls.revision(rv.id, p.rev.n).href) }}{% endblock %}910 {% block scripts %}<script src="{{ assets.diffs }}" defer></script>
@@ -11,23 +12,23 @@ pagination:
1112 {% block content %}1213 {% css %}1314 /* range-diff: a block per pair of commits */14-.pk { font: 600 10.5px var(--mono); text-transform: uppercase; letter-spacing: 0.08em; }15+.pk { font-size: var(--text-sm); font-weight: var(--fw-strong); }1516 .pk.same { color: var(--muted); }1617 .pk.changed { color: var(--warn); }1718 .pk.add { color: var(--add-fg); }1819 .pk.del { color: var(--del-fg); }19-.subj { font-weight: 600; overflow-wrap: anywhere; }20-.pairrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin: 0; padding: 8px; border-bottom: 1px solid var(--border); font-size: 0.9em; }21-details.pairs { margin: 0; padding: 8px; border-bottom: 1px solid var(--border); font-size: 0.9em; }22-details.pairs > summary { cursor: pointer; color: var(--muted); font: 600 11px/1.4 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; }23-details.pairs .pairrow { margin: 0; padding: 6px 0 0; border: 0; }20+.subj { font-weight: var(--fw-strong); overflow-wrap: anywhere; }21+.pairrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); margin: 0; padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-sm); }22+details.pairs { margin: 0; padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-sm); }23+details.pairs > summary { cursor: pointer; color: var(--muted); font-weight: var(--fw-medium); }24+details.pairs .pairrow { margin: 0; padding: var(--space-2) 0 0; border: 0; }2425 .pair .fh .subj { color: var(--fg); }2526 2627 /* No line numbers here: the marker sticks at the left edge, not after them. */2728 .code.range .mk { left: 0; }28-.code.range .l.sect .lc { font-weight: 600; }29-.code.range .lc .i { color: var(--add-fg); font-weight: 600; }30-.code.range .lc .d { color: var(--del-fg); font-weight: 600; }29+.code.range .l.sect .lc { font-weight: var(--fw-strong); }30+.code.range .lc .i { color: var(--add-fg); font-weight: var(--fw-strong); }31+.code.range .lc .d { color: var(--del-fg); font-weight: var(--fw-strong); }3132 {% endcss %}3233 <div class="meta-head">3334 <h1><a href="{{ p.repo.urls.review(rv.id).href }}" data-key="u">{{ rv.title }}</a> <span class="desc">#{{ rv.id }}</span></h1>
@@ -35,7 +36,7 @@ details.pairs .pairrow { margin: 0; padding: 6px 0 0; border: 0; }
3536 {% if p.rev.view == 'diff' %}3637 <div class="byline">What changed from r{{ p.rev.prev }} ({{ p.from.sha | commitLink(p.repo) | safe }}) to r{{ p.rev.n }} ({{ p.rev.sha | commitLink(p.repo) | safe }}), pushed {{ p.rev.ts | ago }}. Both are on the same base, so this is the diff between their tips{% if rv.parent %}, including any change to the layers below{% endif %}.</div>3738 </div>38-<div class="changes"><span class="tools"><button type="button" data-wrap>Wrap lines</button></span>39+<div class="changes"><span class="tools">{{ ui.checkbox('wrap lines', false, 'data-wrap') }}</span>3940 {{ p.files | diffstatHtml | safe }}4041 </div>4142 {% if not p.files.length %}<p class="empty">The trees are identical: only commit messages or history changed.</p>{% endif %}
M site/repo/sites.njk +2 -1
M site/repo/tree.njk +2 -1 mechanical
M site/repo/wiki.njk +9 -8
@@ -4,20 +4,21 @@ pagination:
44 ---55 {% extends "base.njk" %}66 {% import "macros.njk" as m with context %}7+{% import "ui.njk" as ui %}78 {% block head %}{{ m.meta(p.title, p.wiki.html | excerpt(true), p.repo.urls.wiki(p.wiki.slug).href) }}{% endblock %}89 {% block content %}910 {% css %}1011 /* wiki */11-.wiki-layout { display: grid; grid-template-columns: 220px 1fr; gap: 24px; }12-.wiki-nav { border-top: 1px solid var(--border); }13-.wiki-nav a { display: block; padding: 6px 8px; border-bottom: 1px solid var(--border); color: var(--fg); }12+.wiki-layout { display: grid; grid-template-columns: 220px 1fr; gap: var(--space-6); }13+.wiki-nav { border-top: var(--hairline) solid var(--border); }14+.wiki-nav a { display: block; padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); color: var(--fg); }1415 .wiki-nav a:hover { background: var(--hover); text-decoration: none; }15-.wiki-nav a.cur { font-weight: 600; box-shadow: inset 2px 0 0 var(--accent); }16+.wiki-nav a.cur { font-weight: var(--fw-strong); box-shadow: inset 2px 0 0 var(--accent); }1617 @media (max-width: 720px) {17- .wiki-layout { grid-template-columns: 1fr; gap: 8px; }18- .wiki-nav { display: flex; overflow-x: auto; gap: 16px; border-top: 0; border-bottom: 1px solid var(--border); }19- .wiki-nav a { white-space: nowrap; padding: 6px 0; border: 0; }20- .wiki-nav a.cur { box-shadow: inset 0 -2px 0 var(--accent); }18+ .wiki-layout { grid-template-columns: 1fr; gap: var(--space-2); }19+ .wiki-nav { display: flex; overflow-x: auto; gap: var(--space-4); border-top: 0; border-bottom: var(--hairline) solid var(--border); }20+ .wiki-nav a { white-space: nowrap; padding: var(--space-2) 0; border: 0; }21+ .wiki-nav a.cur { box-shadow: inset 0 calc(-1 * var(--bar)) 0 var(--accent); }2122 }2223 {% endcss %}2324 <div class="wiki-layout">
M test/scenarios/insights-pages.sh +1 -1 mechanical
M test/scenarios/log-columns.sh +1 -1 mechanical
M test/scenarios/release-page.sh +2 -2
M test/scenarios/release-tab.sh +1 -1 mechanical
M test/scenarios/web-comment-page.sh +1 -1

4/7 The guide in the forge's palette, fonts and rules

Same tokens as style.css, the forge's three fonts (copied in when the guide is deployed), square corners, no dot grid, lowercase chrome.
7 files changed, +320 -261
M.forge/ci3+
M.forge/sites2+-
M.gitignore1+
Mdemo/make-demo.sh3+-
Mguide/guide.css498+++++++++++++++++-------------
Mguide/index.html43+-
Mguide/internals.html31+-
M .forge/ci +3 -0
M .forge/sites +1 -1
M .gitignore +1 -0 mechanical
M demo/make-demo.sh +2 -1
M guide/guide.css +275 -223
@@ -1,126 +1,177 @@
1-/* The forge guide: one stylesheet, light and dark, phone first. The palette2- is the forge's own (site/assets/style.css); the page reads like a git log,3- with each section a commit on one line. */1+/* The forge guide: one stylesheet, light and dark, phone first. The page2+ reads like a git log, with each section a commit on one line.3+4+ The palette, the fonts and the rules are the forge's own5+ (site/assets/style.css, DESIGN.md), and lib/check-css.ts holds this file6+ to them: a plain page, square corners, content between 1px rules rather7+ than in boxes; Open Sans for the interface, Noto Sans Mono for what is8+ copied or compared, Noto Serif for the prose; hierarchy from weight, size9+ and colour, never capitals or tracking; one accent (#3148F6) for links,10+ the rail, the current entry and the primary button. */11+12+/* == tokens == Every colour, size, weight and family is here; nothing13+ below this block names one. */14+/* The forge's fonts, copied into fonts/ when the guide is deployed15+ (.forge/sites). The sans is preloaded. */16+@font-face { font-family: "Open Sans"; src: url(fonts/open-sans.woff2) format("woff2"); font-weight: 300 800; font-style: normal; font-display: swap; }17+@font-face { font-family: "Noto Sans Mono"; src: url(fonts/noto-sans-mono.woff2) format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }18+@font-face { font-family: "Noto Serif"; src: url(fonts/noto-serif.woff2) format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }19+@font-face { font-family: "Noto Serif"; src: url(fonts/noto-serif-italic.woff2) format("woff2"); font-weight: 400 700; font-style: italic; font-display: swap; }420 :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- --ink: #0e7482;15- --tint: rgba(44, 169, 188, 0.1);16- --grid: rgba(31, 35, 40, 0.09);17- --pass: #1a7f37;18- --fail: #cf222e;19- --warn: #9a6700;20- --merged: #8250df;21- --add: #116329;22- --del: #a40e26;23- --term-bg: #0d1117;24- --term-fg: #e6edf3;25- --term-dim: #8b949e;26- --serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Charter, "Bitstream Charter", Georgia, serif;27- --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;28- --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;29- --radius: 8px;21+ /* colour: the page, surfaces raised over it, ink in three strengths,22+ rules, and the accent */23+ --bg: #e9e9e9;24+ --raised: #ffffff;25+ --hover: #f4f4f4;26+ --fg: #1c2127;27+ --muted: #5b6169;28+ --faint: #7c828a;29+ --border: #d2d4d7;30+ --border-strong: #a9aeb4;31+ --link: #3148f6;32+ --accent: #3148f6;33+ --deep: #1c4b8f;34+ --accent-hover: #1c4b8f;35+ --on-accent: #ffffff;36+ /* meaning: CI results, diffs, a changed line */37+ --pass: #0b7a1d;38+ --fail: #c7162b;39+ --warn: #965800;40+ --add: #0b7a1d;41+ --del: #c7162b;42+ /* the terminal in the hero is dark in both modes */43+ --term-bg: #08090b;44+ --term-fg: #e2e4e7;45+ --term-dim: #8f959d;46+ --term-ok: #4fbf5f;47+ --term-u: #8e9bff;48+ --term-rule: #1f2329;49+50+ /* type: the interface, what is copied or compared, and prose */51+ --sans: "Open Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;52+ --mono: "Noto Sans Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;53+ --serif: "Noto Serif", Georgia, Cambria, "Times New Roman", serif;54+ --text-xs: 12px;55+ --text-code: 12.5px;56+ --text-sm: 13.5px;57+ --text-md: 15px;58+ --text-prose: 17px;59+ --text-lg: 18px;60+ --text-xl: 22px;61+ --text-2xl: 30px;62+ --text-3xl: clamp(2.4rem, 9vw, 4rem);63+ /* mono inline in serif text runs large; this brings it level */64+ --text-inline-mono: 0.88em;65+ --fw-regular: 400;66+ --fw-medium: 500;67+ --fw-strong: 650;68+69+ /* space: a 4px scale */70+ --space-half: 2px;71+ --space-1: 4px;72+ --space-2: 8px;73+ --space-3: 12px;74+ --space-4: 16px;75+ --space-5: 20px;76+ --space-6: 24px;77+ --space-8: 32px;78+ --space-10: 40px;79+ --hairline: 1px;80+ --bar: 3px;81+ --radius: 0;82+ /* the log's rail: where it runs, and how far the sections sit from it */3083 --rail-x: 7px;3184 --gutter: 30px;3285 color-scheme: light dark;3386 }3487 @media (prefers-color-scheme: dark) {3588 :root {36- --bg: #0d1117;37- --fg: #e6edf3;38- --muted: #9198a1;39- --faint: #6e7681;40- --border: #30363d;41- --subtle: #151b23;42- --hover: #1c232c;43- --link: #4493f8;44- --ink: #3cc0d3;45- --tint: rgba(44, 169, 188, 0.14);46- --grid: rgba(230, 237, 243, 0.07);47- --pass: #3fb950;48- --fail: #ff7b72;49- --warn: #d29922;50- --merged: #ab7df8;51- --add: #3fb950;52- --del: #ff7b72;53- --term-bg: #010409;89+ --bg: #08090b;90+ --raised: #111418;91+ --hover: #15181d;92+ --fg: #e2e4e7;93+ --muted: #8f959d;94+ --faint: #6b7178;95+ --border: #1f2329;96+ --border-strong: #3a4048;97+ --link: #8e9bff;98+ --deep: #b4bcff;99+ --accent-hover: #2a3ee0;100+ --pass: #4fbf5f;101+ --fail: #f0606e;102+ --warn: #e3a33b;103+ --add: #4fbf5f;104+ --del: #f0606e;54105 }55106 }107+/* == end tokens == */56108 57-* { box-sizing: border-box; }58-html { -webkit-text-size-adjust: 100%; scroll-padding-top: 16px; }109+*, ::before, ::after { box-sizing: border-box; border-radius: var(--radius); }110+html { -webkit-text-size-adjust: 100%; scroll-padding-top: var(--space-4); }59111 @media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }60-body { margin: 0; background: var(--bg); color: var(--fg); font: 1.0625rem/1.65 var(--serif); font-kerning: normal; text-rendering: optimizeLegibility; }112+body { margin: 0; background: var(--bg); color: var(--fg); font: var(--fw-regular) var(--text-prose)/1.65 var(--serif); font-kerning: normal; text-rendering: optimizeLegibility; }61113 a { color: var(--link); text-underline-offset: 0.18em; text-decoration-thickness: 1px; }62-a:hover { text-decoration-thickness: 2px; }63-:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }114+a:hover { color: var(--deep); text-decoration-thickness: 2px; }115+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }64116 code, kbd, pre { font-family: var(--mono); }65-code { font-size: 0.84em; background: var(--subtle); border: 1px solid var(--border); border-radius: 5px; padding: 0.05em 0.32em; white-space: nowrap; }66-pre code, .plain code { background: none; border: 0; padding: 0; font-size: inherit; white-space: inherit; }67-kbd { font-size: 0.8em; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 0 0.35em; }68-h1, h2, h3 { font-weight: 600; line-height: 1.15; letter-spacing: -0.01em; }69-h3 { font-size: 1.2rem; margin: 2rem 0 0.5rem; }70-p, ul, ol, dl { margin: 0 0 1rem; }71-strong { font-weight: 650; }72-.wrap { max-width: 1180px; margin: 0 auto; padding: 0 18px; }73-.skip { position: absolute; left: -999px; top: 8px; z-index: 5; background: var(--bg); padding: 6px 10px; border: 1px solid var(--border); }74-.skip:focus { left: 8px; }117+code { font-size: var(--text-inline-mono); white-space: nowrap; }118+pre code, .plain code { font-size: inherit; white-space: inherit; }119+kbd { font-size: var(--text-inline-mono); color: var(--muted); background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: 0 var(--space-1); }120+h1, h2, h3 { font-family: var(--sans); font-weight: var(--fw-strong); line-height: 1.2; }121+h3 { font-size: var(--text-lg); margin: var(--space-8) 0 var(--space-2); }122+p, ul, ol, dl { margin: 0 0 var(--space-4); }123+b, strong { font-weight: var(--fw-strong); }124+.wrap { max-width: 1180px; margin: 0 auto; padding: 0 var(--space-4); }125+.skip { position: absolute; left: -999px; top: var(--space-2); z-index: 5; background: var(--raised); padding: var(--space-1) var(--space-3); border: var(--hairline) solid var(--border-strong); font-family: var(--sans); font-size: var(--text-sm); }126+.skip:focus { left: var(--space-2); }75127 .mono { font-family: var(--mono); }76128 .muted { color: var(--muted); }77-78-/* top bar */79-.bar { border-bottom: 1px solid var(--border); font-family: var(--sans); font-size: 0.92rem; }80-.bar .wrap { display: flex; align-items: center; gap: 14px; height: 52px; }81-.brand { display: flex; align-items: center; gap: 9px; color: var(--fg); text-decoration: none; font: 600 1rem var(--mono); }82-.brand span { color: var(--muted); font-weight: 400; }129+/* the logo: the ring in ink, the A in the accent */83130 .mark { display: block; width: 26px; height: 26px; flex: none; }84131 .mark circle { stroke: currentColor; }85-.bar nav { margin-left: auto; display: flex; gap: 4px; }86-.bar nav a { color: var(--muted); text-decoration: none; padding: 6px 10px; border-radius: 6px; }87-.bar nav a:hover { background: var(--hover); color: var(--fg); }88-.bar nav a[aria-current="page"] { color: var(--fg); box-shadow: inset 0 -2px 0 var(--accent); border-radius: 0; }132+.mark path { stroke: var(--accent); }133+134+/* top bar: the name, and the two pages as tabs on the rule */135+.bar { border-bottom: var(--hairline) solid var(--border); font-family: var(--sans); }136+.bar .wrap { display: flex; align-items: center; gap: var(--space-3); height: 52px; }137+.brand { display: flex; align-items: center; gap: var(--space-2); color: var(--fg); text-decoration: none; font-size: var(--text-lg); font-weight: var(--fw-strong); }138+.brand:hover { color: var(--fg); text-decoration: none; }139+.brand span { color: var(--muted); font-weight: var(--fw-regular); }140+.bar nav { margin-left: auto; display: flex; gap: var(--space-6); align-self: stretch; }141+.bar nav a { display: flex; align-items: center; color: var(--muted); text-decoration: none; font-size: var(--text-md); font-weight: var(--fw-medium); }142+.bar nav a:hover { color: var(--fg); }143+.bar nav a[aria-current="page"] { color: var(--fg); box-shadow: inset 0 calc(-1 * var(--bar)) 0 var(--accent); }89144 90145 /* hero */91-.hero { position: relative; overflow: hidden; padding: 44px 0 36px; border-bottom: 1px solid var(--border); }92-.hero::before { content: ""; position: absolute; inset: 0; background-image: radial-gradient(var(--grid) 1.1px, transparent 1.4px); background-size: 22px 22px; -webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 95%); mask-image: linear-gradient(to bottom, #000 30%, transparent 95%); pointer-events: none; }93-.hero::after { content: ""; position: absolute; width: 340px; height: 340px; right: -190px; top: -170px; border: 2px solid var(--accent); border-radius: 50%; opacity: 0.22; pointer-events: none; }94-.hero .wrap { position: relative; display: grid; gap: 30px; }95-.eyebrow, .ref, figcaption, .label { font-family: var(--mono); font-size: 0.78rem; letter-spacing: 0.02em; }96-.eyebrow { color: var(--ink); margin: 0 0 10px; }97-.hero h1 { display: flex; align-items: center; gap: 0.18em; margin: 0 0 16px; font: 700 clamp(3.4rem, 15vw, 6.2rem)/0.95 var(--mono); letter-spacing: -0.055em; }98-.hero h1 .mark { width: 0.86em; height: 0.86em; stroke-width: 2.2; }99-.lede { font-size: 1.28rem; line-height: 1.5; max-width: 34rem; margin: 0 0 22px; }146+.hero { padding: var(--space-10) 0 var(--space-8); border-bottom: var(--hairline) solid var(--border); }147+.hero .wrap { display: grid; gap: var(--space-8); }148+.eyebrow { font-family: var(--sans); font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--muted); margin: 0 0 var(--space-2); }149+.hero h1 { display: flex; align-items: center; gap: 0.2em; margin: 0 0 var(--space-4); font-size: var(--text-3xl); line-height: 1; }150+.hero h1 .mark { width: 0.9em; height: 0.9em; }151+.lede { font-size: var(--text-lg); line-height: 1.5; max-width: 34rem; margin: 0 0 var(--space-6); }152+@media (min-width: 720px) { .lede { font-size: var(--text-xl); } }100153 .lede em { font-style: italic; }101-.cta { display: flex; flex-wrap: wrap; gap: 10px; margin: 0; font-family: var(--sans); font-size: 0.95rem; }102-.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); text-decoration: none; }103-.btn:hover { border-color: var(--accent); }104-.btn.go { background: var(--fg); color: var(--bg); border-color: var(--fg); }105-.btn.go:hover { background: var(--ink); border-color: var(--ink); }154+.cta { display: flex; flex-wrap: wrap; gap: var(--space-2); margin: 0; }155+/* buttons, as the forge's: secondary is a raised surface, primary is156+ filled with the accent */157+.btn { display: inline-flex; align-items: center; min-height: 36px; padding: var(--space-1) var(--space-4); font: var(--fw-medium) var(--text-sm)/1.2 var(--sans); color: var(--fg); background: var(--raised); border: var(--hairline) solid var(--border-strong); text-decoration: none; }158+.btn:hover { color: var(--fg); border-color: var(--muted); text-decoration: none; }159+.btn.go { color: var(--on-accent); background: var(--accent); border-color: var(--accent); }160+.btn.go:hover { color: var(--on-accent); background: var(--accent-hover); border-color: var(--accent-hover); }106161 @media (min-width: 900px) {107162 .hero { padding: 72px 0 64px; }108- .hero::after { width: 560px; height: 560px; right: -220px; top: -250px; }109163 .hero .wrap { grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); align-items: center; gap: 48px; }110164 }111165 112166 /* terminal */113-.term { margin: 0; border-radius: 10px; background: var(--term-bg); color: var(--term-fg); border: 1px solid #30363d; box-shadow: 0 18px 50px rgba(1, 4, 9, 0.18); overflow: hidden; min-width: 0; }114-.term .chrome { display: flex; align-items: center; gap: 6px; padding: 9px 12px; border-bottom: 1px solid #21262d; font: 0.72rem var(--mono); color: var(--term-dim); }115-.term .chrome i { width: 9px; height: 9px; border-radius: 50%; background: #30363d; }116-.term .chrome span { margin-left: 6px; }117-.term pre { margin: 0; padding: 14px 16px 16px; font-size: 0.78rem; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }167+.term { margin: 0; background: var(--term-bg); color: var(--term-fg); border: var(--hairline) solid var(--term-rule); overflow: hidden; min-width: 0; }168+.term .chrome { padding: var(--space-2) var(--space-4); border-bottom: var(--hairline) solid var(--term-rule); font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--term-dim); }169+.term pre { margin: 0; padding: var(--space-3) var(--space-4) var(--space-4); font-size: var(--text-code); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }118170 .term .line { display: block; }119-.term .p { color: var(--accent); user-select: none; -webkit-user-select: none; }120-.term .o { color: var(--term-dim); }121-.term .r { color: #7d8590; }122-.term .ok { color: #3fb950; }123-.term .u { color: #79c0ff; }171+.term .p { color: var(--term-u); user-select: none; -webkit-user-select: none; }172+.term .o, .term .r { color: var(--term-dim); }173+.term .ok { color: var(--term-ok); }174+.term .u { color: var(--term-u); }124175 @media (prefers-reduced-motion: no-preference) {125176 .hero-text > *, .term { animation: rise 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) both; }126177 .hero-text > :nth-child(2) { animation-delay: 0.06s; }
@@ -132,59 +183,63 @@ strong { font-weight: 650; }
132183 }133184 134185 /* page: contents, then the log of sections */135-.page { display: grid; gap: 8px; padding-top: 28px; padding-bottom: 40px; }136-.toc { font: 0.85rem/1.4 var(--mono); }137-.toc h2 { font: 600 0.72rem var(--mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 8px; }138-.toc ol { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; }139-.toc a { display: flex; gap: 8px; padding: 4px 0; color: var(--fg); text-decoration: none; }140-.toc a span { color: var(--muted); }141-.toc a:hover { color: var(--ink); }142-.toc a[aria-current="true"] { color: var(--ink); }143-.toc a[aria-current="true"] span { color: var(--accent); }186+.page { display: grid; gap: var(--space-2); padding-top: var(--space-6); padding-bottom: var(--space-10); }187+.toc { font-family: var(--sans); font-size: var(--text-sm); line-height: 1.4; }188+.toc h2 { font-size: var(--text-xs); font-weight: var(--fw-strong); color: var(--muted); margin: 0 0 var(--space-2); }189+.toc ol { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-half) var(--space-3); }190+.toc a { display: flex; gap: var(--space-2); padding: var(--space-1) 0; color: var(--muted); text-decoration: none; }191+.toc a span { color: var(--faint); font-variant-numeric: tabular-nums; }192+.toc a:hover { color: var(--fg); }193+.toc a[aria-current="true"] { color: var(--fg); font-weight: var(--fw-medium); }194+.toc a[aria-current="true"] span { color: var(--link); }144195 @media (min-width: 1000px) {145196 .page { grid-template-columns: 200px minmax(0, 1fr); gap: 48px; padding-top: 44px; }146- .toc > div { position: sticky; top: 24px; }147- .toc ol { grid-template-columns: 1fr; }148- .toc a { padding: 5px 0 5px 12px; border-left: 2px solid var(--border); }197+ .toc > div { position: sticky; top: var(--space-6); }198+ .toc ol { grid-template-columns: 1fr; gap: 0; }199+ .toc a { padding: var(--space-1) 0 var(--space-1) var(--space-3); border-left: 2px solid var(--border); }149200 .toc a[aria-current="true"] { border-left-color: var(--accent); }150201 }151202 152203 .log { position: relative; padding-left: var(--gutter); min-width: 0; }153-.log::before { content: ""; position: absolute; left: var(--rail-x); top: 10px; bottom: 0; width: 2px; background: linear-gradient(var(--accent), var(--accent) calc(100% - 120px), transparent); opacity: 0.85; }154-.commit { position: relative; padding: 18px 0 30px; }155-.commit > header { position: relative; margin-bottom: 18px; }156-.commit > header::before { content: ""; position: absolute; left: calc(var(--rail-x) - var(--gutter) - 6px); top: 1px; width: 10px; height: 10px; border-radius: 50%; background: var(--bg); border: 2px solid var(--accent); }204+.log::before { content: ""; position: absolute; left: var(--rail-x); top: 10px; bottom: 0; width: 2px; background: linear-gradient(var(--accent), var(--accent) calc(100% - 120px), transparent); }205+.commit { position: relative; padding: var(--space-4) 0 var(--space-8); }206+.commit > header { position: relative; margin-bottom: var(--space-4); }207+/* each section's commit: a small square on the rail, filled when it is208+ the one linked to */209+.commit > header::before { content: ""; position: absolute; left: calc(var(--rail-x) - var(--gutter) - 6px); top: 3px; width: 10px; height: 10px; background: var(--bg); border: 2px solid var(--accent); }157210 .commit:target > header::before, .commit > header:hover::before { background: var(--accent); }158-.ref { margin: 0 0 6px; color: var(--ink); display: flex; gap: 8px; align-items: baseline; }159-.ref b { font-weight: 600; color: var(--muted); }160-.commit h2 { font-size: clamp(1.65rem, 5.2vw, 2.3rem); margin: 0; max-width: 34ch; }211+.ref { margin: 0 0 var(--space-1); display: flex; gap: var(--space-2); align-items: baseline; font-family: var(--mono); font-size: var(--text-xs); color: var(--muted); }212+.ref b { font-weight: var(--fw-medium); color: var(--faint); }213+.commit h2 { font-size: var(--text-xl); margin: 0; max-width: 34ch; }161214 .commit h2 a { color: inherit; text-decoration: none; }215+.commit h2 a:hover { color: var(--deep); }162216 .prose { max-width: 41rem; }163-.prose li { margin: 0.25rem 0; }217+.prose li { margin: var(--space-1) 0; }164218 .prose ul { padding-left: 1.2rem; }165-.aside { margin-top: 16px; font-family: var(--sans); font-size: 0.9rem; color: var(--muted); border-left: 3px solid var(--border); padding: 2px 0 2px 12px; max-width: 41rem; }219+.aside { margin-top: var(--space-4); font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); border-left: var(--bar) solid var(--border); padding: var(--space-half) 0 var(--space-half) var(--space-3); max-width: 41rem; }166220 @media (min-width: 720px) {167221 :root { --gutter: 44px; --rail-x: 9px; }168- .commit { padding: 26px 0 44px; }222+ .commit { padding: var(--space-6) 0 44px; }223+ .commit h2 { font-size: var(--text-2xl); }169224 }170225 171-/* figures */172-figure { margin: 22px 0 26px; }173-.plate { border: 1px solid var(--border); border-radius: var(--radius); background: var(--subtle); padding: 16px; overflow: hidden; }174-figcaption { color: var(--muted); margin-top: 10px; }175-figcaption b { color: var(--ink); font-weight: 600; margin-right: 6px; }176-@media (min-width: 720px) { .plate { padding: 22px 24px; } }226+/* figures: a raised plate on a hairline, a caption under it */227+figure { margin: var(--space-5) 0 var(--space-6); }228+.plate { border: var(--hairline) solid var(--border); background: var(--raised); padding: var(--space-4); overflow: hidden; }229+figcaption { font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-2); }230+figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: var(--space-2); }231+@media (min-width: 720px) { .plate { padding: 22px var(--space-6); } }177232 178233 /* fig: three parents merged into forge */179234 .merge .parents { list-style: none; margin: 0; padding: 0 0 0 26px; display: grid; gap: 10px; position: relative; }180235 .merge .parents::before { content: ""; position: absolute; left: 6px; top: 18px; bottom: -26px; border-left: 2px solid var(--accent); }181-.merge .parents li { position: relative; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }236+.merge .parents li { position: relative; background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: 10px var(--space-3); }182237 .merge .parents li::before { content: ""; position: absolute; left: -21px; top: 16px; width: 20px; border-top: 2px solid var(--accent); }183-.merge .parents b { display: block; font: 600 0.9rem var(--mono); }184-.merge .parents span { display: block; font-size: 0.95rem; color: var(--muted); line-height: 1.45; margin-top: 2px; }185-.merge .child { position: relative; margin: 14px 0 0; display: flex; align-items: center; gap: 10px; padding-left: 0; font: 700 1.15rem var(--mono); letter-spacing: -0.02em; }186-.merge .child .mark { width: 20px; height: 20px; background: var(--subtle); border-radius: 50%; position: relative; }187-.merge .child small { font: 0.78rem var(--mono); color: var(--muted); letter-spacing: 0; font-weight: 400; }238+.merge .parents b { display: block; font-family: var(--sans); font-size: var(--text-md); font-weight: var(--fw-strong); }239+.merge .parents span { display: block; font-size: var(--text-md); color: var(--muted); line-height: 1.5; margin-top: var(--space-half); }240+.merge .child { position: relative; margin: 14px 0 0; display: flex; align-items: center; gap: 10px; font-family: var(--sans); font-size: var(--text-lg); font-weight: var(--fw-strong); }241+.merge .child .mark { width: 22px; height: 22px; background: var(--raised); position: relative; }242+.merge .child small { font-size: var(--text-sm); font-weight: var(--fw-regular); color: var(--muted); }188243 @media (min-width: 720px) {189244 .merge .parents { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; padding: 0 0 28px; }190245 .merge .parents::before { left: calc((100% - 28px) / 6); right: calc((100% - 28px) / 6); top: auto; bottom: 0; border-left: 0; border-top: 2px solid var(--accent); }
@@ -196,38 +251,39 @@ figcaption b { color: var(--ink); font-weight: 600; margin-right: 6px; }
196251 /* fig: the push, as a trace down one line */197252 .flow { list-style: none; margin: 0; padding: 0; position: relative; --x: 6px; }198253 .flow::before { content: ""; position: absolute; left: var(--x); top: 8px; bottom: 14px; width: 2px; background: var(--border); }199-.flow > li { position: relative; padding: 0 0 16px 26px; }200-.flow > li::before { content: ""; position: absolute; left: calc(var(--x) - 5px); top: 7px; width: 8px; height: 8px; border-radius: 50%; background: var(--subtle); border: 2px solid var(--accent); }201-.flow .where { display: block; font: 600 0.8rem var(--mono); color: var(--ink); }202-.flow p { margin: 2px 0 0; font-size: 0.98rem; }203-.flow .src { font: 0.72rem var(--mono); color: var(--muted); margin-top: 4px; }204-.flow ul { margin: 6px 0 0; padding: 0; list-style: none; font-size: 0.95rem; }205-.flow ul li { position: relative; padding-left: 16px; margin: 3px 0; }206-.flow ul li::before { content: ""; position: absolute; left: 0; top: 0.75em; width: 10px; border-top: 2px solid var(--accent); opacity: 0.6; }254+.flow > li { position: relative; padding: 0 0 var(--space-4) 26px; }255+.flow > li::before { content: ""; position: absolute; left: calc(var(--x) - 5px); top: 7px; width: 8px; height: 8px; background: var(--raised); border: 2px solid var(--accent); }256+.flow .where { display: block; font-family: var(--mono); font-size: var(--text-code); font-weight: var(--fw-strong); color: var(--fg); }257+.flow p { margin: var(--space-half) 0 0; font-size: var(--text-md); }258+.flow .src { font-family: var(--mono); font-size: var(--text-xs); color: var(--muted); margin-top: var(--space-1); }259+.flow ul { margin: 6px 0 0; padding: 0; list-style: none; font-size: var(--text-md); }260+.flow ul li { position: relative; padding-left: var(--space-4); margin: 3px 0; }261+.flow ul li::before { content: ""; position: absolute; left: 0; top: 0.75em; width: 10px; border-top: 2px solid var(--accent); }207262 .flow .returns { padding-bottom: 18px; }208263 .flow .returns::before { display: none; }209-.flow .returns span { display: block; border-top: 1px dashed var(--faint); padding-top: 6px; font: 0.75rem var(--mono); color: var(--muted); }264+.flow .returns span { display: block; border-top: var(--hairline) dashed var(--border-strong); padding-top: 6px; font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); }210265 @media (min-width: 720px) {211266 .flow { --x: 148px; }212267 .flow > li { display: grid; grid-template-columns: 128px 1fr; gap: 0 40px; padding-left: 0; }213- .flow .where { text-align: right; padding-top: 1px; }268+ .flow .where { text-align: right; padding-top: 3px; }214269 .flow .returns { grid-template-columns: 1fr; padding-left: 168px; }215270 }216271 217272 /* fig: a stack of reviews, as a graph */218-.graph { list-style: none; margin: 0; padding: 0; font-size: 0.95rem; }273+.graph { list-style: none; margin: 0; padding: 0; font-size: var(--text-md); }219274 .graph ol { list-style: none; margin: 0; padding: 0; }220-.graph .layer { position: relative; display: grid; gap: 4px 18px; padding: 6px 0; }275+.graph .layer { position: relative; display: grid; gap: var(--space-1) 18px; padding: 6px 0; }221276 .graph .layer::before { content: ""; position: absolute; left: 6px; top: 0; bottom: 0; width: 2px; background: var(--accent); }222277 .graph > .layer:first-child::before { top: 1.3em; }223278 .graph .base::before { background: var(--faint); bottom: auto; height: 1.3em; }224-.graph .c { position: relative; padding: 5px 0 5px 28px; display: flex; flex-wrap: wrap; gap: 2px 8px; align-items: baseline; }225-.graph .c::after { content: ""; position: absolute; left: 1px; top: 0.85em; width: 8px; height: 8px; border-radius: 50%; background: var(--subtle); border: 2px solid var(--accent); }279+.graph .c { position: relative; padding: 5px 0 5px 28px; display: flex; flex-wrap: wrap; gap: var(--space-half) var(--space-2); align-items: baseline; }280+.graph .c::after { content: ""; position: absolute; left: 1px; top: 0.85em; width: 8px; height: 8px; background: var(--raised); border: 2px solid var(--accent); }226281 .graph .base .c::after { border-color: var(--faint); }227-.graph code { font-size: 0.78rem; color: var(--muted); background: none; border: 0; padding: 0; }228-.pill { font: 0.72rem var(--mono); padding: 1px 7px; border-radius: 999px; border: 1px solid var(--accent); color: var(--ink); background: var(--tint); white-space: nowrap; }229-.pill.main { border-color: var(--faint); color: var(--muted); background: none; }230-.graph .tag { margin: 0 0 4px 28px; font-family: var(--sans); font-size: 0.85rem; color: var(--muted); border-left: 2px solid var(--border); padding: 2px 0 2px 10px; }282+.graph code { font-size: var(--text-xs); color: var(--muted); }283+/* a branch name on a commit: a square label on a hairline */284+.pill { font-family: var(--mono); font-size: var(--text-xs); padding: 0 6px; border: var(--hairline) solid var(--accent); color: var(--link); white-space: nowrap; }285+.pill.main { border-color: var(--border-strong); color: var(--muted); }286+.graph .tag { margin: 0 0 var(--space-1) 28px; font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); border-left: 2px solid var(--border); padding: var(--space-half) 0 var(--space-half) 10px; }231287 .graph .tag b { display: block; color: var(--fg); }232288 @media (min-width: 720px) {233289 .graph .layer { grid-template-columns: minmax(0, 1fr) 210px; align-items: center; }
@@ -235,44 +291,42 @@ figcaption b { color: var(--ink); font-weight: 600; margin-right: 6px; }
235291 }236292 237293 /* code-ish figures: range-diff, statements */238-.code { margin: 0; font: 0.76rem/1.55 var(--mono); white-space: pre; overflow-x: auto; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; tab-size: 4; }239-.code .same { color: var(--muted); }240-.code .chg { color: var(--warn); font-weight: 600; }294+.code { margin: 0; font-size: var(--text-code); line-height: 1.55; white-space: pre; overflow-x: auto; background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-3) 14px; tab-size: 4; }295+.code .same, .code .dim { color: var(--muted); }296+.code .chg { color: var(--warn); font-weight: var(--fw-strong); }241297 .code .add { color: var(--add); }242298 .code .del { color: var(--del); }243-.code .dim { color: var(--muted); }244-.code .key { color: var(--ink); }245-.code .n { display: inline-block; min-width: 1.5em; margin-left: 8px; padding: 0 5px; border-radius: 999px; background: var(--accent); color: #fff; font-size: 0.68rem; font-weight: 700; text-align: center; line-height: 1.5; }246-@media (prefers-color-scheme: dark) { .code .n { color: #0d1117; } }299+.code .key { color: var(--link); }300+/* numbered markers, square, white on the accent in both modes */301+.code .n { display: inline-block; min-width: 1.6em; margin-left: var(--space-2); padding: 0 var(--space-1); background: var(--accent); color: var(--on-accent); font-size: var(--text-xs); font-weight: var(--fw-strong); text-align: center; line-height: 1.5; }247302 248-.annotated { display: grid; gap: 16px; }249-.annotated ol { margin: 0; padding: 0; list-style: none; counter-reset: n; font-size: 0.95rem; }250-.annotated ol li { counter-increment: n; position: relative; padding-left: 30px; margin: 0 0 8px; }251-.annotated ol li::before { content: counter(n); position: absolute; left: 0; top: 0.2em; min-width: 1.5em; padding: 0 5px; border-radius: 999px; background: var(--accent); color: #fff; font: 700 0.68rem/1.5 var(--mono); text-align: center; }252-@media (prefers-color-scheme: dark) { .annotated ol li::before { color: #0d1117; } }303+.annotated { display: grid; gap: var(--space-4); }304+.annotated ol { margin: 0; padding: 0; list-style: none; counter-reset: n; font-size: var(--text-md); }305+.annotated ol li { counter-increment: n; position: relative; padding-left: 30px; margin: 0 0 var(--space-2); }306+.annotated ol li::before { content: counter(n); position: absolute; left: 0; top: 0.25em; min-width: 1.6em; padding: 0 var(--space-1); background: var(--accent); color: var(--on-accent); font: var(--fw-strong) var(--text-xs)/1.5 var(--mono); text-align: center; }253307 .annotated figcaption { grid-column: 1 / -1; margin: 0; }254308 @media (min-width: 900px) { .annotated { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); align-items: start; } }255309 256-/* proves / doesn't */257-.verdict { display: grid; gap: 14px; margin: 18px 0 24px; }258-.verdict > div { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px 6px; border-top: 3px solid var(--pass); }310+/* proves / doesn't: two columns under a bar in pass and fail */311+.verdict { display: grid; gap: var(--space-4) var(--space-8); margin: 18px 0 var(--space-6); }312+.verdict > div { padding: var(--space-3) 0 0; border-top: var(--bar) solid var(--pass); }259313 .verdict > div.no { border-top-color: var(--fail); }260-.verdict h3 { margin: 0 0 8px; font-size: 1.02rem; }261-.verdict ul { list-style: none; padding: 0; font-size: 0.97rem; }262-.verdict li { position: relative; padding-left: 24px; margin: 0 0 8px; }263-.verdict li::before { position: absolute; left: 0; font: 700 0.95rem var(--sans); }314+.verdict h3 { margin: 0 0 var(--space-2); font-size: var(--text-md); }315+.verdict ul { list-style: none; padding: 0; font-size: var(--text-md); }316+.verdict li { position: relative; padding-left: var(--space-6); margin: 0 0 var(--space-2); }317+.verdict li::before { position: absolute; left: 0; font: var(--fw-strong) var(--text-md) var(--sans); }264318 .verdict .yes li::before { content: "✓"; color: var(--pass); }265319 .verdict .no li::before { content: "✗"; color: var(--fail); }266320 @media (min-width: 720px) { .verdict { grid-template-columns: 1fr 1fr; } }267321 268322 /* fig: from ref to hostname */269323 .chain { list-style: none; margin: 0; padding: 0; }270-.chain li { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px 12px; align-items: baseline; }271-.chain .k { font: 0.7rem var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }272-.chain .v { font: 0.84rem var(--mono); background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; overflow-wrap: anywhere; }273-.chain .v small { display: block; font: 0.85rem var(--serif); color: var(--muted); margin-top: 2px; }324+.chain li { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-half) var(--space-3); align-items: baseline; }325+.chain .k { font-family: var(--sans); font-size: var(--text-xs); font-weight: var(--fw-medium); color: var(--muted); }326+.chain .v { font-family: var(--mono); font-size: var(--text-code); background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: 6px 10px; overflow-wrap: anywhere; }327+.chain .v small { display: block; font-family: var(--serif); font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-half); }274328 .chain .hop { min-height: 34px; }275-.chain .hop span { grid-column: -2; position: relative; padding: 6px 0 6px 26px; font-family: var(--sans); font-size: 0.82rem; color: var(--muted); }329+.chain .hop span { grid-column: -2; position: relative; padding: 6px 0 6px 26px; font-family: var(--sans); font-size: var(--text-xs); color: var(--muted); }276330 .chain .hop span::before { content: ""; position: absolute; left: 11px; top: 0; bottom: 0; border-left: 2px solid var(--accent); }277331 .chain .hop span::after { content: ""; position: absolute; left: 7px; bottom: 0; border: 5px solid transparent; border-top: 6px solid var(--accent); border-bottom: 0; }278332 .chain .hop.up span::after { bottom: auto; top: 0; border-top: 0; border-bottom: 6px solid var(--accent); }
@@ -282,92 +336,90 @@ figcaption b { color: var(--ink); font-weight: 600; margin-right: 6px; }
282336 }283337 .split { display: grid; gap: 18px; }284338 @media (min-width: 900px) { .split { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: 28px; } }285-.split > .prose { font-size: 0.98rem; }339+.split > .prose { font-size: var(--text-md); }286340 287-/* tables */288-.table { overflow-x: auto; margin: 18px 0 24px; border: 1px solid var(--border); border-radius: var(--radius); }289-table { width: 100%; border-collapse: collapse; font-family: var(--sans); font-size: 0.88rem; line-height: 1.45; }290-th, td { text-align: left; vertical-align: top; padding: 9px 12px; border-bottom: 1px solid var(--border); }341+/* tables: no outer box, rows between hairlines, headers small and muted */342+.table { overflow-x: auto; margin: 18px 0 var(--space-6); border-top: var(--hairline) solid var(--border); border-bottom: var(--hairline) solid var(--border); }343+table { width: 100%; border-collapse: collapse; font-family: var(--sans); font-size: var(--text-sm); line-height: 1.5; }344+th, td { text-align: left; vertical-align: top; padding: var(--space-2) var(--space-3); border-bottom: var(--hairline) solid var(--border); }345+th:first-child, td:first-child { padding-left: 0; }291346 tr:last-child > * { border-bottom: 0; }292-thead th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); background: var(--subtle); font-weight: 600; }293-tbody th { font-weight: 500; }294-td.y::before { content: "✓ "; font-weight: 700; color: var(--pass); }295-td.n::before { content: "✗ "; font-weight: 700; color: var(--fail); }347+thead th { font-size: var(--text-xs); font-weight: var(--fw-medium); color: var(--muted); padding-top: var(--space-3); padding-bottom: var(--space-1); }348+tbody th { font-weight: var(--fw-medium); }349+td.y::before { content: "✓ "; font-weight: var(--fw-strong); color: var(--pass); }350+td.n::before { content: "✗ "; font-weight: var(--fw-strong); color: var(--fail); }296351 td.n { color: var(--muted); }297-td code, th code { font-size: 0.82rem; white-space: normal; }298-@media (max-width: 719px) { table { font-size: 0.84rem; } th, td { padding: 8px 9px; } }352+td code, th code { font-size: var(--text-code); white-space: normal; }353+@media (max-width: 719px) { th, td { padding: var(--space-2) var(--space-2); } }299354 /* tables marked .rows restack on phones: the first cell on its own line,300355 then the rest, labelled from data-l when there is more than one */301356 @media (max-width: 719px) {302357 table.rows thead { display: none; }303- table.rows tr { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--border); }358+ table.rows tr { display: grid; grid-template-columns: 1fr 1fr; border-bottom: var(--hairline) solid var(--border); }304359 table.rows tr:last-child { border-bottom: 0; }305- table.rows th, table.rows td { border: 0; }360+ table.rows th, table.rows td { border: 0; padding-left: 0; }306361 table.rows tr > :first-child { grid-column: 1 / -1; padding-bottom: 0; }307362 table.rows td:not([data-l]):not(:first-child) { grid-column: 1 / -1; padding-top: 3px; }308- table.rows td[data-l] { position: relative; padding-top: 24px; }309- table.rows td[data-l]::after { content: attr(data-l); position: absolute; left: 9px; top: 6px; font: 0.68rem var(--mono); color: var(--muted); }363+ table.rows td[data-l] { position: relative; padding-top: var(--space-6); }364+ table.rows td[data-l]::after { content: attr(data-l); position: absolute; left: 0; top: 6px; font-size: var(--text-xs); font-weight: var(--fw-medium); color: var(--muted); }310365 }311366 312-313367 /* fig: one key, two generated files */314-.fan { display: grid; gap: 12px; }315-.fan .file { display: block; font: 600 0.75rem var(--mono); color: var(--ink); margin-bottom: 4px; }316-.fan .box { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; }317-.fan .box > code { display: block; font-size: 0.76rem; line-height: 1.5; background: none; border: 0; padding: 0; white-space: normal; overflow-wrap: anywhere; }318-.fan .box p { margin: 6px 0 0; font-size: 0.9rem; color: var(--muted); }319-.fan .by { font: 0.75rem var(--mono); color: var(--muted); display: flex; align-items: center; gap: 10px; }320-.fan .by::before, .fan .by::after { content: ""; flex: 1; border-top: 2px solid var(--accent); opacity: 0.6; }321-.fan .outs { display: grid; gap: 12px; }368+.fan { display: grid; gap: var(--space-3); }369+.fan .file { display: block; font-family: var(--mono); font-size: var(--text-xs); font-weight: var(--fw-strong); color: var(--fg); margin-bottom: var(--space-1); }370+.fan .box { background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: 10px var(--space-3); }371+.fan .box > code { display: block; font-size: var(--text-code); line-height: 1.5; white-space: normal; overflow-wrap: anywhere; }372+.fan .box p { margin: 6px 0 0; font-size: var(--text-sm); color: var(--muted); }373+.fan .by { font-family: var(--mono); font-size: var(--text-xs); color: var(--muted); display: flex; align-items: center; gap: 10px; }374+.fan .by::before, .fan .by::after { content: ""; flex: 1; border-top: 2px solid var(--accent); }375+.fan .outs { display: grid; gap: var(--space-3); }322376 @media (min-width: 900px) { .fan .outs { grid-template-columns: 1fr 1fr; } }323377 324378 /* fig: hosting */325-.hosts { display: grid; gap: 18px; margin: 22px 0 26px; }379+.hosts { display: grid; gap: 18px; margin: var(--space-5) 0 var(--space-6); }326380 @media (min-width: 900px) { .hosts { grid-template-columns: 1.25fr 1fr; } }327381 .hosts figure { margin: 0; }328382 .hops { list-style: none; margin: 0; padding: 0; }329-.hops .node { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }330-.hops .node b { display: block; font: 600 0.88rem var(--mono); }331-.hops .node span { display: block; font-size: 0.9rem; color: var(--muted); line-height: 1.45; margin-top: 2px; }332-.hops .node.home { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }333-.hops .node ul { margin: 8px 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 6px; }334-.hops .node ul li { font: 0.72rem var(--mono); border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; color: var(--fg); background: var(--subtle); }335-.hops .wire { position: relative; padding: 8px 0 8px 34px; font-family: var(--sans); font-size: 0.8rem; color: var(--muted); }383+.hops .node { background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: 10px var(--space-3); }384+.hops .node b { display: block; font-family: var(--sans); font-size: var(--text-md); font-weight: var(--fw-strong); }385+.hops .node span { display: block; font-size: var(--text-md); color: var(--muted); line-height: 1.5; margin-top: var(--space-half); }386+.hops .node.home { border-color: var(--accent); border-left-width: var(--bar); }387+.hops .node ul { margin: var(--space-2) 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 6px; }388+.hops .node ul li { font-family: var(--mono); font-size: var(--text-xs); border: var(--hairline) solid var(--border-strong); padding: 0 6px; color: var(--fg); }389+.hops .wire { position: relative; padding: var(--space-2) 0 var(--space-2) 34px; font-family: var(--sans); font-size: var(--text-xs); color: var(--muted); }336390 .hops .wire::before { content: ""; position: absolute; left: 18px; top: 0; bottom: 0; border-left: 2px solid var(--accent); }337391 .hops .wire.tunnel::before { border-left: 6px double var(--accent); left: 16px; }338392 339-/* cheat sheet */340-.cheat { display: grid; gap: 16px; }393+/* cheat sheet: groups under a strong rule, entries between hairlines */394+.cheat { display: grid; gap: var(--space-6) var(--space-8); }341395 @media (min-width: 900px) { .cheat { grid-template-columns: 1fr 1fr; } }342-.cheat section { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }343-.cheat h3 { margin: 0; padding: 9px 14px; font: 600 0.75rem var(--mono); text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); background: var(--subtle); border-bottom: 1px solid var(--border); }396+.cheat section { border-top: var(--hairline) solid var(--border-strong); }397+.cheat h3 { margin: 0; padding: var(--space-2) 0 var(--space-1); font-size: var(--text-sm); font-weight: var(--fw-strong); color: var(--muted); }344398 .cheat dl { margin: 0; }345-.cheat dl > div { padding: 9px 14px; border-bottom: 1px solid var(--border); }399+.cheat dl > div { padding: var(--space-2) 0; border-bottom: var(--hairline) solid var(--border); }346400 .cheat dl > div:last-child { border-bottom: 0; }347-.cheat dt { font: 0.8rem/1.5 var(--mono); overflow-wrap: anywhere; }348-.cheat dt::before { content: "$ "; color: var(--accent); }401+.cheat dt { font-family: var(--mono); font-size: var(--text-code); line-height: 1.5; overflow-wrap: anywhere; }402+.cheat dt::before { content: "$ "; color: var(--link); }349403 .cheat dt.nop::before { content: ""; }350-.cheat dd { margin: 2px 0 0; font-size: 0.9rem; color: var(--muted); line-height: 1.45; }404+.cheat dd { margin: var(--space-half) 0 0; font-size: var(--text-md); color: var(--muted); line-height: 1.5; }351405 352-/* not yet */353-.gaps { list-style: none; padding: 0; display: grid; gap: 10px; max-width: 41rem; }354-.gaps li { border: 1px dashed var(--border); border-radius: var(--radius); padding: 10px 14px; }355-.gaps b { font-weight: 650; }406+/* not yet: a list between hairlines */407+.gaps { list-style: none; padding: 0; max-width: 41rem; border-bottom: var(--hairline) solid var(--border); }408+.gaps li { border-top: var(--hairline) solid var(--border); padding: 10px 0; }356409 357410 /* files and trees */358-.tree { margin: 0; font: 0.76rem/1.6 var(--mono); white-space: pre; overflow-x: auto; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; }411+.tree { margin: 0; font-size: var(--text-code); line-height: 1.6; white-space: pre; overflow-x: auto; background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-3) 14px; }359412 .tree .c { color: var(--muted); }360-@media (max-width: 719px) { .tree .c { display: block; white-space: pre-wrap; padding-left: 3.2em; font-size: 0.92em; } }361-.tree .d { color: var(--ink); font-weight: 600; }413+@media (max-width: 719px) { .tree .c { display: block; white-space: pre-wrap; padding-left: 3.2em; } }414+.tree .d { color: var(--fg); font-weight: var(--fw-strong); }362415 363-footer.end { border-top: 1px solid var(--border); padding: 26px 0 40px; font-family: var(--sans); font-size: 0.88rem; color: var(--muted); }364-footer.end .wrap { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: center; }416+footer.end { border-top: var(--hairline) solid var(--border); padding: var(--space-6) 0 var(--space-10); font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); }417+footer.end .wrap { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-5); align-items: center; }365418 footer.end .mark { width: 20px; height: 20px; color: var(--fg); }366419 footer.end p { margin: 0; }367420 368421 /* the internals page: a shorter hero */369-.hero.short { padding: 34px 0 30px; }370-.hero.short h1 { font-size: clamp(2.6rem, 11vw, 4.4rem); }422+.hero.short { padding: var(--space-8) 0 var(--space-8); }371423 .hero.short .lede { max-width: 40rem; }372424 @media (min-width: 900px) { .hero.short { padding: 56px 0 48px; } .hero.short .wrap { grid-template-columns: 1fr; } }373425
M guide/index.html +22 -21
@@ -5,9 +5,10 @@
55 <meta name="viewport" content="width=device-width, initial-scale=1">66 <title>forge: a git forge made of plain git</title>77 <meta name="description" content="How forge works: plain git over ssh, static pages built after each push, reviews as stacked branches, CI signed on your own machine, and sites that ship with repos.">8-<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">9-<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)">10-<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cstyle%3Ecircle%7Bstroke:%23000%7D@media (prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23bdbdbd%7D%7D%3C/style%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M16 15 12 7 8 15Zm-1 0L11.5 8' stroke='%232ca9bc'/%3E%3C/svg%3E">8+<meta name="theme-color" content="#e9e9e9" media="(prefers-color-scheme: light)">9+<meta name="theme-color" content="#08090b" media="(prefers-color-scheme: dark)">10+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cstyle%3Ecircle%7Bstroke:%231c2127%7Dpath%7Bstroke:%233148f6%7D@media (prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23e2e4e7%7D%7D%3C/style%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M16 15 12 7 8 15Zm-1 0L11.5 8'/%3E%3C/svg%3E">11+<link rel="preload" href="fonts/open-sans.woff2" as="font" type="font/woff2" crossorigin>1112 <link rel="stylesheet" href="guide.css">1213 <script src="guide.js" defer></script>1314 </head>
@@ -15,8 +16,8 @@
1516 <a class="skip" href="#main">Skip to the guide</a>1617 <header class="bar">1718 <div class="wrap">18-<a class="brand" href="./"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>forge <span>guide</span></a>19-<nav aria-label="guide"><a href="./" aria-current="page">Guide</a><a href="internals.html">Internals</a></nav>19+<a class="brand" href="./"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>forge <span>guide</span></a>20+<nav aria-label="guide"><a href="./" aria-current="page">guide</a><a href="internals.html">internals</a></nav>2021 </div>2122 </header>2223
@@ -24,12 +25,12 @@
2425 <div class="wrap">2526 <div class="hero-text">2627 <p class="eyebrow">a personal git forge</p>27-<h1 id="top"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>forge</h1>28+<h1 id="top"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>forge</h1>2829 <p class="lede">Plain git on a server, and web pages written after every push. Reviews are branches, CI runs on <em>your</em> machine and is signed with your SSH key, and any repo can ship a static site.</p>29-<p class="cta"><a class="btn go" href="#push">Follow a push</a><a class="btn" href="#cli">Cheat sheet</a><a class="btn" href="internals.html">Internals</a></p>30+<p class="cta"><a class="btn go" href="#push">follow a push</a><a class="btn" href="#cli">cheat sheet</a><a class="btn" href="internals.html">internals</a></p>3031 </div>3132 <figure class="term" aria-label="A terminal session: attest, push, deploy a preview">32-<div class="chrome" aria-hidden="true"><i></i><i></i><i></i><span>~/greet (dami/flags)</span></div>33+<div class="chrome" aria-hidden="true">~/greet (dami/flags)</div>3334 <pre><span class="line" style="--i:0"><span class="p">$ </span>forge attest</span><span class="line o" style="--i:1">80b9dda running .forge/ci: add --shout</span><span class="line o" style="--i:2"> | ok greet --shout</span><span class="line" style="--i:3">80b9dda <span class="ok">pass</span> in 2s, signed</span><span class="line" style="--i:4"><span class="p">$ </span>forge push -t "Flags"</span><span class="line o" style="--i:5">pushed dami/flags</span><span class="line" style="--i:6"><span class="p">$ </span>forge site deploy docs</span><span class="line o" style="--i:7">site docs: sh docs/build.sh</span><span class="line r" style="--i:8">remote: site docs/dami~flags: <span class="u">https://greet-docs--dami-flags.pages.example.com/</span></span><span class="line o" style="--i:9">site docs: deployed 942ff39 -&gt; https://greet-docs--dami-flags.pages.example.com/</span></pre>3435 </figure>3536 </div>
@@ -38,7 +39,7 @@
3839 <div class="wrap page">3940 <nav class="toc" aria-label="On this page">4041 <div>41-<h2>On this page</h2>42+<h2>on this page</h2>4243 <ol>4344 <li><a href="#what"><span>01</span>what and why</a></li>4445 <li><a href="#push"><span>02</span>a push</a></li>
@@ -67,7 +68,7 @@
6768 <li><b>stagit</b><span>Log, files, blobs and refs as plain pages, written when something is pushed instead of computed per request.</span></li>6869 <li><b>args.io</b><span>Reviews as stacked branches with revisions, CI attested on your machine, a site per repo.</span></li>6970 </ol>70-<p class="child"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>forge <small>a merge of three ideas</small></p>71+<p class="child"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>forge <small>a merge of three ideas</small></p>7172 <figcaption><b>fig. 1</b>where it comes from</figcaption>7273 </figure>7374 <div class="prose">
@@ -178,7 +179,7 @@ log sha256:6cc79019... 4 lines
178179 <p class="prose">A deploy is a commit on <code>refs/sites/&lt;name&gt;</code> whose tree is the built output. So git provides content-addressed storage, dedup across deploys, history, and revert by pushing an older deploy. Trailers record the source commit, the build command and who built it, and the commit carries an SSH signature when your key can sign.</p>179180 <div class="split">180181 <div class="prose">181-<p>Declare sites in <code>.forge/sites</code>, one per line: a name, the output directory, and a build command, or <code>-</code> for none. This guide is <code>guide guide -</code>.</p>182+<p>Declare sites in <code>.forge/sites</code>, one per line: a name, the output directory, and a build command, or <code>-</code> for none. This guide is <code>guide guide</code>, with a build command that copies the forge's fonts in.</p>182183 <p><code>forge site deploy &lt;name&gt;</code> builds from a clean worktree of <code>HEAD</code>, with <code>FORGE_SITE_BASE</code> set to the path it will be served from (<code>/</code> on its own hostname). From <code>main</code> it publishes the site. From any other branch it publishes that review's preview, <code>refs/site-previews/&lt;name&gt;/&lt;branch&gt;</code>, which the review page links and marks stale once the branch moves on. <code>forge site push &lt;dir&gt; &lt;name&gt;</code> publishes a directory you already built.</p>183184 <p>Only the owner and collaborators deploy sites. Anyone can deploy previews of their own branches.</p>184185 </div>
@@ -203,7 +204,7 @@ log sha256:6cc79019... 4 lines
203204 </div>204205 <div class="table">205206 <table>206-<thead><tr><th scope="col">Deploy</th><th scope="col">Hostname under the pages domain</th></tr></thead>207+<thead><tr><th scope="col">deploy</th><th scope="col">hostname under the pages domain</th></tr></thead>207208 <tbody>208209 <tr><td>a site of the forge's owner: <code>dami/greet</code>, site <code>docs</code></td><td><code>greet-docs</code></td></tr>209210 <tr><td>another user's site: <code>ana/dotfiles</code>, site <code>home</code></td><td><code>dotfiles-home-ana</code></td></tr>
@@ -234,7 +235,7 @@ log sha256:6cc79019... 4 lines
234235 <p class="prose">Repos live at <code>/srv/git/&lt;owner&gt;/&lt;repo&gt;.git</code>, and the directory names the owner. <code>ssh git@host new &lt;repo&gt;</code> creates one under your name, and so does pushing to one that isn't there yet. The owner, and anyone listed in <code>&lt;repo&gt;.git/forge/collaborators</code>, can push anything; everyone else works under their own name:</p>235236 <div class="table">236237 <table class="rows">237-<thead><tr><th scope="col">Ref</th><th scope="col">Owner, collaborators</th><th scope="col">Anyone else, e.g. ana</th></tr></thead>238+<thead><tr><th scope="col">ref</th><th scope="col">owner, collaborators</th><th scope="col">anyone else, e.g. ana</th></tr></thead>238239 <tbody>239240 <tr><th scope="row"><code>main</code> (the default branch)</th><td class="y" data-l="owner, collaborators">fast-forward only, unless <code>forge.allowforce</code>; never deleted</td><td class="n" data-l="anyone else">no</td></tr>240241 <tr><th scope="row"><code>refs/heads/<wbr>ana/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="y" data-l="anyone else">yes, her own prefix</td></tr>
@@ -278,14 +279,14 @@ log sha256:6cc79019... 4 lines
278279 <section class="commit" id="cli" aria-labelledby="cli-h">279280 <header><p class="ref"><b>08</b> cheat sheet</p><h2 id="cli-h"><a href="#cli">Day to day</a></h2></header>280281 <div class="cheat">281-<section aria-labelledby="cs-setup"><h3 id="cs-setup">Once</h3><dl>282+<section aria-labelledby="cs-setup"><h3 id="cs-setup">once</h3><dl>282283 <div><dt>git config --global forge.signingkey ~/.ssh/id_ed25519.pub</dt><dd>The key that signs CI results and deploys; a <code>.pub</code> path signs through ssh-agent.</dd></div>283284 <div><dt>ssh git@host signers &gt; ~/.config/forge/allowed_signers</dt><dd>Then point <code>forge.allowedsigners</code> at it, so <code>forge verify</code> can check signatures.</dd></div>284285 <div><dt>ssh git@host new greet "say hello"</dt><dd>Create a repo under your name; prints the remote to add.</dd></div>285286 <div><dt>git push git@host:dami/greet.git main</dt><dd>Or just push: a repo under your name that doesn't exist yet is created first, the same way.</dd></div>286287 <div><dt>forge import https://github.com/you/thing.git</dt><dd>Copy a repo in (set <code>forge.host</code> first). <code>--branches</code> takes every branch.</dd></div>287288 </dl></section>288-<section aria-labelledby="cs-review"><h3 id="cs-review">A review</h3><dl>289+<section aria-labelledby="cs-review"><h3 id="cs-review">a review</h3><dl>289290 <div><dt>git switch -c dami/topic</dt><dd>Branch under your name; once pushed, it is a review.</dd></div>290291 <div><dt>forge attest --stack</dt><dd>CI for every commit on top of <code>main</code>, signed.</dd></div>291292 <div><dt>forge push -t "Title"</dt><dd>Push the branch and the CI notes. <code>--draft</code>, <code>--ready</code>, <code>--target &lt;branch&gt;</code>, <code>-d</code> for a description.</dd></div>
@@ -296,21 +297,21 @@ log sha256:6cc79019... 4 lines
296297 <div><dt>git push origin dami/topic:main</dt><dd>Land it (or <code>forge land</code>). <code>main</code> only fast-forwards.</dd></div>297298 <div><dt>ssh git@host land dami/greet 7</dt><dd>Have the forge land review 7, no clone needed: it pushes to itself as you, so the same gates apply, and deletes the landed branches. <code>forge land --server</code> does it from a clone.</dd></div>298299 </dl></section>299-<section aria-labelledby="cs-ci"><h3 id="cs-ci">CI</h3><dl>300+<section aria-labelledby="cs-ci"><h3 id="cs-ci">ci</h3><dl>300301 <div><dt>forge attest [rev...]</dt><dd>Attest given commits (default <code>HEAD</code>). <code>--force</code> re-runs a passed tree, <code>--clean</code> also removes ignored files from the worktree.</dd></div>301302 <div><dt>forge verify [rev...]</dt><dd>Show each commit's attestations and who signed them.</dd></div>302303 </dl></section>303-<section aria-labelledby="cs-sites"><h3 id="cs-sites">Sites</h3><dl>304+<section aria-labelledby="cs-sites"><h3 id="cs-sites">sites</h3><dl>304305 <div><dt>forge site deploy [name...]</dt><dd>Build and publish from a clean worktree of <code>HEAD</code>: the site on <code>main</code>, a preview elsewhere. <code>--here</code> builds in your work tree.</dd></div>305306 <div><dt>forge site push &lt;dir&gt; &lt;name&gt;</dt><dd>Publish a directory you built yourself.</dd></div>306307 <div><dt>forge site url &lt;name&gt; [branch]</dt><dd>Where a deploy is served.</dd></div>307308 </dl></section>308-<section aria-labelledby="cs-releases"><h3 id="cs-releases">Releases</h3><dl>309+<section aria-labelledby="cs-releases"><h3 id="cs-releases">releases</h3><dl>309310 <div><dt>forge release publish &lt;tag&gt; &lt;file&gt;...</dt><dd>The tag's files with a signed <code>SHA256SUMS</code>, pushed with the tag and your CI notes; again to replace them.</dd></div>310311 <div><dt>forge release verify &lt;file&gt;...</dt><dd>Check downloads against <code>SHA256SUMS</code> and its signature against the forge's keys.</dd></div>311312 <div><dt>forge release ls, rm &lt;tag&gt;</dt><dd>This repo's releases; delete one.</dd></div>312313 </dl></section>313-<section aria-labelledby="cs-server"><h3 id="cs-server">On the server, as root</h3><dl>314+<section aria-labelledby="cs-server"><h3 id="cs-server">on the server, as root</h3><dl>314315 <div><dt>forge-admin user add ana 'ssh-ed25519 AAAA...'</dt><dd>Add a user and key; <code>user rm ana</code> revokes their keys.</dd></div>315316 <div><dt>forge-admin repo new dami/greet "description"</dt><dd>Or <code>repo import dami/greet &lt;url&gt;</code> to fetch one server side.</dd></div>316317 <div><dt>forge-admin build [owner/repo]</dt><dd>Rebuild pages; <code>metrics</code> shows where builds spend their time.</dd></div>
@@ -318,7 +319,7 @@ log sha256:6cc79019... 4 lines
318319 <div><dt>forge-admin backups</dt><dd>How the last backup went, then the snapshots. <code>backup</code> takes one now; the container takes one daily.</dd></div>319320 <div><dt>forge-admin restore latest</dt><dd>Bring the forge back on an empty box, from the newest snapshot or a named one.</dd></div>320321 </dl></section>321-<section aria-labelledby="cs-repo"><h3 id="cs-repo">In a bare repo</h3><dl>322+<section aria-labelledby="cs-repo"><h3 id="cs-repo">in a bare repo</h3><dl>322323 <div><dt>git config forge.requireci tip</dt><dd>Require a verified pass for the new tip of <code>main</code>; <code>all</code> checks every new commit.</dd></div>323324 <div><dt>git config forge.allowforce true</dt><dd>Let the owner and collaborators force-push <code>main</code>.</dd></div>324325 <div><dt class="nop">forge/collaborators</dt><dd>Names, one per line, who can push anything.</dd></div>
@@ -342,7 +343,7 @@ log sha256:6cc79019... 4 lines
342343 343344 <footer class="end">344345 <div class="wrap">345-<svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>346+<svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>346347 <p>This guide is <code>guide/</code> in forge's own repo, published with <code>forge site deploy guide</code>. For how the code fits together, read <a href="internals.html">the internals</a>.</p>347348 </div>348349 </footer>
M guide/internals.html +16 -15
@@ -5,9 +5,10 @@
55 <meta name="viewport" content="width=device-width, initial-scale=1">66 <title>forge internals: where things are and how they fit</title>77 <meta name="description" content="For whoever maintains forge next: the source map, state on disk, what runs, how a build works, URLs, settings and upkeep.">8-<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">9-<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)">10-<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cstyle%3Ecircle%7Bstroke:%23000%7D@media (prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23bdbdbd%7D%7D%3C/style%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M16 15 12 7 8 15Zm-1 0L11.5 8' stroke='%232ca9bc'/%3E%3C/svg%3E">8+<meta name="theme-color" content="#e9e9e9" media="(prefers-color-scheme: light)">9+<meta name="theme-color" content="#08090b" media="(prefers-color-scheme: dark)">10+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cstyle%3Ecircle%7Bstroke:%231c2127%7Dpath%7Bstroke:%233148f6%7D@media (prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23e2e4e7%7D%7D%3C/style%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M16 15 12 7 8 15Zm-1 0L11.5 8'/%3E%3C/svg%3E">11+<link rel="preload" href="fonts/open-sans.woff2" as="font" type="font/woff2" crossorigin>1112 <link rel="stylesheet" href="guide.css">1213 <script src="guide.js" defer></script>1314 </head>
@@ -15,8 +16,8 @@
1516 <a class="skip" href="#main">Skip to the internals</a>1617 <header class="bar">1718 <div class="wrap">18-<a class="brand" href="./"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>forge <span>guide</span></a>19-<nav aria-label="guide"><a href="./">Guide</a><a href="internals.html" aria-current="page">Internals</a></nav>19+<a class="brand" href="./"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>forge <span>guide</span></a>20+<nav aria-label="guide"><a href="./">guide</a><a href="internals.html" aria-current="page">internals</a></nav>2021 </div>2122 </header>2223
@@ -33,7 +34,7 @@
3334 <div class="wrap page">3435 <nav class="toc" aria-label="On this page">3536 <div>36-<h2>On this page</h2>37+<h2>on this page</h2>3738 <ol>3839 <li><a href="#map"><span>01</span>source</a></li>3940 <li><a href="#disk"><span>02</span>on disk</a></li>
@@ -54,7 +55,7 @@
5455 <p class="prose">Four kinds of code, kept small: one shell script for everything git and ssh touch, a TypeScript data layer that Node 22 runs as it is (types are stripped, never compiled), Nunjucks templates with a base stylesheet, and container and deploy files.</p>5556 <div class="table">5657 <table class="rows">57-<thead><tr><th scope="col">Path</th><th scope="col">What it is</th></tr></thead>58+<thead><tr><th scope="col">path</th><th scope="col">what it is</th></tr></thead>5859 <tbody>5960 <tr><th scope="row"><code>bin/forge</code></th><td>The whole CLI in POSIX sh. Developer commands (<code>attest</code>, <code>verify</code>, <code>push</code>, <code>site</code>, <code>import</code>) run in a clone; server commands (<code>server init</code>, <code>user</code>, <code>keys sync</code>, <code>repo</code>, <code>build</code>, <code>gc</code>, <code>shell</code>, <code>hook</code>) run as the git user. Both hooks are this script.</td></tr>6061 <tr><th scope="row"><code>lib/forge.ts</code></th><td>Finds repos and flattens them into one list per page type for 11ty. Fingerprints every page so unchanged ones are skipped, and holds the filters that render heavy HTML when a page is written.</td></tr>
@@ -121,7 +122,7 @@ deleted 1788292812 dami <span class="dim"># only o
121122 <p class="prose">The container's entrypoint is <code>tini</code>, which also reaps the background builds, and then <code>server/container/start.sh</code>. It creates host keys on first boot, runs <code>forge server init</code>, writes <code>.forge/config</code> from the environment, adds the owner's key, syncs keys, writes the Caddyfile with <code>forge server caddyfile</code>, and starts:</p>122123 <div class="table">123124 <table class="rows">124-<thead><tr><th scope="col">Process</th><th scope="col">Listens</th><th scope="col">As</th><th scope="col">Why</th></tr></thead>125+<thead><tr><th scope="col">process</th><th scope="col">listens</th><th scope="col">as</th><th scope="col">why</th></tr></thead>125126 <tbody>126127 <tr><th scope="row">sshd</th><td data-l="listens"><code>:2222</code></td><td data-l="as">root</td><td>git over ssh, only for the git user, keys only.</td></tr>127128 <tr><th scope="row">fcgiwrap</th><td data-l="listens">unix socket</td><td data-l="as">git</td><td>Runs <code>git http-backend</code> for HTTPS clones; Caddy routes only <code>info/refs</code> and <code>git-upload-pack</code> to it.</td></tr>
@@ -162,7 +163,7 @@ deleted 1788292812 dami <span class="dim"># only o
162163 <p class="prose">Every page is a directory with an <code>index.html</code>, so URLs read like paths. Branch names may contain <code>/</code>, which would make <code>/tree/&lt;branch&gt;/&lt;path&gt;</code> ambiguous, so it is written <code>~</code>, which git forbids in ref names: <code>dami/flags</code> becomes <code>dami~flags</code>.</p>163164 <div class="table">164165 <table class="rows">165-<thead><tr><th scope="col">URL</th><th scope="col">Page</th></tr></thead>166+<thead><tr><th scope="col">url</th><th scope="col">page</th></tr></thead>166167 <tbody>167168 <tr><td><code>/&lt;owner&gt;/&lt;repo&gt;/</code></td><td>open reviews, recent commits, README</td></tr>168169 <tr><td><code>.../log/&lt;branch&gt;/</code></td><td>log for a branch</td></tr>
@@ -188,12 +189,12 @@ deleted 1788292812 dami <span class="dim"># only o
188189 <div><dt>FORGE_PAGES_DOMAIN, FORGE_OWNER</dt><dd>Turn on per-site hostnames; the owner's sites get the short ones.</dd></div>189190 <div><dt>FORGE_MAX_COMMITS, FORGE_MAX_FILES, FORGE_MAX_BLOB, FORGE_MAX_DIFF_LINES</dt><dd>Limits per branch log, per tree, per file shown and per diff shown: 1000, 5000, 1 MiB, 20000 lines.</dd></div>190191 </dl></section>191-<section aria-labelledby="k-repo"><h3 id="k-repo">A bare repo's git config</h3><dl>192+<section aria-labelledby="k-repo"><h3 id="k-repo">a bare repo's git config</h3><dl>192193 <div><dt>forge.requireci tip | all</dt><dd>A verified pass before <code>main</code> moves, for the new tip or every new commit.</dd></div>193194 <div><dt>forge.allowforce true</dt><dd>Let admins force-push <code>main</code>.</dd></div>194195 <div><dt>transfer.hideRefs refs/forge</dt><dd>Set by forge on every repo, with <code>receive.advertisePushOptions true</code> and <code>commitGraph.changedPathsVersion 2</code>; see <a href="#upkeep">upkeep</a>.</dd></div>195196 </dl></section>196-<section aria-labelledby="k-dev"><h3 id="k-dev">Your git config</h3><dl>197+<section aria-labelledby="k-dev"><h3 id="k-dev">your git config</h3><dl>197198 <div><dt>forge.signingkey, forge.allowedsigners</dt><dd>Sign with this key; verify against this file.</dd></div>198199 <div><dt>forge.host, forge.remote, forge.base</dt><dd>Where <code>forge import</code> goes; which remote and base branch the other commands use.</dd></div>199200 <div><dt>forge.cidir, forge.nix</dt><dd>Where CI worktrees live; <code>forge.nix false</code> skips <code>nix develop</code>.</dd></div>
@@ -203,7 +204,7 @@ deleted 1788292812 dami <span class="dim"># only o
203204 <div><dt>FORGE_BACKUP_AT, _KEEP_DAILY, _KEEP_WEEKLY</dt><dd>When the daily backup runs (UTC) and what retention keeps: 03:30, 7 and 5.</dd></div>204205 <div><dt>FORGE_MIRROR_S3, _KEY_ID, _SECRET, _DELAY</dt><dd>The pages mirror's bucket and keys, and how long it waits after a build: 30 seconds.</dd></div>205206 </dl></section>206-<section aria-labelledby="k-env"><h3 id="k-env">The environment, for tests</h3><dl>207+<section aria-labelledby="k-env"><h3 id="k-env">the environment, for tests</h3><dl>207208 <div><dt>FORGE_NOW</dt><dd>Pins the clock reviews are stamped with; the demo moves it with its commits.</dd></div>208209 <div><dt>FORGE_NO_BUILD, FORGE_SYNC_BUILD</dt><dd>Skip the build after a push, or run it in the foreground.</dd></div>209210 <div><dt>FORGE_STRICT</dt><dd>1 makes a template that prints an undefined or null value fail the build; CI sets it.</dd></div>
@@ -235,13 +236,13 @@ deleted 1788292812 dami <span class="dim"># only o
235236 <section class="commit" id="hacking" aria-labelledby="hacking-h">236237 <header><p class="ref"><b>08</b> hacking</p><h2 id="hacking-h"><a href="#hacking">Working on forge</a></h2></header>237238 <div class="cheat">238-<section aria-labelledby="hk-run"><h3 id="hk-run">Run it</h3><dl>239+<section aria-labelledby="hk-run"><h3 id="hk-run">run it</h3><dl>239240 <div><dt>npm install</dt><dd>11ty, markdown-it, sanitize-html, mermaid; TypeScript only for checking.</dd></div>240241 <div><dt>sh demo/make-demo.sh</dt><dd>A throwaway forge in <code>demo/root</code>: two users, stacked reviews with revisions, CI that fails and passes, a wiki, sites and previews, all through the real hooks over file:// pushes.</dd></div>241242 <div><dt>node demo/serve.ts 4817</dt><dd>Serves it with live updates and cache headers, the way Caddy and the events server do.</dd></div>242243 <div><dt>npm run dev</dt><dd>Serves the demo from <code>_site/</code> on :8080 and rebuilds it, reloading the browser, when templates, assets or <code>lib/</code> change. It never writes to <code>demo/root</code>.</dd></div>243244 </dl></section>244-<section aria-labelledby="hk-check"><h3 id="hk-check">Check it</h3><dl>245+<section aria-labelledby="hk-check"><h3 id="hk-check">check it</h3><dl>245246 <div><dt>sh .forge/ci</dt><dd>Lints the shell, type-checks, builds the demo, checks a few pages, and checks this guide's links. It is forge's own CI, so <code>forge attest</code> runs it and signs the result.</dd></div>246247 <div><dt>npx tsc -p .</dt><dd>Type check only. <code>tsc</code> never emits.</dd></div>247248 <div><dt>node lib/check-links.ts guide</dt><dd>Every relative link and anchor in the guide resolves.</dd></div>
@@ -259,7 +260,7 @@ deleted 1788292812 dami <span class="dim"># only o
259260 260261 <footer class="end">261262 <div class="wrap">262-<svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8" stroke="#2ca9bc"/></svg>263+<svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>263264 <p>Back to <a href="./">the guide</a>. The README in the repo has the same facts in plain text.</p>264265 </div>265266 </footer>

5/7 Check the design rules in the forge's CSS and templates

lib/check-css.ts reads style.css, every template's {% css %} blocks and style attributes, and the guide's CSS, and fails on a colour, font size, weight or family written outside the tokens, a rounded corner, capitals by CSS, letter-spacing, or a middle dot anywhere in site/, lib/ or guide/.
5 files changed, +316 -0
M.forge/ci3+
Alib/check-css.ts225++++++++++++++++++++++++++++++
Atest/scenarios/css-lint.sh45++++++
Atest/scenarios/pages-no-caps.sh19+++
Atest/scenarios/shelves-site-toggle.sh24+++
M .forge/ci +3 -0
A lib/check-css.ts +225 -0
@@ -0,0 +1,225 @@
1+// The design rules (DESIGN.md), checked: CI runs this over the forge's CSS,2+// which is site/assets/style.css, the {% css %} blocks and style=""3+// attributes in site/'s templates, and the guide's guide.css. Outside a4+// stylesheet's token block (between "== tokens ==" and "== end tokens =="5+// comments) a declaration may not name a colour, a font size, weight or6+// family: those are tokens. Nowhere may anything be rounded, capitalised7+// by CSS, or letter-spaced. And no file in site/, lib/ or guide/ holds a8+// middle dot (U+00B7).9+//10+// node lib/check-css.ts the forge's files11+// node lib/check-css.ts <path>... these files, or what is in these12+// directories (.css, .njk; other files13+// are checked for the middle dot only)14+import fs from 'node:fs';15+import path from 'node:path';16+import { fileURLToPath } from 'node:url';17+18+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');19+20+export interface Problem {21+ file: string;22+ line: number;23+ message: string;24+}25+26+// CSS named colours; transparent, currentColor and system colours are fine.27+const NAMED = new Set(28+ ('aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral ' +29+ 'cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange ' +30+ 'darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey ' +31+ 'dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ' +32+ 'ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink ' +33+ 'lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine ' +34+ 'mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose ' +35+ 'moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru ' +36+ 'pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue ' +37+ 'slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen').split(' '),38+);39+const COLOUR_FN = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(/i;40+const HEX = /#[0-9a-f]{3,8}\b/i;41+const DOT = String.fromCharCode(0xb7);42+43+// var(...) taken out, nested ones too, so what is left is what the44+// declaration says itself.45+const withoutVars = (v: string): string => {46+ let out = v;47+ for (let was = ''; was !== out; ) {48+ was = out;49+ out = out.replace(/var\([^()]*(?:\([^()]*\)[^()]*)*\)/g, ' ');50+ }51+ return out;52+};53+54+const namesColour = (v: string): string | null => {55+ const bare = v.replace(/url\([^)]*\)/g, ' ');56+ const m = HEX.exec(bare) ?? COLOUR_FN.exec(bare);57+ if (m) return m[0];58+ for (const w of bare.toLowerCase().match(/[a-z-]+/g) ?? []) if (NAMED.has(w)) return w;59+ return null;60+};61+62+// One declaration's problems. tokens: it is in a token block.63+function declaration(prop: string, value: string, tokens: boolean): string[] {64+ const p = prop.toLowerCase();65+ const v = value.trim();66+ const bad: string[] = [];67+ if (p === 'letter-spacing') bad.push(`letter-spacing (${v}): no tracking; hierarchy comes from weight, size and colour`);68+ if (p === 'text-transform' && /\b(uppercase|capitalize)\b/i.test(v)) bad.push(`text-transform: ${v}: write chrome lowercase instead`);69+ if (/(^|-)radius$/.test(p) && !/^(0(px)?|var\(--radius\))$/.test(v)) bad.push(`${p}: ${v}: corners are square`);70+ if (tokens) return bad;71+ const c = namesColour(v);72+ if (c) bad.push(`${p}: a colour literal (${c}) outside the tokens; use a colour token`);73+ const own = withoutVars(v).trim();74+ if (p === 'font-size' && own && own !== 'inherit') bad.push(`font-size: ${v}: use a --text-* token`);75+ if (p === 'font-weight' && own && own !== 'inherit') bad.push(`font-weight: ${v}: use a --fw-* token`);76+ if (p === 'font-family' && own && own !== 'inherit') bad.push(`font-family: ${v}: use --sans, --mono or --serif`);77+ if (p === 'font') {78+ // What may be left of the shorthand: a line height after the slash.79+ const rest = own.replace(/\/\s*[\d.]+(?:px|em|rem|%)?/g, ' ').trim();80+ if (rest && rest !== 'inherit') bad.push(`font: ${v}: name the weight, size and family with tokens`);81+ }82+ return bad;83+}84+85+// A stylesheet's problems. offset: the line it starts on in its file;86+// tokensAllowed: it may have a token block (a stylesheet, not a template).87+export function checkCss(css: string, file: string, offset = 1, tokensAllowed = true): Problem[] {88+ const out: Problem[] = [];89+ // Comments become spaces (newlines kept, for line numbers); the token90+ // block is where its markers say.91+ let text = '';92+ const inTokens: boolean[] = [];93+ let tokens = false;94+ for (let i = 0; i < css.length; ) {95+ if (css.startsWith('/*', i)) {96+ const end = css.indexOf('*/', i + 2);97+ const c = css.slice(i, end < 0 ? css.length : end + 2);98+ if (/==\s*end tokens\s*==/.test(c)) tokens = false;99+ else if (/==\s*tokens\s*==/.test(c)) {100+ if (!tokensAllowed) out.push({ file, line: offset + (css.slice(0, i).match(/\n/g)?.length ?? 0), message: 'a token block belongs in style.css, not a template' });101+ tokens = tokensAllowed;102+ }103+ for (const ch of c) {104+ text += ch === '\n' ? '\n' : ' ';105+ inTokens.push(tokens);106+ }107+ i += c.length;108+ } else {109+ text += css[i];110+ inTokens.push(tokens);111+ i++;112+ }113+ }114+ // Declarations are what comes before a ; or }, after the last { ; or };115+ // what comes before a { is a selector or an at-rule's prelude.116+ let start = 0;117+ let quote = '';118+ let paren = 0;119+ for (let i = 0; i < text.length; i++) {120+ const ch = text[i];121+ if (quote) {122+ if (ch === '\\') i++;123+ else if (ch === quote) quote = '';124+ continue;125+ }126+ if (ch === '"' || ch === "'") quote = ch;127+ else if (ch === '(') paren++;128+ else if (ch === ')') paren--;129+ else if (paren === 0 && (ch === '{' || ch === '}' || ch === ';')) {130+ const seg = text.slice(start, i);131+ if (ch !== '{') {132+ const m = /^\s*(-?-?[a-zA-Z][\w-]*)\s*:([\s\S]*)$/.exec(seg);133+ if (m) {134+ const at = start + seg.search(/\S/);135+ const line = offset + (text.slice(0, at).match(/\n/g)?.length ?? 0);136+ for (const message of declaration(m[1], m[2], inTokens[at])) out.push({ file, line, message });137+ }138+ }139+ start = i + 1;140+ }141+ }142+ return out;143+}144+145+// A template's CSS: its {% css %} blocks and style="" attributes.146+export function checkTemplate(src: string, file: string): Problem[] {147+ const out: Problem[] = [];148+ const lineAt = (i: number): number => 1 + (src.slice(0, i).match(/\n/g)?.length ?? 0);149+ for (const m of src.matchAll(/\{%-?\s*css\s*-?%\}([\s\S]*?)\{%-?\s*endcss\s*-?%\}/g)) {150+ const body = m[1];151+ out.push(...checkCss(body, file, lineAt(m.index + m[0].indexOf(body)), false));152+ }153+ for (const m of src.matchAll(/\sstyle="([^"]*)"/g)) out.push(...checkCss(m[1] + ';', file, lineAt(m.index), false));154+ return out;155+}156+157+export function checkDot(text: string, file: string): Problem[] {158+ const out: Problem[] = [];159+ text.split('\n').forEach((l, n) => {160+ if (l.includes(DOT)) out.push({ file, line: n + 1, message: 'a middle dot (U+00B7): use a hyphen, comma or pipe' });161+ });162+ return out;163+}164+165+export interface Token {166+ name: string;167+ light: string;168+ dark: string | null;169+}170+171+// The tokens in a stylesheet's token block, as the style guide (/design/)172+// shows them: in groups, each titled by the comment above it, with each173+// token's value and its dark-mode value if it has one.174+export function tokens(css: string): { title: string; tokens: Token[] }[] {175+ const block = /==\s*tokens\s*==[\s\S]*?\*\/([\s\S]*?)\/\*\s*==\s*end tokens\s*==/.exec(css)?.[1] ?? '';176+ const light = /(?:^|\n):root\s*\{([\s\S]*?)\n\}/.exec(block)?.[1] ?? '';177+ const darkBlock = /prefers-color-scheme:\s*dark\)\s*\{\s*:root\s*\{([\s\S]*?)\}/.exec(block)?.[1] ?? '';178+ const dark = new Map([...darkBlock.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]));179+ const groups: { title: string; tokens: Token[] }[] = [];180+ for (const m of light.matchAll(/\/\*([\s\S]*?)\*\/|(--[\w-]+)\s*:\s*([^;]+);/g)) {181+ if (m[1] != null) groups.push({ title: m[1].replace(/\s+/g, ' ').trim(), tokens: [] });182+ else {183+ if (!groups.length) groups.push({ title: '', tokens: [] });184+ groups[groups.length - 1].tokens.push({ name: m[2], light: m[3].trim(), dark: dark.get(m[2]) ?? null });185+ }186+ }187+ return groups.filter((g) => g.tokens.length);188+}189+190+// The forge's own, from style.css.191+export const forgeTokens = (): { title: string; tokens: Token[] }[] => tokens(fs.readFileSync(path.join(root, 'site', 'assets', 'style.css'), 'utf8'));192+193+const BINARY = /\.(woff2?|ttf|otf|webp|png|jpe?g|gif|avif|ico|wasm|db|parquet)$/i;194+195+function walk(dir: string): string[] {196+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {197+ const p = path.join(dir, e.name);198+ return e.isDirectory() ? (e.name === 'node_modules' ? [] : walk(p)) : [p];199+ });200+}201+202+export function check(files: string[]): Problem[] {203+ const out: Problem[] = [];204+ for (const f of files) {205+ if (BINARY.test(f)) continue;206+ const text = fs.readFileSync(f, 'utf8');207+ const name = path.relative(root, f) || f;208+ out.push(...checkDot(text, name));209+ if (f.endsWith('.css')) out.push(...checkCss(text, name));210+ else if (f.endsWith('.njk')) out.push(...checkTemplate(text, name));211+ }212+ return out;213+}214+215+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {216+ const args = process.argv.slice(2);217+ const files = (args.length ? args : ['site', 'lib', 'guide'].map((d) => path.join(root, d))).flatMap((a) => (fs.statSync(a).isDirectory() ? walk(path.resolve(a)) : [path.resolve(a)]));218+ const problems = check(files);219+ for (const p of problems) console.error(`${p.file}:${p.line}: ${p.message}`);220+ if (problems.length) {221+ console.error(`${problems.length} problem${problems.length === 1 ? '' : 's'}; the rules are in DESIGN.md`);222+ process.exit(1);223+ }224+ console.log(`css: ${files.filter((f) => /\.(css|njk)$/.test(f)).length} stylesheets and templates keep to DESIGN.md`);225+}
A test/scenarios/css-lint.sh +45 -0
@@ -0,0 +1,45 @@
1+# The design rules hold for the forge's own CSS and templates, and2+# lib/check-css.ts fails on each kind of break: a colour, size, weight or3+# family written outside the tokens, a rounded corner, capitals by CSS,4+# letter-spacing, a token block in a template, and a middle dot anywhere.5+. "$(dirname "$0")/../lib.sh"6+check() { node "$T_HOME/lib/check-css.ts" "$@"; }7+ok check8+d=$T/lint9+mkdir -p "$d"10+cp "$T_HOME/site/assets/style.css" "$d/style.css"11+ok check "$d/style.css"12+plant() { # plant <name> <ext> <text> <expected message>13+ f=$d/$1.$214+ if [ "$2" = css ]; then { cat "$T_HOME/site/assets/style.css"; printf '%s\n' "$3"; } >"$f"15+ else printf '%s\n' "$3" >"$f"; fi16+ fails_with "$4" check "$f"17+}18+plant caps css '.x { text-transform: uppercase; }' 'text-transform: uppercase'19+plant tracking css '.x { letter-spacing: 0.08em; }' 'letter-spacing'20+plant round css '.x { border-radius: 4px; }' 'corners are square'21+plant hex css '.x { color: #fff; }' 'colour literal \(#fff\)'22+plant rgba css '.x { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); }' 'colour literal'23+plant named css '.x { border: 1px solid white; }' 'colour literal \(white\)'24+plant size css '.x { font-size: 11px; }' 'use a --text-\* token'25+plant weight css '.x { font-weight: 600; }' 'use a --fw-\* token'26+plant family css '.x { font-family: Georgia, serif; }' 'use --sans, --mono or --serif'27+plant short css '.x { font: 600 11px/1.4 var(--mono); }' 'name the weight, size and family'28+plant block njk '{% css %}29+.x { color: var(--fg); letter-spacing: 1px; }30+{% endcss %}31+<p class="x">x</p>' 'block.njk:2: letter-spacing'32+plant inline njk '<p style="color: #c00">x</p>' 'colour literal'33+plant tokens njk '{% css %}34+/* == tokens == */35+:root { --x: #fff; }36+/* == end tokens == */37+{% endcss %}' 'a token block belongs in style.css'38+plant dot txt "$(printf 'a \302\267 b')" 'a middle dot'39+# What may stand: tokens, a line height in the shorthand, var() in a40+# template's style attribute.41+printf '%s\n' '{% css %}42+.x { font: var(--fw-strong) var(--text-sm)/1.4 var(--sans); color: var(--muted); border-radius: var(--radius); }43+{% endcss %}44+<p class="x" style="--depth: 2; width: var(--space-4)">x</p>' >"$d/fine.njk"45+ok check "$d/fine.njk"
A test/scenarios/pages-no-caps.sh +19 -0
A test/scenarios/shelves-site-toggle.sh +24 -0

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

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.
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

7/7 DESIGN.md: the rules for how pages look, and for changing them

2 files changed, +101 -3
ADESIGN.md96++++++++++++++++++++++++++++++
MREADME.md8++-
A DESIGN.md +96 -0
@@ -0,0 +1,96 @@
1+# Design2+3+How the forge's pages look, and the rules for changing them. They are4+written for whoever edits the templates, people and agents alike. CI holds5+the CSS to them (`node lib/check-css.ts`, run by `.forge/ci`), and the style6+guide at `/design/` (`site/design.njk`) draws every token and control with7+the real stylesheet and macros. Check it after a change.8+9+## The look10+11+A plain page (no texture, no grid), square corners, and hairline rules12+between things rather than boxes around them; only a file viewer, a diff or13+a code block is a bordered region. Structure comes from the order of things14+and the rules between them, in the spirit of the Vanilla framework. Phone15+first: lists restack, tabs scroll sideways.16+17+## Tokens18+19+Every colour, font size, weight, font family, space, rule width and the20+radius is a custom property in the token block at the top of21+`site/assets/style.css`, between the `== tokens ==` and `== end tokens ==`22+comments, with its dark value in the `prefers-color-scheme: dark` block23+under it. Nothing outside that block names a colour, size, weight or24+family: not `style.css` below it, not a template's `{% css %}` block, not25+a `style=""` attribute. If no token fits, add one there (with a comment if26+it starts a group: the style guide titles groups by those comments).27+28+- Colour: `--bg` the page, `--raised` surfaces over it (white in light29+ mode), `--hover`, ink in three strengths `--fg`, `--muted` (at least30+ 4.5:1 on the page) and `--faint`, rules `--border` and `--border-strong`.31+ One accent, `#3148F6`: `--link` for links (lighter in dark mode),32+ `--accent` for the current tab's bar, focus, primary fills, `--deep` on33+ hover. Meaning: `--add-*`, `--del-*`, `--pass`, `--fail`, `--warn`,34+ `--status-*` fills.35+- Type: `--text-xs` 12, `--text-code` 12.5, `--text-sm` 13.5, `--text-md`36+ 15 (body), `--text-prose` 16, `--text-lg` 18, `--text-xl` 22,37+ `--text-2xl` 28. Weights `--fw-regular` 400 (body), `--fw-medium` 50038+ (links, controls), `--fw-strong` 650 (emphasis, headings).39+- Space: `--space-1` to `--space-10` on a 4px scale, `--hairline` 1px for40+ rules, `--bar` 3px for the current tab, `--radius` 0.41+42+## Fonts43+44+Self-hosted variable woff2 in `site/assets/fonts/` (how they were cut:45+`make-fonts.sh`; licenses: the `OFL-*.txt` files there).46+47+- `--sans`, Open Sans: all interface text. Navigation, tabs, lists,48+ subjects, page and section headings, forms, buttons, labels.49+- `--mono`, Noto Sans Mono: anything copied or compared. Hashes, review50+ ids, branch names, paths, counts like +12, commit kinds, code, diffs,51+ clone and install commands.52+- `--serif`, Noto Serif: only rendered prose people wrote. READMEs and53+ markdown files, the wiki, release notes, review descriptions and54+ comments, the guide's text. Never interface chrome.55+56+## Words57+58+- No capitals by CSS and no letter-spacing, anywhere. Interface chrome59+ (tabs, buttons, labels, table headers, section headings, status labels,60+ summaries) is written in lowercase in the templates. Hierarchy comes61+ from weight, size and colour.62+- What users wrote (commit subjects, review titles, repo descriptions) is63+ never case-transformed.64+- Kinds, topics and filters are plain words, not chips.65+- Never the middle dot (U+00B7), in code, comments, UI strings or66+ commits; use a comma, a hyphen or a pipe.67+68+## Controls69+70+Words for where you are, symbols for how you look at it.71+72+- Tabs say where you are (`ui.tabs`): the repo's sections, the shelves on73+ the index (with an "all" tab).74+- A checkbox turns one thing on or off (`ui.checkbox`): wrap lines,75+ viewed.76+- An icon toggle is a checkbox drawn as a symbol (`ui.iconToggle`): a77+ button with `aria-pressed`, `aria-label` and `title`, the size of an78+ icon. Example: the globe, "only repos with a site".79+- A segmented control picks one of a few (`ui.segmented`): the sort,80+ newest first or by name.81+- Buttons do things (`ui.button`); one primary, filled with the accent,82+ per place at most. A toggle that is on is filled with ink. A filled primary darkens on hover (`--accent-hover`); `--deep` is for text.83+- Links only go places. Never use a link, or a word that changes when84+ clicked, as a toggle.85+86+Use the macros in `site/_includes/ui.njk` (also `ui.input`,87+`ui.textarea`, `ui.listTable`, `ui.status`, `ui.heading`, `ui.icon`)88+rather than writing the markup again. A new control goes there first,89+with its CSS in `style.css` under "controls", and onto `/design/`.90+91+## After a change92+93+Run `sh .forge/ci` (it runs the CSS check and the scenarios). After a94+deploy, look at the pages: the home page, a repo, its commits, a review95+and its diff, a README, releases and `/design/`, in light and dark, at96+desktop and phone widths. Grepping HTML is not looking.
M README.md +5 -3
@@ -1321,8 +1321,10 @@ pagination:
13211321 {% endblock %}13221322 ```13231323 1324-`site/assets/style.css` holds what every page needs: colors, layout, lists,1325-code views. CSS for one feature sits next to its markup in a `{% css %}`1324+`site/assets/style.css` holds what every page needs: the design tokens1325+(every colour, size, weight and font), layout, lists, code views. The rules1326+for the look, and for changing it, are in [DESIGN.md](DESIGN.md); the1327+controls are macros in `site/_includes/ui.njk`, and `/design/` shows both. CSS for one feature sits next to its markup in a `{% css %}`13261328 block, in the template, include or macro that renders it (the discussion,13271329 the search overlay, the shortcuts dialog, the review bar, review lists, the13281330 wiki), and 11ty's bundle plugin gives each page one file of the blocks it
@@ -1331,7 +1333,7 @@ rendered, `/assets/<hash>.css`, shared by pages of a kind. Import
13311333 13321334 Assets ship under content-hashed names (`/assets/style.<hash>.css`,13331335 `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 same1336+the stylesheet's `url(./fonts/open-sans.woff2)` becomes the font's hashed name. The same13351337 sources give the same names, and pages' fingerprints include them, so13361338 unchanged pages are still hard-linked. A build keeps the files of the last13371339 five code versions (listed in `assets/versions.json`), because pages built by