Part of #337. Found while landing #358 (PR #409), which closed the four hand-rolled section headings. The kicker layer is left, and it is a different fault — which is why it is here rather than folded into #358 or #380.
The ladder letters by rung. The design letters by component.
_Rung (lib/shared/theme/app_text.dart) bakes one tracking into each step — label and micro both at 0.14em — so AppText.label() can only ever letter at 0.14em. The design letters seven different values at those same two rungs, all from prototype/index.html:
| Design class |
Tracking |
Rung |
Face |
.smallcaps |
0.14em |
label |
Plex Sans 500 |
.challenge-kicker |
0.14em |
micro |
Mono 500 |
.lesson-row .meta |
0.08em |
label |
— |
.bag-opt-s |
0.08em |
label |
— |
.challenge-pill |
0.08em |
micro |
Mono 500 |
.cheer-points |
0.1em |
label |
Mono 500 |
.seq-hint |
0.12em |
micro |
— |
.collect-card .cc-sub |
0.16em |
micro |
— |
| tab label |
0.18em |
micro |
already OffTokens.tabLabelTracking |
.tap-cue |
0.24em |
label |
already OffTokens.tapCueTracking |
AppText already separates face from size, for exactly this reason — one rung, more than one face. Tracking needs the same treatment and did not get it.
(That table is index.html, the shipped design. Across all eighteen prototype/*.html the vocabulary is wider still — 0.02 through 0.24em — but the extra values are the design documents' own page chrome rather than app components.)
What that leaves in the app
Fourteen call sites name their own tracking in logical pixels — a private const per file, which satisfies the magic-number lint without putting the value on the ladder. Every one reads labelSmall, labelMedium or AppText.label — all of which resolve to the label rung at 11px — so the px values convert to em directly:
| px |
em at 11px |
Sites |
| 0.6 |
0.055 |
challenge_log_sheet:10, challenge_stat_row:10, challenge_suggestion:11, module_challenge_section:13, card_stamp_section:12, active_challenge_card:16, today_card_widget:33 |
| 0.8 |
0.073 |
mini_games_catalog_widget:121, mini_game_gate_sheet:71 |
| 1.2 |
0.109 |
keep_sharp_card_body:33, card_shell:26 |
| 1.4 |
0.127 |
multi_choice_list:15 |
| 1.6 |
0.145 |
practical_card_view:18, card_takeaway:7 |
Most are a hand-rounding of a real design value: 0.8px ≈ 0.08em, 1.2px ≈ 0.1em, 1.4px ≈ 0.12em, 1.6px ≈ 0.14em — the smallcaps rule itself, arrived at by eye.
A fifteenth, grinder_dial:85, already writes 0.22 * grinderUnitSize — em-shaped, just not on the ladder. It folds into the axis for free.
One case is already solved, and is the worked example. The loading screen's 2.64px wordmark was on this list until #413: it is now OffTokens.tapCueTracking (0.24em, cited to index.html:1111), read by a shared TapCue widget.
The 0.6px group: one component, one document
Seven sites, six of them the Coffee Challenge kicker — 'COFFEE CHALLENGE', 'COFFEE CHALLENGES', 'MODULE COFFEE CHALLENGE', 'COFFEE CHALLENGE UNLOCKED' — plus the Today card's "Today's lesson" eyebrow. At 0.6px they sit between a third and a half of the design's value, so this is not a rounding: it is markedly under-tracked.
An earlier reading of this ticket held that index.html and challenge.html disagreed about the kicker — 0.14em against 0.16em — and that picking between them was a ruling. They do not disagree, because they are not describing the same element.
challenge.html never restyles the app. It embeds it: challenge.html:176 is <iframe src="index.html?screen=${slug}"> inside a 300px .phone-shell, so every app pixel that document shows is index.html rendering itself, kicker included.
Its .smallcaps at 0.16em has exactly one use, challenge.html:83:
<div class="smallcaps">BREWPATH · COFFEE CHALLENGE WALKTHROUGH</div>
<h1>One coffee challenge, start to stamp.</h1>
That is the walkthrough's own breadcrumb, in a .head block whose siblings are .page { padding: 56px 48px 96px }, max-width: 1100px, a 42px Fraunces <h1> and a "← Back to all screens" link — a desktop spec page, not a 390px phone. It is the same category this ticket already excluded when it set 0.06em aside as "the design documents' own page chrome rather than app components"; the filter simply was not applied here.
So .challenge-kicker at index.html:502 is the only source that styles this component, and docs/README.md rule 2 gives prototype source the fact. The seven sites take 0.14em. No ADR, no precedence appeal — there is nothing to rule between.
(0.16em stays in the vocabulary for the component that actually wears it: .collect-card .cc-sub, index.html:712, on the micro rung.)
The ruling: the ladder gets a tracking axis
_Rung gains a tracking axis beside its face axis, so a call site asks for a design value — AppText.label(tracking: …) — rather than naming pixels. The values are the design's own: 0.08 / 0.1 / 0.12 / 0.14 / 0.16em, plus 0.22em for the grinder dial.
The cost, stated: this widens the API the "cannot go off-ladder" guarantee rests on. That was accepted over the alternative — one OffToken per value — because the register already carries three tracking entries (collectionCountTracking, tabLabelTracking, tapCueTracking) and this ticket alone would take it to eight, at which point the register is a second type system beside the first. Exceptions stay exceptions: OffTokens keeps only the tab label (0.18em) and tap cue (0.24em) trackings, which are page chrome the ladder should not learn.
Acceptance criteria
What shipped, where it departed
#463. Two departures from the ruling above, both deliberate:
AppTracking carries three values, not five. Only a value something in lib/ renders is on the axis. 0.16em earned its place mid-branch: the dictionary parity work (#398) merged a StatusChipMini label at a private 1.52, which is 0.16em at the micro rung exactly (dictionary.jsx:124), and the guard caught it on the merge commit. .collect-card .cc-sub is the second component that wants it, and #434 takes it when the collectible tile's sub-line is built. Only 0.1em (.cheer-points) is still out, because the app has no cheer burst to draw.
The grinder dial is exempt rather than folded in. The design draws the dial — lesson.jsx:776 — and sets its readout fontSize="10" letterSpacing="0.22em" (:821), which is grinderUnitSize and 0.22 * grinderUnitSize exactly. It stays off the axis because the design writes that as an attribute on a drawing rather than a rule on a rung, and the widget scales from its own canvas to whatever width the card gives it, so a rung-derived value would be multiplied by that fit.
(An earlier revision of this section claimed the 0.22em had no design source. That was a bad search — prototype/*.html only, where an SVG in lesson.jsx cannot be found. The value was always cited correctly.)
Not in scope
Weight — #380, now closed, touched many of the same copyWith calls. Nothing to rebase against.
Part of #337. Found while landing #358 (PR #409), which closed the four hand-rolled section headings. The kicker layer is left, and it is a different fault — which is why it is here rather than folded into #358 or #380.
The ladder letters by rung. The design letters by component.
_Rung(lib/shared/theme/app_text.dart) bakes one tracking into each step —labelandmicroboth at 0.14em — soAppText.label()can only ever letter at 0.14em. The design letters seven different values at those same two rungs, all fromprototype/index.html:.smallcaps.challenge-kicker.lesson-row .meta.bag-opt-s.challenge-pill.cheer-points.seq-hint.collect-card .cc-subOffTokens.tabLabelTracking.tap-cueOffTokens.tapCueTrackingAppTextalready separates face from size, for exactly this reason — one rung, more than one face. Tracking needs the same treatment and did not get it.(That table is
index.html, the shipped design. Across all eighteenprototype/*.htmlthe vocabulary is wider still — 0.02 through 0.24em — but the extra values are the design documents' own page chrome rather than app components.)What that leaves in the app
Fourteen call sites name their own tracking in logical pixels — a private
constper file, which satisfies the magic-number lint without putting the value on the ladder. Every one readslabelSmall,labelMediumorAppText.label— all of which resolve to the label rung at 11px — so the px values convert to em directly:challenge_log_sheet:10,challenge_stat_row:10,challenge_suggestion:11,module_challenge_section:13,card_stamp_section:12,active_challenge_card:16,today_card_widget:33mini_games_catalog_widget:121,mini_game_gate_sheet:71keep_sharp_card_body:33,card_shell:26multi_choice_list:15practical_card_view:18,card_takeaway:7Most are a hand-rounding of a real design value: 0.8px ≈ 0.08em, 1.2px ≈ 0.1em, 1.4px ≈ 0.12em, 1.6px ≈ 0.14em — the smallcaps rule itself, arrived at by eye.
A fifteenth,
grinder_dial:85, already writes0.22 * grinderUnitSize— em-shaped, just not on the ladder. It folds into the axis for free.One case is already solved, and is the worked example. The loading screen's 2.64px wordmark was on this list until #413: it is now
OffTokens.tapCueTracking(0.24em, cited toindex.html:1111), read by a sharedTapCuewidget.The 0.6px group: one component, one document
Seven sites, six of them the Coffee Challenge kicker —
'COFFEE CHALLENGE','COFFEE CHALLENGES','MODULE COFFEE CHALLENGE','COFFEE CHALLENGE UNLOCKED'— plus the Today card's"Today's lesson"eyebrow. At 0.6px they sit between a third and a half of the design's value, so this is not a rounding: it is markedly under-tracked.An earlier reading of this ticket held that
index.htmlandchallenge.htmldisagreed about the kicker — 0.14em against 0.16em — and that picking between them was a ruling. They do not disagree, because they are not describing the same element.challenge.htmlnever restyles the app. It embeds it:challenge.html:176is<iframe src="index.html?screen=${slug}">inside a 300px.phone-shell, so every app pixel that document shows isindex.htmlrendering itself, kicker included.Its
.smallcapsat 0.16em has exactly one use,challenge.html:83:That is the walkthrough's own breadcrumb, in a
.headblock whose siblings are.page { padding: 56px 48px 96px },max-width: 1100px, a 42px Fraunces<h1>and a "← Back to all screens" link — a desktop spec page, not a 390px phone. It is the same category this ticket already excluded when it set 0.06em aside as "the design documents' own page chrome rather than app components"; the filter simply was not applied here.So
.challenge-kickeratindex.html:502is the only source that styles this component, anddocs/README.mdrule 2 gives prototype source the fact. The seven sites take 0.14em. No ADR, no precedence appeal — there is nothing to rule between.(0.16em stays in the vocabulary for the component that actually wears it:
.collect-card .cc-sub,index.html:712, on the micro rung.)The ruling: the ladder gets a tracking axis
_Runggains a tracking axis beside its face axis, so a call site asks for a design value —AppText.label(tracking: …)— rather than naming pixels. The values are the design's own: 0.08 / 0.1 / 0.12 / 0.14 / 0.16em, plus 0.22em for the grinder dial.The cost, stated: this widens the API the "cannot go off-ladder" guarantee rests on. That was accepted over the alternative — one
OffTokenper value — because the register already carries three tracking entries (collectionCountTracking,tabLabelTracking,tapCueTracking) and this ticket alone would take it to eight, at which point the register is a second type system beside the first. Exceptions stay exceptions:OffTokenskeeps only the tab label (0.18em) and tap cue (0.24em) trackings, which are page chrome the ladder should not learn.Acceptance criteria
lib/names its own tracking in pixels —tracking_axis_test.dartsweeps every hand-written source underlib/forletterSpacing:andletterSpacingDelta:, with four files exempted and each carrying its reason. (Two entries survive inOffTokens, not three: the ruling below kept the tab label and the tap cue, andcollectionCountTrackingmoved onto the axis.)app_text.dartcites to.smallcaps(index.html:229) and.challenge-kicker(:502) at both the rung and the axis.font_weight_call_sites_test.dart(53 call sites request weights the design forbids and the bundle lacks #380's guard), sharingtest/support/dart_sources.dart. Verified by injectingletterSpacing: 0.6intotoday_card_widget.dartand watching it fail with the file named.flutter analyze,dart format,dart run dart_code_linter:metrics analyze libandflutter test(1943) all clean; changelog bullet under Unreleased → Changed.What shipped, where it departed
#463. Two departures from the ruling above, both deliberate:
AppTrackingcarries three values, not five. Only a value something inlib/renders is on the axis. 0.16em earned its place mid-branch: the dictionary parity work (#398) merged aStatusChipMinilabel at a private1.52, which is 0.16em at the micro rung exactly (dictionary.jsx:124), and the guard caught it on the merge commit..collect-card .cc-subis the second component that wants it, and #434 takes it when the collectible tile's sub-line is built. Only 0.1em (.cheer-points) is still out, because the app has no cheer burst to draw.The grinder dial is exempt rather than folded in. The design draws the dial —
lesson.jsx:776— and sets its readoutfontSize="10" letterSpacing="0.22em"(:821), which isgrinderUnitSizeand0.22 * grinderUnitSizeexactly. It stays off the axis because the design writes that as an attribute on a drawing rather than a rule on a rung, and the widget scales from its own canvas to whatever width the card gives it, so a rung-derived value would be multiplied by that fit.(An earlier revision of this section claimed the 0.22em had no design source. That was a bad search —
prototype/*.htmlonly, where an SVG inlesson.jsxcannot be found. The value was always cited correctly.)Not in scope
Weight — #380, now closed, touched many of the same
copyWithcalls. Nothing to rebase against.