Skip to content

The ladder letters by rung; the design letters by component #410

Description

@maximsan

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

  • No call site in lib/ names its own tracking in pixels — tracking_axis_test.dart sweeps every hand-written source under lib/ for letterSpacing: and letterSpacingDelta:, with four files exempted and each carrying its reason. (Two entries survive in OffTokens, not three: the ruling below kept the tab label and the tap cue, and collectionCountTracking moved onto the axis.)
  • The seven 0.6px sites letter at 0.14em — they now take the label rung's own tracking, which app_text.dart cites to .smallcaps (index.html:229) and .challenge-kicker (:502) at both the rung and the axis.
  • A test fails when a new call site letters its own — built in the shape of font_weight_call_sites_test.dart (53 call sites request weights the design forbids and the bundle lacks #380's guard), sharing test/support/dart_sources.dart. Verified by injecting letterSpacing: 0.6 into today_card_widget.dart and watching it fail with the file named.
  • flutter analyze, dart format, dart run dart_code_linter:metrics analyze lib and flutter test (1943) all clean; changelog bullet under Unreleased → Changed.

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    design-parityCloses a finding in the app-vs-prototype divergence registerenhancementNew feature or requestready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions