refactor(ui): name one text role at every renderer call site - #1893

Merged
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet
Aug 2, 2026
Merged

refactor(ui): name one text role at every renderer call site#1893
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

A text style is one indivisible role — size, leading, weight and family chosen together — so a call site names a role and declares nothing else about type. #1857 converged the sizes and #1878 the leadings; both removed the divergence of the day without removing the ability to diverge again, because the four properties stayed four separate choices per site. This makes them one choice.

The vehicle is the font: shorthand, which #1878 banned outright. That ban is inverted, not lifted: the shorthand is the only mechanism CSS has that makes the four inseparable, and its reset semantics — it resets weight, style, variant and family — turn from hazard into guarantee once the only legal value is a role token. The alternative the issue names, Astryx's <Text>/<Heading> components, cannot carry this surface: Text.d.ts types as as 'span' | 'p' | 'div' | 'label' | 'h1' | 'h2' | 'h3', which terminates 142 of the 348 selectors (strong, small, li, dt, code, pre, h4, th, time), and it exposes size/weight override props, so the triplet would not actually be indivisible.

Roles are Astryx's 14, composed from its generated atoms only. There are no product roles: the 42 blocks whose measured values had no Astryx equivalent were hand-rolled picks the ladder never offered — every Astryx heading is 600, yet 10 blocks used 700; the 12px tier offers 400 and 600, yet 11 used 500.

Two things had to move out of the call site for this to work. Family is an inheritance axis (--maka-font-family), because the shorthand's family slot is mandatory. And the role table is anchored on :rootand:where(code, kbd, samp, pre), because var() inside a custom property is substituted where the property is declared and the resolved string is what inherits — a table composed only on :root freezes the sans stack into every role. That is not a code-reading conclusion: measured, .maka-tool-diff-body, a real <pre>, read --maka-font-family: "Geist Mono Variable" and computed font-family: -apple-system. With the second anchor every code element gets the mono role for free, which is why 8 render sites moved to <code> markup instead of gaining a product role.

Six guards collapse into one. A ban on product --leading-* tiers, on literal ratios, on !important sizes and leadings, on em/rem multipliers, and the size↔leading pairing check are each a way for the four to come apart — none is expressible once a call site cannot write a font longhand. findLeadingPairingOffenders is deleted rather than kept beside the new check; two authorities on one question means the weaker one is the one people read. Six product tokens (--font-size-heading/stat/ui/caption, --font-sans, --font-default) reached zero consumers and are deleted with them.

Closes#1875.

Verification

Measured against 105ec3e87, not the branch point, since #1876 moved the session rail under this work.

Computed-style regression net. 16,076 text elements across 86 surfaces (15 e2e windows + every settings page and module hub via a navigation walk + 62 Storybook stories), four properties each, captured from a pristine-main worktree and re-captured here. 118 elements differ, every one a convergence this PR intends:

nchangewhat
46weight 600→400page titles, now on Astryx display roles (size and leading unchanged)
24weight 500→600prompt-rail titles → heading-5
19weight 500→600catalog badges, palette group headings, graph section label
11weight 700→600<strong> section titles — Astryx has no 700
9family mono→sansprose and numeric alignment where mono was decoration, not code
4weight 500→400hand-rolled 12px medium, emphasis already carried by color or pill chrome
5weight 600→400button, tab and count labels a container was leaking weight into

Defects the measurement found that reading the code did not. Each is recorded in the commit that fixes it: .maka-hero h1 was assigned from a measured value that described a different rule masking it; per-rule redundancy is not compositional (deleting 79 individually-redundant rules drifted 85 elements 12px→14px); custom-property substitution is not lazy (the single-anchor role table above); --maka-text-display-1 was referenced by the hero and defined nowhere, so the whole declaration was invalid and the element silently inherited; .maka-session-rename-input declared font: inheritafter its new role, leaving the role line dead. Also fixed a latent off-grid bug: .maka-tool-name rendered 16px against an inherited 1.6667 ratio = 26.67px.

What the net did not cover, and what review found there. The 118 are every element the walk rendered; 188 of the 371 role-declaring rules were never on a rendered surface, and four defects were living in that gap. Two were visible: .plan-proposal-kicker had moved from the 12px tier to the 14px one, because a grouped rule gave it a role and its own rule gave it another — the cross-rule form of the dead-role bug, and the reason the contract now has an arm for it. Two were resets the net does not sample: font: clears font-variant-numeric, and the stat tiles' tabular-nums sat in a rule the role's rule outranked. Six more rules in the gap drop --font-mono for the sans stack. Those six are the same call as the nine measured ones — mono was decoration, not code — and unlike the four above the cascade settles it without a render: none of the six is a code/kbd/samp/pre element, so the family slot resolves to the body stack and the other three axes are unchanged. Fifteen sites in total, not nine.

Mutation verification — 19 mutations, each backed up and restored with cp (no git checkout/restore/stash). The first eight cover the guard as originally written: a font longhand at a call site, a hand-composed shorthand, an undefined role, a role built from a foreign atom, a single-anchor table, a third family authority, an Astryx atom rebound to a literal, and a second font: declaration after a role. Review pointed out that all eight only re-trigger assertions already written, and none touches a boundary this PR introduced — so eleven more do: a longhand, a literal ratio and a hand-composed shorthand insidemaka-tokens.css; a role token rebound to a value; the family axis rebound to a literal stack; an Astryx atom rebound to calc(); a longhand inside a rule-nested @media; an upper-cased property name; a second role rule for one selector; the role table anchored on :root alone; and the family longhand moved off the code anchor. All nineteen fire; the tree restores green.

Visual evidence. Live-app captures, main on the left and this branch on the right, composed with ImageMagick (+append) — no generative model touched the pixels.

Module hub — page title 25px 600→400, skill ids stay mono as <code> markupimage
Settings · 通用 — page title 22px 600→400, size and leading unchangedimage
Settings · 记忆 — strong 700→600, filename stays mono while the prose beside it drops to sans, path is now <code>image
Settings · 权限与能力 — capability ids as <code>, section titles on heading rolesimage

Checks run:lint ✅ · format:check ✅ · typecheck ✅ · @maka/desktop test 1339 ✅ · @maka/ui test 239 ✅ · desktop e2e 73/73 ✅ · check-dead-css --check ✅ · astryx:theme --check ✅.

Not run: no per-provider or new E2E fixture was added — the three new invariants that need a live document are steps inside the existing e2e/type-scale.spec.ts probe.

Review focus

Page titles lose their weight, not their size..settingsPageHeader h2, .maka-module-main-header h2 and .maka-plan-heading h2 were display sizes (22px/25px) carrying a hand-rolled semibold. Astryx's display roles are weight 400 by design. Matching on weight instead would pick heading-1 and collapse 46 page titles to 20px, level with the section headings under them — so this trades weight to keep hierarchy. It is the most visible change in the PR and the one most worth disagreeing with, and it is a deliberate override of Astryx's own guidance, which reserves display for hero, marketing and data-callout text and says not to use it for document headings. Recorded at the call site rather than only here.

The component route is closed by the vendor's type union, not just by as.Text's as prop accepts only span | p | div | label | h1 | h2 | h3, which strands the strong / small / li / dt / code / pre selectors — but the harder blocker is BuiltinTextType, which has no heading roles at all: 99 call sites name heading-1..5 and <Text> cannot express any of them. Heading can, and unlike Text it exposes no size / weight overrides — the divisibility argument applies to Text only. For page headings Heading remains the more idiomatic seam; this PR does not move markup that far.

Redundant role declarations are kept, deliberately. The joint-redundancy probe says 79 of 371 role-declaring rules change nothing when removed — but only 183 of the 371 are exercised by any surface the harness reaches, so 51% of the vocabulary is unverifiable, and this PR has already been bitten twice by "the measured value described a different rule than the one I was editing". Deleting on that evidence would be asserting redundancy about rules the measurement never saw. A no-op font: var(--maka-text-body) costs nothing structurally: the point of the role token is that a call site can no longer diverge into four values.

A text style is one role, not four independent picks. #1857 converged the
sizes and #1878 the leadings; both removed the divergence of the day without
removing the ability to diverge again, because size, leading, weight and
family stayed four separate choices at each of 348 call sites.
The `font:` shorthand is the only mechanism CSS has that makes them
inseparable. Its reset semantics are why it fits rather than a hazard to work
around: it resets weight, style, variant and family, and the role token
supplies them, so no site can move one and leave the others.
Composed from Astryx's generated atoms only, so the ladder stays a pure
function of typography.scale. Family is not part of the call-site triplet:
`font:` requires a family slot that would override the one monospace
authority in this file, so the slot reads --maka-font-family, rebound on
:where(code, kbd, samp, pre) and inherited from there.
Measured behavior-neutral: 12262 text elements across 86 surfaces (24 live
app states, 62 Storybook stories) compared before and after, 0 changed.
381 declaration blocks across 44 stylesheets now declare one `font:
var(--maka-text-<role>)` and nothing else about type. Exactly one raw font
longhand survives in the renderer — the monospace stack in maka-tokens.css
that owns the family axis.
Roles are assigned from MEASURED computed styles, not from declared values,
so the ~30 selectors that took their weight from the UA or an ancestor keep
it: 187 of the 385 blocks render in at least one of the 100 surfaces the
harness reaches and were classified from what the browser reported there.
Three things measurement caught that reading the CSS did not:
- `.maka-hero h1` declares 28px but every probed instance is also
`.maka-hero-empty-chat h1` at 20px, so the measured role described the
other rule. Taking it would have shrunk the onboarding hero, the one
surface that keeps the large scale and that no fixture renders. A measured
role is now rejected when it disagrees with the block's own declared size.
- Per-rule redundancy is not compositional: two rules that both pin 12px on
one element are each individually removable and jointly load-bearing.
Deleting on that basis drifted 85 elements 12px→14px, so this commit only
migrates; deletion is decided against the final tree.
- A role composed on :root freezes its family slot there — custom property
var() references are substituted where DECLARED, and the resolved string is
what inherits. A real <pre> whose --maka-font-family read Geist Mono still
computed -apple-system. The role table is anchored on `:root, :where(code,
kbd, samp, pre)` so code elements compose their own mono-family roles.
`.maka-tool-name` also loses a latent defect: measured 16px against a 1.6667
ratio inherited from the supporting role, 26.67px, off the 4px grid.
The 348-call-site migration was assigned from measured computed values, so
the roles it picked were the ones the browser reported — which is exactly
why re-measuring the migrated tree found the places where the measured
value described something other than the call site's intent. Every change
below is a diff bucket from that re-measurement, named and closed.
Markup, not a call-site family. A slug, an id, a task key, a capability id,
a memory path, a tool name, a summarized error — these are code, and the
role table composes the code family for the code element group, so saying
so in the markup is also what makes them render monospaced. 8 render sites
move to <code>; `SettingRow`'s `mono` prop now picks the element.
UA bold is not a role. 17 blocks selected strong/h*/th and declared no
weight of their own, so they took the UA's 700 by accident. Naming a body
or supporting role resets that to 400 — measured, 21 elements went 700→400
before this. They are section titles at 14px and 12px, which is heading-4
and heading-5: same size, same leading, weight 700→600 (Astryx has no 700).
Page titles keep their size, not their weight. `.settingsPageHeader h2`,
`.maka-module-main-header h2` and `.maka-plan-heading h2` were display
sizes carrying a hand-rolled semibold. Matching on weight picked heading-1
and collapsed 66 page titles to 20px, level with the section headings
under them. Display roles keep 25px/22px and take Astryx's display weight
of 400.
A family-only rule hides its size. `.maka-mcp-*` set only font-family, and
its three selectors inherited two different sizes — 14px in the card body,
12px in the description. One role could not express that, so the rule
splits: code role for the 14px pair, supporting for the description.
`.maka-nav-kbd` opts back out of mono. It read `font-family: inherit`
before; as a <kbd> it now composes the mono role table. It names the family
axis instead — one declaration, and the role stays whole.
Remaining measured delta is 206 of 16,063 elements across 86 surfaces,
every bucket a convergence this PR intends: 66 page titles 600→400, 32
badges and 28 rail prompts 500→600, 21 strong 700→600, 30 family changes
where mono was decoration rather than code, and 13 hand-rolled 12px/500
sites landing on supporting, their emphasis already carried by color or
pill chrome.
Size, leading, weight and family were four independent properties, so each
needed its own guard: a ban on product `--leading-*` tiers, on literal
ratios, on `!important` sizes and leadings, on em/rem multipliers, and a
pairing check that resolved a block's size and leading through the
generated theme to prove they named the same tier. Every one of those
describes a way for the four to come apart.
None of them is expressible now. A call site that cannot write a font
longhand cannot write a literal ratio, an em multiplier, a forced size, or
a mismatched pair — so they collapse into one assertion: outside the role
table, every text style is `font: var(--maka-text-<role>)` and no
stylesheet declares font-size, line-height, font-weight or font-family.
`findLeadingPairingOffenders` is deleted rather than kept alongside it;
two authorities on one question means the weaker one is the one people
read.
`findFontShorthandOffenders` inverts instead of tightening. The shorthand
was banned because it hides bare weights and leadings; it is now the only
legal form, because it is the one CSS mechanism that makes the four
inseparable. The replacement also checks that every role NAMED is a role
DEFINED — a var() that resolves to nothing makes the whole declaration
invalid at computed-value time, so the element silently keeps what it
inherits. That arm found a live defect: `--maka-text-display-1` was
referenced by the hero and defined nowhere, and every other check in the
file was green.
Two things text cannot prove keep their own guards. The role table's shape
is asserted in the contract (each role composed only from its own Astryx
atoms, declared on both anchors, family axis pinned once per anchor). What
those resolve to in a document is measured in e2e: a <pre> and a <div>
naming the same role must agree on size, weight and leading and differ
only in family — the substitution-timing fact that made the first, single
-anchor role table wrong while reading perfectly in CSS.
Seven mutations verified, each restored with cp: a longhand at a call
site, a hand-composed shorthand, an undefined role, a role built from a
foreign atom, a single-anchor table, a third family authority, and an
Astryx atom rebound to a literal. Desktop 1323 pass, ui 239 pass.
Six product tokens reached zero consumers when call sites stopped naming
sizes and families, and check-dead-css found all six: --font-size-heading
/ stat / ui / caption, --font-sans, --font-default. They existed so a call
site could say "this is the caption size" or "this is the mono stack" —
a role says both, together with the weight and the leading, so a second
set of names for the same rungs is exactly the second authority the
convergence exists to remove. The role table now reads --font-family-code
directly, and nothing in the renderer aliases an Astryx type name.
The contract that pinned those aliases is replaced by the stronger thing
they were approximating: there is no product name for a size or a family
at all. --font-weight-bold survives as the top rung of the series the
generated theme reads, in RESERVED_SCALE_TOKENS with that reason.
Also caught rebasing onto #1876: `.maka-session-rename-input` declared
`font: inherit` before its longhands, so migrating the longhands to a role
left the reset winning and the role line dead. `findTextRoleOffenders`
now counts font declarations before reading the last one — a block that
declares a role and then overrides it reads as legal to any check that
asks only what the browser uses. Eight mutations verified with cp.
Measured against 105ec3e rather than the branch point, since #1876 moved
the session rail under this work: 118 of 16,076 text elements across 86
surfaces change, every one a bucket this PR intends.
Review found four sites where naming a role changed more than the role.
The `font:` shorthand resets `font-variant-*`, `font-style` and the feature
settings along with the four axes it names. Within a rule every site already
declared those after the role; across rules two did not. `.maka-stat-tile-value`
held the tabular figures the tiles exist to align while the role sat in the
`-outline` / `-filled` modifiers, so the role won and the digits stopped
aligning; reordering makes the base rule the later one. `code.settingsReadOnlyValue`
restates the declaration because it is the higher-specificity selector.
A grouped rule that hands a selector a role, followed by that selector's own
rule handing it another, leaves the first dead — and a later retune of the
group moves every other member while this site silently stays put.
`.plan-proposal-kicker` had already drifted that way, from the 12px tier to
the 14px one; the 12px tier offers 400 and 600, and an eyebrow over a title
takes the 600. `.maka-agent-graph-section-label` was the same shape without a
size change.
`data-mono` said in an attribute what the `<code>` element already says.
The collapse to one guard was real for the shape it checks and a regression
for what it can see. Three holes, each found by mutation:
Scope. `readCallSiteCss()` skipped all of maka-tokens.css so the role table
could write its one family longhand. That file is 1400 lines, ~40 of them
ordinary component rules, and they were exempt from the only remaining guard —
a literal ratio, an em multiplier, an `!important` size and a hand-composed
shorthand all passed there while failing everywhere else. The exemption is now
the one declaration that needs it: `font-family` on the code element group.
Level. `--maka-text-*` and `--maka-font-family` are the vocabulary this branch
introduced and nothing checked what they were bound to. One rule rebinding a
role to `700 44px/1.05 Impact` re-chose all four axes while its call site still
named exactly one role. Astryx's atoms were checked, but only against a leading
digit, so `calc(2.5)` and `max(24px, 1rem)` walked through. Both arms are now
one rule: a type token may be rebound to another token, never to a value.
Parser. The hand-rolled brace walk dropped at-rule bodies whole, so a longhand
inside a rule-nested `@media` — the shape Astryx itself uses for coarse
pointers — was invisible; it also treated a brace inside a string as
structural, and had lost the case-insensitivity its predecessor had. Replaced
with postcss, which is already what Vite parses this same CSS with. A
rule-nested at-rule is its own cascade context, so a responsive role swap is
not counted as a duplicate.
One arm is new rather than restored: a selector may be given a role by only
one rule per cascade context. That is the cross-rule form of the dead-role
bug the in-rule count already caught.
The e2e step that read the four deleted size aliases now measures the roles
instead. It had been failing since those names were deleted, and because a
failing step aborts the test, every later step — the off-grid sweep and both
two-anchor probes — had stopped running. The two-anchor probe also pins the
three non-family axes to the supporting tier's own values; equality between
the two probes alone would hold if the role stopped resolving for both.
Mutation-verified (cp backup/restore, no git):
longhand / literal ratio / hand-composed shorthand inside maka-tokens.css,
role token rebound to a value, family axis rebound to a stack, atom rebound
to calc(), longhand in a rule-nested @media, upper-cased longhand, a second
role rule for one selector, role table on :root alone, family longhand moved
off the code anchor — 11 of 11 caught, baseline clean.
… role costs
`--font-weight-bold` had no consumer once call sites stopped declaring weight,
and the generated theme reads only normal / medium / semibold. It survived on a
check-dead-css allowlist entry whose stated reason was that the scale reads as
a scale with it present — an aesthetic, not a constraint, and a product name
for an axis the roles own.
Four things the role table implies and did not say: the shorthand resets font
sub-properties, so any the site needs must come after it; a 12px site that
titles something below it takes heading-5 and everything else takes supporting,
which is what makes the hand-rolled 500s reviewable; 97 call sites name a
heading role for its weight rather than because they are headings, so a
`weights.heading` retune moves them; and `large` / `heading-6` are absent
because nothing uses them, not because the projection is partial. Astryx does
support product roles through defineTheme component overrides — staying a pure
projection of the vendor set is a choice, and now says so.
Display on a page title is a deliberate override of Astryx's own guidance,
recorded where the override is. Every heading role is weight 600 and tops out
at 20px, so matching on weight would collapse a 25px page title level with the
section headings under it.
Also three comments naming tokens this branch deleted.
@Astro-Han
Astro-Han marked this pull request as ready for review August 2, 2026 09:10
@Astro-Han
Astro-Han merged commit 8c84265 into mainAug 2, 2026
5 checks passed
@Astro-Han
Astro-Han deleted the refactor/typography-role-triplet branch August 2, 2026 09:12
Astro-Han added a commit that referenced this pull request Aug 2, 2026
Squashed for rebase onto #1893; final message written at the end.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
… exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
* fix(ui): give the single-line chip family a box the component owns
Squashed for rebase onto #1893; final message written at the end.
* fix(ui): derive the chip population from shape alone and assert every exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
* test(desktop): close the guard holes three re-reviews found in the chip contracts
Every finding below was reproduced as a mutation that passed green, and each
fix is re-verified by the same mutation failing. Four root causes, three of
which delete something.
The predicate read text, not declarations. `/padding/` also matches
`background-clip: padding-box`, which is how a scrollbar thumb — padding-less,
and a box no reader perceives as an object — entered the chip population and
earned an exemption group of its own. The group is deleted with the substring
that created it, and the padding vocabulary becomes one shared authority.
The same guard-narrower-than-CSS mistake, twice more. The Badge contract's
`OWNED` gained the logical padding family and lost `padding-top/right/bottom/
left`, so `padding-left: 9px` on a Badge className redrew the component's box
while green. And the off-ruler exemption proved "still pinned" with the
constrains-a-block-size vocabulary, so rewriting its `height` as `min-height`
handed the box back to the line box and stayed green.
Which merge view follows from which question, applied to all three sites
rather than one. A must-NOT contract has no conditional distinction: pinning a
wrapping row, or re-adding the height override #1879 removed, inside
`@media (max-width: 620px)` passed the unconditional view. A must-HOLD PAIR
needs the opposite — the flattened view let a Badge release `height` in one
breakpoint and `white-space` in the mutually exclusive one, releasing the box
at no viewport at all, so releases are now read per cascade context.
A scan that cannot read a call site has to say so. `<Badge {...{ className:
'x' }} />` is legal JSX that the static scan cannot read and the `className={`
scan does not match, so both contracts concluded there was nothing to govern.
Spreads join computed classNames as unreadable; `//` prop comments parse.
(TypeScript 7 is the Go port and ships no JS compiler API, so an AST is not
available to reach for — but the property that matters is loud-vs-silent, not
lexer-vs-parser.)
Three smaller ones: rungs are read from the ruler's own `:root` scope, so a
`--h-control-*` declared under `.dark` is no longer a tier a chip may name;
`--radius-pill` must exist, because renaming it reported CHIP POPULATION 0 and
passed; `.maka-sidebar-update-button` returns to COMPONENT_OWNED, where an
entry that was dead weight as a skip is the only assertion reaching it.
The e2e no longer credits the tier-choice arithmetic to a check that does not
exist. Measured with a throwaway probe: all four newly pinned chips resolve to
zero nodes in both booted fixtures, so that choice is unasserted, and the
comment says so.
Verified: 10/10 mutations caught, each confirmed to land before being trusted.
lint, format:check, typecheck, desktop 1364, ui 241, e2e 77, check-dead-css.
Sign up for freeto 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.

refactor(ui): compose text styles as role triplets at the call site

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

refactor(ui): name one text role at every renderer call site - #1893

Merged
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet
Aug 2, 2026
Merged

refactor(ui): name one text role at every renderer call site#1893
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

A text style is one indivisible role — size, leading, weight and family chosen together — so a call site names a role and declares nothing else about type. #1857 converged the sizes and #1878 the leadings; both removed the divergence of the day without removing the ability to diverge again, because the four properties stayed four separate choices per site. This makes them one choice.

The vehicle is the font: shorthand, which #1878 banned outright. That ban is inverted, not lifted: the shorthand is the only mechanism CSS has that makes the four inseparable, and its reset semantics — it resets weight, style, variant and family — turn from hazard into guarantee once the only legal value is a role token. The alternative the issue names, Astryx's <Text>/<Heading> components, cannot carry this surface: Text.d.ts types as as 'span' | 'p' | 'div' | 'label' | 'h1' | 'h2' | 'h3', which terminates 142 of the 348 selectors (strong, small, li, dt, code, pre, h4, th, time), and it exposes size/weight override props, so the triplet would not actually be indivisible.

Roles are Astryx's 14, composed from its generated atoms only. There are no product roles: the 42 blocks whose measured values had no Astryx equivalent were hand-rolled picks the ladder never offered — every Astryx heading is 600, yet 10 blocks used 700; the 12px tier offers 400 and 600, yet 11 used 500.

Two things had to move out of the call site for this to work. Family is an inheritance axis (--maka-font-family), because the shorthand's family slot is mandatory. And the role table is anchored on :rootand:where(code, kbd, samp, pre), because var() inside a custom property is substituted where the property is declared and the resolved string is what inherits — a table composed only on :root freezes the sans stack into every role. That is not a code-reading conclusion: measured, .maka-tool-diff-body, a real <pre>, read --maka-font-family: "Geist Mono Variable" and computed font-family: -apple-system. With the second anchor every code element gets the mono role for free, which is why 8 render sites moved to <code> markup instead of gaining a product role.

Six guards collapse into one. A ban on product --leading-* tiers, on literal ratios, on !important sizes and leadings, on em/rem multipliers, and the size↔leading pairing check are each a way for the four to come apart — none is expressible once a call site cannot write a font longhand. findLeadingPairingOffenders is deleted rather than kept beside the new check; two authorities on one question means the weaker one is the one people read. Six product tokens (--font-size-heading/stat/ui/caption, --font-sans, --font-default) reached zero consumers and are deleted with them.

Closes#1875.

Verification

Measured against 105ec3e87, not the branch point, since #1876 moved the session rail under this work.

Computed-style regression net. 16,076 text elements across 86 surfaces (15 e2e windows + every settings page and module hub via a navigation walk + 62 Storybook stories), four properties each, captured from a pristine-main worktree and re-captured here. 118 elements differ, every one a convergence this PR intends:

nchangewhat
46weight 600→400page titles, now on Astryx display roles (size and leading unchanged)
24weight 500→600prompt-rail titles → heading-5
19weight 500→600catalog badges, palette group headings, graph section label
11weight 700→600<strong> section titles — Astryx has no 700
9family mono→sansprose and numeric alignment where mono was decoration, not code
4weight 500→400hand-rolled 12px medium, emphasis already carried by color or pill chrome
5weight 600→400button, tab and count labels a container was leaking weight into

Defects the measurement found that reading the code did not. Each is recorded in the commit that fixes it: .maka-hero h1 was assigned from a measured value that described a different rule masking it; per-rule redundancy is not compositional (deleting 79 individually-redundant rules drifted 85 elements 12px→14px); custom-property substitution is not lazy (the single-anchor role table above); --maka-text-display-1 was referenced by the hero and defined nowhere, so the whole declaration was invalid and the element silently inherited; .maka-session-rename-input declared font: inheritafter its new role, leaving the role line dead. Also fixed a latent off-grid bug: .maka-tool-name rendered 16px against an inherited 1.6667 ratio = 26.67px.

What the net did not cover, and what review found there. The 118 are every element the walk rendered; 188 of the 371 role-declaring rules were never on a rendered surface, and four defects were living in that gap. Two were visible: .plan-proposal-kicker had moved from the 12px tier to the 14px one, because a grouped rule gave it a role and its own rule gave it another — the cross-rule form of the dead-role bug, and the reason the contract now has an arm for it. Two were resets the net does not sample: font: clears font-variant-numeric, and the stat tiles' tabular-nums sat in a rule the role's rule outranked. Six more rules in the gap drop --font-mono for the sans stack. Those six are the same call as the nine measured ones — mono was decoration, not code — and unlike the four above the cascade settles it without a render: none of the six is a code/kbd/samp/pre element, so the family slot resolves to the body stack and the other three axes are unchanged. Fifteen sites in total, not nine.

Mutation verification — 19 mutations, each backed up and restored with cp (no git checkout/restore/stash). The first eight cover the guard as originally written: a font longhand at a call site, a hand-composed shorthand, an undefined role, a role built from a foreign atom, a single-anchor table, a third family authority, an Astryx atom rebound to a literal, and a second font: declaration after a role. Review pointed out that all eight only re-trigger assertions already written, and none touches a boundary this PR introduced — so eleven more do: a longhand, a literal ratio and a hand-composed shorthand insidemaka-tokens.css; a role token rebound to a value; the family axis rebound to a literal stack; an Astryx atom rebound to calc(); a longhand inside a rule-nested @media; an upper-cased property name; a second role rule for one selector; the role table anchored on :root alone; and the family longhand moved off the code anchor. All nineteen fire; the tree restores green.

Visual evidence. Live-app captures, main on the left and this branch on the right, composed with ImageMagick (+append) — no generative model touched the pixels.

Module hub — page title 25px 600→400, skill ids stay mono as <code> markupimage
Settings · 通用 — page title 22px 600→400, size and leading unchangedimage
Settings · 记忆 — strong 700→600, filename stays mono while the prose beside it drops to sans, path is now <code>image
Settings · 权限与能力 — capability ids as <code>, section titles on heading rolesimage

Checks run:lint ✅ · format:check ✅ · typecheck ✅ · @maka/desktop test 1339 ✅ · @maka/ui test 239 ✅ · desktop e2e 73/73 ✅ · check-dead-css --check ✅ · astryx:theme --check ✅.

Not run: no per-provider or new E2E fixture was added — the three new invariants that need a live document are steps inside the existing e2e/type-scale.spec.ts probe.

Review focus

Page titles lose their weight, not their size..settingsPageHeader h2, .maka-module-main-header h2 and .maka-plan-heading h2 were display sizes (22px/25px) carrying a hand-rolled semibold. Astryx's display roles are weight 400 by design. Matching on weight instead would pick heading-1 and collapse 46 page titles to 20px, level with the section headings under them — so this trades weight to keep hierarchy. It is the most visible change in the PR and the one most worth disagreeing with, and it is a deliberate override of Astryx's own guidance, which reserves display for hero, marketing and data-callout text and says not to use it for document headings. Recorded at the call site rather than only here.

The component route is closed by the vendor's type union, not just by as.Text's as prop accepts only span | p | div | label | h1 | h2 | h3, which strands the strong / small / li / dt / code / pre selectors — but the harder blocker is BuiltinTextType, which has no heading roles at all: 99 call sites name heading-1..5 and <Text> cannot express any of them. Heading can, and unlike Text it exposes no size / weight overrides — the divisibility argument applies to Text only. For page headings Heading remains the more idiomatic seam; this PR does not move markup that far.

Redundant role declarations are kept, deliberately. The joint-redundancy probe says 79 of 371 role-declaring rules change nothing when removed — but only 183 of the 371 are exercised by any surface the harness reaches, so 51% of the vocabulary is unverifiable, and this PR has already been bitten twice by "the measured value described a different rule than the one I was editing". Deleting on that evidence would be asserting redundancy about rules the measurement never saw. A no-op font: var(--maka-text-body) costs nothing structurally: the point of the role token is that a call site can no longer diverge into four values.

A text style is one role, not four independent picks. #1857 converged the
sizes and #1878 the leadings; both removed the divergence of the day without
removing the ability to diverge again, because size, leading, weight and
family stayed four separate choices at each of 348 call sites.
The `font:` shorthand is the only mechanism CSS has that makes them
inseparable. Its reset semantics are why it fits rather than a hazard to work
around: it resets weight, style, variant and family, and the role token
supplies them, so no site can move one and leave the others.
Composed from Astryx's generated atoms only, so the ladder stays a pure
function of typography.scale. Family is not part of the call-site triplet:
`font:` requires a family slot that would override the one monospace
authority in this file, so the slot reads --maka-font-family, rebound on
:where(code, kbd, samp, pre) and inherited from there.
Measured behavior-neutral: 12262 text elements across 86 surfaces (24 live
app states, 62 Storybook stories) compared before and after, 0 changed.
381 declaration blocks across 44 stylesheets now declare one `font:
var(--maka-text-<role>)` and nothing else about type. Exactly one raw font
longhand survives in the renderer — the monospace stack in maka-tokens.css
that owns the family axis.
Roles are assigned from MEASURED computed styles, not from declared values,
so the ~30 selectors that took their weight from the UA or an ancestor keep
it: 187 of the 385 blocks render in at least one of the 100 surfaces the
harness reaches and were classified from what the browser reported there.
Three things measurement caught that reading the CSS did not:
- `.maka-hero h1` declares 28px but every probed instance is also
`.maka-hero-empty-chat h1` at 20px, so the measured role described the
other rule. Taking it would have shrunk the onboarding hero, the one
surface that keeps the large scale and that no fixture renders. A measured
role is now rejected when it disagrees with the block's own declared size.
- Per-rule redundancy is not compositional: two rules that both pin 12px on
one element are each individually removable and jointly load-bearing.
Deleting on that basis drifted 85 elements 12px→14px, so this commit only
migrates; deletion is decided against the final tree.
- A role composed on :root freezes its family slot there — custom property
var() references are substituted where DECLARED, and the resolved string is
what inherits. A real <pre> whose --maka-font-family read Geist Mono still
computed -apple-system. The role table is anchored on `:root, :where(code,
kbd, samp, pre)` so code elements compose their own mono-family roles.
`.maka-tool-name` also loses a latent defect: measured 16px against a 1.6667
ratio inherited from the supporting role, 26.67px, off the 4px grid.
The 348-call-site migration was assigned from measured computed values, so
the roles it picked were the ones the browser reported — which is exactly
why re-measuring the migrated tree found the places where the measured
value described something other than the call site's intent. Every change
below is a diff bucket from that re-measurement, named and closed.
Markup, not a call-site family. A slug, an id, a task key, a capability id,
a memory path, a tool name, a summarized error — these are code, and the
role table composes the code family for the code element group, so saying
so in the markup is also what makes them render monospaced. 8 render sites
move to <code>; `SettingRow`'s `mono` prop now picks the element.
UA bold is not a role. 17 blocks selected strong/h*/th and declared no
weight of their own, so they took the UA's 700 by accident. Naming a body
or supporting role resets that to 400 — measured, 21 elements went 700→400
before this. They are section titles at 14px and 12px, which is heading-4
and heading-5: same size, same leading, weight 700→600 (Astryx has no 700).
Page titles keep their size, not their weight. `.settingsPageHeader h2`,
`.maka-module-main-header h2` and `.maka-plan-heading h2` were display
sizes carrying a hand-rolled semibold. Matching on weight picked heading-1
and collapsed 66 page titles to 20px, level with the section headings
under them. Display roles keep 25px/22px and take Astryx's display weight
of 400.
A family-only rule hides its size. `.maka-mcp-*` set only font-family, and
its three selectors inherited two different sizes — 14px in the card body,
12px in the description. One role could not express that, so the rule
splits: code role for the 14px pair, supporting for the description.
`.maka-nav-kbd` opts back out of mono. It read `font-family: inherit`
before; as a <kbd> it now composes the mono role table. It names the family
axis instead — one declaration, and the role stays whole.
Remaining measured delta is 206 of 16,063 elements across 86 surfaces,
every bucket a convergence this PR intends: 66 page titles 600→400, 32
badges and 28 rail prompts 500→600, 21 strong 700→600, 30 family changes
where mono was decoration rather than code, and 13 hand-rolled 12px/500
sites landing on supporting, their emphasis already carried by color or
pill chrome.
Size, leading, weight and family were four independent properties, so each
needed its own guard: a ban on product `--leading-*` tiers, on literal
ratios, on `!important` sizes and leadings, on em/rem multipliers, and a
pairing check that resolved a block's size and leading through the
generated theme to prove they named the same tier. Every one of those
describes a way for the four to come apart.
None of them is expressible now. A call site that cannot write a font
longhand cannot write a literal ratio, an em multiplier, a forced size, or
a mismatched pair — so they collapse into one assertion: outside the role
table, every text style is `font: var(--maka-text-<role>)` and no
stylesheet declares font-size, line-height, font-weight or font-family.
`findLeadingPairingOffenders` is deleted rather than kept alongside it;
two authorities on one question means the weaker one is the one people
read.
`findFontShorthandOffenders` inverts instead of tightening. The shorthand
was banned because it hides bare weights and leadings; it is now the only
legal form, because it is the one CSS mechanism that makes the four
inseparable. The replacement also checks that every role NAMED is a role
DEFINED — a var() that resolves to nothing makes the whole declaration
invalid at computed-value time, so the element silently keeps what it
inherits. That arm found a live defect: `--maka-text-display-1` was
referenced by the hero and defined nowhere, and every other check in the
file was green.
Two things text cannot prove keep their own guards. The role table's shape
is asserted in the contract (each role composed only from its own Astryx
atoms, declared on both anchors, family axis pinned once per anchor). What
those resolve to in a document is measured in e2e: a <pre> and a <div>
naming the same role must agree on size, weight and leading and differ
only in family — the substitution-timing fact that made the first, single
-anchor role table wrong while reading perfectly in CSS.
Seven mutations verified, each restored with cp: a longhand at a call
site, a hand-composed shorthand, an undefined role, a role built from a
foreign atom, a single-anchor table, a third family authority, and an
Astryx atom rebound to a literal. Desktop 1323 pass, ui 239 pass.
Six product tokens reached zero consumers when call sites stopped naming
sizes and families, and check-dead-css found all six: --font-size-heading
/ stat / ui / caption, --font-sans, --font-default. They existed so a call
site could say "this is the caption size" or "this is the mono stack" —
a role says both, together with the weight and the leading, so a second
set of names for the same rungs is exactly the second authority the
convergence exists to remove. The role table now reads --font-family-code
directly, and nothing in the renderer aliases an Astryx type name.
The contract that pinned those aliases is replaced by the stronger thing
they were approximating: there is no product name for a size or a family
at all. --font-weight-bold survives as the top rung of the series the
generated theme reads, in RESERVED_SCALE_TOKENS with that reason.
Also caught rebasing onto #1876: `.maka-session-rename-input` declared
`font: inherit` before its longhands, so migrating the longhands to a role
left the reset winning and the role line dead. `findTextRoleOffenders`
now counts font declarations before reading the last one — a block that
declares a role and then overrides it reads as legal to any check that
asks only what the browser uses. Eight mutations verified with cp.
Measured against 105ec3e rather than the branch point, since #1876 moved
the session rail under this work: 118 of 16,076 text elements across 86
surfaces change, every one a bucket this PR intends.
Review found four sites where naming a role changed more than the role.
The `font:` shorthand resets `font-variant-*`, `font-style` and the feature
settings along with the four axes it names. Within a rule every site already
declared those after the role; across rules two did not. `.maka-stat-tile-value`
held the tabular figures the tiles exist to align while the role sat in the
`-outline` / `-filled` modifiers, so the role won and the digits stopped
aligning; reordering makes the base rule the later one. `code.settingsReadOnlyValue`
restates the declaration because it is the higher-specificity selector.
A grouped rule that hands a selector a role, followed by that selector's own
rule handing it another, leaves the first dead — and a later retune of the
group moves every other member while this site silently stays put.
`.plan-proposal-kicker` had already drifted that way, from the 12px tier to
the 14px one; the 12px tier offers 400 and 600, and an eyebrow over a title
takes the 600. `.maka-agent-graph-section-label` was the same shape without a
size change.
`data-mono` said in an attribute what the `<code>` element already says.
The collapse to one guard was real for the shape it checks and a regression
for what it can see. Three holes, each found by mutation:
Scope. `readCallSiteCss()` skipped all of maka-tokens.css so the role table
could write its one family longhand. That file is 1400 lines, ~40 of them
ordinary component rules, and they were exempt from the only remaining guard —
a literal ratio, an em multiplier, an `!important` size and a hand-composed
shorthand all passed there while failing everywhere else. The exemption is now
the one declaration that needs it: `font-family` on the code element group.
Level. `--maka-text-*` and `--maka-font-family` are the vocabulary this branch
introduced and nothing checked what they were bound to. One rule rebinding a
role to `700 44px/1.05 Impact` re-chose all four axes while its call site still
named exactly one role. Astryx's atoms were checked, but only against a leading
digit, so `calc(2.5)` and `max(24px, 1rem)` walked through. Both arms are now
one rule: a type token may be rebound to another token, never to a value.
Parser. The hand-rolled brace walk dropped at-rule bodies whole, so a longhand
inside a rule-nested `@media` — the shape Astryx itself uses for coarse
pointers — was invisible; it also treated a brace inside a string as
structural, and had lost the case-insensitivity its predecessor had. Replaced
with postcss, which is already what Vite parses this same CSS with. A
rule-nested at-rule is its own cascade context, so a responsive role swap is
not counted as a duplicate.
One arm is new rather than restored: a selector may be given a role by only
one rule per cascade context. That is the cross-rule form of the dead-role
bug the in-rule count already caught.
The e2e step that read the four deleted size aliases now measures the roles
instead. It had been failing since those names were deleted, and because a
failing step aborts the test, every later step — the off-grid sweep and both
two-anchor probes — had stopped running. The two-anchor probe also pins the
three non-family axes to the supporting tier's own values; equality between
the two probes alone would hold if the role stopped resolving for both.
Mutation-verified (cp backup/restore, no git):
longhand / literal ratio / hand-composed shorthand inside maka-tokens.css,
role token rebound to a value, family axis rebound to a stack, atom rebound
to calc(), longhand in a rule-nested @media, upper-cased longhand, a second
role rule for one selector, role table on :root alone, family longhand moved
off the code anchor — 11 of 11 caught, baseline clean.
… role costs
`--font-weight-bold` had no consumer once call sites stopped declaring weight,
and the generated theme reads only normal / medium / semibold. It survived on a
check-dead-css allowlist entry whose stated reason was that the scale reads as
a scale with it present — an aesthetic, not a constraint, and a product name
for an axis the roles own.
Four things the role table implies and did not say: the shorthand resets font
sub-properties, so any the site needs must come after it; a 12px site that
titles something below it takes heading-5 and everything else takes supporting,
which is what makes the hand-rolled 500s reviewable; 97 call sites name a
heading role for its weight rather than because they are headings, so a
`weights.heading` retune moves them; and `large` / `heading-6` are absent
because nothing uses them, not because the projection is partial. Astryx does
support product roles through defineTheme component overrides — staying a pure
projection of the vendor set is a choice, and now says so.
Display on a page title is a deliberate override of Astryx's own guidance,
recorded where the override is. Every heading role is weight 600 and tops out
at 20px, so matching on weight would collapse a 25px page title level with the
section headings under it.
Also three comments naming tokens this branch deleted.
@Astro-Han
Astro-Han marked this pull request as ready for review August 2, 2026 09:10
@Astro-Han
Astro-Han merged commit 8c84265 into mainAug 2, 2026
5 checks passed
@Astro-Han
Astro-Han deleted the refactor/typography-role-triplet branch August 2, 2026 09:12
Astro-Han added a commit that referenced this pull request Aug 2, 2026
Squashed for rebase onto #1893; final message written at the end.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
… exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
* fix(ui): give the single-line chip family a box the component owns
Squashed for rebase onto #1893; final message written at the end.
* fix(ui): derive the chip population from shape alone and assert every exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
* test(desktop): close the guard holes three re-reviews found in the chip contracts
Every finding below was reproduced as a mutation that passed green, and each
fix is re-verified by the same mutation failing. Four root causes, three of
which delete something.
The predicate read text, not declarations. `/padding/` also matches
`background-clip: padding-box`, which is how a scrollbar thumb — padding-less,
and a box no reader perceives as an object — entered the chip population and
earned an exemption group of its own. The group is deleted with the substring
that created it, and the padding vocabulary becomes one shared authority.
The same guard-narrower-than-CSS mistake, twice more. The Badge contract's
`OWNED` gained the logical padding family and lost `padding-top/right/bottom/
left`, so `padding-left: 9px` on a Badge className redrew the component's box
while green. And the off-ruler exemption proved "still pinned" with the
constrains-a-block-size vocabulary, so rewriting its `height` as `min-height`
handed the box back to the line box and stayed green.
Which merge view follows from which question, applied to all three sites
rather than one. A must-NOT contract has no conditional distinction: pinning a
wrapping row, or re-adding the height override #1879 removed, inside
`@media (max-width: 620px)` passed the unconditional view. A must-HOLD PAIR
needs the opposite — the flattened view let a Badge release `height` in one
breakpoint and `white-space` in the mutually exclusive one, releasing the box
at no viewport at all, so releases are now read per cascade context.
A scan that cannot read a call site has to say so. `<Badge {...{ className:
'x' }} />` is legal JSX that the static scan cannot read and the `className={`
scan does not match, so both contracts concluded there was nothing to govern.
Spreads join computed classNames as unreadable; `//` prop comments parse.
(TypeScript 7 is the Go port and ships no JS compiler API, so an AST is not
available to reach for — but the property that matters is loud-vs-silent, not
lexer-vs-parser.)
Three smaller ones: rungs are read from the ruler's own `:root` scope, so a
`--h-control-*` declared under `.dark` is no longer a tier a chip may name;
`--radius-pill` must exist, because renaming it reported CHIP POPULATION 0 and
passed; `.maka-sidebar-update-button` returns to COMPONENT_OWNED, where an
entry that was dead weight as a skip is the only assertion reaching it.
The e2e no longer credits the tier-choice arithmetic to a check that does not
exist. Measured with a throwaway probe: all four newly pinned chips resolve to
zero nodes in both booted fixtures, so that choice is unasserted, and the
comment says so.
Verified: 10/10 mutations caught, each confirmed to land before being trusted.
lint, format:check, typecheck, desktop 1364, ui 241, e2e 77, check-dead-css.
Sign up for freeto 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.

refactor(ui): compose text styles as role triplets at the call site

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(ui): name one text role at every renderer call site - #1893

Merged
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet
Aug 2, 2026
Merged

refactor(ui): name one text role at every renderer call site#1893
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

A text style is one indivisible role — size, leading, weight and family chosen together — so a call site names a role and declares nothing else about type. #1857 converged the sizes and #1878 the leadings; both removed the divergence of the day without removing the ability to diverge again, because the four properties stayed four separate choices per site. This makes them one choice.

The vehicle is the font: shorthand, which #1878 banned outright. That ban is inverted, not lifted: the shorthand is the only mechanism CSS has that makes the four inseparable, and its reset semantics — it resets weight, style, variant and family — turn from hazard into guarantee once the only legal value is a role token. The alternative the issue names, Astryx's <Text>/<Heading> components, cannot carry this surface: Text.d.ts types as as 'span' | 'p' | 'div' | 'label' | 'h1' | 'h2' | 'h3', which terminates 142 of the 348 selectors (strong, small, li, dt, code, pre, h4, th, time), and it exposes size/weight override props, so the triplet would not actually be indivisible.

Roles are Astryx's 14, composed from its generated atoms only. There are no product roles: the 42 blocks whose measured values had no Astryx equivalent were hand-rolled picks the ladder never offered — every Astryx heading is 600, yet 10 blocks used 700; the 12px tier offers 400 and 600, yet 11 used 500.

Two things had to move out of the call site for this to work. Family is an inheritance axis (--maka-font-family), because the shorthand's family slot is mandatory. And the role table is anchored on :rootand:where(code, kbd, samp, pre), because var() inside a custom property is substituted where the property is declared and the resolved string is what inherits — a table composed only on :root freezes the sans stack into every role. That is not a code-reading conclusion: measured, .maka-tool-diff-body, a real <pre>, read --maka-font-family: "Geist Mono Variable" and computed font-family: -apple-system. With the second anchor every code element gets the mono role for free, which is why 8 render sites moved to <code> markup instead of gaining a product role.

Six guards collapse into one. A ban on product --leading-* tiers, on literal ratios, on !important sizes and leadings, on em/rem multipliers, and the size↔leading pairing check are each a way for the four to come apart — none is expressible once a call site cannot write a font longhand. findLeadingPairingOffenders is deleted rather than kept beside the new check; two authorities on one question means the weaker one is the one people read. Six product tokens (--font-size-heading/stat/ui/caption, --font-sans, --font-default) reached zero consumers and are deleted with them.

Closes#1875.

Verification

Measured against 105ec3e87, not the branch point, since #1876 moved the session rail under this work.

Computed-style regression net. 16,076 text elements across 86 surfaces (15 e2e windows + every settings page and module hub via a navigation walk + 62 Storybook stories), four properties each, captured from a pristine-main worktree and re-captured here. 118 elements differ, every one a convergence this PR intends:

nchangewhat
46weight 600→400page titles, now on Astryx display roles (size and leading unchanged)
24weight 500→600prompt-rail titles → heading-5
19weight 500→600catalog badges, palette group headings, graph section label
11weight 700→600<strong> section titles — Astryx has no 700
9family mono→sansprose and numeric alignment where mono was decoration, not code
4weight 500→400hand-rolled 12px medium, emphasis already carried by color or pill chrome
5weight 600→400button, tab and count labels a container was leaking weight into

Defects the measurement found that reading the code did not. Each is recorded in the commit that fixes it: .maka-hero h1 was assigned from a measured value that described a different rule masking it; per-rule redundancy is not compositional (deleting 79 individually-redundant rules drifted 85 elements 12px→14px); custom-property substitution is not lazy (the single-anchor role table above); --maka-text-display-1 was referenced by the hero and defined nowhere, so the whole declaration was invalid and the element silently inherited; .maka-session-rename-input declared font: inheritafter its new role, leaving the role line dead. Also fixed a latent off-grid bug: .maka-tool-name rendered 16px against an inherited 1.6667 ratio = 26.67px.

What the net did not cover, and what review found there. The 118 are every element the walk rendered; 188 of the 371 role-declaring rules were never on a rendered surface, and four defects were living in that gap. Two were visible: .plan-proposal-kicker had moved from the 12px tier to the 14px one, because a grouped rule gave it a role and its own rule gave it another — the cross-rule form of the dead-role bug, and the reason the contract now has an arm for it. Two were resets the net does not sample: font: clears font-variant-numeric, and the stat tiles' tabular-nums sat in a rule the role's rule outranked. Six more rules in the gap drop --font-mono for the sans stack. Those six are the same call as the nine measured ones — mono was decoration, not code — and unlike the four above the cascade settles it without a render: none of the six is a code/kbd/samp/pre element, so the family slot resolves to the body stack and the other three axes are unchanged. Fifteen sites in total, not nine.

Mutation verification — 19 mutations, each backed up and restored with cp (no git checkout/restore/stash). The first eight cover the guard as originally written: a font longhand at a call site, a hand-composed shorthand, an undefined role, a role built from a foreign atom, a single-anchor table, a third family authority, an Astryx atom rebound to a literal, and a second font: declaration after a role. Review pointed out that all eight only re-trigger assertions already written, and none touches a boundary this PR introduced — so eleven more do: a longhand, a literal ratio and a hand-composed shorthand insidemaka-tokens.css; a role token rebound to a value; the family axis rebound to a literal stack; an Astryx atom rebound to calc(); a longhand inside a rule-nested @media; an upper-cased property name; a second role rule for one selector; the role table anchored on :root alone; and the family longhand moved off the code anchor. All nineteen fire; the tree restores green.

Visual evidence. Live-app captures, main on the left and this branch on the right, composed with ImageMagick (+append) — no generative model touched the pixels.

Module hub — page title 25px 600→400, skill ids stay mono as <code> markupimage
Settings · 通用 — page title 22px 600→400, size and leading unchangedimage
Settings · 记忆 — strong 700→600, filename stays mono while the prose beside it drops to sans, path is now <code>image
Settings · 权限与能力 — capability ids as <code>, section titles on heading rolesimage

Checks run:lint ✅ · format:check ✅ · typecheck ✅ · @maka/desktop test 1339 ✅ · @maka/ui test 239 ✅ · desktop e2e 73/73 ✅ · check-dead-css --check ✅ · astryx:theme --check ✅.

Not run: no per-provider or new E2E fixture was added — the three new invariants that need a live document are steps inside the existing e2e/type-scale.spec.ts probe.

Review focus

Page titles lose their weight, not their size..settingsPageHeader h2, .maka-module-main-header h2 and .maka-plan-heading h2 were display sizes (22px/25px) carrying a hand-rolled semibold. Astryx's display roles are weight 400 by design. Matching on weight instead would pick heading-1 and collapse 46 page titles to 20px, level with the section headings under them — so this trades weight to keep hierarchy. It is the most visible change in the PR and the one most worth disagreeing with, and it is a deliberate override of Astryx's own guidance, which reserves display for hero, marketing and data-callout text and says not to use it for document headings. Recorded at the call site rather than only here.

The component route is closed by the vendor's type union, not just by as.Text's as prop accepts only span | p | div | label | h1 | h2 | h3, which strands the strong / small / li / dt / code / pre selectors — but the harder blocker is BuiltinTextType, which has no heading roles at all: 99 call sites name heading-1..5 and <Text> cannot express any of them. Heading can, and unlike Text it exposes no size / weight overrides — the divisibility argument applies to Text only. For page headings Heading remains the more idiomatic seam; this PR does not move markup that far.

Redundant role declarations are kept, deliberately. The joint-redundancy probe says 79 of 371 role-declaring rules change nothing when removed — but only 183 of the 371 are exercised by any surface the harness reaches, so 51% of the vocabulary is unverifiable, and this PR has already been bitten twice by "the measured value described a different rule than the one I was editing". Deleting on that evidence would be asserting redundancy about rules the measurement never saw. A no-op font: var(--maka-text-body) costs nothing structurally: the point of the role token is that a call site can no longer diverge into four values.

A text style is one role, not four independent picks. #1857 converged the
sizes and #1878 the leadings; both removed the divergence of the day without
removing the ability to diverge again, because size, leading, weight and
family stayed four separate choices at each of 348 call sites.
The `font:` shorthand is the only mechanism CSS has that makes them
inseparable. Its reset semantics are why it fits rather than a hazard to work
around: it resets weight, style, variant and family, and the role token
supplies them, so no site can move one and leave the others.
Composed from Astryx's generated atoms only, so the ladder stays a pure
function of typography.scale. Family is not part of the call-site triplet:
`font:` requires a family slot that would override the one monospace
authority in this file, so the slot reads --maka-font-family, rebound on
:where(code, kbd, samp, pre) and inherited from there.
Measured behavior-neutral: 12262 text elements across 86 surfaces (24 live
app states, 62 Storybook stories) compared before and after, 0 changed.
381 declaration blocks across 44 stylesheets now declare one `font:
var(--maka-text-<role>)` and nothing else about type. Exactly one raw font
longhand survives in the renderer — the monospace stack in maka-tokens.css
that owns the family axis.
Roles are assigned from MEASURED computed styles, not from declared values,
so the ~30 selectors that took their weight from the UA or an ancestor keep
it: 187 of the 385 blocks render in at least one of the 100 surfaces the
harness reaches and were classified from what the browser reported there.
Three things measurement caught that reading the CSS did not:
- `.maka-hero h1` declares 28px but every probed instance is also
`.maka-hero-empty-chat h1` at 20px, so the measured role described the
other rule. Taking it would have shrunk the onboarding hero, the one
surface that keeps the large scale and that no fixture renders. A measured
role is now rejected when it disagrees with the block's own declared size.
- Per-rule redundancy is not compositional: two rules that both pin 12px on
one element are each individually removable and jointly load-bearing.
Deleting on that basis drifted 85 elements 12px→14px, so this commit only
migrates; deletion is decided against the final tree.
- A role composed on :root freezes its family slot there — custom property
var() references are substituted where DECLARED, and the resolved string is
what inherits. A real <pre> whose --maka-font-family read Geist Mono still
computed -apple-system. The role table is anchored on `:root, :where(code,
kbd, samp, pre)` so code elements compose their own mono-family roles.
`.maka-tool-name` also loses a latent defect: measured 16px against a 1.6667
ratio inherited from the supporting role, 26.67px, off the 4px grid.
The 348-call-site migration was assigned from measured computed values, so
the roles it picked were the ones the browser reported — which is exactly
why re-measuring the migrated tree found the places where the measured
value described something other than the call site's intent. Every change
below is a diff bucket from that re-measurement, named and closed.
Markup, not a call-site family. A slug, an id, a task key, a capability id,
a memory path, a tool name, a summarized error — these are code, and the
role table composes the code family for the code element group, so saying
so in the markup is also what makes them render monospaced. 8 render sites
move to <code>; `SettingRow`'s `mono` prop now picks the element.
UA bold is not a role. 17 blocks selected strong/h*/th and declared no
weight of their own, so they took the UA's 700 by accident. Naming a body
or supporting role resets that to 400 — measured, 21 elements went 700→400
before this. They are section titles at 14px and 12px, which is heading-4
and heading-5: same size, same leading, weight 700→600 (Astryx has no 700).
Page titles keep their size, not their weight. `.settingsPageHeader h2`,
`.maka-module-main-header h2` and `.maka-plan-heading h2` were display
sizes carrying a hand-rolled semibold. Matching on weight picked heading-1
and collapsed 66 page titles to 20px, level with the section headings
under them. Display roles keep 25px/22px and take Astryx's display weight
of 400.
A family-only rule hides its size. `.maka-mcp-*` set only font-family, and
its three selectors inherited two different sizes — 14px in the card body,
12px in the description. One role could not express that, so the rule
splits: code role for the 14px pair, supporting for the description.
`.maka-nav-kbd` opts back out of mono. It read `font-family: inherit`
before; as a <kbd> it now composes the mono role table. It names the family
axis instead — one declaration, and the role stays whole.
Remaining measured delta is 206 of 16,063 elements across 86 surfaces,
every bucket a convergence this PR intends: 66 page titles 600→400, 32
badges and 28 rail prompts 500→600, 21 strong 700→600, 30 family changes
where mono was decoration rather than code, and 13 hand-rolled 12px/500
sites landing on supporting, their emphasis already carried by color or
pill chrome.
Size, leading, weight and family were four independent properties, so each
needed its own guard: a ban on product `--leading-*` tiers, on literal
ratios, on `!important` sizes and leadings, on em/rem multipliers, and a
pairing check that resolved a block's size and leading through the
generated theme to prove they named the same tier. Every one of those
describes a way for the four to come apart.
None of them is expressible now. A call site that cannot write a font
longhand cannot write a literal ratio, an em multiplier, a forced size, or
a mismatched pair — so they collapse into one assertion: outside the role
table, every text style is `font: var(--maka-text-<role>)` and no
stylesheet declares font-size, line-height, font-weight or font-family.
`findLeadingPairingOffenders` is deleted rather than kept alongside it;
two authorities on one question means the weaker one is the one people
read.
`findFontShorthandOffenders` inverts instead of tightening. The shorthand
was banned because it hides bare weights and leadings; it is now the only
legal form, because it is the one CSS mechanism that makes the four
inseparable. The replacement also checks that every role NAMED is a role
DEFINED — a var() that resolves to nothing makes the whole declaration
invalid at computed-value time, so the element silently keeps what it
inherits. That arm found a live defect: `--maka-text-display-1` was
referenced by the hero and defined nowhere, and every other check in the
file was green.
Two things text cannot prove keep their own guards. The role table's shape
is asserted in the contract (each role composed only from its own Astryx
atoms, declared on both anchors, family axis pinned once per anchor). What
those resolve to in a document is measured in e2e: a <pre> and a <div>
naming the same role must agree on size, weight and leading and differ
only in family — the substitution-timing fact that made the first, single
-anchor role table wrong while reading perfectly in CSS.
Seven mutations verified, each restored with cp: a longhand at a call
site, a hand-composed shorthand, an undefined role, a role built from a
foreign atom, a single-anchor table, a third family authority, and an
Astryx atom rebound to a literal. Desktop 1323 pass, ui 239 pass.
Six product tokens reached zero consumers when call sites stopped naming
sizes and families, and check-dead-css found all six: --font-size-heading
/ stat / ui / caption, --font-sans, --font-default. They existed so a call
site could say "this is the caption size" or "this is the mono stack" —
a role says both, together with the weight and the leading, so a second
set of names for the same rungs is exactly the second authority the
convergence exists to remove. The role table now reads --font-family-code
directly, and nothing in the renderer aliases an Astryx type name.
The contract that pinned those aliases is replaced by the stronger thing
they were approximating: there is no product name for a size or a family
at all. --font-weight-bold survives as the top rung of the series the
generated theme reads, in RESERVED_SCALE_TOKENS with that reason.
Also caught rebasing onto #1876: `.maka-session-rename-input` declared
`font: inherit` before its longhands, so migrating the longhands to a role
left the reset winning and the role line dead. `findTextRoleOffenders`
now counts font declarations before reading the last one — a block that
declares a role and then overrides it reads as legal to any check that
asks only what the browser uses. Eight mutations verified with cp.
Measured against 105ec3e rather than the branch point, since #1876 moved
the session rail under this work: 118 of 16,076 text elements across 86
surfaces change, every one a bucket this PR intends.
Review found four sites where naming a role changed more than the role.
The `font:` shorthand resets `font-variant-*`, `font-style` and the feature
settings along with the four axes it names. Within a rule every site already
declared those after the role; across rules two did not. `.maka-stat-tile-value`
held the tabular figures the tiles exist to align while the role sat in the
`-outline` / `-filled` modifiers, so the role won and the digits stopped
aligning; reordering makes the base rule the later one. `code.settingsReadOnlyValue`
restates the declaration because it is the higher-specificity selector.
A grouped rule that hands a selector a role, followed by that selector's own
rule handing it another, leaves the first dead — and a later retune of the
group moves every other member while this site silently stays put.
`.plan-proposal-kicker` had already drifted that way, from the 12px tier to
the 14px one; the 12px tier offers 400 and 600, and an eyebrow over a title
takes the 600. `.maka-agent-graph-section-label` was the same shape without a
size change.
`data-mono` said in an attribute what the `<code>` element already says.
The collapse to one guard was real for the shape it checks and a regression
for what it can see. Three holes, each found by mutation:
Scope. `readCallSiteCss()` skipped all of maka-tokens.css so the role table
could write its one family longhand. That file is 1400 lines, ~40 of them
ordinary component rules, and they were exempt from the only remaining guard —
a literal ratio, an em multiplier, an `!important` size and a hand-composed
shorthand all passed there while failing everywhere else. The exemption is now
the one declaration that needs it: `font-family` on the code element group.
Level. `--maka-text-*` and `--maka-font-family` are the vocabulary this branch
introduced and nothing checked what they were bound to. One rule rebinding a
role to `700 44px/1.05 Impact` re-chose all four axes while its call site still
named exactly one role. Astryx's atoms were checked, but only against a leading
digit, so `calc(2.5)` and `max(24px, 1rem)` walked through. Both arms are now
one rule: a type token may be rebound to another token, never to a value.
Parser. The hand-rolled brace walk dropped at-rule bodies whole, so a longhand
inside a rule-nested `@media` — the shape Astryx itself uses for coarse
pointers — was invisible; it also treated a brace inside a string as
structural, and had lost the case-insensitivity its predecessor had. Replaced
with postcss, which is already what Vite parses this same CSS with. A
rule-nested at-rule is its own cascade context, so a responsive role swap is
not counted as a duplicate.
One arm is new rather than restored: a selector may be given a role by only
one rule per cascade context. That is the cross-rule form of the dead-role
bug the in-rule count already caught.
The e2e step that read the four deleted size aliases now measures the roles
instead. It had been failing since those names were deleted, and because a
failing step aborts the test, every later step — the off-grid sweep and both
two-anchor probes — had stopped running. The two-anchor probe also pins the
three non-family axes to the supporting tier's own values; equality between
the two probes alone would hold if the role stopped resolving for both.
Mutation-verified (cp backup/restore, no git):
longhand / literal ratio / hand-composed shorthand inside maka-tokens.css,
role token rebound to a value, family axis rebound to a stack, atom rebound
to calc(), longhand in a rule-nested @media, upper-cased longhand, a second
role rule for one selector, role table on :root alone, family longhand moved
off the code anchor — 11 of 11 caught, baseline clean.
… role costs
`--font-weight-bold` had no consumer once call sites stopped declaring weight,
and the generated theme reads only normal / medium / semibold. It survived on a
check-dead-css allowlist entry whose stated reason was that the scale reads as
a scale with it present — an aesthetic, not a constraint, and a product name
for an axis the roles own.
Four things the role table implies and did not say: the shorthand resets font
sub-properties, so any the site needs must come after it; a 12px site that
titles something below it takes heading-5 and everything else takes supporting,
which is what makes the hand-rolled 500s reviewable; 97 call sites name a
heading role for its weight rather than because they are headings, so a
`weights.heading` retune moves them; and `large` / `heading-6` are absent
because nothing uses them, not because the projection is partial. Astryx does
support product roles through defineTheme component overrides — staying a pure
projection of the vendor set is a choice, and now says so.
Display on a page title is a deliberate override of Astryx's own guidance,
recorded where the override is. Every heading role is weight 600 and tops out
at 20px, so matching on weight would collapse a 25px page title level with the
section headings under it.
Also three comments naming tokens this branch deleted.
@Astro-Han
Astro-Han marked this pull request as ready for review August 2, 2026 09:10
@Astro-Han
Astro-Han merged commit 8c84265 into mainAug 2, 2026
5 checks passed
@Astro-Han
Astro-Han deleted the refactor/typography-role-triplet branch August 2, 2026 09:12
Astro-Han added a commit that referenced this pull request Aug 2, 2026
Squashed for rebase onto #1893; final message written at the end.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
… exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
* fix(ui): give the single-line chip family a box the component owns
Squashed for rebase onto #1893; final message written at the end.
* fix(ui): derive the chip population from shape alone and assert every exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
* test(desktop): close the guard holes three re-reviews found in the chip contracts
Every finding below was reproduced as a mutation that passed green, and each
fix is re-verified by the same mutation failing. Four root causes, three of
which delete something.
The predicate read text, not declarations. `/padding/` also matches
`background-clip: padding-box`, which is how a scrollbar thumb — padding-less,
and a box no reader perceives as an object — entered the chip population and
earned an exemption group of its own. The group is deleted with the substring
that created it, and the padding vocabulary becomes one shared authority.
The same guard-narrower-than-CSS mistake, twice more. The Badge contract's
`OWNED` gained the logical padding family and lost `padding-top/right/bottom/
left`, so `padding-left: 9px` on a Badge className redrew the component's box
while green. And the off-ruler exemption proved "still pinned" with the
constrains-a-block-size vocabulary, so rewriting its `height` as `min-height`
handed the box back to the line box and stayed green.
Which merge view follows from which question, applied to all three sites
rather than one. A must-NOT contract has no conditional distinction: pinning a
wrapping row, or re-adding the height override #1879 removed, inside
`@media (max-width: 620px)` passed the unconditional view. A must-HOLD PAIR
needs the opposite — the flattened view let a Badge release `height` in one
breakpoint and `white-space` in the mutually exclusive one, releasing the box
at no viewport at all, so releases are now read per cascade context.
A scan that cannot read a call site has to say so. `<Badge {...{ className:
'x' }} />` is legal JSX that the static scan cannot read and the `className={`
scan does not match, so both contracts concluded there was nothing to govern.
Spreads join computed classNames as unreadable; `//` prop comments parse.
(TypeScript 7 is the Go port and ships no JS compiler API, so an AST is not
available to reach for — but the property that matters is loud-vs-silent, not
lexer-vs-parser.)
Three smaller ones: rungs are read from the ruler's own `:root` scope, so a
`--h-control-*` declared under `.dark` is no longer a tier a chip may name;
`--radius-pill` must exist, because renaming it reported CHIP POPULATION 0 and
passed; `.maka-sidebar-update-button` returns to COMPONENT_OWNED, where an
entry that was dead weight as a skip is the only assertion reaching it.
The e2e no longer credits the tier-choice arithmetic to a check that does not
exist. Measured with a throwaway probe: all four newly pinned chips resolve to
zero nodes in both booted fixtures, so that choice is unasserted, and the
comment says so.
Verified: 10/10 mutations caught, each confirmed to land before being trusted.
lint, format:check, typecheck, desktop 1364, ui 241, e2e 77, check-dead-css.
Sign up for freeto 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.

refactor(ui): compose text styles as role triplets at the call site

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(ui): name one text role at every renderer call site - #1893

Merged
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet
Aug 2, 2026
Merged

refactor(ui): name one text role at every renderer call site#1893
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

A text style is one indivisible role — size, leading, weight and family chosen together — so a call site names a role and declares nothing else about type. #1857 converged the sizes and #1878 the leadings; both removed the divergence of the day without removing the ability to diverge again, because the four properties stayed four separate choices per site. This makes them one choice.

The vehicle is the font: shorthand, which #1878 banned outright. That ban is inverted, not lifted: the shorthand is the only mechanism CSS has that makes the four inseparable, and its reset semantics — it resets weight, style, variant and family — turn from hazard into guarantee once the only legal value is a role token. The alternative the issue names, Astryx's <Text>/<Heading> components, cannot carry this surface: Text.d.ts types as as 'span' | 'p' | 'div' | 'label' | 'h1' | 'h2' | 'h3', which terminates 142 of the 348 selectors (strong, small, li, dt, code, pre, h4, th, time), and it exposes size/weight override props, so the triplet would not actually be indivisible.

Roles are Astryx's 14, composed from its generated atoms only. There are no product roles: the 42 blocks whose measured values had no Astryx equivalent were hand-rolled picks the ladder never offered — every Astryx heading is 600, yet 10 blocks used 700; the 12px tier offers 400 and 600, yet 11 used 500.

Two things had to move out of the call site for this to work. Family is an inheritance axis (--maka-font-family), because the shorthand's family slot is mandatory. And the role table is anchored on :rootand:where(code, kbd, samp, pre), because var() inside a custom property is substituted where the property is declared and the resolved string is what inherits — a table composed only on :root freezes the sans stack into every role. That is not a code-reading conclusion: measured, .maka-tool-diff-body, a real <pre>, read --maka-font-family: "Geist Mono Variable" and computed font-family: -apple-system. With the second anchor every code element gets the mono role for free, which is why 8 render sites moved to <code> markup instead of gaining a product role.

Six guards collapse into one. A ban on product --leading-* tiers, on literal ratios, on !important sizes and leadings, on em/rem multipliers, and the size↔leading pairing check are each a way for the four to come apart — none is expressible once a call site cannot write a font longhand. findLeadingPairingOffenders is deleted rather than kept beside the new check; two authorities on one question means the weaker one is the one people read. Six product tokens (--font-size-heading/stat/ui/caption, --font-sans, --font-default) reached zero consumers and are deleted with them.

Closes#1875.

Verification

Measured against 105ec3e87, not the branch point, since #1876 moved the session rail under this work.

Computed-style regression net. 16,076 text elements across 86 surfaces (15 e2e windows + every settings page and module hub via a navigation walk + 62 Storybook stories), four properties each, captured from a pristine-main worktree and re-captured here. 118 elements differ, every one a convergence this PR intends:

nchangewhat
46weight 600→400page titles, now on Astryx display roles (size and leading unchanged)
24weight 500→600prompt-rail titles → heading-5
19weight 500→600catalog badges, palette group headings, graph section label
11weight 700→600<strong> section titles — Astryx has no 700
9family mono→sansprose and numeric alignment where mono was decoration, not code
4weight 500→400hand-rolled 12px medium, emphasis already carried by color or pill chrome
5weight 600→400button, tab and count labels a container was leaking weight into

Defects the measurement found that reading the code did not. Each is recorded in the commit that fixes it: .maka-hero h1 was assigned from a measured value that described a different rule masking it; per-rule redundancy is not compositional (deleting 79 individually-redundant rules drifted 85 elements 12px→14px); custom-property substitution is not lazy (the single-anchor role table above); --maka-text-display-1 was referenced by the hero and defined nowhere, so the whole declaration was invalid and the element silently inherited; .maka-session-rename-input declared font: inheritafter its new role, leaving the role line dead. Also fixed a latent off-grid bug: .maka-tool-name rendered 16px against an inherited 1.6667 ratio = 26.67px.

What the net did not cover, and what review found there. The 118 are every element the walk rendered; 188 of the 371 role-declaring rules were never on a rendered surface, and four defects were living in that gap. Two were visible: .plan-proposal-kicker had moved from the 12px tier to the 14px one, because a grouped rule gave it a role and its own rule gave it another — the cross-rule form of the dead-role bug, and the reason the contract now has an arm for it. Two were resets the net does not sample: font: clears font-variant-numeric, and the stat tiles' tabular-nums sat in a rule the role's rule outranked. Six more rules in the gap drop --font-mono for the sans stack. Those six are the same call as the nine measured ones — mono was decoration, not code — and unlike the four above the cascade settles it without a render: none of the six is a code/kbd/samp/pre element, so the family slot resolves to the body stack and the other three axes are unchanged. Fifteen sites in total, not nine.

Mutation verification — 19 mutations, each backed up and restored with cp (no git checkout/restore/stash). The first eight cover the guard as originally written: a font longhand at a call site, a hand-composed shorthand, an undefined role, a role built from a foreign atom, a single-anchor table, a third family authority, an Astryx atom rebound to a literal, and a second font: declaration after a role. Review pointed out that all eight only re-trigger assertions already written, and none touches a boundary this PR introduced — so eleven more do: a longhand, a literal ratio and a hand-composed shorthand insidemaka-tokens.css; a role token rebound to a value; the family axis rebound to a literal stack; an Astryx atom rebound to calc(); a longhand inside a rule-nested @media; an upper-cased property name; a second role rule for one selector; the role table anchored on :root alone; and the family longhand moved off the code anchor. All nineteen fire; the tree restores green.

Visual evidence. Live-app captures, main on the left and this branch on the right, composed with ImageMagick (+append) — no generative model touched the pixels.

Module hub — page title 25px 600→400, skill ids stay mono as <code> markupimage
Settings · 通用 — page title 22px 600→400, size and leading unchangedimage
Settings · 记忆 — strong 700→600, filename stays mono while the prose beside it drops to sans, path is now <code>image
Settings · 权限与能力 — capability ids as <code>, section titles on heading rolesimage

Checks run:lint ✅ · format:check ✅ · typecheck ✅ · @maka/desktop test 1339 ✅ · @maka/ui test 239 ✅ · desktop e2e 73/73 ✅ · check-dead-css --check ✅ · astryx:theme --check ✅.

Not run: no per-provider or new E2E fixture was added — the three new invariants that need a live document are steps inside the existing e2e/type-scale.spec.ts probe.

Review focus

Page titles lose their weight, not their size..settingsPageHeader h2, .maka-module-main-header h2 and .maka-plan-heading h2 were display sizes (22px/25px) carrying a hand-rolled semibold. Astryx's display roles are weight 400 by design. Matching on weight instead would pick heading-1 and collapse 46 page titles to 20px, level with the section headings under them — so this trades weight to keep hierarchy. It is the most visible change in the PR and the one most worth disagreeing with, and it is a deliberate override of Astryx's own guidance, which reserves display for hero, marketing and data-callout text and says not to use it for document headings. Recorded at the call site rather than only here.

The component route is closed by the vendor's type union, not just by as.Text's as prop accepts only span | p | div | label | h1 | h2 | h3, which strands the strong / small / li / dt / code / pre selectors — but the harder blocker is BuiltinTextType, which has no heading roles at all: 99 call sites name heading-1..5 and <Text> cannot express any of them. Heading can, and unlike Text it exposes no size / weight overrides — the divisibility argument applies to Text only. For page headings Heading remains the more idiomatic seam; this PR does not move markup that far.

Redundant role declarations are kept, deliberately. The joint-redundancy probe says 79 of 371 role-declaring rules change nothing when removed — but only 183 of the 371 are exercised by any surface the harness reaches, so 51% of the vocabulary is unverifiable, and this PR has already been bitten twice by "the measured value described a different rule than the one I was editing". Deleting on that evidence would be asserting redundancy about rules the measurement never saw. A no-op font: var(--maka-text-body) costs nothing structurally: the point of the role token is that a call site can no longer diverge into four values.

A text style is one role, not four independent picks. #1857 converged the
sizes and #1878 the leadings; both removed the divergence of the day without
removing the ability to diverge again, because size, leading, weight and
family stayed four separate choices at each of 348 call sites.
The `font:` shorthand is the only mechanism CSS has that makes them
inseparable. Its reset semantics are why it fits rather than a hazard to work
around: it resets weight, style, variant and family, and the role token
supplies them, so no site can move one and leave the others.
Composed from Astryx's generated atoms only, so the ladder stays a pure
function of typography.scale. Family is not part of the call-site triplet:
`font:` requires a family slot that would override the one monospace
authority in this file, so the slot reads --maka-font-family, rebound on
:where(code, kbd, samp, pre) and inherited from there.
Measured behavior-neutral: 12262 text elements across 86 surfaces (24 live
app states, 62 Storybook stories) compared before and after, 0 changed.
381 declaration blocks across 44 stylesheets now declare one `font:
var(--maka-text-<role>)` and nothing else about type. Exactly one raw font
longhand survives in the renderer — the monospace stack in maka-tokens.css
that owns the family axis.
Roles are assigned from MEASURED computed styles, not from declared values,
so the ~30 selectors that took their weight from the UA or an ancestor keep
it: 187 of the 385 blocks render in at least one of the 100 surfaces the
harness reaches and were classified from what the browser reported there.
Three things measurement caught that reading the CSS did not:
- `.maka-hero h1` declares 28px but every probed instance is also
`.maka-hero-empty-chat h1` at 20px, so the measured role described the
other rule. Taking it would have shrunk the onboarding hero, the one
surface that keeps the large scale and that no fixture renders. A measured
role is now rejected when it disagrees with the block's own declared size.
- Per-rule redundancy is not compositional: two rules that both pin 12px on
one element are each individually removable and jointly load-bearing.
Deleting on that basis drifted 85 elements 12px→14px, so this commit only
migrates; deletion is decided against the final tree.
- A role composed on :root freezes its family slot there — custom property
var() references are substituted where DECLARED, and the resolved string is
what inherits. A real <pre> whose --maka-font-family read Geist Mono still
computed -apple-system. The role table is anchored on `:root, :where(code,
kbd, samp, pre)` so code elements compose their own mono-family roles.
`.maka-tool-name` also loses a latent defect: measured 16px against a 1.6667
ratio inherited from the supporting role, 26.67px, off the 4px grid.
The 348-call-site migration was assigned from measured computed values, so
the roles it picked were the ones the browser reported — which is exactly
why re-measuring the migrated tree found the places where the measured
value described something other than the call site's intent. Every change
below is a diff bucket from that re-measurement, named and closed.
Markup, not a call-site family. A slug, an id, a task key, a capability id,
a memory path, a tool name, a summarized error — these are code, and the
role table composes the code family for the code element group, so saying
so in the markup is also what makes them render monospaced. 8 render sites
move to <code>; `SettingRow`'s `mono` prop now picks the element.
UA bold is not a role. 17 blocks selected strong/h*/th and declared no
weight of their own, so they took the UA's 700 by accident. Naming a body
or supporting role resets that to 400 — measured, 21 elements went 700→400
before this. They are section titles at 14px and 12px, which is heading-4
and heading-5: same size, same leading, weight 700→600 (Astryx has no 700).
Page titles keep their size, not their weight. `.settingsPageHeader h2`,
`.maka-module-main-header h2` and `.maka-plan-heading h2` were display
sizes carrying a hand-rolled semibold. Matching on weight picked heading-1
and collapsed 66 page titles to 20px, level with the section headings
under them. Display roles keep 25px/22px and take Astryx's display weight
of 400.
A family-only rule hides its size. `.maka-mcp-*` set only font-family, and
its three selectors inherited two different sizes — 14px in the card body,
12px in the description. One role could not express that, so the rule
splits: code role for the 14px pair, supporting for the description.
`.maka-nav-kbd` opts back out of mono. It read `font-family: inherit`
before; as a <kbd> it now composes the mono role table. It names the family
axis instead — one declaration, and the role stays whole.
Remaining measured delta is 206 of 16,063 elements across 86 surfaces,
every bucket a convergence this PR intends: 66 page titles 600→400, 32
badges and 28 rail prompts 500→600, 21 strong 700→600, 30 family changes
where mono was decoration rather than code, and 13 hand-rolled 12px/500
sites landing on supporting, their emphasis already carried by color or
pill chrome.
Size, leading, weight and family were four independent properties, so each
needed its own guard: a ban on product `--leading-*` tiers, on literal
ratios, on `!important` sizes and leadings, on em/rem multipliers, and a
pairing check that resolved a block's size and leading through the
generated theme to prove they named the same tier. Every one of those
describes a way for the four to come apart.
None of them is expressible now. A call site that cannot write a font
longhand cannot write a literal ratio, an em multiplier, a forced size, or
a mismatched pair — so they collapse into one assertion: outside the role
table, every text style is `font: var(--maka-text-<role>)` and no
stylesheet declares font-size, line-height, font-weight or font-family.
`findLeadingPairingOffenders` is deleted rather than kept alongside it;
two authorities on one question means the weaker one is the one people
read.
`findFontShorthandOffenders` inverts instead of tightening. The shorthand
was banned because it hides bare weights and leadings; it is now the only
legal form, because it is the one CSS mechanism that makes the four
inseparable. The replacement also checks that every role NAMED is a role
DEFINED — a var() that resolves to nothing makes the whole declaration
invalid at computed-value time, so the element silently keeps what it
inherits. That arm found a live defect: `--maka-text-display-1` was
referenced by the hero and defined nowhere, and every other check in the
file was green.
Two things text cannot prove keep their own guards. The role table's shape
is asserted in the contract (each role composed only from its own Astryx
atoms, declared on both anchors, family axis pinned once per anchor). What
those resolve to in a document is measured in e2e: a <pre> and a <div>
naming the same role must agree on size, weight and leading and differ
only in family — the substitution-timing fact that made the first, single
-anchor role table wrong while reading perfectly in CSS.
Seven mutations verified, each restored with cp: a longhand at a call
site, a hand-composed shorthand, an undefined role, a role built from a
foreign atom, a single-anchor table, a third family authority, and an
Astryx atom rebound to a literal. Desktop 1323 pass, ui 239 pass.
Six product tokens reached zero consumers when call sites stopped naming
sizes and families, and check-dead-css found all six: --font-size-heading
/ stat / ui / caption, --font-sans, --font-default. They existed so a call
site could say "this is the caption size" or "this is the mono stack" —
a role says both, together with the weight and the leading, so a second
set of names for the same rungs is exactly the second authority the
convergence exists to remove. The role table now reads --font-family-code
directly, and nothing in the renderer aliases an Astryx type name.
The contract that pinned those aliases is replaced by the stronger thing
they were approximating: there is no product name for a size or a family
at all. --font-weight-bold survives as the top rung of the series the
generated theme reads, in RESERVED_SCALE_TOKENS with that reason.
Also caught rebasing onto #1876: `.maka-session-rename-input` declared
`font: inherit` before its longhands, so migrating the longhands to a role
left the reset winning and the role line dead. `findTextRoleOffenders`
now counts font declarations before reading the last one — a block that
declares a role and then overrides it reads as legal to any check that
asks only what the browser uses. Eight mutations verified with cp.
Measured against 105ec3e rather than the branch point, since #1876 moved
the session rail under this work: 118 of 16,076 text elements across 86
surfaces change, every one a bucket this PR intends.
Review found four sites where naming a role changed more than the role.
The `font:` shorthand resets `font-variant-*`, `font-style` and the feature
settings along with the four axes it names. Within a rule every site already
declared those after the role; across rules two did not. `.maka-stat-tile-value`
held the tabular figures the tiles exist to align while the role sat in the
`-outline` / `-filled` modifiers, so the role won and the digits stopped
aligning; reordering makes the base rule the later one. `code.settingsReadOnlyValue`
restates the declaration because it is the higher-specificity selector.
A grouped rule that hands a selector a role, followed by that selector's own
rule handing it another, leaves the first dead — and a later retune of the
group moves every other member while this site silently stays put.
`.plan-proposal-kicker` had already drifted that way, from the 12px tier to
the 14px one; the 12px tier offers 400 and 600, and an eyebrow over a title
takes the 600. `.maka-agent-graph-section-label` was the same shape without a
size change.
`data-mono` said in an attribute what the `<code>` element already says.
The collapse to one guard was real for the shape it checks and a regression
for what it can see. Three holes, each found by mutation:
Scope. `readCallSiteCss()` skipped all of maka-tokens.css so the role table
could write its one family longhand. That file is 1400 lines, ~40 of them
ordinary component rules, and they were exempt from the only remaining guard —
a literal ratio, an em multiplier, an `!important` size and a hand-composed
shorthand all passed there while failing everywhere else. The exemption is now
the one declaration that needs it: `font-family` on the code element group.
Level. `--maka-text-*` and `--maka-font-family` are the vocabulary this branch
introduced and nothing checked what they were bound to. One rule rebinding a
role to `700 44px/1.05 Impact` re-chose all four axes while its call site still
named exactly one role. Astryx's atoms were checked, but only against a leading
digit, so `calc(2.5)` and `max(24px, 1rem)` walked through. Both arms are now
one rule: a type token may be rebound to another token, never to a value.
Parser. The hand-rolled brace walk dropped at-rule bodies whole, so a longhand
inside a rule-nested `@media` — the shape Astryx itself uses for coarse
pointers — was invisible; it also treated a brace inside a string as
structural, and had lost the case-insensitivity its predecessor had. Replaced
with postcss, which is already what Vite parses this same CSS with. A
rule-nested at-rule is its own cascade context, so a responsive role swap is
not counted as a duplicate.
One arm is new rather than restored: a selector may be given a role by only
one rule per cascade context. That is the cross-rule form of the dead-role
bug the in-rule count already caught.
The e2e step that read the four deleted size aliases now measures the roles
instead. It had been failing since those names were deleted, and because a
failing step aborts the test, every later step — the off-grid sweep and both
two-anchor probes — had stopped running. The two-anchor probe also pins the
three non-family axes to the supporting tier's own values; equality between
the two probes alone would hold if the role stopped resolving for both.
Mutation-verified (cp backup/restore, no git):
longhand / literal ratio / hand-composed shorthand inside maka-tokens.css,
role token rebound to a value, family axis rebound to a stack, atom rebound
to calc(), longhand in a rule-nested @media, upper-cased longhand, a second
role rule for one selector, role table on :root alone, family longhand moved
off the code anchor — 11 of 11 caught, baseline clean.
… role costs
`--font-weight-bold` had no consumer once call sites stopped declaring weight,
and the generated theme reads only normal / medium / semibold. It survived on a
check-dead-css allowlist entry whose stated reason was that the scale reads as
a scale with it present — an aesthetic, not a constraint, and a product name
for an axis the roles own.
Four things the role table implies and did not say: the shorthand resets font
sub-properties, so any the site needs must come after it; a 12px site that
titles something below it takes heading-5 and everything else takes supporting,
which is what makes the hand-rolled 500s reviewable; 97 call sites name a
heading role for its weight rather than because they are headings, so a
`weights.heading` retune moves them; and `large` / `heading-6` are absent
because nothing uses them, not because the projection is partial. Astryx does
support product roles through defineTheme component overrides — staying a pure
projection of the vendor set is a choice, and now says so.
Display on a page title is a deliberate override of Astryx's own guidance,
recorded where the override is. Every heading role is weight 600 and tops out
at 20px, so matching on weight would collapse a 25px page title level with the
section headings under it.
Also three comments naming tokens this branch deleted.
@Astro-Han
Astro-Han marked this pull request as ready for review August 2, 2026 09:10
@Astro-Han
Astro-Han merged commit 8c84265 into mainAug 2, 2026
5 checks passed
@Astro-Han
Astro-Han deleted the refactor/typography-role-triplet branch August 2, 2026 09:12
Astro-Han added a commit that referenced this pull request Aug 2, 2026
Squashed for rebase onto #1893; final message written at the end.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
… exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
* fix(ui): give the single-line chip family a box the component owns
Squashed for rebase onto #1893; final message written at the end.
* fix(ui): derive the chip population from shape alone and assert every exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
* test(desktop): close the guard holes three re-reviews found in the chip contracts
Every finding below was reproduced as a mutation that passed green, and each
fix is re-verified by the same mutation failing. Four root causes, three of
which delete something.
The predicate read text, not declarations. `/padding/` also matches
`background-clip: padding-box`, which is how a scrollbar thumb — padding-less,
and a box no reader perceives as an object — entered the chip population and
earned an exemption group of its own. The group is deleted with the substring
that created it, and the padding vocabulary becomes one shared authority.
The same guard-narrower-than-CSS mistake, twice more. The Badge contract's
`OWNED` gained the logical padding family and lost `padding-top/right/bottom/
left`, so `padding-left: 9px` on a Badge className redrew the component's box
while green. And the off-ruler exemption proved "still pinned" with the
constrains-a-block-size vocabulary, so rewriting its `height` as `min-height`
handed the box back to the line box and stayed green.
Which merge view follows from which question, applied to all three sites
rather than one. A must-NOT contract has no conditional distinction: pinning a
wrapping row, or re-adding the height override #1879 removed, inside
`@media (max-width: 620px)` passed the unconditional view. A must-HOLD PAIR
needs the opposite — the flattened view let a Badge release `height` in one
breakpoint and `white-space` in the mutually exclusive one, releasing the box
at no viewport at all, so releases are now read per cascade context.
A scan that cannot read a call site has to say so. `<Badge {...{ className:
'x' }} />` is legal JSX that the static scan cannot read and the `className={`
scan does not match, so both contracts concluded there was nothing to govern.
Spreads join computed classNames as unreadable; `//` prop comments parse.
(TypeScript 7 is the Go port and ships no JS compiler API, so an AST is not
available to reach for — but the property that matters is loud-vs-silent, not
lexer-vs-parser.)
Three smaller ones: rungs are read from the ruler's own `:root` scope, so a
`--h-control-*` declared under `.dark` is no longer a tier a chip may name;
`--radius-pill` must exist, because renaming it reported CHIP POPULATION 0 and
passed; `.maka-sidebar-update-button` returns to COMPONENT_OWNED, where an
entry that was dead weight as a skip is the only assertion reaching it.
The e2e no longer credits the tier-choice arithmetic to a check that does not
exist. Measured with a throwaway probe: all four newly pinned chips resolve to
zero nodes in both booted fixtures, so that choice is unasserted, and the
comment says so.
Verified: 10/10 mutations caught, each confirmed to land before being trusted.
lint, format:check, typecheck, desktop 1364, ui 241, e2e 77, check-dead-css.
Sign up for freeto 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.

refactor(ui): compose text styles as role triplets at the call site

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

refactor(ui): name one text role at every renderer call site - #1893

Merged
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet
Aug 2, 2026
Merged

refactor(ui): name one text role at every renderer call site#1893
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

A text style is one indivisible role — size, leading, weight and family chosen together — so a call site names a role and declares nothing else about type. #1857 converged the sizes and #1878 the leadings; both removed the divergence of the day without removing the ability to diverge again, because the four properties stayed four separate choices per site. This makes them one choice.

The vehicle is the font: shorthand, which #1878 banned outright. That ban is inverted, not lifted: the shorthand is the only mechanism CSS has that makes the four inseparable, and its reset semantics — it resets weight, style, variant and family — turn from hazard into guarantee once the only legal value is a role token. The alternative the issue names, Astryx's <Text>/<Heading> components, cannot carry this surface: Text.d.ts types as as 'span' | 'p' | 'div' | 'label' | 'h1' | 'h2' | 'h3', which terminates 142 of the 348 selectors (strong, small, li, dt, code, pre, h4, th, time), and it exposes size/weight override props, so the triplet would not actually be indivisible.

Roles are Astryx's 14, composed from its generated atoms only. There are no product roles: the 42 blocks whose measured values had no Astryx equivalent were hand-rolled picks the ladder never offered — every Astryx heading is 600, yet 10 blocks used 700; the 12px tier offers 400 and 600, yet 11 used 500.

Two things had to move out of the call site for this to work. Family is an inheritance axis (--maka-font-family), because the shorthand's family slot is mandatory. And the role table is anchored on :rootand:where(code, kbd, samp, pre), because var() inside a custom property is substituted where the property is declared and the resolved string is what inherits — a table composed only on :root freezes the sans stack into every role. That is not a code-reading conclusion: measured, .maka-tool-diff-body, a real <pre>, read --maka-font-family: "Geist Mono Variable" and computed font-family: -apple-system. With the second anchor every code element gets the mono role for free, which is why 8 render sites moved to <code> markup instead of gaining a product role.

Six guards collapse into one. A ban on product --leading-* tiers, on literal ratios, on !important sizes and leadings, on em/rem multipliers, and the size↔leading pairing check are each a way for the four to come apart — none is expressible once a call site cannot write a font longhand. findLeadingPairingOffenders is deleted rather than kept beside the new check; two authorities on one question means the weaker one is the one people read. Six product tokens (--font-size-heading/stat/ui/caption, --font-sans, --font-default) reached zero consumers and are deleted with them.

Closes#1875.

Verification

Measured against 105ec3e87, not the branch point, since #1876 moved the session rail under this work.

Computed-style regression net. 16,076 text elements across 86 surfaces (15 e2e windows + every settings page and module hub via a navigation walk + 62 Storybook stories), four properties each, captured from a pristine-main worktree and re-captured here. 118 elements differ, every one a convergence this PR intends:

nchangewhat
46weight 600→400page titles, now on Astryx display roles (size and leading unchanged)
24weight 500→600prompt-rail titles → heading-5
19weight 500→600catalog badges, palette group headings, graph section label
11weight 700→600<strong> section titles — Astryx has no 700
9family mono→sansprose and numeric alignment where mono was decoration, not code
4weight 500→400hand-rolled 12px medium, emphasis already carried by color or pill chrome
5weight 600→400button, tab and count labels a container was leaking weight into

Defects the measurement found that reading the code did not. Each is recorded in the commit that fixes it: .maka-hero h1 was assigned from a measured value that described a different rule masking it; per-rule redundancy is not compositional (deleting 79 individually-redundant rules drifted 85 elements 12px→14px); custom-property substitution is not lazy (the single-anchor role table above); --maka-text-display-1 was referenced by the hero and defined nowhere, so the whole declaration was invalid and the element silently inherited; .maka-session-rename-input declared font: inheritafter its new role, leaving the role line dead. Also fixed a latent off-grid bug: .maka-tool-name rendered 16px against an inherited 1.6667 ratio = 26.67px.

What the net did not cover, and what review found there. The 118 are every element the walk rendered; 188 of the 371 role-declaring rules were never on a rendered surface, and four defects were living in that gap. Two were visible: .plan-proposal-kicker had moved from the 12px tier to the 14px one, because a grouped rule gave it a role and its own rule gave it another — the cross-rule form of the dead-role bug, and the reason the contract now has an arm for it. Two were resets the net does not sample: font: clears font-variant-numeric, and the stat tiles' tabular-nums sat in a rule the role's rule outranked. Six more rules in the gap drop --font-mono for the sans stack. Those six are the same call as the nine measured ones — mono was decoration, not code — and unlike the four above the cascade settles it without a render: none of the six is a code/kbd/samp/pre element, so the family slot resolves to the body stack and the other three axes are unchanged. Fifteen sites in total, not nine.

Mutation verification — 19 mutations, each backed up and restored with cp (no git checkout/restore/stash). The first eight cover the guard as originally written: a font longhand at a call site, a hand-composed shorthand, an undefined role, a role built from a foreign atom, a single-anchor table, a third family authority, an Astryx atom rebound to a literal, and a second font: declaration after a role. Review pointed out that all eight only re-trigger assertions already written, and none touches a boundary this PR introduced — so eleven more do: a longhand, a literal ratio and a hand-composed shorthand insidemaka-tokens.css; a role token rebound to a value; the family axis rebound to a literal stack; an Astryx atom rebound to calc(); a longhand inside a rule-nested @media; an upper-cased property name; a second role rule for one selector; the role table anchored on :root alone; and the family longhand moved off the code anchor. All nineteen fire; the tree restores green.

Visual evidence. Live-app captures, main on the left and this branch on the right, composed with ImageMagick (+append) — no generative model touched the pixels.

Module hub — page title 25px 600→400, skill ids stay mono as <code> markupimage
Settings · 通用 — page title 22px 600→400, size and leading unchangedimage
Settings · 记忆 — strong 700→600, filename stays mono while the prose beside it drops to sans, path is now <code>image
Settings · 权限与能力 — capability ids as <code>, section titles on heading rolesimage

Checks run:lint ✅ · format:check ✅ · typecheck ✅ · @maka/desktop test 1339 ✅ · @maka/ui test 239 ✅ · desktop e2e 73/73 ✅ · check-dead-css --check ✅ · astryx:theme --check ✅.

Not run: no per-provider or new E2E fixture was added — the three new invariants that need a live document are steps inside the existing e2e/type-scale.spec.ts probe.

Review focus

Page titles lose their weight, not their size..settingsPageHeader h2, .maka-module-main-header h2 and .maka-plan-heading h2 were display sizes (22px/25px) carrying a hand-rolled semibold. Astryx's display roles are weight 400 by design. Matching on weight instead would pick heading-1 and collapse 46 page titles to 20px, level with the section headings under them — so this trades weight to keep hierarchy. It is the most visible change in the PR and the one most worth disagreeing with, and it is a deliberate override of Astryx's own guidance, which reserves display for hero, marketing and data-callout text and says not to use it for document headings. Recorded at the call site rather than only here.

The component route is closed by the vendor's type union, not just by as.Text's as prop accepts only span | p | div | label | h1 | h2 | h3, which strands the strong / small / li / dt / code / pre selectors — but the harder blocker is BuiltinTextType, which has no heading roles at all: 99 call sites name heading-1..5 and <Text> cannot express any of them. Heading can, and unlike Text it exposes no size / weight overrides — the divisibility argument applies to Text only. For page headings Heading remains the more idiomatic seam; this PR does not move markup that far.

Redundant role declarations are kept, deliberately. The joint-redundancy probe says 79 of 371 role-declaring rules change nothing when removed — but only 183 of the 371 are exercised by any surface the harness reaches, so 51% of the vocabulary is unverifiable, and this PR has already been bitten twice by "the measured value described a different rule than the one I was editing". Deleting on that evidence would be asserting redundancy about rules the measurement never saw. A no-op font: var(--maka-text-body) costs nothing structurally: the point of the role token is that a call site can no longer diverge into four values.

A text style is one role, not four independent picks. #1857 converged the
sizes and #1878 the leadings; both removed the divergence of the day without
removing the ability to diverge again, because size, leading, weight and
family stayed four separate choices at each of 348 call sites.
The `font:` shorthand is the only mechanism CSS has that makes them
inseparable. Its reset semantics are why it fits rather than a hazard to work
around: it resets weight, style, variant and family, and the role token
supplies them, so no site can move one and leave the others.
Composed from Astryx's generated atoms only, so the ladder stays a pure
function of typography.scale. Family is not part of the call-site triplet:
`font:` requires a family slot that would override the one monospace
authority in this file, so the slot reads --maka-font-family, rebound on
:where(code, kbd, samp, pre) and inherited from there.
Measured behavior-neutral: 12262 text elements across 86 surfaces (24 live
app states, 62 Storybook stories) compared before and after, 0 changed.
381 declaration blocks across 44 stylesheets now declare one `font:
var(--maka-text-<role>)` and nothing else about type. Exactly one raw font
longhand survives in the renderer — the monospace stack in maka-tokens.css
that owns the family axis.
Roles are assigned from MEASURED computed styles, not from declared values,
so the ~30 selectors that took their weight from the UA or an ancestor keep
it: 187 of the 385 blocks render in at least one of the 100 surfaces the
harness reaches and were classified from what the browser reported there.
Three things measurement caught that reading the CSS did not:
- `.maka-hero h1` declares 28px but every probed instance is also
`.maka-hero-empty-chat h1` at 20px, so the measured role described the
other rule. Taking it would have shrunk the onboarding hero, the one
surface that keeps the large scale and that no fixture renders. A measured
role is now rejected when it disagrees with the block's own declared size.
- Per-rule redundancy is not compositional: two rules that both pin 12px on
one element are each individually removable and jointly load-bearing.
Deleting on that basis drifted 85 elements 12px→14px, so this commit only
migrates; deletion is decided against the final tree.
- A role composed on :root freezes its family slot there — custom property
var() references are substituted where DECLARED, and the resolved string is
what inherits. A real <pre> whose --maka-font-family read Geist Mono still
computed -apple-system. The role table is anchored on `:root, :where(code,
kbd, samp, pre)` so code elements compose their own mono-family roles.
`.maka-tool-name` also loses a latent defect: measured 16px against a 1.6667
ratio inherited from the supporting role, 26.67px, off the 4px grid.
The 348-call-site migration was assigned from measured computed values, so
the roles it picked were the ones the browser reported — which is exactly
why re-measuring the migrated tree found the places where the measured
value described something other than the call site's intent. Every change
below is a diff bucket from that re-measurement, named and closed.
Markup, not a call-site family. A slug, an id, a task key, a capability id,
a memory path, a tool name, a summarized error — these are code, and the
role table composes the code family for the code element group, so saying
so in the markup is also what makes them render monospaced. 8 render sites
move to <code>; `SettingRow`'s `mono` prop now picks the element.
UA bold is not a role. 17 blocks selected strong/h*/th and declared no
weight of their own, so they took the UA's 700 by accident. Naming a body
or supporting role resets that to 400 — measured, 21 elements went 700→400
before this. They are section titles at 14px and 12px, which is heading-4
and heading-5: same size, same leading, weight 700→600 (Astryx has no 700).
Page titles keep their size, not their weight. `.settingsPageHeader h2`,
`.maka-module-main-header h2` and `.maka-plan-heading h2` were display
sizes carrying a hand-rolled semibold. Matching on weight picked heading-1
and collapsed 66 page titles to 20px, level with the section headings
under them. Display roles keep 25px/22px and take Astryx's display weight
of 400.
A family-only rule hides its size. `.maka-mcp-*` set only font-family, and
its three selectors inherited two different sizes — 14px in the card body,
12px in the description. One role could not express that, so the rule
splits: code role for the 14px pair, supporting for the description.
`.maka-nav-kbd` opts back out of mono. It read `font-family: inherit`
before; as a <kbd> it now composes the mono role table. It names the family
axis instead — one declaration, and the role stays whole.
Remaining measured delta is 206 of 16,063 elements across 86 surfaces,
every bucket a convergence this PR intends: 66 page titles 600→400, 32
badges and 28 rail prompts 500→600, 21 strong 700→600, 30 family changes
where mono was decoration rather than code, and 13 hand-rolled 12px/500
sites landing on supporting, their emphasis already carried by color or
pill chrome.
Size, leading, weight and family were four independent properties, so each
needed its own guard: a ban on product `--leading-*` tiers, on literal
ratios, on `!important` sizes and leadings, on em/rem multipliers, and a
pairing check that resolved a block's size and leading through the
generated theme to prove they named the same tier. Every one of those
describes a way for the four to come apart.
None of them is expressible now. A call site that cannot write a font
longhand cannot write a literal ratio, an em multiplier, a forced size, or
a mismatched pair — so they collapse into one assertion: outside the role
table, every text style is `font: var(--maka-text-<role>)` and no
stylesheet declares font-size, line-height, font-weight or font-family.
`findLeadingPairingOffenders` is deleted rather than kept alongside it;
two authorities on one question means the weaker one is the one people
read.
`findFontShorthandOffenders` inverts instead of tightening. The shorthand
was banned because it hides bare weights and leadings; it is now the only
legal form, because it is the one CSS mechanism that makes the four
inseparable. The replacement also checks that every role NAMED is a role
DEFINED — a var() that resolves to nothing makes the whole declaration
invalid at computed-value time, so the element silently keeps what it
inherits. That arm found a live defect: `--maka-text-display-1` was
referenced by the hero and defined nowhere, and every other check in the
file was green.
Two things text cannot prove keep their own guards. The role table's shape
is asserted in the contract (each role composed only from its own Astryx
atoms, declared on both anchors, family axis pinned once per anchor). What
those resolve to in a document is measured in e2e: a <pre> and a <div>
naming the same role must agree on size, weight and leading and differ
only in family — the substitution-timing fact that made the first, single
-anchor role table wrong while reading perfectly in CSS.
Seven mutations verified, each restored with cp: a longhand at a call
site, a hand-composed shorthand, an undefined role, a role built from a
foreign atom, a single-anchor table, a third family authority, and an
Astryx atom rebound to a literal. Desktop 1323 pass, ui 239 pass.
Six product tokens reached zero consumers when call sites stopped naming
sizes and families, and check-dead-css found all six: --font-size-heading
/ stat / ui / caption, --font-sans, --font-default. They existed so a call
site could say "this is the caption size" or "this is the mono stack" —
a role says both, together with the weight and the leading, so a second
set of names for the same rungs is exactly the second authority the
convergence exists to remove. The role table now reads --font-family-code
directly, and nothing in the renderer aliases an Astryx type name.
The contract that pinned those aliases is replaced by the stronger thing
they were approximating: there is no product name for a size or a family
at all. --font-weight-bold survives as the top rung of the series the
generated theme reads, in RESERVED_SCALE_TOKENS with that reason.
Also caught rebasing onto #1876: `.maka-session-rename-input` declared
`font: inherit` before its longhands, so migrating the longhands to a role
left the reset winning and the role line dead. `findTextRoleOffenders`
now counts font declarations before reading the last one — a block that
declares a role and then overrides it reads as legal to any check that
asks only what the browser uses. Eight mutations verified with cp.
Measured against 105ec3e rather than the branch point, since #1876 moved
the session rail under this work: 118 of 16,076 text elements across 86
surfaces change, every one a bucket this PR intends.
Review found four sites where naming a role changed more than the role.
The `font:` shorthand resets `font-variant-*`, `font-style` and the feature
settings along with the four axes it names. Within a rule every site already
declared those after the role; across rules two did not. `.maka-stat-tile-value`
held the tabular figures the tiles exist to align while the role sat in the
`-outline` / `-filled` modifiers, so the role won and the digits stopped
aligning; reordering makes the base rule the later one. `code.settingsReadOnlyValue`
restates the declaration because it is the higher-specificity selector.
A grouped rule that hands a selector a role, followed by that selector's own
rule handing it another, leaves the first dead — and a later retune of the
group moves every other member while this site silently stays put.
`.plan-proposal-kicker` had already drifted that way, from the 12px tier to
the 14px one; the 12px tier offers 400 and 600, and an eyebrow over a title
takes the 600. `.maka-agent-graph-section-label` was the same shape without a
size change.
`data-mono` said in an attribute what the `<code>` element already says.
The collapse to one guard was real for the shape it checks and a regression
for what it can see. Three holes, each found by mutation:
Scope. `readCallSiteCss()` skipped all of maka-tokens.css so the role table
could write its one family longhand. That file is 1400 lines, ~40 of them
ordinary component rules, and they were exempt from the only remaining guard —
a literal ratio, an em multiplier, an `!important` size and a hand-composed
shorthand all passed there while failing everywhere else. The exemption is now
the one declaration that needs it: `font-family` on the code element group.
Level. `--maka-text-*` and `--maka-font-family` are the vocabulary this branch
introduced and nothing checked what they were bound to. One rule rebinding a
role to `700 44px/1.05 Impact` re-chose all four axes while its call site still
named exactly one role. Astryx's atoms were checked, but only against a leading
digit, so `calc(2.5)` and `max(24px, 1rem)` walked through. Both arms are now
one rule: a type token may be rebound to another token, never to a value.
Parser. The hand-rolled brace walk dropped at-rule bodies whole, so a longhand
inside a rule-nested `@media` — the shape Astryx itself uses for coarse
pointers — was invisible; it also treated a brace inside a string as
structural, and had lost the case-insensitivity its predecessor had. Replaced
with postcss, which is already what Vite parses this same CSS with. A
rule-nested at-rule is its own cascade context, so a responsive role swap is
not counted as a duplicate.
One arm is new rather than restored: a selector may be given a role by only
one rule per cascade context. That is the cross-rule form of the dead-role
bug the in-rule count already caught.
The e2e step that read the four deleted size aliases now measures the roles
instead. It had been failing since those names were deleted, and because a
failing step aborts the test, every later step — the off-grid sweep and both
two-anchor probes — had stopped running. The two-anchor probe also pins the
three non-family axes to the supporting tier's own values; equality between
the two probes alone would hold if the role stopped resolving for both.
Mutation-verified (cp backup/restore, no git):
longhand / literal ratio / hand-composed shorthand inside maka-tokens.css,
role token rebound to a value, family axis rebound to a stack, atom rebound
to calc(), longhand in a rule-nested @media, upper-cased longhand, a second
role rule for one selector, role table on :root alone, family longhand moved
off the code anchor — 11 of 11 caught, baseline clean.
… role costs
`--font-weight-bold` had no consumer once call sites stopped declaring weight,
and the generated theme reads only normal / medium / semibold. It survived on a
check-dead-css allowlist entry whose stated reason was that the scale reads as
a scale with it present — an aesthetic, not a constraint, and a product name
for an axis the roles own.
Four things the role table implies and did not say: the shorthand resets font
sub-properties, so any the site needs must come after it; a 12px site that
titles something below it takes heading-5 and everything else takes supporting,
which is what makes the hand-rolled 500s reviewable; 97 call sites name a
heading role for its weight rather than because they are headings, so a
`weights.heading` retune moves them; and `large` / `heading-6` are absent
because nothing uses them, not because the projection is partial. Astryx does
support product roles through defineTheme component overrides — staying a pure
projection of the vendor set is a choice, and now says so.
Display on a page title is a deliberate override of Astryx's own guidance,
recorded where the override is. Every heading role is weight 600 and tops out
at 20px, so matching on weight would collapse a 25px page title level with the
section headings under it.
Also three comments naming tokens this branch deleted.
@Astro-Han
Astro-Han marked this pull request as ready for review August 2, 2026 09:10
@Astro-Han
Astro-Han merged commit 8c84265 into mainAug 2, 2026
5 checks passed
@Astro-Han
Astro-Han deleted the refactor/typography-role-triplet branch August 2, 2026 09:12
Astro-Han added a commit that referenced this pull request Aug 2, 2026
Squashed for rebase onto #1893; final message written at the end.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
… exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
* fix(ui): give the single-line chip family a box the component owns
Squashed for rebase onto #1893; final message written at the end.
* fix(ui): derive the chip population from shape alone and assert every exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
* test(desktop): close the guard holes three re-reviews found in the chip contracts
Every finding below was reproduced as a mutation that passed green, and each
fix is re-verified by the same mutation failing. Four root causes, three of
which delete something.
The predicate read text, not declarations. `/padding/` also matches
`background-clip: padding-box`, which is how a scrollbar thumb — padding-less,
and a box no reader perceives as an object — entered the chip population and
earned an exemption group of its own. The group is deleted with the substring
that created it, and the padding vocabulary becomes one shared authority.
The same guard-narrower-than-CSS mistake, twice more. The Badge contract's
`OWNED` gained the logical padding family and lost `padding-top/right/bottom/
left`, so `padding-left: 9px` on a Badge className redrew the component's box
while green. And the off-ruler exemption proved "still pinned" with the
constrains-a-block-size vocabulary, so rewriting its `height` as `min-height`
handed the box back to the line box and stayed green.
Which merge view follows from which question, applied to all three sites
rather than one. A must-NOT contract has no conditional distinction: pinning a
wrapping row, or re-adding the height override #1879 removed, inside
`@media (max-width: 620px)` passed the unconditional view. A must-HOLD PAIR
needs the opposite — the flattened view let a Badge release `height` in one
breakpoint and `white-space` in the mutually exclusive one, releasing the box
at no viewport at all, so releases are now read per cascade context.
A scan that cannot read a call site has to say so. `<Badge {...{ className:
'x' }} />` is legal JSX that the static scan cannot read and the `className={`
scan does not match, so both contracts concluded there was nothing to govern.
Spreads join computed classNames as unreadable; `//` prop comments parse.
(TypeScript 7 is the Go port and ships no JS compiler API, so an AST is not
available to reach for — but the property that matters is loud-vs-silent, not
lexer-vs-parser.)
Three smaller ones: rungs are read from the ruler's own `:root` scope, so a
`--h-control-*` declared under `.dark` is no longer a tier a chip may name;
`--radius-pill` must exist, because renaming it reported CHIP POPULATION 0 and
passed; `.maka-sidebar-update-button` returns to COMPONENT_OWNED, where an
entry that was dead weight as a skip is the only assertion reaching it.
The e2e no longer credits the tier-choice arithmetic to a check that does not
exist. Measured with a throwaway probe: all four newly pinned chips resolve to
zero nodes in both booted fixtures, so that choice is unasserted, and the
comment says so.
Verified: 10/10 mutations caught, each confirmed to land before being trusted.
lint, format:check, typecheck, desktop 1364, ui 241, e2e 77, check-dead-css.
Sign up for freeto 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.

refactor(ui): compose text styles as role triplets at the call site

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(ui): name one text role at every renderer call site - #1893

Merged
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet
Aug 2, 2026
Merged

refactor(ui): name one text role at every renderer call site#1893
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

A text style is one indivisible role — size, leading, weight and family chosen together — so a call site names a role and declares nothing else about type. #1857 converged the sizes and #1878 the leadings; both removed the divergence of the day without removing the ability to diverge again, because the four properties stayed four separate choices per site. This makes them one choice.

The vehicle is the font: shorthand, which #1878 banned outright. That ban is inverted, not lifted: the shorthand is the only mechanism CSS has that makes the four inseparable, and its reset semantics — it resets weight, style, variant and family — turn from hazard into guarantee once the only legal value is a role token. The alternative the issue names, Astryx's <Text>/<Heading> components, cannot carry this surface: Text.d.ts types as as 'span' | 'p' | 'div' | 'label' | 'h1' | 'h2' | 'h3', which terminates 142 of the 348 selectors (strong, small, li, dt, code, pre, h4, th, time), and it exposes size/weight override props, so the triplet would not actually be indivisible.

Roles are Astryx's 14, composed from its generated atoms only. There are no product roles: the 42 blocks whose measured values had no Astryx equivalent were hand-rolled picks the ladder never offered — every Astryx heading is 600, yet 10 blocks used 700; the 12px tier offers 400 and 600, yet 11 used 500.

Two things had to move out of the call site for this to work. Family is an inheritance axis (--maka-font-family), because the shorthand's family slot is mandatory. And the role table is anchored on :rootand:where(code, kbd, samp, pre), because var() inside a custom property is substituted where the property is declared and the resolved string is what inherits — a table composed only on :root freezes the sans stack into every role. That is not a code-reading conclusion: measured, .maka-tool-diff-body, a real <pre>, read --maka-font-family: "Geist Mono Variable" and computed font-family: -apple-system. With the second anchor every code element gets the mono role for free, which is why 8 render sites moved to <code> markup instead of gaining a product role.

Six guards collapse into one. A ban on product --leading-* tiers, on literal ratios, on !important sizes and leadings, on em/rem multipliers, and the size↔leading pairing check are each a way for the four to come apart — none is expressible once a call site cannot write a font longhand. findLeadingPairingOffenders is deleted rather than kept beside the new check; two authorities on one question means the weaker one is the one people read. Six product tokens (--font-size-heading/stat/ui/caption, --font-sans, --font-default) reached zero consumers and are deleted with them.

Closes#1875.

Verification

Measured against 105ec3e87, not the branch point, since #1876 moved the session rail under this work.

Computed-style regression net. 16,076 text elements across 86 surfaces (15 e2e windows + every settings page and module hub via a navigation walk + 62 Storybook stories), four properties each, captured from a pristine-main worktree and re-captured here. 118 elements differ, every one a convergence this PR intends:

nchangewhat
46weight 600→400page titles, now on Astryx display roles (size and leading unchanged)
24weight 500→600prompt-rail titles → heading-5
19weight 500→600catalog badges, palette group headings, graph section label
11weight 700→600<strong> section titles — Astryx has no 700
9family mono→sansprose and numeric alignment where mono was decoration, not code
4weight 500→400hand-rolled 12px medium, emphasis already carried by color or pill chrome
5weight 600→400button, tab and count labels a container was leaking weight into

Defects the measurement found that reading the code did not. Each is recorded in the commit that fixes it: .maka-hero h1 was assigned from a measured value that described a different rule masking it; per-rule redundancy is not compositional (deleting 79 individually-redundant rules drifted 85 elements 12px→14px); custom-property substitution is not lazy (the single-anchor role table above); --maka-text-display-1 was referenced by the hero and defined nowhere, so the whole declaration was invalid and the element silently inherited; .maka-session-rename-input declared font: inheritafter its new role, leaving the role line dead. Also fixed a latent off-grid bug: .maka-tool-name rendered 16px against an inherited 1.6667 ratio = 26.67px.

What the net did not cover, and what review found there. The 118 are every element the walk rendered; 188 of the 371 role-declaring rules were never on a rendered surface, and four defects were living in that gap. Two were visible: .plan-proposal-kicker had moved from the 12px tier to the 14px one, because a grouped rule gave it a role and its own rule gave it another — the cross-rule form of the dead-role bug, and the reason the contract now has an arm for it. Two were resets the net does not sample: font: clears font-variant-numeric, and the stat tiles' tabular-nums sat in a rule the role's rule outranked. Six more rules in the gap drop --font-mono for the sans stack. Those six are the same call as the nine measured ones — mono was decoration, not code — and unlike the four above the cascade settles it without a render: none of the six is a code/kbd/samp/pre element, so the family slot resolves to the body stack and the other three axes are unchanged. Fifteen sites in total, not nine.

Mutation verification — 19 mutations, each backed up and restored with cp (no git checkout/restore/stash). The first eight cover the guard as originally written: a font longhand at a call site, a hand-composed shorthand, an undefined role, a role built from a foreign atom, a single-anchor table, a third family authority, an Astryx atom rebound to a literal, and a second font: declaration after a role. Review pointed out that all eight only re-trigger assertions already written, and none touches a boundary this PR introduced — so eleven more do: a longhand, a literal ratio and a hand-composed shorthand insidemaka-tokens.css; a role token rebound to a value; the family axis rebound to a literal stack; an Astryx atom rebound to calc(); a longhand inside a rule-nested @media; an upper-cased property name; a second role rule for one selector; the role table anchored on :root alone; and the family longhand moved off the code anchor. All nineteen fire; the tree restores green.

Visual evidence. Live-app captures, main on the left and this branch on the right, composed with ImageMagick (+append) — no generative model touched the pixels.

Module hub — page title 25px 600→400, skill ids stay mono as <code> markupimage
Settings · 通用 — page title 22px 600→400, size and leading unchangedimage
Settings · 记忆 — strong 700→600, filename stays mono while the prose beside it drops to sans, path is now <code>image
Settings · 权限与能力 — capability ids as <code>, section titles on heading rolesimage

Checks run:lint ✅ · format:check ✅ · typecheck ✅ · @maka/desktop test 1339 ✅ · @maka/ui test 239 ✅ · desktop e2e 73/73 ✅ · check-dead-css --check ✅ · astryx:theme --check ✅.

Not run: no per-provider or new E2E fixture was added — the three new invariants that need a live document are steps inside the existing e2e/type-scale.spec.ts probe.

Review focus

Page titles lose their weight, not their size..settingsPageHeader h2, .maka-module-main-header h2 and .maka-plan-heading h2 were display sizes (22px/25px) carrying a hand-rolled semibold. Astryx's display roles are weight 400 by design. Matching on weight instead would pick heading-1 and collapse 46 page titles to 20px, level with the section headings under them — so this trades weight to keep hierarchy. It is the most visible change in the PR and the one most worth disagreeing with, and it is a deliberate override of Astryx's own guidance, which reserves display for hero, marketing and data-callout text and says not to use it for document headings. Recorded at the call site rather than only here.

The component route is closed by the vendor's type union, not just by as.Text's as prop accepts only span | p | div | label | h1 | h2 | h3, which strands the strong / small / li / dt / code / pre selectors — but the harder blocker is BuiltinTextType, which has no heading roles at all: 99 call sites name heading-1..5 and <Text> cannot express any of them. Heading can, and unlike Text it exposes no size / weight overrides — the divisibility argument applies to Text only. For page headings Heading remains the more idiomatic seam; this PR does not move markup that far.

Redundant role declarations are kept, deliberately. The joint-redundancy probe says 79 of 371 role-declaring rules change nothing when removed — but only 183 of the 371 are exercised by any surface the harness reaches, so 51% of the vocabulary is unverifiable, and this PR has already been bitten twice by "the measured value described a different rule than the one I was editing". Deleting on that evidence would be asserting redundancy about rules the measurement never saw. A no-op font: var(--maka-text-body) costs nothing structurally: the point of the role token is that a call site can no longer diverge into four values.

A text style is one role, not four independent picks. #1857 converged the
sizes and #1878 the leadings; both removed the divergence of the day without
removing the ability to diverge again, because size, leading, weight and
family stayed four separate choices at each of 348 call sites.
The `font:` shorthand is the only mechanism CSS has that makes them
inseparable. Its reset semantics are why it fits rather than a hazard to work
around: it resets weight, style, variant and family, and the role token
supplies them, so no site can move one and leave the others.
Composed from Astryx's generated atoms only, so the ladder stays a pure
function of typography.scale. Family is not part of the call-site triplet:
`font:` requires a family slot that would override the one monospace
authority in this file, so the slot reads --maka-font-family, rebound on
:where(code, kbd, samp, pre) and inherited from there.
Measured behavior-neutral: 12262 text elements across 86 surfaces (24 live
app states, 62 Storybook stories) compared before and after, 0 changed.
381 declaration blocks across 44 stylesheets now declare one `font:
var(--maka-text-<role>)` and nothing else about type. Exactly one raw font
longhand survives in the renderer — the monospace stack in maka-tokens.css
that owns the family axis.
Roles are assigned from MEASURED computed styles, not from declared values,
so the ~30 selectors that took their weight from the UA or an ancestor keep
it: 187 of the 385 blocks render in at least one of the 100 surfaces the
harness reaches and were classified from what the browser reported there.
Three things measurement caught that reading the CSS did not:
- `.maka-hero h1` declares 28px but every probed instance is also
`.maka-hero-empty-chat h1` at 20px, so the measured role described the
other rule. Taking it would have shrunk the onboarding hero, the one
surface that keeps the large scale and that no fixture renders. A measured
role is now rejected when it disagrees with the block's own declared size.
- Per-rule redundancy is not compositional: two rules that both pin 12px on
one element are each individually removable and jointly load-bearing.
Deleting on that basis drifted 85 elements 12px→14px, so this commit only
migrates; deletion is decided against the final tree.
- A role composed on :root freezes its family slot there — custom property
var() references are substituted where DECLARED, and the resolved string is
what inherits. A real <pre> whose --maka-font-family read Geist Mono still
computed -apple-system. The role table is anchored on `:root, :where(code,
kbd, samp, pre)` so code elements compose their own mono-family roles.
`.maka-tool-name` also loses a latent defect: measured 16px against a 1.6667
ratio inherited from the supporting role, 26.67px, off the 4px grid.
The 348-call-site migration was assigned from measured computed values, so
the roles it picked were the ones the browser reported — which is exactly
why re-measuring the migrated tree found the places where the measured
value described something other than the call site's intent. Every change
below is a diff bucket from that re-measurement, named and closed.
Markup, not a call-site family. A slug, an id, a task key, a capability id,
a memory path, a tool name, a summarized error — these are code, and the
role table composes the code family for the code element group, so saying
so in the markup is also what makes them render monospaced. 8 render sites
move to <code>; `SettingRow`'s `mono` prop now picks the element.
UA bold is not a role. 17 blocks selected strong/h*/th and declared no
weight of their own, so they took the UA's 700 by accident. Naming a body
or supporting role resets that to 400 — measured, 21 elements went 700→400
before this. They are section titles at 14px and 12px, which is heading-4
and heading-5: same size, same leading, weight 700→600 (Astryx has no 700).
Page titles keep their size, not their weight. `.settingsPageHeader h2`,
`.maka-module-main-header h2` and `.maka-plan-heading h2` were display
sizes carrying a hand-rolled semibold. Matching on weight picked heading-1
and collapsed 66 page titles to 20px, level with the section headings
under them. Display roles keep 25px/22px and take Astryx's display weight
of 400.
A family-only rule hides its size. `.maka-mcp-*` set only font-family, and
its three selectors inherited two different sizes — 14px in the card body,
12px in the description. One role could not express that, so the rule
splits: code role for the 14px pair, supporting for the description.
`.maka-nav-kbd` opts back out of mono. It read `font-family: inherit`
before; as a <kbd> it now composes the mono role table. It names the family
axis instead — one declaration, and the role stays whole.
Remaining measured delta is 206 of 16,063 elements across 86 surfaces,
every bucket a convergence this PR intends: 66 page titles 600→400, 32
badges and 28 rail prompts 500→600, 21 strong 700→600, 30 family changes
where mono was decoration rather than code, and 13 hand-rolled 12px/500
sites landing on supporting, their emphasis already carried by color or
pill chrome.
Size, leading, weight and family were four independent properties, so each
needed its own guard: a ban on product `--leading-*` tiers, on literal
ratios, on `!important` sizes and leadings, on em/rem multipliers, and a
pairing check that resolved a block's size and leading through the
generated theme to prove they named the same tier. Every one of those
describes a way for the four to come apart.
None of them is expressible now. A call site that cannot write a font
longhand cannot write a literal ratio, an em multiplier, a forced size, or
a mismatched pair — so they collapse into one assertion: outside the role
table, every text style is `font: var(--maka-text-<role>)` and no
stylesheet declares font-size, line-height, font-weight or font-family.
`findLeadingPairingOffenders` is deleted rather than kept alongside it;
two authorities on one question means the weaker one is the one people
read.
`findFontShorthandOffenders` inverts instead of tightening. The shorthand
was banned because it hides bare weights and leadings; it is now the only
legal form, because it is the one CSS mechanism that makes the four
inseparable. The replacement also checks that every role NAMED is a role
DEFINED — a var() that resolves to nothing makes the whole declaration
invalid at computed-value time, so the element silently keeps what it
inherits. That arm found a live defect: `--maka-text-display-1` was
referenced by the hero and defined nowhere, and every other check in the
file was green.
Two things text cannot prove keep their own guards. The role table's shape
is asserted in the contract (each role composed only from its own Astryx
atoms, declared on both anchors, family axis pinned once per anchor). What
those resolve to in a document is measured in e2e: a <pre> and a <div>
naming the same role must agree on size, weight and leading and differ
only in family — the substitution-timing fact that made the first, single
-anchor role table wrong while reading perfectly in CSS.
Seven mutations verified, each restored with cp: a longhand at a call
site, a hand-composed shorthand, an undefined role, a role built from a
foreign atom, a single-anchor table, a third family authority, and an
Astryx atom rebound to a literal. Desktop 1323 pass, ui 239 pass.
Six product tokens reached zero consumers when call sites stopped naming
sizes and families, and check-dead-css found all six: --font-size-heading
/ stat / ui / caption, --font-sans, --font-default. They existed so a call
site could say "this is the caption size" or "this is the mono stack" —
a role says both, together with the weight and the leading, so a second
set of names for the same rungs is exactly the second authority the
convergence exists to remove. The role table now reads --font-family-code
directly, and nothing in the renderer aliases an Astryx type name.
The contract that pinned those aliases is replaced by the stronger thing
they were approximating: there is no product name for a size or a family
at all. --font-weight-bold survives as the top rung of the series the
generated theme reads, in RESERVED_SCALE_TOKENS with that reason.
Also caught rebasing onto #1876: `.maka-session-rename-input` declared
`font: inherit` before its longhands, so migrating the longhands to a role
left the reset winning and the role line dead. `findTextRoleOffenders`
now counts font declarations before reading the last one — a block that
declares a role and then overrides it reads as legal to any check that
asks only what the browser uses. Eight mutations verified with cp.
Measured against 105ec3e rather than the branch point, since #1876 moved
the session rail under this work: 118 of 16,076 text elements across 86
surfaces change, every one a bucket this PR intends.
Review found four sites where naming a role changed more than the role.
The `font:` shorthand resets `font-variant-*`, `font-style` and the feature
settings along with the four axes it names. Within a rule every site already
declared those after the role; across rules two did not. `.maka-stat-tile-value`
held the tabular figures the tiles exist to align while the role sat in the
`-outline` / `-filled` modifiers, so the role won and the digits stopped
aligning; reordering makes the base rule the later one. `code.settingsReadOnlyValue`
restates the declaration because it is the higher-specificity selector.
A grouped rule that hands a selector a role, followed by that selector's own
rule handing it another, leaves the first dead — and a later retune of the
group moves every other member while this site silently stays put.
`.plan-proposal-kicker` had already drifted that way, from the 12px tier to
the 14px one; the 12px tier offers 400 and 600, and an eyebrow over a title
takes the 600. `.maka-agent-graph-section-label` was the same shape without a
size change.
`data-mono` said in an attribute what the `<code>` element already says.
The collapse to one guard was real for the shape it checks and a regression
for what it can see. Three holes, each found by mutation:
Scope. `readCallSiteCss()` skipped all of maka-tokens.css so the role table
could write its one family longhand. That file is 1400 lines, ~40 of them
ordinary component rules, and they were exempt from the only remaining guard —
a literal ratio, an em multiplier, an `!important` size and a hand-composed
shorthand all passed there while failing everywhere else. The exemption is now
the one declaration that needs it: `font-family` on the code element group.
Level. `--maka-text-*` and `--maka-font-family` are the vocabulary this branch
introduced and nothing checked what they were bound to. One rule rebinding a
role to `700 44px/1.05 Impact` re-chose all four axes while its call site still
named exactly one role. Astryx's atoms were checked, but only against a leading
digit, so `calc(2.5)` and `max(24px, 1rem)` walked through. Both arms are now
one rule: a type token may be rebound to another token, never to a value.
Parser. The hand-rolled brace walk dropped at-rule bodies whole, so a longhand
inside a rule-nested `@media` — the shape Astryx itself uses for coarse
pointers — was invisible; it also treated a brace inside a string as
structural, and had lost the case-insensitivity its predecessor had. Replaced
with postcss, which is already what Vite parses this same CSS with. A
rule-nested at-rule is its own cascade context, so a responsive role swap is
not counted as a duplicate.
One arm is new rather than restored: a selector may be given a role by only
one rule per cascade context. That is the cross-rule form of the dead-role
bug the in-rule count already caught.
The e2e step that read the four deleted size aliases now measures the roles
instead. It had been failing since those names were deleted, and because a
failing step aborts the test, every later step — the off-grid sweep and both
two-anchor probes — had stopped running. The two-anchor probe also pins the
three non-family axes to the supporting tier's own values; equality between
the two probes alone would hold if the role stopped resolving for both.
Mutation-verified (cp backup/restore, no git):
longhand / literal ratio / hand-composed shorthand inside maka-tokens.css,
role token rebound to a value, family axis rebound to a stack, atom rebound
to calc(), longhand in a rule-nested @media, upper-cased longhand, a second
role rule for one selector, role table on :root alone, family longhand moved
off the code anchor — 11 of 11 caught, baseline clean.
… role costs
`--font-weight-bold` had no consumer once call sites stopped declaring weight,
and the generated theme reads only normal / medium / semibold. It survived on a
check-dead-css allowlist entry whose stated reason was that the scale reads as
a scale with it present — an aesthetic, not a constraint, and a product name
for an axis the roles own.
Four things the role table implies and did not say: the shorthand resets font
sub-properties, so any the site needs must come after it; a 12px site that
titles something below it takes heading-5 and everything else takes supporting,
which is what makes the hand-rolled 500s reviewable; 97 call sites name a
heading role for its weight rather than because they are headings, so a
`weights.heading` retune moves them; and `large` / `heading-6` are absent
because nothing uses them, not because the projection is partial. Astryx does
support product roles through defineTheme component overrides — staying a pure
projection of the vendor set is a choice, and now says so.
Display on a page title is a deliberate override of Astryx's own guidance,
recorded where the override is. Every heading role is weight 600 and tops out
at 20px, so matching on weight would collapse a 25px page title level with the
section headings under it.
Also three comments naming tokens this branch deleted.
@Astro-Han
Astro-Han marked this pull request as ready for review August 2, 2026 09:10
@Astro-Han
Astro-Han merged commit 8c84265 into mainAug 2, 2026
5 checks passed
@Astro-Han
Astro-Han deleted the refactor/typography-role-triplet branch August 2, 2026 09:12
Astro-Han added a commit that referenced this pull request Aug 2, 2026
Squashed for rebase onto #1893; final message written at the end.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
… exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
* fix(ui): give the single-line chip family a box the component owns
Squashed for rebase onto #1893; final message written at the end.
* fix(ui): derive the chip population from shape alone and assert every exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
* test(desktop): close the guard holes three re-reviews found in the chip contracts
Every finding below was reproduced as a mutation that passed green, and each
fix is re-verified by the same mutation failing. Four root causes, three of
which delete something.
The predicate read text, not declarations. `/padding/` also matches
`background-clip: padding-box`, which is how a scrollbar thumb — padding-less,
and a box no reader perceives as an object — entered the chip population and
earned an exemption group of its own. The group is deleted with the substring
that created it, and the padding vocabulary becomes one shared authority.
The same guard-narrower-than-CSS mistake, twice more. The Badge contract's
`OWNED` gained the logical padding family and lost `padding-top/right/bottom/
left`, so `padding-left: 9px` on a Badge className redrew the component's box
while green. And the off-ruler exemption proved "still pinned" with the
constrains-a-block-size vocabulary, so rewriting its `height` as `min-height`
handed the box back to the line box and stayed green.
Which merge view follows from which question, applied to all three sites
rather than one. A must-NOT contract has no conditional distinction: pinning a
wrapping row, or re-adding the height override #1879 removed, inside
`@media (max-width: 620px)` passed the unconditional view. A must-HOLD PAIR
needs the opposite — the flattened view let a Badge release `height` in one
breakpoint and `white-space` in the mutually exclusive one, releasing the box
at no viewport at all, so releases are now read per cascade context.
A scan that cannot read a call site has to say so. `<Badge {...{ className:
'x' }} />` is legal JSX that the static scan cannot read and the `className={`
scan does not match, so both contracts concluded there was nothing to govern.
Spreads join computed classNames as unreadable; `//` prop comments parse.
(TypeScript 7 is the Go port and ships no JS compiler API, so an AST is not
available to reach for — but the property that matters is loud-vs-silent, not
lexer-vs-parser.)
Three smaller ones: rungs are read from the ruler's own `:root` scope, so a
`--h-control-*` declared under `.dark` is no longer a tier a chip may name;
`--radius-pill` must exist, because renaming it reported CHIP POPULATION 0 and
passed; `.maka-sidebar-update-button` returns to COMPONENT_OWNED, where an
entry that was dead weight as a skip is the only assertion reaching it.
The e2e no longer credits the tier-choice arithmetic to a check that does not
exist. Measured with a throwaway probe: all four newly pinned chips resolve to
zero nodes in both booted fixtures, so that choice is unasserted, and the
comment says so.
Verified: 10/10 mutations caught, each confirmed to land before being trusted.
lint, format:check, typecheck, desktop 1364, ui 241, e2e 77, check-dead-css.
Sign up for freeto 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.

refactor(ui): compose text styles as role triplets at the call site

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(ui): name one text role at every renderer call site - #1893

Merged
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet
Aug 2, 2026
Merged

refactor(ui): name one text role at every renderer call site#1893
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

A text style is one indivisible role — size, leading, weight and family chosen together — so a call site names a role and declares nothing else about type. #1857 converged the sizes and #1878 the leadings; both removed the divergence of the day without removing the ability to diverge again, because the four properties stayed four separate choices per site. This makes them one choice.

The vehicle is the font: shorthand, which #1878 banned outright. That ban is inverted, not lifted: the shorthand is the only mechanism CSS has that makes the four inseparable, and its reset semantics — it resets weight, style, variant and family — turn from hazard into guarantee once the only legal value is a role token. The alternative the issue names, Astryx's <Text>/<Heading> components, cannot carry this surface: Text.d.ts types as as 'span' | 'p' | 'div' | 'label' | 'h1' | 'h2' | 'h3', which terminates 142 of the 348 selectors (strong, small, li, dt, code, pre, h4, th, time), and it exposes size/weight override props, so the triplet would not actually be indivisible.

Roles are Astryx's 14, composed from its generated atoms only. There are no product roles: the 42 blocks whose measured values had no Astryx equivalent were hand-rolled picks the ladder never offered — every Astryx heading is 600, yet 10 blocks used 700; the 12px tier offers 400 and 600, yet 11 used 500.

Two things had to move out of the call site for this to work. Family is an inheritance axis (--maka-font-family), because the shorthand's family slot is mandatory. And the role table is anchored on :rootand:where(code, kbd, samp, pre), because var() inside a custom property is substituted where the property is declared and the resolved string is what inherits — a table composed only on :root freezes the sans stack into every role. That is not a code-reading conclusion: measured, .maka-tool-diff-body, a real <pre>, read --maka-font-family: "Geist Mono Variable" and computed font-family: -apple-system. With the second anchor every code element gets the mono role for free, which is why 8 render sites moved to <code> markup instead of gaining a product role.

Six guards collapse into one. A ban on product --leading-* tiers, on literal ratios, on !important sizes and leadings, on em/rem multipliers, and the size↔leading pairing check are each a way for the four to come apart — none is expressible once a call site cannot write a font longhand. findLeadingPairingOffenders is deleted rather than kept beside the new check; two authorities on one question means the weaker one is the one people read. Six product tokens (--font-size-heading/stat/ui/caption, --font-sans, --font-default) reached zero consumers and are deleted with them.

Closes#1875.

Verification

Measured against 105ec3e87, not the branch point, since #1876 moved the session rail under this work.

Computed-style regression net. 16,076 text elements across 86 surfaces (15 e2e windows + every settings page and module hub via a navigation walk + 62 Storybook stories), four properties each, captured from a pristine-main worktree and re-captured here. 118 elements differ, every one a convergence this PR intends:

nchangewhat
46weight 600→400page titles, now on Astryx display roles (size and leading unchanged)
24weight 500→600prompt-rail titles → heading-5
19weight 500→600catalog badges, palette group headings, graph section label
11weight 700→600<strong> section titles — Astryx has no 700
9family mono→sansprose and numeric alignment where mono was decoration, not code
4weight 500→400hand-rolled 12px medium, emphasis already carried by color or pill chrome
5weight 600→400button, tab and count labels a container was leaking weight into

Defects the measurement found that reading the code did not. Each is recorded in the commit that fixes it: .maka-hero h1 was assigned from a measured value that described a different rule masking it; per-rule redundancy is not compositional (deleting 79 individually-redundant rules drifted 85 elements 12px→14px); custom-property substitution is not lazy (the single-anchor role table above); --maka-text-display-1 was referenced by the hero and defined nowhere, so the whole declaration was invalid and the element silently inherited; .maka-session-rename-input declared font: inheritafter its new role, leaving the role line dead. Also fixed a latent off-grid bug: .maka-tool-name rendered 16px against an inherited 1.6667 ratio = 26.67px.

What the net did not cover, and what review found there. The 118 are every element the walk rendered; 188 of the 371 role-declaring rules were never on a rendered surface, and four defects were living in that gap. Two were visible: .plan-proposal-kicker had moved from the 12px tier to the 14px one, because a grouped rule gave it a role and its own rule gave it another — the cross-rule form of the dead-role bug, and the reason the contract now has an arm for it. Two were resets the net does not sample: font: clears font-variant-numeric, and the stat tiles' tabular-nums sat in a rule the role's rule outranked. Six more rules in the gap drop --font-mono for the sans stack. Those six are the same call as the nine measured ones — mono was decoration, not code — and unlike the four above the cascade settles it without a render: none of the six is a code/kbd/samp/pre element, so the family slot resolves to the body stack and the other three axes are unchanged. Fifteen sites in total, not nine.

Mutation verification — 19 mutations, each backed up and restored with cp (no git checkout/restore/stash). The first eight cover the guard as originally written: a font longhand at a call site, a hand-composed shorthand, an undefined role, a role built from a foreign atom, a single-anchor table, a third family authority, an Astryx atom rebound to a literal, and a second font: declaration after a role. Review pointed out that all eight only re-trigger assertions already written, and none touches a boundary this PR introduced — so eleven more do: a longhand, a literal ratio and a hand-composed shorthand insidemaka-tokens.css; a role token rebound to a value; the family axis rebound to a literal stack; an Astryx atom rebound to calc(); a longhand inside a rule-nested @media; an upper-cased property name; a second role rule for one selector; the role table anchored on :root alone; and the family longhand moved off the code anchor. All nineteen fire; the tree restores green.

Visual evidence. Live-app captures, main on the left and this branch on the right, composed with ImageMagick (+append) — no generative model touched the pixels.

Module hub — page title 25px 600→400, skill ids stay mono as <code> markupimage
Settings · 通用 — page title 22px 600→400, size and leading unchangedimage
Settings · 记忆 — strong 700→600, filename stays mono while the prose beside it drops to sans, path is now <code>image
Settings · 权限与能力 — capability ids as <code>, section titles on heading rolesimage

Checks run:lint ✅ · format:check ✅ · typecheck ✅ · @maka/desktop test 1339 ✅ · @maka/ui test 239 ✅ · desktop e2e 73/73 ✅ · check-dead-css --check ✅ · astryx:theme --check ✅.

Not run: no per-provider or new E2E fixture was added — the three new invariants that need a live document are steps inside the existing e2e/type-scale.spec.ts probe.

Review focus

Page titles lose their weight, not their size..settingsPageHeader h2, .maka-module-main-header h2 and .maka-plan-heading h2 were display sizes (22px/25px) carrying a hand-rolled semibold. Astryx's display roles are weight 400 by design. Matching on weight instead would pick heading-1 and collapse 46 page titles to 20px, level with the section headings under them — so this trades weight to keep hierarchy. It is the most visible change in the PR and the one most worth disagreeing with, and it is a deliberate override of Astryx's own guidance, which reserves display for hero, marketing and data-callout text and says not to use it for document headings. Recorded at the call site rather than only here.

The component route is closed by the vendor's type union, not just by as.Text's as prop accepts only span | p | div | label | h1 | h2 | h3, which strands the strong / small / li / dt / code / pre selectors — but the harder blocker is BuiltinTextType, which has no heading roles at all: 99 call sites name heading-1..5 and <Text> cannot express any of them. Heading can, and unlike Text it exposes no size / weight overrides — the divisibility argument applies to Text only. For page headings Heading remains the more idiomatic seam; this PR does not move markup that far.

Redundant role declarations are kept, deliberately. The joint-redundancy probe says 79 of 371 role-declaring rules change nothing when removed — but only 183 of the 371 are exercised by any surface the harness reaches, so 51% of the vocabulary is unverifiable, and this PR has already been bitten twice by "the measured value described a different rule than the one I was editing". Deleting on that evidence would be asserting redundancy about rules the measurement never saw. A no-op font: var(--maka-text-body) costs nothing structurally: the point of the role token is that a call site can no longer diverge into four values.

A text style is one role, not four independent picks. #1857 converged the
sizes and #1878 the leadings; both removed the divergence of the day without
removing the ability to diverge again, because size, leading, weight and
family stayed four separate choices at each of 348 call sites.
The `font:` shorthand is the only mechanism CSS has that makes them
inseparable. Its reset semantics are why it fits rather than a hazard to work
around: it resets weight, style, variant and family, and the role token
supplies them, so no site can move one and leave the others.
Composed from Astryx's generated atoms only, so the ladder stays a pure
function of typography.scale. Family is not part of the call-site triplet:
`font:` requires a family slot that would override the one monospace
authority in this file, so the slot reads --maka-font-family, rebound on
:where(code, kbd, samp, pre) and inherited from there.
Measured behavior-neutral: 12262 text elements across 86 surfaces (24 live
app states, 62 Storybook stories) compared before and after, 0 changed.
381 declaration blocks across 44 stylesheets now declare one `font:
var(--maka-text-<role>)` and nothing else about type. Exactly one raw font
longhand survives in the renderer — the monospace stack in maka-tokens.css
that owns the family axis.
Roles are assigned from MEASURED computed styles, not from declared values,
so the ~30 selectors that took their weight from the UA or an ancestor keep
it: 187 of the 385 blocks render in at least one of the 100 surfaces the
harness reaches and were classified from what the browser reported there.
Three things measurement caught that reading the CSS did not:
- `.maka-hero h1` declares 28px but every probed instance is also
`.maka-hero-empty-chat h1` at 20px, so the measured role described the
other rule. Taking it would have shrunk the onboarding hero, the one
surface that keeps the large scale and that no fixture renders. A measured
role is now rejected when it disagrees with the block's own declared size.
- Per-rule redundancy is not compositional: two rules that both pin 12px on
one element are each individually removable and jointly load-bearing.
Deleting on that basis drifted 85 elements 12px→14px, so this commit only
migrates; deletion is decided against the final tree.
- A role composed on :root freezes its family slot there — custom property
var() references are substituted where DECLARED, and the resolved string is
what inherits. A real <pre> whose --maka-font-family read Geist Mono still
computed -apple-system. The role table is anchored on `:root, :where(code,
kbd, samp, pre)` so code elements compose their own mono-family roles.
`.maka-tool-name` also loses a latent defect: measured 16px against a 1.6667
ratio inherited from the supporting role, 26.67px, off the 4px grid.
The 348-call-site migration was assigned from measured computed values, so
the roles it picked were the ones the browser reported — which is exactly
why re-measuring the migrated tree found the places where the measured
value described something other than the call site's intent. Every change
below is a diff bucket from that re-measurement, named and closed.
Markup, not a call-site family. A slug, an id, a task key, a capability id,
a memory path, a tool name, a summarized error — these are code, and the
role table composes the code family for the code element group, so saying
so in the markup is also what makes them render monospaced. 8 render sites
move to <code>; `SettingRow`'s `mono` prop now picks the element.
UA bold is not a role. 17 blocks selected strong/h*/th and declared no
weight of their own, so they took the UA's 700 by accident. Naming a body
or supporting role resets that to 400 — measured, 21 elements went 700→400
before this. They are section titles at 14px and 12px, which is heading-4
and heading-5: same size, same leading, weight 700→600 (Astryx has no 700).
Page titles keep their size, not their weight. `.settingsPageHeader h2`,
`.maka-module-main-header h2` and `.maka-plan-heading h2` were display
sizes carrying a hand-rolled semibold. Matching on weight picked heading-1
and collapsed 66 page titles to 20px, level with the section headings
under them. Display roles keep 25px/22px and take Astryx's display weight
of 400.
A family-only rule hides its size. `.maka-mcp-*` set only font-family, and
its three selectors inherited two different sizes — 14px in the card body,
12px in the description. One role could not express that, so the rule
splits: code role for the 14px pair, supporting for the description.
`.maka-nav-kbd` opts back out of mono. It read `font-family: inherit`
before; as a <kbd> it now composes the mono role table. It names the family
axis instead — one declaration, and the role stays whole.
Remaining measured delta is 206 of 16,063 elements across 86 surfaces,
every bucket a convergence this PR intends: 66 page titles 600→400, 32
badges and 28 rail prompts 500→600, 21 strong 700→600, 30 family changes
where mono was decoration rather than code, and 13 hand-rolled 12px/500
sites landing on supporting, their emphasis already carried by color or
pill chrome.
Size, leading, weight and family were four independent properties, so each
needed its own guard: a ban on product `--leading-*` tiers, on literal
ratios, on `!important` sizes and leadings, on em/rem multipliers, and a
pairing check that resolved a block's size and leading through the
generated theme to prove they named the same tier. Every one of those
describes a way for the four to come apart.
None of them is expressible now. A call site that cannot write a font
longhand cannot write a literal ratio, an em multiplier, a forced size, or
a mismatched pair — so they collapse into one assertion: outside the role
table, every text style is `font: var(--maka-text-<role>)` and no
stylesheet declares font-size, line-height, font-weight or font-family.
`findLeadingPairingOffenders` is deleted rather than kept alongside it;
two authorities on one question means the weaker one is the one people
read.
`findFontShorthandOffenders` inverts instead of tightening. The shorthand
was banned because it hides bare weights and leadings; it is now the only
legal form, because it is the one CSS mechanism that makes the four
inseparable. The replacement also checks that every role NAMED is a role
DEFINED — a var() that resolves to nothing makes the whole declaration
invalid at computed-value time, so the element silently keeps what it
inherits. That arm found a live defect: `--maka-text-display-1` was
referenced by the hero and defined nowhere, and every other check in the
file was green.
Two things text cannot prove keep their own guards. The role table's shape
is asserted in the contract (each role composed only from its own Astryx
atoms, declared on both anchors, family axis pinned once per anchor). What
those resolve to in a document is measured in e2e: a <pre> and a <div>
naming the same role must agree on size, weight and leading and differ
only in family — the substitution-timing fact that made the first, single
-anchor role table wrong while reading perfectly in CSS.
Seven mutations verified, each restored with cp: a longhand at a call
site, a hand-composed shorthand, an undefined role, a role built from a
foreign atom, a single-anchor table, a third family authority, and an
Astryx atom rebound to a literal. Desktop 1323 pass, ui 239 pass.
Six product tokens reached zero consumers when call sites stopped naming
sizes and families, and check-dead-css found all six: --font-size-heading
/ stat / ui / caption, --font-sans, --font-default. They existed so a call
site could say "this is the caption size" or "this is the mono stack" —
a role says both, together with the weight and the leading, so a second
set of names for the same rungs is exactly the second authority the
convergence exists to remove. The role table now reads --font-family-code
directly, and nothing in the renderer aliases an Astryx type name.
The contract that pinned those aliases is replaced by the stronger thing
they were approximating: there is no product name for a size or a family
at all. --font-weight-bold survives as the top rung of the series the
generated theme reads, in RESERVED_SCALE_TOKENS with that reason.
Also caught rebasing onto #1876: `.maka-session-rename-input` declared
`font: inherit` before its longhands, so migrating the longhands to a role
left the reset winning and the role line dead. `findTextRoleOffenders`
now counts font declarations before reading the last one — a block that
declares a role and then overrides it reads as legal to any check that
asks only what the browser uses. Eight mutations verified with cp.
Measured against 105ec3e rather than the branch point, since #1876 moved
the session rail under this work: 118 of 16,076 text elements across 86
surfaces change, every one a bucket this PR intends.
Review found four sites where naming a role changed more than the role.
The `font:` shorthand resets `font-variant-*`, `font-style` and the feature
settings along with the four axes it names. Within a rule every site already
declared those after the role; across rules two did not. `.maka-stat-tile-value`
held the tabular figures the tiles exist to align while the role sat in the
`-outline` / `-filled` modifiers, so the role won and the digits stopped
aligning; reordering makes the base rule the later one. `code.settingsReadOnlyValue`
restates the declaration because it is the higher-specificity selector.
A grouped rule that hands a selector a role, followed by that selector's own
rule handing it another, leaves the first dead — and a later retune of the
group moves every other member while this site silently stays put.
`.plan-proposal-kicker` had already drifted that way, from the 12px tier to
the 14px one; the 12px tier offers 400 and 600, and an eyebrow over a title
takes the 600. `.maka-agent-graph-section-label` was the same shape without a
size change.
`data-mono` said in an attribute what the `<code>` element already says.
The collapse to one guard was real for the shape it checks and a regression
for what it can see. Three holes, each found by mutation:
Scope. `readCallSiteCss()` skipped all of maka-tokens.css so the role table
could write its one family longhand. That file is 1400 lines, ~40 of them
ordinary component rules, and they were exempt from the only remaining guard —
a literal ratio, an em multiplier, an `!important` size and a hand-composed
shorthand all passed there while failing everywhere else. The exemption is now
the one declaration that needs it: `font-family` on the code element group.
Level. `--maka-text-*` and `--maka-font-family` are the vocabulary this branch
introduced and nothing checked what they were bound to. One rule rebinding a
role to `700 44px/1.05 Impact` re-chose all four axes while its call site still
named exactly one role. Astryx's atoms were checked, but only against a leading
digit, so `calc(2.5)` and `max(24px, 1rem)` walked through. Both arms are now
one rule: a type token may be rebound to another token, never to a value.
Parser. The hand-rolled brace walk dropped at-rule bodies whole, so a longhand
inside a rule-nested `@media` — the shape Astryx itself uses for coarse
pointers — was invisible; it also treated a brace inside a string as
structural, and had lost the case-insensitivity its predecessor had. Replaced
with postcss, which is already what Vite parses this same CSS with. A
rule-nested at-rule is its own cascade context, so a responsive role swap is
not counted as a duplicate.
One arm is new rather than restored: a selector may be given a role by only
one rule per cascade context. That is the cross-rule form of the dead-role
bug the in-rule count already caught.
The e2e step that read the four deleted size aliases now measures the roles
instead. It had been failing since those names were deleted, and because a
failing step aborts the test, every later step — the off-grid sweep and both
two-anchor probes — had stopped running. The two-anchor probe also pins the
three non-family axes to the supporting tier's own values; equality between
the two probes alone would hold if the role stopped resolving for both.
Mutation-verified (cp backup/restore, no git):
longhand / literal ratio / hand-composed shorthand inside maka-tokens.css,
role token rebound to a value, family axis rebound to a stack, atom rebound
to calc(), longhand in a rule-nested @media, upper-cased longhand, a second
role rule for one selector, role table on :root alone, family longhand moved
off the code anchor — 11 of 11 caught, baseline clean.
… role costs
`--font-weight-bold` had no consumer once call sites stopped declaring weight,
and the generated theme reads only normal / medium / semibold. It survived on a
check-dead-css allowlist entry whose stated reason was that the scale reads as
a scale with it present — an aesthetic, not a constraint, and a product name
for an axis the roles own.
Four things the role table implies and did not say: the shorthand resets font
sub-properties, so any the site needs must come after it; a 12px site that
titles something below it takes heading-5 and everything else takes supporting,
which is what makes the hand-rolled 500s reviewable; 97 call sites name a
heading role for its weight rather than because they are headings, so a
`weights.heading` retune moves them; and `large` / `heading-6` are absent
because nothing uses them, not because the projection is partial. Astryx does
support product roles through defineTheme component overrides — staying a pure
projection of the vendor set is a choice, and now says so.
Display on a page title is a deliberate override of Astryx's own guidance,
recorded where the override is. Every heading role is weight 600 and tops out
at 20px, so matching on weight would collapse a 25px page title level with the
section headings under it.
Also three comments naming tokens this branch deleted.
@Astro-Han
Astro-Han marked this pull request as ready for review August 2, 2026 09:10
@Astro-Han
Astro-Han merged commit 8c84265 into mainAug 2, 2026
5 checks passed
@Astro-Han
Astro-Han deleted the refactor/typography-role-triplet branch August 2, 2026 09:12
Astro-Han added a commit that referenced this pull request Aug 2, 2026
Squashed for rebase onto #1893; final message written at the end.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
… exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
* fix(ui): give the single-line chip family a box the component owns
Squashed for rebase onto #1893; final message written at the end.
* fix(ui): derive the chip population from shape alone and assert every exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
* test(desktop): close the guard holes three re-reviews found in the chip contracts
Every finding below was reproduced as a mutation that passed green, and each
fix is re-verified by the same mutation failing. Four root causes, three of
which delete something.
The predicate read text, not declarations. `/padding/` also matches
`background-clip: padding-box`, which is how a scrollbar thumb — padding-less,
and a box no reader perceives as an object — entered the chip population and
earned an exemption group of its own. The group is deleted with the substring
that created it, and the padding vocabulary becomes one shared authority.
The same guard-narrower-than-CSS mistake, twice more. The Badge contract's
`OWNED` gained the logical padding family and lost `padding-top/right/bottom/
left`, so `padding-left: 9px` on a Badge className redrew the component's box
while green. And the off-ruler exemption proved "still pinned" with the
constrains-a-block-size vocabulary, so rewriting its `height` as `min-height`
handed the box back to the line box and stayed green.
Which merge view follows from which question, applied to all three sites
rather than one. A must-NOT contract has no conditional distinction: pinning a
wrapping row, or re-adding the height override #1879 removed, inside
`@media (max-width: 620px)` passed the unconditional view. A must-HOLD PAIR
needs the opposite — the flattened view let a Badge release `height` in one
breakpoint and `white-space` in the mutually exclusive one, releasing the box
at no viewport at all, so releases are now read per cascade context.
A scan that cannot read a call site has to say so. `<Badge {...{ className:
'x' }} />` is legal JSX that the static scan cannot read and the `className={`
scan does not match, so both contracts concluded there was nothing to govern.
Spreads join computed classNames as unreadable; `//` prop comments parse.
(TypeScript 7 is the Go port and ships no JS compiler API, so an AST is not
available to reach for — but the property that matters is loud-vs-silent, not
lexer-vs-parser.)
Three smaller ones: rungs are read from the ruler's own `:root` scope, so a
`--h-control-*` declared under `.dark` is no longer a tier a chip may name;
`--radius-pill` must exist, because renaming it reported CHIP POPULATION 0 and
passed; `.maka-sidebar-update-button` returns to COMPONENT_OWNED, where an
entry that was dead weight as a skip is the only assertion reaching it.
The e2e no longer credits the tier-choice arithmetic to a check that does not
exist. Measured with a throwaway probe: all four newly pinned chips resolve to
zero nodes in both booted fixtures, so that choice is unasserted, and the
comment says so.
Verified: 10/10 mutations caught, each confirmed to land before being trusted.
lint, format:check, typecheck, desktop 1364, ui 241, e2e 77, check-dead-css.
Sign up for freeto 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.

refactor(ui): compose text styles as role triplets at the call site

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

refactor(ui): name one text role at every renderer call site - #1893

Merged
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet
Aug 2, 2026
Merged

refactor(ui): name one text role at every renderer call site#1893
Astro-Han merged 8 commits into
mainfrom
refactor/typography-role-triplet

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

A text style is one indivisible role — size, leading, weight and family chosen together — so a call site names a role and declares nothing else about type. #1857 converged the sizes and #1878 the leadings; both removed the divergence of the day without removing the ability to diverge again, because the four properties stayed four separate choices per site. This makes them one choice.

The vehicle is the font: shorthand, which #1878 banned outright. That ban is inverted, not lifted: the shorthand is the only mechanism CSS has that makes the four inseparable, and its reset semantics — it resets weight, style, variant and family — turn from hazard into guarantee once the only legal value is a role token. The alternative the issue names, Astryx's <Text>/<Heading> components, cannot carry this surface: Text.d.ts types as as 'span' | 'p' | 'div' | 'label' | 'h1' | 'h2' | 'h3', which terminates 142 of the 348 selectors (strong, small, li, dt, code, pre, h4, th, time), and it exposes size/weight override props, so the triplet would not actually be indivisible.

Roles are Astryx's 14, composed from its generated atoms only. There are no product roles: the 42 blocks whose measured values had no Astryx equivalent were hand-rolled picks the ladder never offered — every Astryx heading is 600, yet 10 blocks used 700; the 12px tier offers 400 and 600, yet 11 used 500.

Two things had to move out of the call site for this to work. Family is an inheritance axis (--maka-font-family), because the shorthand's family slot is mandatory. And the role table is anchored on :rootand:where(code, kbd, samp, pre), because var() inside a custom property is substituted where the property is declared and the resolved string is what inherits — a table composed only on :root freezes the sans stack into every role. That is not a code-reading conclusion: measured, .maka-tool-diff-body, a real <pre>, read --maka-font-family: "Geist Mono Variable" and computed font-family: -apple-system. With the second anchor every code element gets the mono role for free, which is why 8 render sites moved to <code> markup instead of gaining a product role.

Six guards collapse into one. A ban on product --leading-* tiers, on literal ratios, on !important sizes and leadings, on em/rem multipliers, and the size↔leading pairing check are each a way for the four to come apart — none is expressible once a call site cannot write a font longhand. findLeadingPairingOffenders is deleted rather than kept beside the new check; two authorities on one question means the weaker one is the one people read. Six product tokens (--font-size-heading/stat/ui/caption, --font-sans, --font-default) reached zero consumers and are deleted with them.

Closes#1875.

Verification

Measured against 105ec3e87, not the branch point, since #1876 moved the session rail under this work.

Computed-style regression net. 16,076 text elements across 86 surfaces (15 e2e windows + every settings page and module hub via a navigation walk + 62 Storybook stories), four properties each, captured from a pristine-main worktree and re-captured here. 118 elements differ, every one a convergence this PR intends:

nchangewhat
46weight 600→400page titles, now on Astryx display roles (size and leading unchanged)
24weight 500→600prompt-rail titles → heading-5
19weight 500→600catalog badges, palette group headings, graph section label
11weight 700→600<strong> section titles — Astryx has no 700
9family mono→sansprose and numeric alignment where mono was decoration, not code
4weight 500→400hand-rolled 12px medium, emphasis already carried by color or pill chrome
5weight 600→400button, tab and count labels a container was leaking weight into

Defects the measurement found that reading the code did not. Each is recorded in the commit that fixes it: .maka-hero h1 was assigned from a measured value that described a different rule masking it; per-rule redundancy is not compositional (deleting 79 individually-redundant rules drifted 85 elements 12px→14px); custom-property substitution is not lazy (the single-anchor role table above); --maka-text-display-1 was referenced by the hero and defined nowhere, so the whole declaration was invalid and the element silently inherited; .maka-session-rename-input declared font: inheritafter its new role, leaving the role line dead. Also fixed a latent off-grid bug: .maka-tool-name rendered 16px against an inherited 1.6667 ratio = 26.67px.

What the net did not cover, and what review found there. The 118 are every element the walk rendered; 188 of the 371 role-declaring rules were never on a rendered surface, and four defects were living in that gap. Two were visible: .plan-proposal-kicker had moved from the 12px tier to the 14px one, because a grouped rule gave it a role and its own rule gave it another — the cross-rule form of the dead-role bug, and the reason the contract now has an arm for it. Two were resets the net does not sample: font: clears font-variant-numeric, and the stat tiles' tabular-nums sat in a rule the role's rule outranked. Six more rules in the gap drop --font-mono for the sans stack. Those six are the same call as the nine measured ones — mono was decoration, not code — and unlike the four above the cascade settles it without a render: none of the six is a code/kbd/samp/pre element, so the family slot resolves to the body stack and the other three axes are unchanged. Fifteen sites in total, not nine.

Mutation verification — 19 mutations, each backed up and restored with cp (no git checkout/restore/stash). The first eight cover the guard as originally written: a font longhand at a call site, a hand-composed shorthand, an undefined role, a role built from a foreign atom, a single-anchor table, a third family authority, an Astryx atom rebound to a literal, and a second font: declaration after a role. Review pointed out that all eight only re-trigger assertions already written, and none touches a boundary this PR introduced — so eleven more do: a longhand, a literal ratio and a hand-composed shorthand insidemaka-tokens.css; a role token rebound to a value; the family axis rebound to a literal stack; an Astryx atom rebound to calc(); a longhand inside a rule-nested @media; an upper-cased property name; a second role rule for one selector; the role table anchored on :root alone; and the family longhand moved off the code anchor. All nineteen fire; the tree restores green.

Visual evidence. Live-app captures, main on the left and this branch on the right, composed with ImageMagick (+append) — no generative model touched the pixels.

Module hub — page title 25px 600→400, skill ids stay mono as <code> markupimage
Settings · 通用 — page title 22px 600→400, size and leading unchangedimage
Settings · 记忆 — strong 700→600, filename stays mono while the prose beside it drops to sans, path is now <code>image
Settings · 权限与能力 — capability ids as <code>, section titles on heading rolesimage

Checks run:lint ✅ · format:check ✅ · typecheck ✅ · @maka/desktop test 1339 ✅ · @maka/ui test 239 ✅ · desktop e2e 73/73 ✅ · check-dead-css --check ✅ · astryx:theme --check ✅.

Not run: no per-provider or new E2E fixture was added — the three new invariants that need a live document are steps inside the existing e2e/type-scale.spec.ts probe.

Review focus

Page titles lose their weight, not their size..settingsPageHeader h2, .maka-module-main-header h2 and .maka-plan-heading h2 were display sizes (22px/25px) carrying a hand-rolled semibold. Astryx's display roles are weight 400 by design. Matching on weight instead would pick heading-1 and collapse 46 page titles to 20px, level with the section headings under them — so this trades weight to keep hierarchy. It is the most visible change in the PR and the one most worth disagreeing with, and it is a deliberate override of Astryx's own guidance, which reserves display for hero, marketing and data-callout text and says not to use it for document headings. Recorded at the call site rather than only here.

The component route is closed by the vendor's type union, not just by as.Text's as prop accepts only span | p | div | label | h1 | h2 | h3, which strands the strong / small / li / dt / code / pre selectors — but the harder blocker is BuiltinTextType, which has no heading roles at all: 99 call sites name heading-1..5 and <Text> cannot express any of them. Heading can, and unlike Text it exposes no size / weight overrides — the divisibility argument applies to Text only. For page headings Heading remains the more idiomatic seam; this PR does not move markup that far.

Redundant role declarations are kept, deliberately. The joint-redundancy probe says 79 of 371 role-declaring rules change nothing when removed — but only 183 of the 371 are exercised by any surface the harness reaches, so 51% of the vocabulary is unverifiable, and this PR has already been bitten twice by "the measured value described a different rule than the one I was editing". Deleting on that evidence would be asserting redundancy about rules the measurement never saw. A no-op font: var(--maka-text-body) costs nothing structurally: the point of the role token is that a call site can no longer diverge into four values.

A text style is one role, not four independent picks. #1857 converged the
sizes and #1878 the leadings; both removed the divergence of the day without
removing the ability to diverge again, because size, leading, weight and
family stayed four separate choices at each of 348 call sites.
The `font:` shorthand is the only mechanism CSS has that makes them
inseparable. Its reset semantics are why it fits rather than a hazard to work
around: it resets weight, style, variant and family, and the role token
supplies them, so no site can move one and leave the others.
Composed from Astryx's generated atoms only, so the ladder stays a pure
function of typography.scale. Family is not part of the call-site triplet:
`font:` requires a family slot that would override the one monospace
authority in this file, so the slot reads --maka-font-family, rebound on
:where(code, kbd, samp, pre) and inherited from there.
Measured behavior-neutral: 12262 text elements across 86 surfaces (24 live
app states, 62 Storybook stories) compared before and after, 0 changed.
381 declaration blocks across 44 stylesheets now declare one `font:
var(--maka-text-<role>)` and nothing else about type. Exactly one raw font
longhand survives in the renderer — the monospace stack in maka-tokens.css
that owns the family axis.
Roles are assigned from MEASURED computed styles, not from declared values,
so the ~30 selectors that took their weight from the UA or an ancestor keep
it: 187 of the 385 blocks render in at least one of the 100 surfaces the
harness reaches and were classified from what the browser reported there.
Three things measurement caught that reading the CSS did not:
- `.maka-hero h1` declares 28px but every probed instance is also
`.maka-hero-empty-chat h1` at 20px, so the measured role described the
other rule. Taking it would have shrunk the onboarding hero, the one
surface that keeps the large scale and that no fixture renders. A measured
role is now rejected when it disagrees with the block's own declared size.
- Per-rule redundancy is not compositional: two rules that both pin 12px on
one element are each individually removable and jointly load-bearing.
Deleting on that basis drifted 85 elements 12px→14px, so this commit only
migrates; deletion is decided against the final tree.
- A role composed on :root freezes its family slot there — custom property
var() references are substituted where DECLARED, and the resolved string is
what inherits. A real <pre> whose --maka-font-family read Geist Mono still
computed -apple-system. The role table is anchored on `:root, :where(code,
kbd, samp, pre)` so code elements compose their own mono-family roles.
`.maka-tool-name` also loses a latent defect: measured 16px against a 1.6667
ratio inherited from the supporting role, 26.67px, off the 4px grid.
The 348-call-site migration was assigned from measured computed values, so
the roles it picked were the ones the browser reported — which is exactly
why re-measuring the migrated tree found the places where the measured
value described something other than the call site's intent. Every change
below is a diff bucket from that re-measurement, named and closed.
Markup, not a call-site family. A slug, an id, a task key, a capability id,
a memory path, a tool name, a summarized error — these are code, and the
role table composes the code family for the code element group, so saying
so in the markup is also what makes them render monospaced. 8 render sites
move to <code>; `SettingRow`'s `mono` prop now picks the element.
UA bold is not a role. 17 blocks selected strong/h*/th and declared no
weight of their own, so they took the UA's 700 by accident. Naming a body
or supporting role resets that to 400 — measured, 21 elements went 700→400
before this. They are section titles at 14px and 12px, which is heading-4
and heading-5: same size, same leading, weight 700→600 (Astryx has no 700).
Page titles keep their size, not their weight. `.settingsPageHeader h2`,
`.maka-module-main-header h2` and `.maka-plan-heading h2` were display
sizes carrying a hand-rolled semibold. Matching on weight picked heading-1
and collapsed 66 page titles to 20px, level with the section headings
under them. Display roles keep 25px/22px and take Astryx's display weight
of 400.
A family-only rule hides its size. `.maka-mcp-*` set only font-family, and
its three selectors inherited two different sizes — 14px in the card body,
12px in the description. One role could not express that, so the rule
splits: code role for the 14px pair, supporting for the description.
`.maka-nav-kbd` opts back out of mono. It read `font-family: inherit`
before; as a <kbd> it now composes the mono role table. It names the family
axis instead — one declaration, and the role stays whole.
Remaining measured delta is 206 of 16,063 elements across 86 surfaces,
every bucket a convergence this PR intends: 66 page titles 600→400, 32
badges and 28 rail prompts 500→600, 21 strong 700→600, 30 family changes
where mono was decoration rather than code, and 13 hand-rolled 12px/500
sites landing on supporting, their emphasis already carried by color or
pill chrome.
Size, leading, weight and family were four independent properties, so each
needed its own guard: a ban on product `--leading-*` tiers, on literal
ratios, on `!important` sizes and leadings, on em/rem multipliers, and a
pairing check that resolved a block's size and leading through the
generated theme to prove they named the same tier. Every one of those
describes a way for the four to come apart.
None of them is expressible now. A call site that cannot write a font
longhand cannot write a literal ratio, an em multiplier, a forced size, or
a mismatched pair — so they collapse into one assertion: outside the role
table, every text style is `font: var(--maka-text-<role>)` and no
stylesheet declares font-size, line-height, font-weight or font-family.
`findLeadingPairingOffenders` is deleted rather than kept alongside it;
two authorities on one question means the weaker one is the one people
read.
`findFontShorthandOffenders` inverts instead of tightening. The shorthand
was banned because it hides bare weights and leadings; it is now the only
legal form, because it is the one CSS mechanism that makes the four
inseparable. The replacement also checks that every role NAMED is a role
DEFINED — a var() that resolves to nothing makes the whole declaration
invalid at computed-value time, so the element silently keeps what it
inherits. That arm found a live defect: `--maka-text-display-1` was
referenced by the hero and defined nowhere, and every other check in the
file was green.
Two things text cannot prove keep their own guards. The role table's shape
is asserted in the contract (each role composed only from its own Astryx
atoms, declared on both anchors, family axis pinned once per anchor). What
those resolve to in a document is measured in e2e: a <pre> and a <div>
naming the same role must agree on size, weight and leading and differ
only in family — the substitution-timing fact that made the first, single
-anchor role table wrong while reading perfectly in CSS.
Seven mutations verified, each restored with cp: a longhand at a call
site, a hand-composed shorthand, an undefined role, a role built from a
foreign atom, a single-anchor table, a third family authority, and an
Astryx atom rebound to a literal. Desktop 1323 pass, ui 239 pass.
Six product tokens reached zero consumers when call sites stopped naming
sizes and families, and check-dead-css found all six: --font-size-heading
/ stat / ui / caption, --font-sans, --font-default. They existed so a call
site could say "this is the caption size" or "this is the mono stack" —
a role says both, together with the weight and the leading, so a second
set of names for the same rungs is exactly the second authority the
convergence exists to remove. The role table now reads --font-family-code
directly, and nothing in the renderer aliases an Astryx type name.
The contract that pinned those aliases is replaced by the stronger thing
they were approximating: there is no product name for a size or a family
at all. --font-weight-bold survives as the top rung of the series the
generated theme reads, in RESERVED_SCALE_TOKENS with that reason.
Also caught rebasing onto #1876: `.maka-session-rename-input` declared
`font: inherit` before its longhands, so migrating the longhands to a role
left the reset winning and the role line dead. `findTextRoleOffenders`
now counts font declarations before reading the last one — a block that
declares a role and then overrides it reads as legal to any check that
asks only what the browser uses. Eight mutations verified with cp.
Measured against 105ec3e rather than the branch point, since #1876 moved
the session rail under this work: 118 of 16,076 text elements across 86
surfaces change, every one a bucket this PR intends.
Review found four sites where naming a role changed more than the role.
The `font:` shorthand resets `font-variant-*`, `font-style` and the feature
settings along with the four axes it names. Within a rule every site already
declared those after the role; across rules two did not. `.maka-stat-tile-value`
held the tabular figures the tiles exist to align while the role sat in the
`-outline` / `-filled` modifiers, so the role won and the digits stopped
aligning; reordering makes the base rule the later one. `code.settingsReadOnlyValue`
restates the declaration because it is the higher-specificity selector.
A grouped rule that hands a selector a role, followed by that selector's own
rule handing it another, leaves the first dead — and a later retune of the
group moves every other member while this site silently stays put.
`.plan-proposal-kicker` had already drifted that way, from the 12px tier to
the 14px one; the 12px tier offers 400 and 600, and an eyebrow over a title
takes the 600. `.maka-agent-graph-section-label` was the same shape without a
size change.
`data-mono` said in an attribute what the `<code>` element already says.
The collapse to one guard was real for the shape it checks and a regression
for what it can see. Three holes, each found by mutation:
Scope. `readCallSiteCss()` skipped all of maka-tokens.css so the role table
could write its one family longhand. That file is 1400 lines, ~40 of them
ordinary component rules, and they were exempt from the only remaining guard —
a literal ratio, an em multiplier, an `!important` size and a hand-composed
shorthand all passed there while failing everywhere else. The exemption is now
the one declaration that needs it: `font-family` on the code element group.
Level. `--maka-text-*` and `--maka-font-family` are the vocabulary this branch
introduced and nothing checked what they were bound to. One rule rebinding a
role to `700 44px/1.05 Impact` re-chose all four axes while its call site still
named exactly one role. Astryx's atoms were checked, but only against a leading
digit, so `calc(2.5)` and `max(24px, 1rem)` walked through. Both arms are now
one rule: a type token may be rebound to another token, never to a value.
Parser. The hand-rolled brace walk dropped at-rule bodies whole, so a longhand
inside a rule-nested `@media` — the shape Astryx itself uses for coarse
pointers — was invisible; it also treated a brace inside a string as
structural, and had lost the case-insensitivity its predecessor had. Replaced
with postcss, which is already what Vite parses this same CSS with. A
rule-nested at-rule is its own cascade context, so a responsive role swap is
not counted as a duplicate.
One arm is new rather than restored: a selector may be given a role by only
one rule per cascade context. That is the cross-rule form of the dead-role
bug the in-rule count already caught.
The e2e step that read the four deleted size aliases now measures the roles
instead. It had been failing since those names were deleted, and because a
failing step aborts the test, every later step — the off-grid sweep and both
two-anchor probes — had stopped running. The two-anchor probe also pins the
three non-family axes to the supporting tier's own values; equality between
the two probes alone would hold if the role stopped resolving for both.
Mutation-verified (cp backup/restore, no git):
longhand / literal ratio / hand-composed shorthand inside maka-tokens.css,
role token rebound to a value, family axis rebound to a stack, atom rebound
to calc(), longhand in a rule-nested @media, upper-cased longhand, a second
role rule for one selector, role table on :root alone, family longhand moved
off the code anchor — 11 of 11 caught, baseline clean.
… role costs
`--font-weight-bold` had no consumer once call sites stopped declaring weight,
and the generated theme reads only normal / medium / semibold. It survived on a
check-dead-css allowlist entry whose stated reason was that the scale reads as
a scale with it present — an aesthetic, not a constraint, and a product name
for an axis the roles own.
Four things the role table implies and did not say: the shorthand resets font
sub-properties, so any the site needs must come after it; a 12px site that
titles something below it takes heading-5 and everything else takes supporting,
which is what makes the hand-rolled 500s reviewable; 97 call sites name a
heading role for its weight rather than because they are headings, so a
`weights.heading` retune moves them; and `large` / `heading-6` are absent
because nothing uses them, not because the projection is partial. Astryx does
support product roles through defineTheme component overrides — staying a pure
projection of the vendor set is a choice, and now says so.
Display on a page title is a deliberate override of Astryx's own guidance,
recorded where the override is. Every heading role is weight 600 and tops out
at 20px, so matching on weight would collapse a 25px page title level with the
section headings under it.
Also three comments naming tokens this branch deleted.
@Astro-Han
Astro-Han marked this pull request as ready for review August 2, 2026 09:10
@Astro-Han
Astro-Han merged commit 8c84265 into mainAug 2, 2026
5 checks passed
@Astro-Han
Astro-Han deleted the refactor/typography-role-triplet branch August 2, 2026 09:12
Astro-Han added a commit that referenced this pull request Aug 2, 2026
Squashed for rebase onto #1893; final message written at the end.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
… exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
* fix(ui): give the single-line chip family a box the component owns
Squashed for rebase onto #1893; final message written at the end.
* fix(ui): derive the chip population from shape alone and assert every exemption
Review of the previous revision found three ways the guards were narrower than
their own documentation, all of the same shape: a check that reports green
because it cannot see the thing it claims to govern.
- `isChip` required a rule to declare its own type. A compound chip delegates
type to a child and a simple one may just inherit, so the arm was hiding
three shipping chips with the #1879 defect while reporting the other 15
green. Two arms — pill radius and padding — see all 20 pill boxes, and no
longer read a vocabulary that #1893 already moved once, so the unanchored
`chips.length >= 8` floor that watched for exactly that drift is deleted with
it.
- The exemption set was one list honoured by skipping, so a mutation adding
`height: 1px` to a rule excused as component-sized stayed green. It is now
three reason-groups, each asserted: WRAPS must declare no block size (and is
the wrap contract's own list rather than a second copy of it), COMPONENT_OWNED
must declare none at all, PINNED_OFF_RULER must still be pinned.
- The Badge contract read the unconditional view, on which
`.settingsHealthBlockerBadge` — which lives entirely inside a breakpoint —
merged to nothing, so the exemption naming it suppressed nothing. It reads a
conditional-inclusive view now and asserts that the release is real.
Also: `min-height`/`max-height`/`padding-inline-start` all constrained a box
while passing both checks; the ruler accepted tiers it does not define; and the
JSX scanner ended a tag at the first `>` in a prop, and then, once rewritten,
at an apostrophe inside a prop comment — which silently dropped three live call
sites until the mutation sweep caught it.
Four boxes the widened scan surfaced, each measured live before its tier:
`.maka-quote-chip-collapsed` and `.maka-deep-research-run-count` at 24px
natural (pixel-neutral on `--h-control-sm`), `.maka-firstrun-step` at 29px
(`--h-control-lg`, +3px), and `.maka-mcp-install-button`, a bare 34px square
that swaps with a 28px `Button size="sm"` in the same slot.
* test(desktop): close the guard holes three re-reviews found in the chip contracts
Every finding below was reproduced as a mutation that passed green, and each
fix is re-verified by the same mutation failing. Four root causes, three of
which delete something.
The predicate read text, not declarations. `/padding/` also matches
`background-clip: padding-box`, which is how a scrollbar thumb — padding-less,
and a box no reader perceives as an object — entered the chip population and
earned an exemption group of its own. The group is deleted with the substring
that created it, and the padding vocabulary becomes one shared authority.
The same guard-narrower-than-CSS mistake, twice more. The Badge contract's
`OWNED` gained the logical padding family and lost `padding-top/right/bottom/
left`, so `padding-left: 9px` on a Badge className redrew the component's box
while green. And the off-ruler exemption proved "still pinned" with the
constrains-a-block-size vocabulary, so rewriting its `height` as `min-height`
handed the box back to the line box and stayed green.
Which merge view follows from which question, applied to all three sites
rather than one. A must-NOT contract has no conditional distinction: pinning a
wrapping row, or re-adding the height override #1879 removed, inside
`@media (max-width: 620px)` passed the unconditional view. A must-HOLD PAIR
needs the opposite — the flattened view let a Badge release `height` in one
breakpoint and `white-space` in the mutually exclusive one, releasing the box
at no viewport at all, so releases are now read per cascade context.
A scan that cannot read a call site has to say so. `<Badge {...{ className:
'x' }} />` is legal JSX that the static scan cannot read and the `className={`
scan does not match, so both contracts concluded there was nothing to govern.
Spreads join computed classNames as unreadable; `//` prop comments parse.
(TypeScript 7 is the Go port and ships no JS compiler API, so an AST is not
available to reach for — but the property that matters is loud-vs-silent, not
lexer-vs-parser.)
Three smaller ones: rungs are read from the ruler's own `:root` scope, so a
`--h-control-*` declared under `.dark` is no longer a tier a chip may name;
`--radius-pill` must exist, because renaming it reported CHIP POPULATION 0 and
passed; `.maka-sidebar-update-button` returns to COMPONENT_OWNED, where an
entry that was dead weight as a skip is the only assertion reaching it.
The e2e no longer credits the tier-choice arithmetic to a check that does not
exist. Measured with a throwaway probe: all four newly pinned chips resolve to
zero nodes in both booted fixtures, so that choice is unasserted, and the
comment says so.
Verified: 10/10 mutations caught, each confirmed to land before being trusted.
lint, format:check, typecheck, desktop 1364, ui 241, e2e 77, check-dead-css.
Sign up for freeto 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.

refactor(ui): compose text styles as role triplets at the call site

1 participant

@Astro-Han