From c6e40ba287145ce681cb9f19e3635b882e6c7c06 Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:37:59 +0200 Subject: [PATCH] Refuse a length, a weight and a font stack typed into what the build reads [#29] The row named for the token file decided colours and nothing else. The file carries seven groups that hold values, and the colours sit in three of them, so a type size, a corner radius, a ring width or a font stack written next to the thing it describes was a second definition of a published value that every route in this tree reported green. It is the harder half to see: a wrong colour looks wrong on the page, and a wrong length renders perfectly for as long as nobody compares it against the file. The operator now reads a shape per group rather than the hex forms alone. Digits touching a length unit are a length; the two font declarations are named by their property, because a stack is a list somebody shortens and a weight is three digits that look like nothing in particular. The bound is the shape of the literal rather than its meaning, which is the bound the version row already declares for itself, so the file stays the authority for the set. The fixture map holds one violation per row and cannot tell one arm of an operator from another, so the three new shapes would have been unproven under it while the row went on reporting ok. They carry a pair of their own instead, one fixture per shape and one line per thing the row has to walk past, and docs/near-miss.md says which rows that applies to rather than leaving the map to be read as proof of every branch behind a row. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- docs/near-miss.md | 16 +++++++ internal/invariant/invariant.go | 71 +++++++++++++++++++++++++--- internal/invariant/invariant_test.go | 56 +++++++++++++++++++++- 3 files changed, 134 insertions(+), 9 deletions(-) diff --git a/docs/near-miss.md b/docs/near-miss.md index 09c559f..2bc6d17 100644 --- a/docs/near-miss.md +++ b/docs/near-miss.md @@ -255,6 +255,22 @@ suite is a weaker record than a red check for the purpose this document serves, and it is a stronger one for keeping the pair true as the table changes, so both are named. +One bound on that, because it is the shape a pair goes stale in without anybody +editing it. The fixture map holds one violation per row, so what it proves is +that the row refused something, never which branch of the operator behind it did +the refusing. A row reading several shapes can lose one and stay green under it. +The row about where a token value is read from is the one that has several: it +reads a colour, a length, a font family and a font weight, and its entry in the +map is a colour. What holds the other three is a pair of its own, one fixture per +shape and one line per thing the row has to walk past: + + go test ./internal/invariant -run 'TestTheTokenRow' -count=1 + +The same goes for the two rows whose permitted values are tested separately, the +loopback addresses a test may bind and the version-shaped numbers a workflow may +carry. Where a row is named here with no such test beside it, one violation is +the whole of what it has been shown to refuse. + ## Reproducible build It refuses a source whose two builds do not produce the same bytes. diff --git a/internal/invariant/invariant.go b/internal/invariant/invariant.go index 5afca96..b79690a 100644 --- a/internal/invariant/invariant.go +++ b/internal/invariant/invariant.go @@ -156,6 +156,25 @@ var ( // colour. hexColour = regexp.MustCompile(`(?i)#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{4}|[0-9a-f]{3})\b`) fragmentReference = regexp.MustCompile(`(?is)\b(?:href|src)\s*=\s*"#[^"]*"`) + // A number carrying one of the units a length is written in on the web. + // The token file states its lengths as bare numbers of + // density-independent pixels and says so in its own how-to-read-this + // block, so what a page writes is that number with a unit stuck to it, + // and the unit is what tells a length in a stylesheet apart from a + // number in a sentence. The digits have to touch the unit: a prose + // sentence putting a space between a figure and a word is not a length, + // and a word ending in one of these letters carries no digits in front + // of it. + cssLength = regexp.MustCompile(`(?i)\b[0-9]+(?:\.[0-9]+)?(?:px|rem|em|ch|vh|vw|pt)\b`) + // The two declarations the file's font stacks and type weights are + // spelled as. The property rather than the value, because a stack is a + // list somebody shortens and a weight is three digits that look like + // nothing in particular, and neither is recognisable on its own the way + // a hex colour is. What precedes the name is part of the pattern for the + // reason the colour scheme property carries one: a hyphen in front of it + // is a different property. + fontFamilyProperty = regexp.MustCompile(`(?is)(^|[^-a-z])font-family\s*:`) + fontWeightProperty = regexp.MustCompile(`(?is)(^|[^-a-z])font-weight\s*:`) // A run of digits separated by dots, which is every version-shaped thing // in a line. The row compares each whole run against the one version // this repository holds rather than searching for that version inside a @@ -432,9 +451,9 @@ func Rules() []Rule { { ID: "design-tokens-live-in-exactly-one-file", Subject: BuildInputs, - Reason: "a colour typed into a template is a second definition of a value published somewhere else, and the day the published one moves the page goes on rendering the old one perfectly, so nobody sees it", - Refuses: "a colour written into what the build reads, in any of the hex forms the published file uses, outside a fragment reference", - decide: decideTypedColour, + Reason: "a value typed into a template is a second definition of a value published somewhere else, and the day the published one moves the page goes on rendering the old one perfectly, so nobody sees it; the file carries lengths, weights and font stacks beside the colours, and a wrong length is the harder one to see because a wrong colour at least looks wrong", + Refuses: "a colour, a length, a font family or a font weight written into what the build reads, outside a fragment reference", + decide: decideTypedTokenValue, }, { ID: "version-lives-in-exactly-one-file", @@ -776,7 +795,30 @@ func decideSecondVersion(body []byte) []string { return details } -func decideTypedColour(body []byte) []string { +// decideTypedTokenValue refuses a value the token file is the authority for, +// written into something the build reads. +// +// It reads a shape per group of values the file carries rather than the colours +// alone. The row's name has always been about tokens and what it decided was +// about colour, and the gap is easy to miss for the reason it exists: a colour +// typed next to the thing it describes is visible to anybody who opens the page +// afterwards with the published file beside it, and a type size, a corner radius +// or a font stack typed the same way is not. The page that demonstrates the +// design system is where all four get written next to what they describe, which +// is why the shapes are here before that page rather than after it. +// +// The bound is the shape of the literal rather than its meaning, which is the +// bound the version row declares for itself and for the same reason. A hex run +// is a colour, digits touching a unit are a length, and the two font +// declarations are named by their property because a stack and a three-digit +// weight are not recognisable on their own. A value spelled any other way walks +// through, so the file stays the authority for the set rather than this row. +// +// What it deliberately does not read is a fragment reference. An address ending +// in a name of hex length is a link to a place on the page and not a colour, and +// a row that could not tell the two apart would refuse the link the frame is +// built around. +func decideTypedTokenValue(body []byte) []string { var details []string for i, line := range strings.Split(string(body), "\n") { text := fragmentReference.ReplaceAllString(line, "") @@ -785,6 +827,21 @@ func decideTypedColour(body []byte) []string { "line %d writes the colour %s, and %s is the one file a colour is read from", i+1, m, tokens.File)) } + for _, m := range cssLength.FindAllString(text, -1) { + details = append(details, fmt.Sprintf( + "line %d writes the length %s, and %s is the one file a length is read from", + i+1, m, tokens.File)) + } + if fontFamilyProperty.MatchString(text) { + details = append(details, fmt.Sprintf( + "line %d declares a font family, and %s is the one file a font stack is read from", + i+1, tokens.File)) + } + if fontWeightProperty.MatchString(text) { + details = append(details, fmt.Sprintf( + "line %d declares a font weight, and %s is the one file a weight is read from", + i+1, tokens.File)) + } } return details } @@ -950,8 +1007,8 @@ func gather(root string) (map[string][]file, error) { return nil, err } - // The rule about where a colour is read from is a rule about there being - // exactly one such file, and a run that decided it against a tree + // The rule about where a token value is read from is a rule about there + // being exactly one such file, and a run that decided it against a tree // carrying none would report that no second definition was found in a // tree with no first one. So the copy being absent is refused here, by // name, rather than passing as a row with nothing to compare. @@ -959,7 +1016,7 @@ func gather(root string) (map[string][]file, error) { return nil, fmt.Errorf("%s is not tracked in this tree, so the build wrote no %s and the row about an expired reporting route has nothing to read", security.File, security.Path) } if len(tracked.tokenCopies) == 0 { - return nil, fmt.Errorf("%s is not tracked in this tree, and the row about where a colour is read from is a row about there being exactly one such file", tokens.File) + return nil, fmt.Errorf("%s is not tracked in this tree, and the row about where a token value is read from is a row about there being exactly one such file", tokens.File) } return map[string][]file{ diff --git a/internal/invariant/invariant_test.go b/internal/invariant/invariant_test.go index 4baffc3..f2b83a9 100644 --- a/internal/invariant/invariant_test.go +++ b/internal/invariant/invariant_test.go @@ -359,6 +359,58 @@ func TestTheReleaseVersionRowTellsAnotherVersionApart(t *testing.T) { } } +// Every group of values the token file carries, in the shape it reaches a build +// input in. The row is named for the file rather than for one of its groups, so +// a group it walks past is a value somebody may type next to the thing it +// describes with nothing to stop them, and the page that describes all four is +// the page where that happens. +// +// Each fixture is one line of a stylesheet somebody writes while looking at the +// published value, which is the whole of this mistake: it renders correctly, it +// keeps rendering correctly after the published value moves, and the only party +// who finds out is a client built from the file. +func TestTheTokenRowRefusesEveryShapeTheFileCarries(t *testing.T) { + for name, fixture := range map[string]struct{ line, names string }{ + "a colour out of the surface group": {` `, "#121216"}, + "a type size": {` `, "56px"}, + "a corner radius": {` `, "12px"}, + "the width a column stops at": {` `, "1080px"}, + "a font stack": {` `, "font stack"}, + "a type weight": {` `, "weight"}, + } { + got := decideTypedTokenValue([]byte(fixture.line + "\n")) + if len(got) == 0 { + t.Errorf("%s was not refused", name) + continue + } + if !strings.Contains(got[0], fixture.names) { + t.Errorf("%s was refused without naming %q: %v", name, fixture.names, got) + } + if !strings.Contains(got[0], tokens.File) { + t.Errorf("%s was refused without naming where the value is read from: %v", name, got) + } + } +} + +// What the row leaves alone, and every line here is one this tree carries or +// would carry. A row that refused one of them reds a clean build input for +// something that is not a token, which is how a row gets taken back out. +func TestTheTokenRowTellsAValueApartFromASentence(t *testing.T) { + for name, line := range map[string]string{ + "the link the frame is built around": ` Skip to the content`, + "a figure with its unit spelled out": "The published page is read from 35 cm and a television from 3 m.", + "a word that ends in a unit": "Nothing here is a problem anybody has to solve.", + "a number in a sentence": "Twelve plugins, and 1080 of the lines below are prose.", + "a date": "Run 2026-08-13 against the default branch.", + "a property that only ends in one": ` `, + "a value taken from the token itself": ` `, + } { + if got := decideTypedTokenValue([]byte(line + "\n")); len(got) != 0 { + t.Errorf("the row refused %s: %v", name, got) + } + } +} + // The copy at the end of a sentence, which is how a document actually writes a // version, and the refusal has to say where the version belongs or the next // person deletes the sentence instead of reading it from the one file. @@ -1196,7 +1248,7 @@ func TestTheColourRowRefusesATypedColourAndLeavesAFragmentAlone(t *testing.T) { "a colour in the prose the build reads": `The accent is #5B9CFF on a dark ground.`, } for name, line := range refused { - got := decideTypedColour([]byte(line)) + got := decideTypedTokenValue([]byte(line)) if len(got) != 1 { t.Errorf("the colour row did not refuse %s: %v", name, got) continue @@ -1213,7 +1265,7 @@ func TestTheColourRowRefusesATypedColourAndLeavesAFragmentAlone(t *testing.T) { "a run of digits that is five": `

Case #12345 is closed.

`, } for name, line := range spared { - if got := decideTypedColour([]byte(line)); len(got) != 0 { + if got := decideTypedTokenValue([]byte(line)); len(got) != 0 { t.Errorf("the colour row refused %s: %v", name, got) } }