Skip to content

refactor(ui): move typography authority to the Astryx type scale - #1857

Merged
Astro-Han merged 10 commits into
mainfrom
refactor/typography-astryx-type-scale
Aug 2, 2026
Merged

refactor(ui): move typography authority to the Astryx type scale#1857
Astro-Han merged 10 commits into
mainfrom
refactor/typography-astryx-type-scale

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

The main conversation renders with a broken type hierarchy because Maka's density is expressed as html { font-size: 13px }. That is not a type scale — it is an implicit ×0.8125 multiplier on every rem in the document, including the radius and spacing constants Astryx compiles against a 16px root. Body copy was then pinned back on the Theme wrapper (astryx-mount.css) to undo the multiplier for prose alone, which left every other tier shrunk: supporting text at 9.75px instead of 12, h1 at 19.5 instead of 24, --radius-element at 8px instead of 10. One intent, two contradicting expressions, and a compensating patch between them.

This moves the intent to where Astryx expects it — typography.scale in makaTheme.ts — and then retunes the values against what shipped agent UIs actually do.

Consolidation. The four hand-written tiers (--font-size-caption/ui/heading/stat) become aliases of the generated ladder instead of independent values. The root returns to the browser default and the compensating pin is deleted. Four more parallel authorities go with it: the font stacks (Astryx's neutral default leads with Figtree, which Maka does not bundle and whose stack carries no CJK face), Astryx's reset hard-coding a monospace stack on :where(code, kbd, samp, pre) that never consults --font-family-code, the stale rem literals in product CSS and TSX, and eleven em heading multipliers across hero, onboarding, settings and the module pages. After this the renderer has no independent font-size authority left, and a contract test says so.

Retuning. With one authority in place, the ladder moves from {base: 13, ratio: 1.15} (a faithful reproduction of the existing tiers, chosen so the consolidation's visual delta stayed attributable) to {base: 14, ratio: 1.125}. Basis: the shipped bundles of Cursor 3.14.7 (CDP against the live Agents window), Claude Code's desktop surface (the Epitaxy layer inside Claude.app) and Codex desktop (openai-codex-electron). All three sit at 14px body; all three keep secondary text at 12–14px, none near the 11px Maka used for reasoning and tool rows. 1.125 is the only ratio that moves base to 14 while keeping 11 and 20 on the ladder, and is what Astryx's own expandTypeScale header recommends for "Dense/functional". Body leading recomputes to 20px — the same absolute leading as before, so the type grows without the paragraph loosening.

Transcript-scoped policy, all taking values from the ladder:

  • Reasoning and tool-call rows read at body size, not supporting size. They carry what the agent is doing, and at 11px were the least legible text in the product. Cursor makes the same call and de-emphasises purely with colour. This is done by rebinding --text-supporting-size / --text-supporting-leading on the trigger — Astryx's supporting atoms read those two tokens, so inheritance reaches every span that opts into the role at any depth, with no !important and no dependency on child order.
  • Markdown headings flatten to two steps — h1 at lg, h2–h6 at body, all weight 600, all on one baseline. Astryx's ladder is a document scale and a turn is not a document: an agent emits ## every few lines, and 1.4× per level shreds one reply into unrelated slabs. Cursor, Claude Code and Codex all flatten here relative to their own document styles. Scoped to .maka-turn; Daily Review, settings and docs keep the full ladder.
  • Markdown renders at density="compact", Astryx's own answer to heading spacing tuned for pages rather than turns.

astryx-tokens also moves after astryx-components in the cascade order — see Review focus.

Verification

Measured in the live app via CDP computed styles (Playwright + Electron, fake backend), same transcript across runs:

before after
html font-size 13px 16px
body / chrome 13px 14px
transcript body 13px / 21.0002px 14px / 20px
reasoning + tool rows 9.75px 14px
markdown h1 19.5 / 25.9994px 16 / 20px @600
markdown h2–h4 16.25 / 13.8 / 13px 14 / 20px @600
inline code + code blocks 13px 14 / 20px
--font-size-caption 11px 12px
--radius-element 8px 10px
markdown font-family Figtree (unbundled) -apple-system
code font-family ui-monospace (reset) "Geist Mono Variable"

New guards. Three declarations hold the scale up and every one of them was silent — mutating each left all 24 existing CSS-governance tests green:

  • apps/desktop/src/main/__tests__/type-scale-contract.test.ts (8 cases) pins the unpinned root, the layer order, the four aliases, the ladder rungs they point at, the monospace routing, the turn-scoped heading flattening, the role-token rebind, and the absence of em/rem font-sizes. Six mutations were run against it; each fails exactly one case.
  • apps/desktop/e2e/type-scale.spec.ts measures what text cannot prove: custom properties resolve by tree distance while rules resolve by layer, and the two disagree at :root. It probes the tokens at :root in px, so it sees what a portaled Astryx component sees. Reverting the layer order turns --font-size-heading into 17px — Astryx's neutral value — and the spec fails on it. Verified by mutation.
  • CI now runs astryx:theme -- --check. makaTheme.ts is the type-scale authority and nothing stopped it from drifting from the artifacts it generates.

Checks: @maka/desktop unit (1314 passed), @maka/ui unit (239 passed), repo-wide typecheck (clean), lint, format:check, check-a11y, check-console, check-dead-css --check, astryx:theme -- --check.

Playwright: 70 passed, 0 failed. (An earlier revision of this branch reported 4 failures; those were #1860's, and bb44e37e6 fixed them on main.)

Rebased onto main after #1860 and #1862. Two conflicts, both in files this PR rewrites: #1862's user-bubble geometry deletion (kept as-is, alongside this PR's baseline-grid token) and the --font-size-base: inherit pin in astryx-mount.css, which this PR deletes because the name it was unshadowing no longer exists on the product side.

#1860 also added a session-list section-title pin whose stated reason was the 13px root — Astryx's supporting/sm collapsing to 9.75px. With the root back at the browser default, the ladder says what the pin was trying to say, so the pin is gone:

before after
section title 14px / 19.25px 12px / 20px
nav label 14px 14px

Two regressions were caught and fixed rather than shipped:

  • .modelPickerProviderMark — converting product CSS off rem had swept up the one place the unit is load-bearing; refactor(ci): split Storybook from e2e and demote floor layout to CSS contracts #1854 added a contract test asserting a fixed 1rem square. Fixed in 5d943e8a9, now the one deliberate rem in product CSS with a comment saying why.
  • The reasoning rows never got the retune. > span:not(:last-child) reads as "every span but the chevron", but ChatReasoning wraps its label, duration and preview one <div> deeper, so the rule enlarged the leading icon and left the reasoning text at supporting size — the single row this PR exists for. Replaced with the token rebind described above.

What three reviews changed

Two fresh-eye agents and Codex reviewed this independently. They converged, and the result is 251423edd. Everything below was measured in the live app before being acted on.

One behaviour defect. density="compact" was hardcoded in MarkdownBody, whose other caller is the Daily Review panel — so the review got transcript block spacing with document heading sizes, contradicting the very argument that scopes the heading flattening to .maka-turn. Density is now the caller's choice, defaulting to default.

Six comments asserted something false. The load-bearing one: this PR claimed the old layer order merely leaked at :root and that the theme "still won inside the Theme wrapper by tree distance". Probed on main at both <html> and the inner wrapper, --font-size-lg is 13.8125px and --font-size-xl is 16.25px — Astryx's neutral values. The generated theme was applying nowhere at all. Also corrected: Astryx's spacing and radius tokens are px and never moved (what is rem is its Icon atoms — see below); the disclosure rows measured 9.75px on main, not the 11px claimed; the body leading went 21px → 20px rather than staying put; and 1.125 is the natural ratio, not the only one.

An undocumented improvement. The 13px root was shrinking Astryx's entire icon set, which it authors in rem as "the px-equivalents at a 16px root":

Astryx icon box before after
astryx-icon.md 16.25px 20px
astryx-icon.sm 13px 16px
astryx-icon.xsm 9.75px 12px

Five test holes, each proved by mutation. @maka/ui/styles.css reaches the renderer through a bare specifier that the CSS import expander skipped along with node_modules, hiding ~30 font-size declarations from every scan. --maka-line-body had no guard at all — deleting it was green. The root-pin ban missed html[data-os="darwin"] { font-size: 13px }. The unscoped-heading and !important bans read one file each. And the monospace routing was text-asserted on the false grounds that it "follows arithmetically" — it is a layer-order question, now probed live.

Rejected after checking. That the model-picker mark's 1rem is an unnoticed 23% growth: it is an icon box, and 1rem is Astryx's sm icon at a 16px root, so 13px was the anomaly. Declined: a live ChatReasoning probe — no fixture renders one, and seeding it would change a shared scenario every spec sees; the gap is stated in the spec rather than left implicit.

Review focus

The cascade-order change in cascade-layers.css reaches beyond typography, and an earlier revision of this description wrongly said it could not.

It is required: astryx.css ships the neutral defaults on :root, so a theme layered before it can never override them there, and the product aliases resolve exactly there. It is also the order Astryx itself prescribes (README.md, integration snippet: reset, theme, base, astryx-base, astryx-theme, components) — the old order was the deviation. Reverting it puts --font-size-heading back to Astryx's 17px, measured.

But maka.css carries 67 .astryx-* class rules besides its tokens, and Astryx components are product markup. "67 rules change" would be wrong, though — what decides is whether a declaration is CONTESTED (both sides set it, so layer order picks) or UNCONTESTED. Compared declaration by declaration: 44 are contested but resolve to the same value (35 Heading/Text atoms, 9 categorical Badges), 12 are uncontested and were already in effect, and 11 actually move — the destructive Button, 5 semantic Badges, 4 StatusDots, the info Banner background. Two spot-checked live:

background color
destructive Button, before rgb(165, 12, 37) rgb(255, 255, 255)
destructive Button, after rgb(250, 206, 203) rgb(165, 12, 37)
accent StatusDot, before rgb(38, 38, 38)
accent StatusDot, after rgb(0, 116, 226)

Badge tones and status dots move the same way. These are the neutral theme's own values, generated by astryx theme build from the base Maka extends — the theme was simply never taking effect. Card and Section padding are NOT affected, despite looking like they would: --astryx-card-padding and --astryx-section-padding are theming hooks Astryx never declares itself, so those custom properties were unopposed under either order.

Deliberately out of scope: a user-facing text-size setting. All three benchmarked products offer one (Cursor 4 steps, Claude Code 3, Codex continuous 11–16) and Maka does not; that wants its own issue and a mechanism for deriving spacing from the size, not just the size itself.

Deliberately preserved: the rem → px conversions freeze the geometry the app ships today rather than restoring each length's pre-0.8125 intent. Restoring intent would move those lengths by +23% and turn a typography PR into a relayout.

@Astro-Han
Astro-Han marked this pull request as draft August 1, 2026 17:12
@Astro-Han
Astro-Han force-pushed the refactor/typography-astryx-type-scale branch 2 times, most recently from edba92d to fbb0c03 Compare August 2, 2026 02:41
Maka's density was expressed as `html { font-size: 13px }`, which is not a
type scale but an implicit x0.8125 multiplier on every rem in the document —
including the radius and spacing constants Astryx compiles against a 16px
root. Body copy was then pinned back to 13px on the Theme wrapper to undo the
multiplier for prose alone, leaving every other tier shrunk: supporting text
rendered at 9.75px instead of 12, h1 at 19.5 instead of 24, --radius-element
at 8px instead of 10. One intent, two contradicting expressions, and a
compensating patch between them.

Declare the intent where Astryx expects it instead. `{base: 13, ratio: 1.15}`
reproduces Maka's four hand-written tiers exactly (caption 11 / ui 13 /
heading 15 / stat 20) and supplies the matching 4px-grid line heights, so
those names become aliases rather than independent values. The root returns
to 16px and the compensating pin is gone.

The font stacks move for the same reason: Astryx's neutral default leads with
Figtree, which Maka does not bundle and whose stack carries no CJK face, so
Astryx surfaces silently fell back while the product used its own stack.
--font-sans / --font-mono now alias the theme tokens.

astryx-tokens also moves after astryx-components in the cascade order. A
theme layered before the component library can never override the neutral
defaults astryx.css declares on `:root`; that only looked harmless while
nothing resolved a token there, because inside the wrapper the theme still
won on tree distance. The 22 rem literals left in product CSS become absolute
px, matching the spacing scale's existing rule.

Measured in the live app (before -> after): body 13 -> 13px, bubble leading
21.0002 -> 20px, h1 19.5/25.9994 -> 20/28px, h2 16.25/22.75 -> 17/24px,
li and code leading 18.5718 -> 20px, supporting 9.75 -> 11px,
--radius-element 8 -> 10px. The four product tiers are unchanged.
Astryx's reset hard-codes a monospace stack on :where(code, kbd, samp, pre)
that never consults --font-family-code, so every code element — Markdown code
blocks in the transcript included — opted out of the theme and rendered in
ui-monospace rather than the Geist Mono the app bundles and declares.

:where() has zero specificity and reset is the lowest layer, so pointing the
same selector at the token is the whole fix. This was the last parallel
typography authority left after the type-scale consolidation.

Verified in the live app: code blocks now resolve
"Geist Mono Variable", "JetBrains Mono", ui-monospace, … at 13px.
The consolidated ladder was 13/1.15 — a faithful reproduction of the tiers
Maka had already converged on, chosen so the previous commits' visual delta
stayed attributable. With the authority in one place, retune the values.

Read the shipped bundles of the three products Maka is benchmarked against:
Cursor 3.14.7 (CDP against the live Agents window), Claude Code's desktop
surface (the Epitaxy layer inside Claude.app), and Codex desktop
(openai-codex-electron). All three sit at 14px body. All three keep secondary
text at 12-14px; none goes near the 11px Maka used for reasoning and tool
rows, which carry what the agent is doing and were the least legible text in
the product. Cursor puts tool rows at full body size and de-emphasises purely
with colour.

  scale: {base: 14, ratio: 1.125}

1.125 is the only ratio that moves base to 14 while keeping 11 and 20 on the
ladder, so caption and stat stay put and only base (13->14) and heading
(15->16) move. It is also what Astryx's expandTypeScale header recommends for
"Dense/functional". Body leading recomputes to 20px — the same absolute
leading as before, so the type grows without the paragraph loosening.

Three transcript-scoped overrides, all taking values from the ladder:

- Reasoning and tool-call rows read at body size, not supporting size.
- Markdown headings flatten to two steps: h1 at lg, h2-h6 at body, all
  weight 600, all on one baseline. Astryx's ladder is a document scale and a
  turn is not a document — an agent emits `##` every few lines, and 1.4x per
  level shreds one reply into unrelated slabs. Cursor, Claude Code and Codex
  all flatten here relative to their own document styles; Claude Code merges
  h4-h6 into `strong`. Settings and docs keep the full ladder.
- Markdown renders at density="compact", Astryx's own answer to heading
  spacing tuned for pages rather than turns.

Measured in the live app: body 14/20, tool rows 12->14, h1 16/20, h2-h4
14/20 at weight 600, inline code and code blocks 14/20.
Converting product CSS off rem swept up .modelPickerProviderMark, which is
the one place the unit is load-bearing rather than habitual:
chat-shell-layout-contract.test.ts asserts a fixed 1rem square so the mark
tracks the root instead of any single font tier and cannot be squashed by
flex. The contract landed in #1854 and the rebase surfaced the collision.
Four defects, one mistake: transcript-only typography was attached to
selectors that were either wider than the transcript or narrower than the
DOM they were aiming at.

The reasoning rows never got the retune. `> span:not(:last-child)` reads as
"every span but the chevron"; ChatReasoning wraps its label, duration and
preview in a `<div>` (packages/ui/src/astryx-chat-reasoning.tsx), so the rule
enlarged the leading icon wrapper and left the reasoning text at supporting
size -- the single row this work exists for. Astryx's supporting atoms are
`font-size: var(--text-supporting-size)` and the matching leading, so the
rows now rebind those two tokens on the trigger: inheritance reaches every
span that opts into the role, at any depth, and the rule stops depending on
Astryx's child order.

`.astryx-heading.level-N` matched nothing. Astryx's Markdown renderer emits
bare h1-h6 with StyleX props; those classes come from the standalone Heading
component, which Markdown never mounts. They were live enough to turn the CI
`Dead CSS` gate red (7 classes over a baseline of 0) and dead enough to have
no effect.

The heading flattening reached Daily Review. Its argument is that an agent
turn is not a document -- but a review report IS one, and both render through
the shared MarkdownBody contract. Scoped to `.maka-turn`.

`--maka-chat-line` was declared on `.maka-turn, .maka-chat-message` and read
by rules matching outside them, where it resolved to nothing and took
`line-height` down with it as an invalid value. It is a calc of two global
tokens, so the scoping was invented coupling: renamed `--maka-line-body` and
declared with the tiers it derives from.

Drops the typography `!important`s while here. Astryx's StyleX lives in the
earlier astryx-components layer, so product CSS in `components` already wins
on layer order whatever the specificity; the one real inline-style opponent
was an ejected chevron's `0.75rem`, removed separately. Also updates the
comments the 14/1.125 retune left describing 13/1.15.
Eleven renderer headings still sized themselves with `em` multipliers --
2.1333, 1.7333, 1.4667 and friends across hero, onboarding, settings and the
module pages. Those decimals are the tell: each was hand-derived against
whatever the body happened to be when it was written, so every base change
since has silently rescaled them. The hero comment claimed 32px; 2.1333em was
derived from a 15px body and had been rendering 27.7px under a 13px one, and
would now render 29.9px. That is three different sizes for one decision.

Each now names the ladder rung nearest what the page actually shipped, so
this is a convergence rather than a visual redesign: 28 / 22 / 20 / 18 / 16.
With these gone the renderer has no independent font-size authority left.

Two rem lengths in TSX went with them. The earlier sweep only read `.css`,
but removing `html { font-size: 13px }` is a document-level change: the tool
code block's `16rem` max height would have gone 208px -> 256px, and the
reasoning chevron's inline `0.75rem` had been disagreeing with its own
width/height attributes for as long as the root was pinned. The attributes
already carry the size, so the inline style is simply gone.
Three declarations hold the scale up, and reverting any of them was silent:
the root staying at the browser default, the generated theme layered after
the Astryx component sheet, and the product names staying aliases. Mutating
each one left all 24 existing CSS-governance tests green.

`type-scale-contract.test.ts` pins them as text, plus the ladder rungs the
four aliases point at, the monospace routing, the turn-scoped heading
flattening, the role-token rebind, and the absence of em/rem font-sizes.
Six mutations were run against it; each fails exactly one test.

`e2e/type-scale.spec.ts` measures what text cannot prove. Custom properties
resolve by tree distance while rules resolve by layer, and the two disagree
at `:root` -- which is exactly how an earlier revision of this work shipped
aliases resolving to Astryx's neutral defaults with every file reading
correctly. It probes the tokens at `:root` in px, so it sees what a portaled
Astryx component sees. Reverting the layer order turns `--font-size-heading`
into 17px, the neutral value, and the spec fails on it.

Wires `astryx:theme -- --check` into CI: makaTheme.ts is now the type-scale
authority, and nothing stopped it from drifting from the artifacts it
generates.

Corrects the cascade-layers comment while here. It claimed promoting the
theme "cannot reach product markup" because the sheet only carries `.astryx-*`
rules -- but Astryx components ARE product markup, and 67 such rules now win
where they previously lost. Card and Section padding are NOT among them:
`--astryx-*-padding` are theming hooks Astryx never declares itself, so those
applied under either order. Button, badge and status-dot colors did change.
`html { font-size: 13px }` is gone, so the workarounds that existed to
undo it have nothing left to undo.

The session-list section-title pin is the load-bearing one. It was added
one commit before this branch (#1860) because Astryx declares SideNav
section titles as supporting/sm — 0.75rem, which under a 13px root
collapsed to 9.75px, smaller than caption and unreadable beside 13px nav
labels. Pinning them to the product UI tier fixed the symptom by
flattening the hierarchy: titles and labels ended up the same size, told
apart only by colour.

Measured on the sidebarLongSessions fixture after the root moved back to
the browser default:

  section title   14px / 19.25px  ->  12px / 20px
  nav label       14px            ->  14px  (unchanged)

So the ladder now says what the pin was trying to say — titles a tier
below labels, and the leading lands on the same 20px grid as the rest of
the product. The `!important` went with it; product CSS sits in the last
cascade layer and never needed it.

The other two are comments that state the old base as fact: the tracking
scale's worked example and the tool-output panel's body-type note. Both
now name the tier instead of a number, so neither can go stale again the
next time the scale moves.
Three independent reviews (two fresh-eye agents, one Codex) landed on the
same defects. Everything below was verified in the live app before being
acted on; two of the reviewers' own claims did not survive that and are
recorded here as rejected.

The one behaviour change: `density="compact"` was hardcoded in
MarkdownBody, whose other caller is the Daily Review panel. That
contradicted this branch's own reasoning — the heading-size flattening is
scoped to `.maka-turn` precisely because a review report is a document —
so the review kept full heading sizes but got transcript block spacing,
the combination neither half of the argument asks for. Density is now a
caller choice defaulting to `default`, with the two chat-turn call sites
opting in.

Comments that stated something false, each replaced with what was
measured:

  - "the radius and spacing rem constants Astryx compiles against a 16px
    root" (four copies). Astryx's spacing and radius tokens are px
    literals and never moved. What is rem is its Icon size atoms, which
    it documents as the px-equivalents at a 16px root — so the old pin
    was rendering the whole icon set at 9.75/13/16.25/19.5 instead of
    12/16/20/24. Measured on main and on this branch.
  - "inside the Theme wrapper the theme still won, since custom
    properties resolve by tree distance". False. Probed on main at both
    <html> and the inner wrapper: --font-size-lg 13.8125px, --font-size-xl
    16.25px, i.e. Astryx's neutral values. The generated theme was inert
    everywhere, not merely at :root.
  - "at 11px on the old one" for the disclosure rows. Measured 9.75px;
    11px was a reading of an intermediate commit, not of main.
  - "the same absolute leading as before". The transcript was 21px and is
    now 20px — 1px tighter, not unchanged.
  - "1.125 is the only ratio that keeps 11 and 20 on the ladder". 1.13
    also rounds there. It is the natural choice, not the forced one.
  - the cascade-layer blast radius, which said 67 class rules change.
    Compared declaration by declaration: 44 are contested but resolve to
    the same value, 12 are uncontested and already applied, and 11
    actually move. Spot-checked live — destructive Button
    rgb(165,12,37)/white to rgb(250,206,203)/rgb(165,12,37), accent
    StatusDot rgb(38,38,38) to rgb(0,116,226).

DESIGN.md still named maka-tokens.css as the type authority and carried
the 15/13/11 tiers, and maka-tokens.css still claimed to be the single
source of truth for typography. Both now point at makaTheme.ts.

The eight geometry `!important`s went too. The comment four lines below
them already said product CSS wins on layer order alone; deleting them
and re-measuring confirms it — the chevrons still render 14x14 with a
10x10 svg. This file now declares none.

The tests close five holes the reviewers proved by mutation — each of
these edits was previously green:

  - `@maka/ui/styles.css` reaches the renderer through a bare specifier,
    which the CSS import expander skipped along with node_modules. ~30
    font-size declarations were invisible to every scan; the expander now
    resolves first-party workspace sheets.
  - `--maka-line-body` had no guard at all. Deleting it, or replacing it
    with the hand-rolled multiplier this branch exists to retire, passed
    everything. Now pinned in the contract and resolved live in e2e.
  - the root-pin ban only matched a bare `html`/`:root` selector, so
    `html[data-os="darwin"] { font-size: 13px }` slipped through.
  - the unscoped-heading ban and the `font-size: !important` ban only
    read chat-message.css. Both now scan the whole renderer graph; the
    second one would have caught the sidebar pin this branch removed.
  - the monospace routing was asserted as text only, on the stated
    grounds that it "follows arithmetically". It does not — it is a
    layer-order question — and it is now probed live.

Density gets a render contract that fails if the default flips back.

Rejected after checking: that the model-picker mark's `1rem` is an
unnoticed 23% growth to be reverted. It is an icon box, and 1rem is
Astryx's `sm` icon at a 16px root — the old 13px was the anomaly. The
comment now says that instead of the circular "it tracks the root".

Also declined: a live ChatReasoning probe. No fixture renders one, and
seeding it means changing a shared scenario every spec sees; the gap is
now stated in the spec instead of left implicit.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
PR #1860 rendered sidebar sessions as Astryx SideNavItem rows, which broke four e2e specs:

- SideNavItem renders endContent (the row-actions MoreMenu trigger) inside the row's primary <button>, so the row button's accessible name became a composite ending in the trigger's label and name queries hit strict-mode violations. The nesting is SideNavItem's own DOM, and moving the trigger out needs sidebar CSS frozen by PR #1857, so the locators are tightened (project scope .maka-project-item-end; session exact: true).
- The geometry spec's 60-row pin selected rows by title^="会话 ", but the rows' title is now session metadata; the pin was deleted — its only purpose (proving the list overflows) is asserted directly at (1a) via scrollHeight - clientHeight > 50.

e2e: 70 passed / 0 failed; format/lint/typecheck/dead-css and the desktop (1305) and ui (238) unit suites all pass.
@Astro-Han
Astro-Han force-pushed the refactor/typography-astryx-type-scale branch from fbb0c03 to fcd7722 Compare August 2, 2026 03:35
The leading tiers still carried a "single source" claim from #520. Size
now has one authority and a contract that proves it; leading has three
vocabularies, and two of them disagree — --leading-normal is 1.5, i.e.
21px at the body tier and off the 4px grid, where Astryx computes 20px.
The transcript reads Astryx's and is internally consistent; product
chrome sits 1px looser.

Not fixing it here. Converging them moves 66 sites, which is a relayout
with its own before/after, and nothing about this branch blocks it.
Stating it beats leaving a stale claim four lines under the derived
token that contradicts it.
@Astro-Han
Astro-Han marked this pull request as ready for review August 2, 2026 03:46
@Astro-Han
Astro-Han merged commit 06c6161 into main Aug 2, 2026
5 checks passed
Astro-Han added a commit that referenced this pull request Aug 2, 2026
This branch pinned Astryx's four icon sizes to px because the product root was
13px and every rem-based icon landed at 81% — 13px next to the 16px lucide
icons handed to the same slots. That was true when it was written and is not
true now: #1857 landed in the rebase and removed the root pin outright, saying
so in maka-tokens.css ("the root font-size stays at the browser default (16px)
ON PURPOSE") for exactly the same reason this commit existed.

So the pin restates what rem already computes. Measured in Storybook across
three stories, with the rule and with it reverted: xsm 12, sm 16, md 20, root
16px, identical either way.

Keeping it would leave 34 lines of CSS whose comment asserts a 13px root that
main's own token sheet denies — the precise shape of rot the rest of this
branch is removing.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
This branch pinned Astryx's four icon sizes to px because the product root was
13px and every rem-based icon landed at 81% — 13px next to the 16px lucide
icons handed to the same slots. That was true when it was written and is not
true now: #1857 landed in the rebase and removed the root pin outright, saying
so in maka-tokens.css ("the root font-size stays at the browser default (16px)
ON PURPOSE") for exactly the same reason this commit existed.

So the pin restates what rem already computes. Measured in Storybook across
three stories, with the rule and with it reverted: xsm 12, sm 16, md 20, root
16px, identical either way.

Keeping it would leave 34 lines of CSS whose comment asserts a 13px root that
main's own token sheet denies — the precise shape of rot the rest of this
branch is removing.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
This branch pinned Astryx's four icon sizes to px because the product root was
13px and every rem-based icon landed at 81% — 13px next to the 16px lucide
icons handed to the same slots. That was true when it was written and is not
true now: #1857 landed in the rebase and removed the root pin outright, saying
so in maka-tokens.css ("the root font-size stays at the browser default (16px)
ON PURPOSE") for exactly the same reason this commit existed.

So the pin restates what rem already computes. Measured in Storybook across
three stories, with the rule and with it reverted: xsm 12, sm 16, md 20, root
16px, identical either way.

Keeping it would leave 34 lines of CSS whose comment asserts a 13px root that
main's own token sheet denies — the precise shape of rot the rest of this
branch is removing.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
…rail (#1876)

* fix(desktop): paint column surfaces through a transparent titlebar overlay

Stop using AppShell topNav as a full-width chrome row so sidebar canvas and
session --background extend to the window top (Codex/Claude/Cursor). Keep a
single absolute drag overlay with no-drag action clusters, Windows WCO gutters,
and 16px secondary ghost icons aligned with SideNav chrome.

* feat(desktop): collapse the session rail into a 48px icon rail

Continuous Collapse: the 48px rail stays in flow and paints ink only, with
`.appFrame` flipping to the plate background when collapsed, so the transparent
titlebar reads as one surface with the column instead of a chrome bar.

Session rows share the nav items' left edge: `.maka-list-stackContent` no longer
adds a second inline inset on top of SideNav's `scrollable` padding, and its grid
track is `minmax(0, 1fr)` so a long title cannot push the row past the rail and
get its end actions clipped.

`.maka-session-item-label` is block-level flex, not inline-flex. An inline box is
shrink-to-fit with a min-content floor, which for a nowrap title is the whole
string — the label then outgrew its slot and painted over the row meta instead of
ellipsizing.

Width easing is scoped to collapse/expand via `data-easing`. A standing width
transition also caught the per-pointer-move updates from the resize handle and
made the drag lag the cursor.

* fix(desktop): restore Astryx's icon scale under the 13px product root

Astryx sizes icons in rem so they track the root font-size, and documents the
scale as its px-equivalents at a 16px root: 12 / 16 / 20 / 24. Maka's root is
13px, so every icon Astryx renders lands at 81% of that — 13px sitting next to
the 16px lucide icons the product hands to Astryx slots by absolute size. The
nav rail showed it worst: 新任务 / 扩展 / 定时任务 read visibly smaller than the
same-family icons one settings page away.

The 13px type scale is deliberate; the icon scale was never meant to follow it,
so restate Astryx's own intent in px at the renderer mounts rather than move the
rem base. Class-on-class beats StyleX's single-class rules without !important.

* fix(desktop): drop the footer hairline on the collapsed rail

The rule that was supposed to do this targeted `.maka-session-panel-footer`,
whose border is already `0 !important` — it never had a border to clear. The
hairline is drawn one level up, on the sticky-bottom host that separates the
scrolling history from the footer.

At 48px there is no history column left to separate, so the line reads as an
orphan rule floating above the Settings icon on an otherwise ink-only rail.
Scope the collapsed override to the rule that owns the border in sidebar.css
rather than reaching for it from the shell.

* docs(desktop): stop citing contracts that no longer exist

maka-tokens.css claimed five static guards enforced its scales:
motion-token-converge, icon-system-contract, the spacing adjacency pin, and
governance contract #406. All of them went out with the 149 source-scanning
tests in #1724. A comment that promises a guard nobody runs is worse than no
comment — it reads as "the build will catch this" while nothing does.

The rules themselves stay; only the enforcement claims change to what actually
holds them: convention and review.

Also record why chat-message.css reaches for an Astryx duration instead of the
product scale. It is not a leak: Astryx ChatToolCalls opens its own group with
`grid-template-rows --duration-medium --ease-standard`, so a detail row that
expands beside one has to share that clock. Without the note the next reader
"fixes" it onto --duration-large and desyncs the two disclosures.

check-dead-css: allowlist astryx-icon and astryx-app-shell-sidenav, both
themeProps classes that exist only at runtime.

* chore(desktop): delete orphan tokens and let check-dead-css see them

Sixteen tokens in maka-tokens.css had no reader left anywhere: the --action
CTA pair, --toast-accent, --bot-brand-default, --brand-deep-hover,
--system-alert-accent, two --color-* alias tiers, --shadow-modal,
--focus-glow-width, --scale-hover, --ease-drawer, --font-serif, --h-toolbar,
--h-list-header, and --w-sessionlist (session width has been React state for a
while). Their surrounding notes claimed live consumers, so the comments went
with them.

Ten unused tokens stay. A rung of an ordered scale outliving its last consumer
is not rot — deleting the middle of the z-index or control-height ladder is
exactly what invites the next bare number. RESERVED_SCALE_TOKENS carries them
with the series each belongs to.

check-dead-css now sweeps tokens alongside classes, so this converges instead
of drifting back. Two consumer surfaces the class sweep never needed are in
scope for tokens: packages/ui stylesheets, and stories — a story composing a
surface out of the design vocabulary is a real reader, though a product class
only a story mentions is still dead. Verified both ways: an injected orphan
trips --check in either declaration style, and the live renderer resolves every
var() after the deletions.

* fix(desktop): give the session column a plate you can actually see

Nothing draws a line between the session rail and the content column, by
design: Astryx ships a divider only on `AppShell variant="section"` and Maka
runs "surface" (#1732). The separation is meant to come from the material —
`--surface-canvas` behind the rail, `--background` on the content — which is
exactly what the token block has claimed since the gray-plate/white-card model
landed.

The step was too small to deliver it. `l - 0.024` resolves to #f7f7f7 against
white: six sRGB levels, below what reads as a second surface on most displays.
Until now nobody saw the miss, because Astryx painted its own opaque surface
over the frame plate; this branch makes the shell transparent so the plate
finally shows, and what showed was almost nothing.

0.045 is not a taste pick. It is the surface→body step Astryx itself ships
(#ffffff L1.000 → #f1f1f1 L0.958), and it puts light mode within a hair of the
dark step (0.065) this file has carried all along. Dark stays where it is —
it was tuned against a dark plate, reads fine, and is not the boundary anyone
reported losing.

Not variant="section": a hairline plus a wash is two mechanisms for one job,
and section's divider is unconditional, so it would draw a rule down the 48px
icon rail — the same orphan line this branch just removed above Settings.

* refactor(desktop): let AppShell own the column materials

The column boundary was a product invention held together by two `!important`
rules: paint `--surface-canvas` on `.appFrame`, then force AppShell and its
sideNav slot transparent so the plate reads through them. Every layer in
between had to stay transparent forever, and Astryx's own material tokens sat
unused underneath.

Astryx ships this exact model. `variant="elevated"` — its default — paints the
nav column `--color-background-body` and the content column
`--color-background-surface`; with no topNav there is no radius or backdrop,
just the two materials. Maka ran `"surface"`, the one variant that paints both
columns the same and draws no divider, which is why there was nothing to see.

Taking Astryx's paint means fixing whose colors it paints with. Its neutral
pair is static light-dark(), so it cannot follow Maka's six switchable
palettes — and in dark mode the two systems disagree outright: Astryx's surface
is #262626 against the product's #171719, so the nav column rendered LIGHTER
than the content it navigates. makaTheme.ts now points both material tokens
back at the palette. Direction is deliberate and opposite to the type scale
above it: Astryx's scale covers everything Maka needs from type, while its
neutral pair is a fraction of a palette that also carries status, chat, and
per-theme colors.

Dark's plate step joins light's 0.045. The old 0.065 was never seen on the
shell — Astryx painted over the frame plate until now — and against #171719
content it renders a near-black #09090b rail.

Continuous Collapse survives as the one rule that overrides a column material:
collapsed, the 48px rail takes the content surface, because the traffic lights
are wider than the rail and a wash boundary would run a seam through them.

Measured in Storybook (Chromium, sampled pixels), both schemes, both states:
  light  expanded  nav #f0f0f0  content #ffffff
  light  collapsed nav #ffffff  content #ffffff
  dark   expanded  nav #0d0d0f  content #171819
  dark   collapsed nav #171719  content #171819

* fix(desktop): stop tooltips from swallowing clicks under them

Astryx tooltips are text popovers with no interactive content, but they leave
pointer-events at auto, so an open tooltip is a hit target. Anchored to a
control in the titlebar there is no room above it, so the layer flips below the
window's top edge and lands on the SideNav's first row — 86×28 of it, measured
live, directly over 新任务.

The E2E caught it deterministically: click 展开侧边栏, and the pointer stays
parked on its trigger, so the tooltip that opens never closes and the next
click has nowhere to land. A person hits the same edge more briefly on the way
down to the first nav row. It surfaced now because 新任务 moved into the rail
in this branch; before, the row under that tooltip was not a target.

A tooltip is an affordance, never a target. One unscoped rule — the popover
renders in the top layer, where a mount-scoped selector is not reliably in the
chain — and no !important, since StyleX declares no pointer-events of its own.

While here: keep the type-scale comment in makaTheme.ts adjacent to
`typography`. The materials block landed between them last commit.

* feat(desktop): split the columns with a hairline and a quieter wash

Sampled the two apps this shell is measured against, by scanning a pixel row
across their column seam:

  Cursor  nav #edeeef  line 220  content #f6f6f6   ΔL 0.025
  Codex   nav ~#eeefef line 210  content #ffffff   ΔL ~0.010
  Maka    nav #f0f0f0  (none)    content #ffffff   ΔL 0.045

Both draw a 1px rule, and both keep the wash far below what Maka was carrying.
Maka's sidebar was never the darker one — it was the lightest of the three —
but with no line it had to state the whole boundary through lightness, and a
wash that wide reads as a gray area rather than a second panel.

So: wash down to 0.025, plus the rule. The line says where the boundary is;
the wash only says the two columns are different material.

The rule is authored in makaTheme.ts, not as a product override, so AppShell
keeps one paint authority — the theme emits it into its own @scope, and the
color is the product's --border, the same product → Astryx direction as the
materials. Astryx's own divider is not usable here: it is hardcoded to
`variant === 'section'`, which also drops the wash entirely, and it is
unconditional — it would draw down the 48px icon rail.

Collapsed still drops both halves of the edge. The traffic lights measure
~62px against a 48px rail, so any column edge there cuts through the cluster.

Live measurements (2x capture, pixel row across the seam):
  light  nav 247  line 224  content 255
  dark   nav 17   line 38   content 23

* revert(desktop): drop the icon-scale pin, the 13px root is gone

This branch pinned Astryx's four icon sizes to px because the product root was
13px and every rem-based icon landed at 81% — 13px next to the 16px lucide
icons handed to the same slots. That was true when it was written and is not
true now: #1857 landed in the rebase and removed the root pin outright, saying
so in maka-tokens.css ("the root font-size stays at the browser default (16px)
ON PURPOSE") for exactly the same reason this commit existed.

So the pin restates what rem already computes. Measured in Storybook across
three stories, with the rule and with it reverted: xsm 12, sm 16, md 20, root
16px, identical either way.

Keeping it would leave 34 lines of CSS whose comment asserts a 13px root that
main's own token sheet denies — the precise shape of rot the rest of this
branch is removing.

* refactor(desktop): key the column edge on the elevated variant

`base` drew the rule under any variant, including ones that paint the columns
differently or draw their own divider. Astryx keys component overrides by prop
(`variant:elevated`), which emits `.astryx-app-shell-sidenav.elevated` —
verified on the live node, which carries both classes.

* test(e2e): lock the column edge in both sidebar states

The bug this branch was asked to fix — no visible boundary between the session
column and the content column — had nothing holding it. Neither half of the
edge is greppable: the material comes from a generated theme file, the rule
from a component override inside it, and a CSS grep passes on declarations that
never reach the element. So assert the rendered result.

Collapsed is asserted too, and asserts the opposite: both halves go away,
because the traffic lights are wider than the 48px rail and any column edge
there cuts through the cluster. Without that half a reviewer cannot tell the
collapsed rail from a regression.

Both halves ease between states, so the comparisons poll for the resting value
— sampled mid-transition the color comes back as oklab().

Mutation-checked, rebuilding between runs: divider width 1px→0px fails on the
border assertion, dropping the collapsed background override fails on the
material assertion.

* docs(desktop): stop describing tokens this branch deleted

The dead-token sweep removed the declarations and left their prose, so the
token sheet documented six things that no longer exist: the serif family with
its whole section header, a "these three geometry tokens" focus-ring recipe
that has two, a transform-amplitude note listing a hover scale nothing uses,
and the brand/system-banner tint aliases whose comments dangled above the next
section. Also drops the double blank line where --shadow-modal stood.

The lucide stroke note is corrected rather than deleted: it justified keeping
size per call site by pointing at icon rules in astryx-mount.css, and those
were reverted on this same branch when the 13px root that motivated them went
away. The reason still holds — Astryx sizes its slot icons through its own
props — so the sentence now names that instead of a file that no longer says it.

* fix(desktop): give the shell one paint authority and tests that can fail

Five independent reviews of this branch converged on two defects it had
introduced, both of the same kind: a comment asserting a guarantee the code
did not provide.

window-titlebar.spec.ts had stopped seeing the class of defect it exists for.
The strip became an absolute overlay with default pointer-events, so it is the
topmost hit at every point of the band and `elementFromPoint` returns it and
nothing else; every control beneath silently failed the "is this reachable"
precondition and was skipped. The assertion that used to catch this was
inverted in the same change (`band.bottom <= contentTop` became its converse),
leaving a form satisfied by exactly the geometry that creates the hazard.
Mutation-proved: deleting either column's `padding-top` put 新任务, the workbar
tab and 查看上一版本 under the overlay — real 30s click timeouts in three other
specs — while this spec passed. The sweep now probes with `elementsFromPoint`
minus the strip's own subtree, and splits the judgment: the strip's children
are reachable and keep the app-region rules, anything beneath it is a defect
outright, since no-drag shapes the OS rect while the click is already lost to
the strip's hit-testing. That immediately caught the resize handle, which is
positioned against the column box rather than flowing inside it and so kept its
top 36px under the strip; it now starts below it.

The Astryx neutral ramp was half-migrated. Remapping only the two tokens the
shell reads left card, popover, muted and the hairline on the static
light-dark() pair, and a ramp with one half palette-driven and the other frozen
does not merely drift — it inverts: stock muted is L 0.222 against a
--background of 0.18–0.24, so the recessive fill sat at or above the surface it
is recessed into, in 8 of 11 dark palettes. Card, Code, ChatToolCalls, Slider
and TableRow are the transcript, not chrome. The whole ramp now moves, onto the
product's own stated hierarchy; --muted lands on foreground-at-5%, defined
relative to whatever it sits on, so the inversion is unrepresentable rather
than merely corrected.

Also from the reviews, each verified before acting on it:

- The tooltip hit-test rule was global, disabling the hover bridge Astryx
  documents as its WCAG 1.4.13 implementation for all 33 call sites. Scoped to
  the titlebar, the one place the layer cannot flip into empty space, with the
  concession stated.
- Both new `!important`s rested on "StyleX is unlayered", which this app
  contradicts: astryx.css is one @layer astryx-base block imported into
  astryx-components, which product `components` outranks outright. Dropped. The
  `.astryx-button` colour rule went with them — Icon colours the glyph itself,
  so a button-level rule never reached it.
- The content column had two paint authorities, and the new column-material
  test sampled the product's copy, so it would have passed with AppShell
  painting nothing. It samples AppShell's own element now, and the product's
  background is gone.
- `.maka-sidenav-motion` mirrored a width SideNav already owns and then needed
  `width: 100% !important` to beat it. Deleted; the transition lives on the nav.
  Its React easing flag went too: cleared on transitionend, it never cleared
  when no transition ran, leaving every later drag animated — the exact fault it
  existed to prevent. The gate is now derived from the handle's own
  `data-resizing`/focus, so it cannot desynchronize.
- `data-sidebar-state` had three writers and one reader; `data-modal-background-
  hidden` had no readers at all. One writer, on the frame.
- The Storybook shell omitted `data-sidebar-state`, so the pixel-review surface
  rendered a collapsed state the app does not have.

New locks, each mutation-verified to fail: the 48px collapsed rail (the number
the dropped column edge argues from), the transition suppression during resize,
and the column materials read off AppShell.

Not done, and deliberately: the generated edge selector is a bare `.elevated`
class, which Astryx's own themeProps notes advise against — the CLI emits it, so
it is an upstream report, not a local fix.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
* refactor(ui): define text roles as indivisible font shorthands

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.

* refactor(ui): name a text role at every renderer call site

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.

* fix(ui): settle every role that measurement showed diverging

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.

* test(ui): replace six typography guards with the one the triplet gives

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.

* refactor(ui): delete the size and family names the roles replace

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.

* fix(ui): stop a role from resetting or shadowing what it does not name

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.

* test: scan every renderer stylesheet for the text-role vocabulary

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.

* refactor(ui): delete the last product weight name and record what the 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 added a commit that referenced this pull request Aug 21, 2026
Two paragraphs in a chat turn sat 8px apart against 20px body leading, so
a paragraph break was 0.4 of a line — narrower than the line break inside
a paragraph, which is the one vertical distance a reader already knows.
A long answer read as one slab.

Take the block rung to 12px, which is Astryx's own paragraph rhythm at
document density (spacingParagraphDefault, --spacing-3). The transcript
keeps its own heading scale and its own 4px list rung and only stops
being denser than the design system between two paragraphs.

Where the fix does NOT belong, because the obvious reading is wrong:
`density="compact"` in chat-turn.tsx has not owned transcript block
spacing since #2348. That table zeroes every Astryx margin on the
document's children and declares the four gaps itself, in @layer
components — a Markdown density token, or a makaTheme.ts override of
`astryx-markdown-paragraph`, lands in an earlier layer and is inert.
`--md-gap-block` is the only authority, so the call site now says so
rather than leaving the next reader to re-derive it from commit history.

One rung moves, so code blocks and blockquotes move with it: they are
already carried by the same `> * + *` step (Astryx skips its codeblock
wrapper whenever `components.code` is set, which Maka always does), and
splitting a per-element rung out would re-introduce the multi-value
spacing this table exists to collapse. What that costs is contrast at
the top of the ladder — the section step falls from 2x the block gap to
1.33x — and the heading rungs stay put anyway, because heading spacing
is #1857's decision and not this change's business.

Measured in Storybook against live computed styles, before and after,
on Product/Markdown → TranscriptTurn and Product/Shell Official AppShell
→ Native Conversation:

  list rows   4px   4px   unchanged
  blocks      8px  12px   paragraph, list, quote, code block
  section    16px  16px   unchanged (h3-h6)
  chapter    24px  24px   unchanged (h1, h2)

Daily Review renders at density="default" and is out of the selector, so
its ladder is byte-for-byte what it was: 12/12 paragraphs, 16/16 quote
and table, straight from Astryx.

Generated-by: Claude Code
Astro-Han added a commit that referenced this pull request Aug 21, 2026
Transcript paragraphs sat 8px apart against 20px body leading — a paragraph
break narrower than the line break inside a paragraph. Raise the block rung
to 12px, which is Astryx's own `spacingParagraphDefault`, so the transcript
stops being denser than the design system between two paragraphs while
keeping its own heading scale (#1857) and list rung untouched.

The spacing is owned by the compact rhythm table in packages/ui/src/styles.css,
not by Astryx's density tokens: the table zeroes Astryx's margins outright and
declares every gap itself, so retuning `--md-gap-block` is the only lever.

Adds two contract assertions for the ways that table can break silently — the
runtime DOM hooks its selectors need still being emitted, and the ListItem
control padding still being neutralized (the mechanism that inverted the
ladder in #2348). Declared values are deliberately not pinned; a wrong number
is visible the moment anyone looks at a transcript.

Review caught a third assertion claiming compact markdown was transcript-only.
It was already false — the desktop Artifact Preview has rendered
`<MarkdownBody density="compact">` since #2506 — and passed anyway. Removed
rather than widened, since holding it means a library test grepping
application source. The shared surface and the unguarded typography coupling
are documented next to the heading rules instead.

Generated-by: Claude Code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant