cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Pictures: one font, JetBrains Mono, and color families by page

Dami committed a043ee9
Every picture of 012 is set in JetBrains Mono, VHS's and Ghostty's
default: the stills draw from the TTFs in e2e/testdata/fonts (Go Mono
only for characters it lacks), make demos serves ttyd's page with them
added so VHS needs nothing installed, and the site bundles
@fontsource/jetbrains-mono, with the system's sans for body text.

The pictures' colors are families of a dark and a light scheme, listed
in site.md with the pages each covers: Catppuccin, Tokyo Night, Rosé
Pine, GitHub, Gruvbox and Nord. Stills draw their golden's palette in
their family's schemes with each cell held to 012's contrast minimums;
tapes set their scheme and make demos runs 012 with that theme.
TestPictureSchemes checks the schemes' contrast, and doccheck that each
page's pictures are in its family.
commit
a043ee9acf2d8fe2d057ca001b15267b3e0bc1f0
parent
391e1d51801fb0bc47999986efb348708336735b
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.0 of 3, Jev's estimate
83 files changed, +766 -155
MMakefile14+-
MNOTICE11+
Mdemos/arrays.tape1+
Mdemos/charts.tape1+
Mdemos/decimal.tape1+
Mdemos/find-replace.tape1+
Mdemos/follow-log.tape1+
Mdemos/formats.tape1+
Mdemos/freeze-sort-filter.tape1+
Mdemos/import.tape1+
Mdemos/jev.tape1+
Mdemos/layout.tape1+
Ademos/lib/font-page40+++++
Mdemos/lib/setup.tape11+-
Mdemos/lib/ttyd6+-
Mdemos/live.tape1+
Mdemos/locale.tape1+
Mdemos/macros.tape1+
Mdemos/notebook.tape1+
Mdemos/nu-recipe.tape1+
Mdemos/pipeline.tape1+
Mdemos/pivot.tape1+
Mdemos/rules.tape1+
Mdemos/selection.tape1+
Mdemos/share.tape1+
Mdemos/sheet.tape1+
Mdemos/sources.tape1+
Mdemos/themes.tape1+
Mdocs/contributing/site.md70++++++++-
Mdocs/contributing/testing.md4+-
Mdocs/media/evaluate-dark.pngbin
Mdocs/media/evaluate-light.pngbin
Mdocs/media/layout-dark.pngbin
Mdocs/media/layout-light.pngbin
Mdocs/media/live-ask-dark.pngbin
Mdocs/media/live-ask-light.pngbin
Mdocs/media/live-review-dark.pngbin
Mdocs/media/live-review-light.pngbin
Mdocs/media/live-suggestion-dark.pngbin
Mdocs/media/live-suggestion-light.pngbin
Mdocs/media/notebook-cells-dark.pngbin
Mdocs/media/notebook-cells-light.pngbin
Mdocs/media/notebook-grid-dark.pngbin
Mdocs/media/notebook-grid-light.pngbin
Mdocs/media/notebook-nu-error-dark.pngbin
Mdocs/media/notebook-nu-error-light.pngbin
Mdocs/media/notebook-nu-help-dark.pngbin
Mdocs/media/notebook-nu-help-light.pngbin
Mdocs/media/notebook-nu-hover-dark.pngbin
Mdocs/media/notebook-nu-hover-light.pngbin
Mdocs/media/palette-dark.pngbin
Mdocs/media/palette-light.pngbin
Mdocs/media/rules-bars-dark.pngbin
Mdocs/media/rules-bars-light.pngbin
Mdocs/media/rules-panel-dark.pngbin
Mdocs/media/rules-panel-light.pngbin
Mdocs/media/share-presence-dark.pngbin
Mdocs/media/share-presence-light.pngbin
Mdocs/media/source-formulas-dark.pngbin
Mdocs/media/source-formulas-light.pngbin
Mdocs/media/source-formulas-why-dark.pngbin
Mdocs/media/source-formulas-why-light.pngbin
Mdocs/media/source-tab-dark.pngbin
Mdocs/media/source-tab-light.pngbin
Mdocs/media/table-dark.pngbin
Mdocs/media/table-light.pngbin
Mdocs/media/trace-view-dark.pngbin
Mdocs/media/trace-view-light.pngbin
Me2e/stillcells_test.go48------
Me2e/stilldraw_test.go89+++++++-----
Me2e/stills_test.go74++++++----
Ae2e/stillthemes_test.go227++++++++++++++++++++++++++++++
Ae2e/testdata/fonts/JetBrainsMono-Bold.ttfbin
Ae2e/testdata/fonts/JetBrainsMono-BoldItalic.ttfbin
Ae2e/testdata/fonts/JetBrainsMono-Italic.ttfbin
Ae2e/testdata/fonts/JetBrainsMono-Regular.ttfbin
Ae2e/testdata/fonts/OFL.txt93++++++++++++
Ainternal/ui/theme/picture_test.go43++++++
Ascripts/doccheck/families.go127+++++++++++++++++
Mscripts/doccheck/main.go4+
Mwebsite/package-lock.json18+--
Mwebsite/package.json3+-
Mwebsite/src/css/custom.css16+-
M Makefile +10 -4
@@ -118,7 +118,11 @@ oracle:
118118 # github.com/charmbracelet/vhs@latest), ttyd and ffmpeg. The JEV demo119119 # talks to demos/fakejev, started here, never the real service.120120 # Charts are drawn as text, as the tapes and README describe; VHS121-# records text, not images. DEMOS=jev renders just one.121+# records text, not images. DEMOS=jev renders just one. Each tape runs122+# 012 with its theme set to the tape's Set Theme (setup.tape's unless it123+# sets one), and in JetBrains Mono, served to VHS's browser in ttyd's124+# page from e2e/testdata/fonts, so it needn't be installed125+# (docs/contributing/site.md#font-and-colors).122126 DEMOS ?= $(basename $(notdir $(wildcard demos/*.tape)))123127 FAKEJEV_ADDR := 127.0.0.1:8799124128
@@ -127,9 +131,11 @@ demos: build
127131 CGO_ENABLED=0 go build -o bin/fakejev ./demos/fakejev128132 mkdir -p demos/out/stills129133 bin/fakejev -addr $(FAKEJEV_ADDR) & pid=$$!; trap "kill $$pid" EXIT; \130- export DEMOS_TTYD="$$(command -v ttyd)" PATH="$(CURDIR)/demos/lib:$$PATH"; \131- export XDG_CONFIG_HOME="$$(mktemp -d)" O12_JEV_CREDENTIAL_STORE=false O12_THEME= O12_LOCALE=en-US; \132- cd demos && for d in $(DEMOS); do echo "vhs $$d.tape"; vhs -q $$d.tape || exit 1; done134+ export DEMOS_TTYD="$$(command -v ttyd)" PATH="$(CURDIR)/demos/lib:$$PATH" DEMOS_PAGE="$(CURDIR)/demos/out/ttyd.html"; \135+ demos/lib/font-page "$$DEMOS_PAGE" e2e/testdata/fonts || exit 1; \136+ export XDG_CONFIG_HOME="$$(mktemp -d)" O12_JEV_CREDENTIAL_STORE=false O12_LOCALE=en-US; \137+ cd demos && for d in $(DEMOS); do theme="$$(grep -h '^Set Theme' lib/setup.tape $$d.tape | tail -1 | cut -d'"' -f2)"; \138+ echo "vhs $$d.tape ($$theme)"; O12_THEME="$$theme" vhs -q $$d.tape || exit 1; done133139 mkdir -p demos/out/media134140 for d in $(DEMOS); do ffmpeg -v error -y -i demos/out/$$d.gif -filter_complex \135141 "fps=12,tpad=stop_mode=clone:stop_duration=2,split[a][b];[a]palettegen=max_colors=64:stats_mode=diff[p];[b][p]paletteuse=dither=none:diff_mode=rectangle" \
M NOTICE +11 -0
M demos/arrays.tape +1 -0
M demos/charts.tape +1 -0
M demos/decimal.tape +1 -0
M demos/find-replace.tape +1 -0
M demos/follow-log.tape +1 -0
M demos/formats.tape +1 -0
M demos/freeze-sort-filter.tape +1 -0
M demos/import.tape +1 -0
M demos/jev.tape +1 -0
M demos/layout.tape +1 -0
A demos/lib/font-page +40 -0
@@ -0,0 +1,40 @@
1+#!/bin/sh2+# font-page OUT FONTDIR: writes ttyd's own page to OUT with JetBrains3+# Mono added as a web font from FONTDIR's TTFs (e2e/testdata/fonts), for4+# demos/lib/ttyd to serve with ttyd -I. VHS's browser draws with the5+# fonts the system or the page gives it, so with the font in the page6+# the recordings are drawn in it whether or not it is installed. The7+# page comes from the ttyd in DEMOS_TTYD, so it always matches it.8+set -eu9+out=$1 fonts=$210+log=$(mktemp)11+"${DEMOS_TTYD:?set DEMOS_TTYD to the real ttyd}" -p 0 -i 127.0.0.1 true 2>"$log" &12+pid=$!13+trap 'kill $pid 2>/dev/null || true; rm -f "$log"' EXIT14+port=15+for _ in $(seq 50); do16+ port=$(sed -n 's/.*Listening on port: \([0-9]*\).*/\1/p' "$log")17+ [ -n "$port" ] && break18+ sleep 0.119+done20+[ -n "$port" ] || { echo "font-page: ttyd didn't start"; cat "$log"; exit 1; }21+page=$(curl -sf "http://127.0.0.1:$port/")22+case $page in *"</head>"*) ;; *) echo "font-page: no </head> in ttyd's page"; exit 1 ;; esac23+24+face() { # weight style file25+ printf '@font-face{font-family:"JetBrains Mono";font-weight:%s;font-style:%s;src:url(data:font/ttf;base64,' "$1" "$2"26+ base64 <"$fonts/$3" | tr -d '\n'27+ printf ') format("truetype")}'28+}29+{30+ printf '%s' "${page%%</head>*}"31+ printf '<style>'32+ face 400 normal JetBrainsMono-Regular.ttf33+ face 700 normal JetBrainsMono-Bold.ttf34+ face 400 italic JetBrainsMono-Italic.ttf35+ face 700 italic JetBrainsMono-BoldItalic.ttf36+ printf '</style>'37+ # Load every style now, before VHS measures the font.38+ printf '<script>["16px","bold 16px","italic 16px","italic bold 16px"].forEach(function(f){document.fonts.load(f+" \\"JetBrains Mono\\"")})</script>'39+ printf '</head>%s' "${page#*</head>}"40+} >"$out"
M demos/lib/setup.tape +7 -4
M demos/lib/ttyd +5 -1
M demos/live.tape +1 -0
M demos/locale.tape +1 -0
M demos/macros.tape +1 -0
M demos/notebook.tape +1 -0
M demos/nu-recipe.tape +1 -0
M demos/pipeline.tape +1 -0
M demos/pivot.tape +1 -0
M demos/rules.tape +1 -0
M demos/selection.tape +1 -0
M demos/share.tape +1 -0
M demos/sheet.tape +1 -0
M demos/sources.tape +1 -0
M demos/themes.tape +1 -0
M docs/contributing/site.md +62 -8
@@ -116,8 +116,9 @@ overlay frame, the features as cells of a sheet (the one under the
116116 pointer shows its entry in the control panel), and a status line. The117117 colors in `website/src/css/custom.css` are the reference palettes of the118118 golden screens, given the roles the app gives them: cyan where you are,119-bright black header bands, blue links and selection. Headings and chrome120-are IBM Plex Mono, body text IBM Plex Sans, both bundled with the site.119+bright black header bands, blue links and selection. Code, headings and120+chrome are JetBrains Mono, bundled with the site, the font of every121+[picture of 012](#font-and-colors); body text is the system's own sans.121122 122123 ## Diagrams123124
@@ -157,17 +158,19 @@ grid's column headers).
157158 158159 A still is a [golden screen](testing.md#golden-screens) drawn as a PNG.159160 Each is an entry of `docScreens` in `e2e/stills_test.go`: its name,160-the screen whose setup it records, and a terminal size that reads on a161-page (80 columns, unless the state needs more). `make screens` records162-it on a dark and a light terminal and draws both into `docs/media`:161+the screen whose setup it records, a terminal size that reads on a162+page (80 columns, unless the state needs more) and the163+[family](#font-and-colors) of the pages that show it. `make screens`164+records it on a dark and a light terminal and draws both into165+`docs/media`, in the family's dark and light schemes:163166 164167 ```go165-{name: "evaluate", from: "evaluate", cols: 80, rows: 12},168+{name: "evaluate", from: "evaluate", cols: 80, rows: 12, family: "Catppuccin"},166169 // docs/media/evaluate-dark.png and docs/media/evaluate-light.png167170 ```168171 169-The drawing is Go (`e2e/stilldraw_test.go`): the golden's cells in the170-[reference palettes](testing.md#golden-screens), text in Go Mono, and172+The drawing is Go (`e2e/stilldraw_test.go`): the golden's cells in its173+page's [color schemes](#font-and-colors), text in JetBrains Mono, and171174 box drawing, blocks, braille and the chrome's symbols drawn as a172175 terminal draws them, so frames join and every machine draws the same173176 picture. Each PNG records which golden it was drawn from, and
@@ -192,3 +195,54 @@ one color mode; `custom.css` shows the site's the same way:
192195 ([Demo recordings](testing.md#demo-recordings)).193196 - `scripts/doccheck` fails on a still no page shows, or one shown194197 without its other color mode.198+199+## Font and colors200+201+Every picture of 012 is set in one font, JetBrains Mono: the202+recordings, the stills, and the site's code and terminal frames. It is203+VHS's and Ghostty's default, so the pictures look like the terminals204+people use; it reads well small, its italic isn't cursive, and the SIL205+Open Font License lets it be bundled. The site's comes from206+`@fontsource/jetbrains-mono`, and the stills and recordings use the207+TTFs in `e2e/testdata/fonts` (licence in `NOTICE`). VHS's browser draws208+with the fonts the system or the page gives it, so `make demos` serves209+ttyd's own page with the font added (`demos/lib/font-page`) and the210+recordings need nothing installed. Box drawing and blocks are drawn as211+shapes in both, by xterm.js in the recordings and by the stills'212+drawing, so frames join whatever the font.213+214+The pictures' colors are a few terminal color schemes people know, in215+families of a dark and a light scheme, so the docs don't all look alike216+while each picture stays plainly 012. Each page is in one family:217+218+| Family | Dark | Light | Pages |219+|---|---|---|---|220+| Catppuccin | `Catppuccin Mocha` | `Catppuccin Latte` | `getting-started/`, `contributing/`, `reference/`, `formulas/tracing.md` |221+| Tokyo Night | `tokyonight` | `tokyonight-day` | `nushell/` |222+| Rosé Pine | `rose-pine` | `rose-pine-dawn` | `sheets/rules.md`, `sheets/formatting.md`, `sheets/tables.md` |223+| GitHub | `GitHub Dark` | `Github` | `sheets/` |224+| Gruvbox | `GruvboxDark` | `Gruvbox Light` | `files/`, `formulas/` |225+| Nord | `nord` | `nord-light` | `agents/`, `terminal/` |226+227+- A page is in the family that names it, or else the one naming its228+ deepest folder. Its stills are drawn in the family's dark and light229+ schemes, and its recordings use either one (`Set Theme` in the tape,230+ or `demos/lib/setup.tape`'s). The README and the landing page, a tour231+ of every topic, show each recording as its own page does.232+- `scripts/doccheck` fails on a page showing a picture in no family,233+ and on a still or a tape in a family other than its pages'.234+- A family is a restrained, widely used scheme with both a dark and a235+ light variant: no neon or high-saturation schemes. Its schemes are236+ named as in 012's built-in list (`internal/ui/theme/themes.json`,237+ VHS's own list, so `Set Theme` takes the same names), and238+ `TestPictureSchemes` (`internal/ui/theme`) holds each to readable239+ contrast: its text on its background at least 4.5:1, and 012's roles240+ drawn in it (header bands, the selection, the pointer, muted text)241+ at their minimums.242+- The recordings run 012 with its theme set to the tape's scheme243+ (`make demos` sets `O12_THEME` from `Set Theme`), so 012's roles get244+ the scheme's colors with their contrast held, as for anyone who picks245+ that scheme. A still draws its golden's palette colors in the scheme246+ and holds each cell to the same minimums (4.5:1 for text, 3:1 for247+ lines and muted text, 2:1 for faint text), moving the text toward the248+ scheme's text color as 012 does, so it stays drawn from the golden.
M docs/contributing/testing.md +3 -1
M docs/media/evaluate-dark.png +0 -0

Binary file.

M docs/media/evaluate-light.png +0 -0

Binary file.

M docs/media/layout-dark.png +0 -0

Binary file.

M docs/media/layout-light.png +0 -0

Binary file.

M docs/media/live-ask-dark.png +0 -0

Binary file.

M docs/media/live-ask-light.png +0 -0

Binary file.

M docs/media/live-review-dark.png +0 -0

Binary file.

M docs/media/live-review-light.png +0 -0

Binary file.

M docs/media/live-suggestion-dark.png +0 -0

Binary file.

M docs/media/live-suggestion-light.png +0 -0

Binary file.

M docs/media/notebook-cells-dark.png +0 -0

Binary file.

M docs/media/notebook-cells-light.png +0 -0

Binary file.

M docs/media/notebook-grid-dark.png +0 -0

Binary file.

M docs/media/notebook-grid-light.png +0 -0

Binary file.

M docs/media/notebook-nu-error-dark.png +0 -0

Binary file.

M docs/media/notebook-nu-error-light.png +0 -0

Binary file.

M docs/media/notebook-nu-help-dark.png +0 -0

Binary file.

M docs/media/notebook-nu-help-light.png +0 -0

Binary file.

M docs/media/notebook-nu-hover-dark.png +0 -0

Binary file.

M docs/media/notebook-nu-hover-light.png +0 -0

Binary file.

M docs/media/palette-dark.png +0 -0

Binary file.

M docs/media/palette-light.png +0 -0

Binary file.

M docs/media/rules-bars-dark.png +0 -0

Binary file.

M docs/media/rules-bars-light.png +0 -0

Binary file.

M docs/media/rules-panel-dark.png +0 -0

Binary file.

M docs/media/rules-panel-light.png +0 -0

Binary file.

M docs/media/share-presence-dark.png +0 -0

Binary file.

M docs/media/share-presence-light.png +0 -0

Binary file.

M docs/media/source-formulas-dark.png +0 -0

Binary file.

M docs/media/source-formulas-light.png +0 -0

Binary file.

M docs/media/source-formulas-why-dark.png +0 -0

Binary file.

M docs/media/source-formulas-why-light.png +0 -0

Binary file.

M docs/media/source-tab-dark.png +0 -0

Binary file.

M docs/media/source-tab-light.png +0 -0

Binary file.

M docs/media/table-dark.png +0 -0

Binary file.

M docs/media/table-light.png +0 -0

Binary file.

M docs/media/trace-view-dark.png +0 -0

Binary file.

M docs/media/trace-view-light.png +0 -0

Binary file.

M e2e/stillcells_test.go +0 -48
M e2e/stilldraw_test.go +54 -35
@@ -6,6 +6,8 @@ import (
66 "image/color"77 "image/draw"88 "math"9+ "os"10+ "path/filepath"911 1012 "golang.org/x/image/font"1113 "golang.org/x/image/font/gofont/gomono"
@@ -17,11 +19,12 @@ import (
1719 "golang.org/x/image/vector"1820 )1921 20-// Drawing a screen's cells as a picture of a terminal: text in Go Mono21-// (bundled with golang.org/x/image, so every machine draws the same22-// letters), and box drawing, blocks, braille and the symbols 012 draws23-// its chrome with drawn as shapes, as terminals draw them, so lines join24-// across cells.22+// Drawing a screen's cells as a picture of a terminal: text in23+// JetBrains Mono, the font of the recordings and the site (testdata/fonts,24+// so every machine draws the same letters), with Go Mono for the few25+// characters it has none of, and box drawing, blocks, braille and the26+// symbols 012 draws its chrome with drawn as shapes, as terminals draw27+// them, so lines join across cells.2528 2629 const (2730 stillFontSize = 26 // pixels; stills are drawn at twice the size they show
@@ -29,46 +32,63 @@ const (
2932 stillRadius = 18 // the window's corners3033 )3134 32-// stillFaces are Go Mono's four styles at the still's size.35+// stillFaces are the font's four styles at the still's size, regular,36+// bold, italic and bold italic, and Go Mono's for what the font lacks.3337 type stillFaces struct {34- regular, bold, italic, boldItalic font.Face35- cw, ch, ascent int38+ faces, fallback [4]font.Face39+ cw, ch, ascent int3640 }3741 42+// fontFiles are the styles' files in testdata/fonts.43+var fontFiles = [4]string{"JetBrainsMono-Regular.ttf", "JetBrainsMono-Bold.ttf", "JetBrainsMono-Italic.ttf", "JetBrainsMono-BoldItalic.ttf"}44+3845 func loadFaces() (*stillFaces, error) {3946 var fs stillFaces40- for _, f := range []struct {41- ttf []byte42- dst *font.Face43- }{{gomono.TTF, &fs.regular}, {gomonobold.TTF, &fs.bold}, {gomonoitalic.TTF, &fs.italic}, {gomonobolditalic.TTF, &fs.boldItalic}} {44- parsed, err := opentype.Parse(f.ttf)47+ goMono := [4][]byte{gomono.TTF, gomonobold.TTF, gomonoitalic.TTF, gomonobolditalic.TTF}48+ for i, name := range fontFiles {49+ ttf, err := os.ReadFile(filepath.Join("testdata", "fonts", name))4550 if err != nil {4651 return nil, err4752 }48- face, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: stillFontSize, DPI: 72, Hinting: font.HintingNone})49- if err != nil {53+ if fs.faces[i], err = newFace(ttf); err != nil {54+ return nil, fmt.Errorf("%s: %w", name, err)55+ }56+ if fs.fallback[i], err = newFace(goMono[i]); err != nil {5057 return nil, err5158 }52- *f.dst = face5359 }54- adv, _ := fs.regular.GlyphAdvance('0')55- m := fs.regular.Metrics()60+ adv, _ := fs.faces[0].GlyphAdvance('0')61+ m := fs.faces[0].Metrics()5662 fs.cw = adv.Round()5763 fs.ch = int(math.Round(stillFontSize * 1.3))5864 fs.ascent = (fs.ch-(m.Ascent+m.Descent).Round())/2 + m.Ascent.Round()5965 return &fs, nil6066 }6167 68+func newFace(ttf []byte) (font.Face, error) {69+ parsed, err := opentype.Parse(ttf)70+ if err != nil {71+ return nil, err72+ }73+ return opentype.NewFace(parsed, &opentype.FaceOptions{Size: stillFontSize, DPI: 72, Hinting: font.HintingNone})74+}75+76+// face is the face c's rune is drawn in: its style in the font, or in Go77+// Mono when the font has no glyph for it; nil when neither has.6278 func (fs *stillFaces) face(c stillCell) font.Face {63- switch {64- case c.bold && c.italic:65- return fs.boldItalic66- case c.bold:67- return fs.bold68- case c.italic:69- return fs.italic70- }71- return fs.regular79+ i := 080+ if c.bold {81+ i++82+ }83+ if c.italic {84+ i += 285+ }86+ for _, f := range []font.Face{fs.faces[i], fs.fallback[i]} {87+ if _, ok := f.GlyphAdvance(c.r); ok {88+ return f89+ }90+ }91+ return nil7292 }7393 7494 // drawStill draws grid as a terminal window in palette p. It returns the
@@ -112,19 +132,15 @@ func drawCell(img *image.NRGBA, row []stillCell, x int, origin image.Point, p st
112132 if c.r == 0 && c.filled {113133 return true // the second half of a wide character, drawn with the first114134 }115- bg := p.color(c.bg, inkBg)135+ fg, bg := p.inks(c)116136 if c.bg.kind != inkNone {117137 draw.Draw(img, image.Rect(origin.X, origin.Y, origin.X+fs.cw, origin.Y+fs.ch), image.NewUniform(bg), image.Point{}, draw.Src)118138 }119- fg := p.color(c.fg, inkFg)120- if c.faint {121- fg = blend(bg, fg, 0.6)122- }123139 ok := true124140 if c.r != 0 && c.r != ' ' && c.r != ' ' {125141 ok = drawGlyph(img, c, r, fg, fs)126142 }127- lines(img, c, r, fg, p, fs)143+ lines(img, c, r, fg, bg, p, fs)128144 return ok129145 }130146
@@ -135,7 +151,7 @@ func drawGlyph(img *image.NRGBA, c stillCell, r image.Rectangle, fg color.RGBA,
135151 return true136152 }137153 face := fs.face(c)138- if _, ok := face.GlyphAdvance(c.r); !ok {154+ if face == nil {139155 return false140156 }141157 adv, _ := face.GlyphAdvance(c.r)
@@ -147,7 +163,7 @@ func drawGlyph(img *image.NRGBA, c stillCell, r image.Rectangle, fg color.RGBA,
147163 }148164 149165 // lines draws c's underline and strikethrough.150-func lines(img *image.NRGBA, c stillCell, r image.Rectangle, fg color.RGBA, p stillPalette, fs *stillFaces) {166+func lines(img *image.NRGBA, c stillCell, r image.Rectangle, fg, bg color.RGBA, p stillPalette, fs *stillFaces) {151167 t := lineWidth(fs.cw)152168 if c.strike {153169 y := r.Min.Y + fs.ascent - stillFontSize*3/10
@@ -159,6 +175,9 @@ func lines(img *image.NRGBA, c stillCell, r image.Rectangle, fg color.RGBA, p st
159175 col := fg160176 if c.ulInk.kind != inkNone {161177 col = p.color(c.ulInk, inkFg)178+ if c.ulInk.kind == inkPalette {179+ col = readable(col, bg, rgba(p.fg), 3)180+ }162181 }163182 y := r.Min.Y + fs.ascent + t*2164183 switch c.underline {
M e2e/stills_test.go +46 -28
@@ -18,39 +18,42 @@ import (
1818 // Stills are pictures of 012 for the docs, drawn from golden screens:1919 // each docScreen is recorded again from another screen's setup, on a2020 // terminal the size a page shows well, dark and light, and drawn in its21-// reference palette to docs/media/<name>-dark.png and <name>-light.png.22-// `make screens` records and draws them; `make e2e` fails when a23-// still wasn't drawn from its golden as it is.21+// family's dark and light schemes (stillthemes_test.go) to22+// docs/media/<name>-dark.png and <name>-light.png. The family is the one23+// the pages showing it are in (scripts/doccheck checks it). `make24+// screens` records and draws them; `make e2e` fails when a still wasn't25+// drawn from its golden as it is.2426 type docScreen struct {2527 name, from string // docs/media/<name>-*.png; the screen whose setup it records2628 cols, rows uint1629+ family string // a row of the families table in docs/contributing/site.md2730 }2831 2932 var docScreens = []docScreen{30- {name: "trace-view", from: "trace-view", cols: 80, rows: 14},31- {name: "evaluate", from: "evaluate", cols: 80, rows: 12},32- {name: "rules-bars", from: "rules-bars", cols: 80, rows: 12},33- {name: "rules-panel", from: "rules-panel", cols: 100, rows: 12},34- {name: "table", from: "table", cols: 80, rows: 13},35- {name: "layout", from: "layout", cols: 80, rows: 28},36- {name: "palette", from: "palette", cols: 80, rows: 18},37- {name: "notebook-cells", from: "notebook-cells", cols: 80, rows: 26},38- {name: "notebook-nu-error", from: "notebook-nu-error", cols: 80, rows: 16},39- {name: "notebook-nu-hover", from: "notebook-nu-hover", cols: 100, rows: 16},40- {name: "notebook-nu-help", from: "notebook-nu-help", cols: 100, rows: 24},41- {name: "notebook-grid", from: "notebook-grid", cols: 80, rows: 24},42- {name: "share-presence", from: "share-presence", cols: 80, rows: 13},43- {name: "live-suggestion", from: "live-suggestion", cols: 80, rows: 12},44- {name: "live-review", from: "live-review", cols: 100, rows: 12},45- {name: "live-ask", from: "live-ask", cols: 80, rows: 10},46- {name: "source-tab", from: "source-tab", cols: 80, rows: 20},47- {name: "source-formulas", from: "source-formulas", cols: 80, rows: 14},48- {name: "source-formulas-why", from: "source-formulas-why", cols: 80, rows: 19},33+ {name: "trace-view", from: "trace-view", cols: 80, rows: 14, family: "Catppuccin"},34+ {name: "evaluate", from: "evaluate", cols: 80, rows: 12, family: "Catppuccin"},35+ {name: "rules-bars", from: "rules-bars", cols: 80, rows: 12, family: "Rosé Pine"},36+ {name: "rules-panel", from: "rules-panel", cols: 100, rows: 12, family: "Rosé Pine"},37+ {name: "table", from: "table", cols: 80, rows: 13, family: "Rosé Pine"},38+ {name: "layout", from: "layout", cols: 80, rows: 28, family: "Rosé Pine"},39+ {name: "palette", from: "palette", cols: 80, rows: 18, family: "Catppuccin"},40+ {name: "notebook-cells", from: "notebook-cells", cols: 80, rows: 26, family: "Tokyo Night"},41+ {name: "notebook-nu-error", from: "notebook-nu-error", cols: 80, rows: 16, family: "Tokyo Night"},42+ {name: "notebook-nu-hover", from: "notebook-nu-hover", cols: 100, rows: 16, family: "Tokyo Night"},43+ {name: "notebook-nu-help", from: "notebook-nu-help", cols: 100, rows: 24, family: "Tokyo Night"},44+ {name: "notebook-grid", from: "notebook-grid", cols: 80, rows: 24, family: "Tokyo Night"},45+ {name: "share-presence", from: "share-presence", cols: 80, rows: 13, family: "Nord"},46+ {name: "live-suggestion", from: "live-suggestion", cols: 80, rows: 12, family: "Nord"},47+ {name: "live-review", from: "live-review", cols: 100, rows: 12, family: "Nord"},48+ {name: "live-ask", from: "live-ask", cols: 80, rows: 10, family: "Nord"},49+ {name: "source-tab", from: "source-tab", cols: 80, rows: 20, family: "Gruvbox"},50+ {name: "source-formulas", from: "source-formulas", cols: 80, rows: 14, family: "Gruvbox"},51+ {name: "source-formulas-why", from: "source-formulas-why", cols: 80, rows: 19, family: "Gruvbox"},4952 }5053 5154 // stillsVersion changes when the drawing does, so every still is5255 // drawn again.53-const stillsVersion = "1"56+const stillsVersion = "2"5457 5558 // addDocScreens adds the docs' screens, docs-<name> and5659 // docs-<name>-light, to the golden screens once every screen they're
@@ -79,18 +82,33 @@ func TestStills(t *testing.T) {
7982 if err != nil {8083 t.Fatal(err)8184 }85+ fams, err := pictureFamilies()86+ if err != nil {87+ t.Fatal(err)88+ }89+ schemes, err := loadSchemes()90+ if err != nil {91+ t.Fatal(err)92+ }8293 for _, d := range docScreens {83- for _, v := range []struct {84- suffix string85- p stillPalette86- }{{"dark", darkStill}, {"light", lightStill}} {94+ fam, ok := fams[d.family]95+ if !ok {96+ t.Errorf("still %s: no family %q in %s", d.name, d.family, familiesDoc)97+ continue98+ }99+ for _, v := range []struct{ suffix, scheme string }{{"dark", fam.dark}, {"light", fam.light}} {100+ p, ok := schemes[v.scheme]101+ if !ok {102+ t.Errorf("family %s: no scheme %q in %s", d.family, v.scheme, schemesFile)103+ continue104+ }87105 screen := "docs-" + d.name88106 if v.suffix == "light" {89107 screen += "-light"90108 }91109 t.Run(d.name+"-"+v.suffix, func(t *testing.T) {92110 path := filepath.Join("..", "docs", "media", d.name+"-"+v.suffix+".png")93- checkStill(t, screen, int(d.cols), int(d.rows), v.p, faces, path)111+ checkStill(t, screen, int(d.cols), int(d.rows), p, faces, path)94112 })95113 }96114 }
A e2e/stillthemes_test.go +227 -0
@@ -0,0 +1,227 @@
1+package e2e2+3+import (4+ "encoding/json"5+ "fmt"6+ "image/color"7+ "math"8+ "os"9+ "regexp"10+ "strconv"11+ "strings"12+)13+14+// Stills are drawn in a terminal color scheme: the golden's palette15+// indexes in the scheme's colors, so a still stays tied to its golden16+// whatever scheme shows it. The schemes are 012's built-in ones17+// (internal/ui/theme/themes.json, the file VHS's Set Theme reads too),18+// and the families the docs use are the table in19+// docs/contributing/site.md, which the tapes follow as well.20+21+const (22+ schemesFile = "../internal/ui/theme/themes.json"23+ familiesDoc = "../docs/contributing/site.md"24+)25+26+// stillPalette is a terminal's colors: a scheme's 16 ANSI colors, text27+// and background.28+type stillPalette struct {29+ name string30+ ansi [16]uint3231+ fg, bg uint3232+}33+34+// family is a row of the families table: a dark scheme and a light one.35+type family struct{ dark, light string }36+37+// familyRow is a row of the table: | Family | `dark` | `light` | pages |.38+var familyRow = regexp.MustCompile("(?m)^\\| ([^|`]+?) \\| `([^`]+)` \\| `([^`]+)` \\|")39+40+// pictureFamilies reads the families table from the docs.41+func pictureFamilies() (map[string]family, error) {42+ src, err := os.ReadFile(familiesDoc)43+ if err != nil {44+ return nil, err45+ }46+ fams := map[string]family{}47+ for _, m := range familyRow.FindAllStringSubmatch(string(src), -1) {48+ fams[m[1]] = family{dark: m[2], light: m[3]}49+ }50+ if len(fams) == 0 {51+ return nil, fmt.Errorf("%s: no families table", familiesDoc)52+ }53+ return fams, nil54+}55+56+// loadSchemes reads the built-in schemes by name.57+func loadSchemes() (map[string]stillPalette, error) {58+ src, err := os.ReadFile(schemesFile)59+ if err != nil {60+ return nil, err61+ }62+ var raw []map[string]any63+ if err := json.Unmarshal(src, &raw); err != nil {64+ return nil, err65+ }66+ slots := [16]string{"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",67+ "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"}68+ out := map[string]stillPalette{}69+ for _, s := range raw {70+ name, _ := s["name"].(string)71+ p := stillPalette{name: name}72+ ok := hexInto(s["foreground"], &p.fg) && hexInto(s["background"], &p.bg)73+ for i, k := range slots {74+ ok = ok && hexInto(s[k], &p.ansi[i])75+ }76+ if ok {77+ out[name] = p78+ }79+ }80+ return out, nil81+}82+83+func hexInto(v any, dst *uint32) bool {84+ s, _ := v.(string)85+ n, err := strconv.ParseUint(strings.TrimPrefix(s, "#"), 16, 32)86+ if err != nil || len(s) != 7 {87+ return false88+ }89+ *dst = uint32(n)90+ return true91+}92+93+// color resolves an ink; def is what inkNone means in its slot.94+func (p stillPalette) color(k ink, def inkKind) color.RGBA {95+ if k.kind == inkNone {96+ k.kind = def97+ }98+ switch k.kind {99+ case inkFg:100+ return rgba(p.fg)101+ case inkPalette:102+ return rgba(p.xterm(k.palette))103+ case inkRGB:104+ return k.rgb105+ }106+ return rgba(p.bg)107+}108+109+// xterm is palette entry n: the 16 ANSI colors, then xterm's 6x6x6 cube110+// and gray ramp.111+func (p stillPalette) xterm(n int) uint32 {112+ switch {113+ case n < 16:114+ return p.ansi[n]115+ case n < 232:116+ n -= 16117+ level := func(v int) uint32 {118+ if v == 0 {119+ return 0120+ }121+ return uint32(55 + 40*v)122+ }123+ return level(n/36)<<16 | level(n/6%6)<<8 | level(n%6)124+ }125+ g := uint32(8 + 10*(n-232))126+ return g<<16 | g<<8 | g127+}128+129+// inks are a cell's colors in p, held to the contrast 012's schemes hold130+// their roles to (internal/ui/theme's FromPalette), since a scheme's131+// colors weren't chosen for 012's: a background from the palette stands132+// apart from the screen, and text moves toward the scheme's text color,133+// or black or white, until it reaches its minimum. Colors the user chose134+// as RGB are drawn as they are.135+func (p stillPalette) inks(c stillCell) (fg, bg color.RGBA) {136+ text, screen := rgba(p.fg), rgba(p.bg)137+ bg = p.color(c.bg, inkBg)138+ if c.bg.kind == inkPalette {139+ bg = distinct(bg, screen, text)140+ }141+ fg = p.color(c.fg, inkFg)142+ if c.faint {143+ fg = blend(bg, fg, 0.6)144+ }145+ if c.fg.kind != inkRGB && c.bg.kind != inkRGB {146+ fg = readable(fg, bg, text, minContrast(c))147+ }148+ return fg, bg149+}150+151+// minContrast is a cell's minimum, as theme's: 4.5:1 for text, 3:1 for152+// lines and 012's muted gray (hints, borders), 2:1 for faint text.153+func minContrast(c stillCell) float64 {154+ switch {155+ case c.faint:156+ return 2157+ case c.r >= 0x2500 && c.r <= 0x28ff, c.fg.kind == inkPalette && c.fg.palette == 8:158+ return 3159+ }160+ return 4.5161+}162+163+// The contrast arithmetic is internal/ui/theme's (contrast.go), which164+// this module can't import: WCAG 2 contrast, and a color that falls165+// short moved just far enough.166+167+func luminance(c color.RGBA) float64 {168+ lin := func(v uint8) float64 {169+ f := float64(v) / 0xff170+ if f <= 0.04045 {171+ return f / 12.92172+ }173+ return math.Pow((f+0.055)/1.055, 2.4)174+ }175+ return 0.2126*lin(c.R) + 0.7152*lin(c.G) + 0.0722*lin(c.B)176+}177+178+func contrast(a, b color.RGBA) float64 {179+ la, lb := luminance(a), luminance(b)180+ if la < lb {181+ la, lb = lb, la182+ }183+ return (la + 0.05) / (lb + 0.05)184+}185+186+// readable is fg, or fg moved toward toward, or failing that black or187+// white, until it has min contrast with bg.188+func readable(fg, bg, toward color.RGBA, min float64) color.RGBA {189+ if contrast(fg, bg) >= min {190+ return fg191+ }192+ if contrast(toward, bg) >= min {193+ return blendUntil(fg, toward, func(c color.RGBA) bool { return contrast(c, bg) >= min })194+ }195+ end := color.RGBA{0xff, 0xff, 0xff, 0xff}196+ if luminance(fg) <= luminance(bg) {197+ end = color.RGBA{0, 0, 0, 0xff}198+ }199+ if contrast(end, bg) < min {200+ end = color.RGBA{0xff - end.R, 0xff - end.G, 0xff - end.B, 0xff}201+ }202+ return blendUntil(fg, end, func(c color.RGBA) bool { return contrast(c, bg) >= min })203+}204+205+// distinct is bg, or bg moved toward toward until it stands apart from206+// the screen (1.2:1).207+func distinct(bg, screen, toward color.RGBA) color.RGBA {208+ if contrast(bg, screen) >= 1.2 {209+ return bg210+ }211+ return blendUntil(bg, toward, func(c color.RGBA) bool { return contrast(c, screen) >= 1.2 })212+}213+214+// blendUntil is the color nearest from on the way to end for which ok215+// holds; ok(end) must.216+func blendUntil(from, end color.RGBA, ok func(color.RGBA) bool) color.RGBA {217+ lo, hi := 0.0, 1.0218+ for range 24 {219+ mid := (lo + hi) / 2220+ if ok(blend(from, end, mid)) {221+ hi = mid222+ } else {223+ lo = mid224+ }225+ }226+ return blend(from, end, hi)227+}
A e2e/testdata/fonts/JetBrainsMono-Bold.ttf +0 -0

Binary file.

A e2e/testdata/fonts/JetBrainsMono-BoldItalic.ttf +0 -0

Binary file.

A e2e/testdata/fonts/JetBrainsMono-Italic.ttf +0 -0

Binary file.

A e2e/testdata/fonts/JetBrainsMono-Regular.ttf +0 -0

Binary file.

A e2e/testdata/fonts/OFL.txt +93 -0
@@ -0,0 +1,93 @@
1+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)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 internal/ui/theme/picture_test.go +43 -0
A scripts/doccheck/families.go +127 -0
@@ -0,0 +1,127 @@
1+package main2+3+import (4+ "os"5+ "path"6+ "path/filepath"7+ "regexp"8+ "slices"9+ "strings"10+)11+12+// The pictures' color schemes: docs/contributing/site.md has a table of13+// families, each a dark and a light scheme and the pages in it. Every14+// page that shows a picture is in one family, and so is every picture15+// it shows: a still's docScreens entry names its family, and a tape's16+// Set Theme is one of the family's two schemes.17+18+const familiesDoc = "docs/contributing/site.md"19+20+var (21+ // familyRow is a row of the table: | Family | `dark` | `light` | pages |.22+ familyRow = regexp.MustCompile("(?m)^\\| ([^|`]+?) \\| `([^`]+)` \\| `([^`]+)` \\| (.+) \\|$")23+ setTheme = regexp.MustCompile(`(?m)^Set Theme\s+"([^"]+)"`)24+ // stillFamily is a docScreens entry's name and family.25+ stillFamily = regexp.MustCompile(`(?m)^\s*\{name: "([^"]+)", from:.*family: "([^"]+)"\}`)26+)27+28+type family struct {29+ name, dark, light string30+ pages []string // pages and folders, relative to docs/31+}32+33+// readFamilies reads the families table.34+func readFamilies() ([]family, error) {35+ src, err := os.ReadFile(familiesDoc)36+ if err != nil {37+ return nil, err38+ }39+ var out []family40+ for _, m := range familyRow.FindAllStringSubmatch(string(src), -1) {41+ f := family{name: m[1], dark: m[2], light: m[3]}42+ for _, p := range strings.Split(m[4], ",") {43+ f.pages = append(f.pages, strings.Trim(strings.TrimSpace(p), "`"))44+ }45+ out = append(out, f)46+ }47+ return out, nil48+}49+50+// familyOf is the family of a page under docs/: the one naming the page,51+// or else the deepest folder holding it; nil when none does.52+func familyOf(fams []family, page string) *family {53+ rel := strings.TrimPrefix(page, "docs/")54+ var best *family55+ bestLen := -156+ for i, f := range fams {57+ for _, p := range f.pages {58+ if (p == rel || strings.HasSuffix(p, "/") && strings.HasPrefix(rel, p)) && len(p) > bestLen {59+ best, bestLen = &fams[i], len(p)60+ }61+ }62+ }63+ return best64+}65+66+// checkFamilies reports pictures in a family other than the pages67+// showing them, and pages showing pictures in no family. shown maps a68+// media path to the docs showing it.69+func checkFamilies(shown map[string]map[string]string) []string {70+ fams, err := readFamilies()71+ if err != nil {72+ return []string{familiesDoc + ": " + err.Error()}73+ }74+ if len(fams) == 0 {75+ return []string{familiesDoc + ": no table of the pictures' families"}76+ }77+ // check reports each docs page showing media whose family doesn't78+ // pass ok.79+ var out []string80+ check := func(source string, media []string, ok func(*family) bool, want string) {81+ for _, m := range media {82+ for _, page := range sortedKeys(shown["docs/media/"+m]) {83+ if !strings.HasPrefix(page, "docs/") {84+ continue // the README and the landing page show every topic's85+ }86+ switch f := familyOf(fams, page); {87+ case f == nil:88+ out = append(out, page+": shows "+m+" but is in no family of "+familiesDoc)89+ case !ok(f):90+ out = append(out, source+": "+want+", but "+page+", which shows "+m+", is in the family "+f.name)91+ }92+ }93+ }94+ }95+ setup, _ := os.ReadFile("demos/lib/setup.tape")96+ tapes, _ := filepath.Glob("demos/*.tape")97+ for _, t := range tapes {98+ src, err := os.ReadFile(t)99+ if err != nil {100+ continue101+ }102+ themes := setTheme.FindAllStringSubmatch(string(setup)+string(src), -1)103+ if len(themes) == 0 {104+ out = append(out, t+": no Set Theme, here or in demos/lib/setup.tape")105+ continue106+ }107+ theme := themes[len(themes)-1][1]108+ var made []string109+ for _, re := range []*regexp.Regexp{tapeOutput, tapeScreenshot} {110+ for _, m := range re.FindAllStringSubmatch(string(src), -1) {111+ made = append(made, path.Base(m[1]))112+ }113+ }114+ check(t, made, func(f *family) bool { return theme == f.dark || theme == f.light }, "its theme is "+theme)115+ }116+ if src, err := os.ReadFile(stillsSource); err == nil {117+ for _, m := range stillFamily.FindAllStringSubmatch(string(src), -1) {118+ name, fam := m[1], m[2]119+ if !slices.ContainsFunc(fams, func(f family) bool { return f.name == fam }) {120+ out = append(out, stillsSource+": the still "+name+" is in the family "+fam+", which "+familiesDoc+" doesn't list")121+ }122+ check(stillsSource, []string{name + "-dark.png", name + "-light.png"},123+ func(f *family) bool { return f.name == fam }, "the still "+name+" is in the family "+fam)124+ }125+ }126+ return out127+}
M scripts/doccheck/main.go +4 -0
M website/package-lock.json +4 -14
@@ -11,8 +11,7 @@
1111 "@docusaurus/preset-classic": "3.10.2",1212 "@docusaurus/theme-mermaid": "3.10.2",1313 "@easyops-cn/docusaurus-search-local": "0.55.3",14- "@fontsource/ibm-plex-mono": "5.3.0",15- "@fontsource/ibm-plex-sans": "5.3.0",14+ "@fontsource/jetbrains-mono": "5.3.0",1615 "@mdx-js/react": "3.1.1",1716 "clsx": "2.1.1",1817 "prism-react-renderer": "2.4.1",
@@ -4985,19 +4984,10 @@
49854984 "node": ">=18"49864985 }49874986 },4988- "node_modules/@fontsource/ibm-plex-mono": {4987+ "node_modules/@fontsource/jetbrains-mono": {49894988 "version": "5.3.0",4990- "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz",4991- "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==",4992- "license": "OFL-1.1",4993- "funding": {4994- "url": "https://github.com/sponsors/ayuhito"4995- }4996- },4997- "node_modules/@fontsource/ibm-plex-sans": {4998- "version": "5.3.0",4999- "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz",5000- "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==",4989+ "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",4990+ "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",50014991 "license": "OFL-1.1",50024992 "funding": {50034993 "url": "https://github.com/sponsors/ayuhito"
M website/package.json +1 -2
M website/src/css/custom.css +6 -10