Skip to content

Draw the software cards as SVGs at build, without a browser - #345

Merged
nstarman merged 18 commits into
mainfrom
add/software-card-svgs
Oct 6, 2026
Merged

nstarman merged 18 commits into
mainfrom
add/software-card-svgs

Conversation

@nstarman

@nstarman nstarman commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Changing code or schema

Stacked on #343 (stars button) and #344 (SVG export): this branch contains both, so until they merge the diff shows their commits too. The change itself is the last commit.

What and why: the profile README needs the lead and headline software cards as images, and shouldn't need Playwright and Chromium to get them. The cards are laid out by CSS, so there was no server-side way to ask for one; this adds one.

  • /cards/<id>-<light|dark>.svg and /cards/index.json: static endpoints (src/pages/cards/), built with the site, plain node.
  • src/lib/cardlayout.js: lays a software card out without a browser, as the same model cardpdf.js measures, so cardsvg.js writes it. Text is measured from the Plex files (opentype.js, to a hundredth of a px of what Chrome gets). The presets and width come from TIER_PRESET / CARD_SVG_WIDTH in cards.js, so the README follows the site's presets.
  • Each card has its stars button (Add a stars button to software cards #343); links are real <a>s in the file (live when opened or inlined; GitHub's <img> won't run them, see Add an SVG export to the Card Builder #344).

Keeping it in sync (tests/cardlayout.test.js, 45 tests):

  1. its numbers are read from global.css: colours, the look's steps, the formulas of a set-width card, line heights and gaps;
  2. it must reproduce, op by op (position ≤0.15 px, text width ≤1.5 px, text/colour/href/icon exact), what the browser measured for every lead/headline card in both themes — tests/fixtures/software-cards.json, recorded by scripts/record-card-layouts.mjs;
  3. that recording is stamped with a hash of the card's CSS rules and Card.astro (scripts/lib/cardsync.mjs), so editing either fails the test until it's re-recorded and compared; an unrelated stylesheet edit doesn't;
  4. it throws on any name asking for more than it draws (figure, authors, other buttons, height, spaces, other looks), and a preset that moves past it fails;
  5. against the real data: a card per lead/headline package, its links in order, its stars, every word of its text, valid SVG.

I checked the tests bite: changing a card margin or the title weight in the CSS fails the hash test; giving the headline preset a figure fails four.

Limits: drawn at the 16px root size the site has at 1280px+; fonts fall back to the reader's system face when shown through <img>, with each line held to its measured width.

  • npm run test:schema passes; 623 unit tests and the build pass

Screenshots: the SVG cards against the CSS cards

Each row is one package. Left to right: the real card (Chrome, the embed page at 400 px); the SVG this PR builds, with the Plex fonts loaded; their difference (black is identical — what's left is anti-aliasing); and the SVG as the README shows it, through <img>, where the page's fonts are not available so text falls back to the system face (each line is held to its measured width).

Light

svg vs css, light

Dark

svg vs css, dark

coordinax (38 ★) and potamides (3 ★) have no stars button: a package under 40 stars gets none (STARS_MIN, in the site's cards and embeds as well, so all three agree).

🤖 Generated with Claude Code

@github-actions github-actions Bot added this to the ♾️&➡ milestone Oct 6, 2026
Comment thread tests/cardlayout.test.js Fixed
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Preview: https://2606bf3a.nstarkman-space.pages.dev

Built from ec4531ef4abdca5eef7a32204e371369894eccf6, including the four CV PDFs.
Updated on every push to this pull request.

@nstarman
nstarman force-pushed the add/software-card-svgs branch 3 times, most recently from 0a88edf to 3583448 Compare October 6, 2026 19:22
nstarman added a commit that referenced this pull request Oct 6, 2026
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
nstarman and others added 8 commits October 6, 2026 15:58
/cards/<id>-<theme>.svg and /cards/index.json, from a static endpoint: the
lead and headline packages' cards, laid out in node (src/lib/cardlayout.js)
from the same presets the site uses, text measured from the Plex files,
written by cardsvg.js. For the profile README, which needs images and no
longer needs Playwright to get them.

tests/cardlayout.test.js holds it to the CSS: its colours, steps and
formulas are read from global.css; it reproduces the browser's own
measurement of every such card; that measurement is stamped with a hash
of the card's CSS and markup, so a change to either fails the test until
it is measured again (scripts/record-card-layouts.mjs). It throws on any
name that asks for more than it draws.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ties over generated data

The recording of Chrome's layout is now at 200, 280, 400 and 640 px (both
themes at 400), so wrapping and a second row of buttons are compared, not
reasoned about. That found a real gap: a button is never under 24 px
(.iconbtn's min size), which a narrow card's formula goes below.

tests/cardlayout.property.test.js, with fast-check: over generated widths,
texts, roles and buttons, nothing is dropped or added, no line passes the
padding, buttons never overlap and wrap only for want of room, the card is
no lower for more words, the SVG parses with everything escaped, and a name
it cannot draw is refused as such. Wrapping is monotone in the box and loses
no character. Writing it found that a title is broken anywhere
(overflow-wrap:anywhere), which the renderer did not do.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
With none, there is nothing after the words but the padding. (Found by
asking Chrome about a card with every button removed; the unit tests have
the case.)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
opentype.js did not apply every kerning pair or any ligature, and was up to
1.8 px off Chrome on a line at a small size (found by the differential
fuzz). HarfBuzz is Chrome's own shaper: across every line of the recorded
cards it is within 0.15 px, so the golden tolerance for a line's width
drops from 1.5 to 0.25.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A button of a few stars reads as a liability, not a credential (the README's
old pills had the same rule, at 50). STARS_MIN is the one place that says so,
so the site's cards, the embeds and the README's images agree. The recording
of Chrome's layout is made again without those buttons.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… sizes too near a step to call

Chrome's step in a line's rounded ascent is within about 0.003px of font
size of where the font's metrics put it, with no one rule that fits (the
differential fuzz found a card of 412px, whose text is 16.0992px, a pixel
short). nearStep says which sizes that is, for the fuzz to leave out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…roups (#346)

The card's CSS and markup changed, so the hash the recording is stamped with
no longer matched. The layouts it holds are identical: #346's rules are all
for what a name with more than one group adds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@nstarman
nstarman force-pushed the add/software-card-svgs branch from 7022d3d to dde3f08 Compare October 6, 2026 20:00
nstarman and others added 4 commits October 6, 2026 16:13
…s.test.js

Chrome shapes and measures a line at its font size taken down to a hundredth
of a px (10.656px is 10.65px), in a float, and rounds a half down in the
ascent and descent of the line box. Found by measuring Chrome, after the
differential fuzz found a line a hair too wide to fit that Chrome fits.

An earlier commit left a duplicate closing brace at the end of
tests/cards.test.js, so that file did not load and its tests were not running
(the totals were lower and nothing said why). They run again: 684.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Only the least a button is (24px) bounds it below; a one-digit star count
made a button narrower than the plain ones beside it. Found by the
differential fuzz.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The box is a grid of three columns with a .4em gap between; the slots with
nothing in them take no column but their gaps stay, so the row is 0.8em
narrower than the card's content. A third button that fitted ours went to a
second row in Chrome. Found by the differential fuzz. Also: the first SVG
render in the tests gets 30s, for a busy machine.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… item's strut

Chrome lays lengths out in 1/64 of a px, down. And a button sits in its list
item on its baseline, so the item is as high as the box and the item's own
strut (its font at the body's 1.55) need together: at some sizes the strut
reaches below the box and the item is taller than the button (26.594 for a
26.1875 button at 320px). Both found by the differential fuzz.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
nstarman and others added 5 commits October 6, 2026 16:37
…s built for JS does not

Found by the differential fuzz (a line of jTA… was 0.75px too wide), then by
measuring every pair of ASCII letters, digits and marks in Chrome against
HarfBuzz: 6241 pairs, three differ: jT, jV, jW.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Looks: only look:feature, which the lead preset names, is drawn, and a name is
accepted if it is a tier's preset at a set width, which replaces a 17-line
whitelist of what it may and may not say. The recording of Chrome's layout is
one case to a line with each icon's markup once: 52 lines, not 8,222, and
marked generated. Tests that restated others (the CSS proportions the hash
already guards, a property for names it refuses, a second render of the SVG)
are gone, and so are two duplicates in softwarecards.js and a flag in the
layout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A 17.9929px line of 18px content has no half-leading in Chrome, not -1 (found
by the differential fuzz at a card of 238px).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…n en dash, an ellipsis, a question mark

Found by the differential fuzz (a line broken after Ol- before 99), then by
trying each mark in a narrow box in Chrome.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…toward zero

A 26.9998px line of 25px content has a whole px above it in Chrome (found by
the differential fuzz at a card of 524px).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e product down to a 64th

Fitted to Chrome's line heights and offsets at 3000 font sizes (0 line-height
and 1 offset mismatches): 1.5 of 13.3344px is 19.984375, not 20.0016, and a
half-leading is halved in 64ths toward zero before it is floored. Replaces the
two rounding rules the differential fuzz had forced one case at a time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@nstarman
nstarman merged commit e27ff31 into main Oct 6, 2026
6 of 7 checks passed
@nstarman
nstarman deleted the add/software-card-svgs branch October 6, 2026 21:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants