Skip to content

Control ramp: per-size geometry as tokens (D54–D55) - #58

Merged
handamade merged 13 commits into
mainfrom
dkurkin/d54-control-ramp-tokens
Jul 31, 2026
Merged

Control ramp: per-size geometry as tokens (D54–D55)#58
handamade merged 13 commits into
mainfrom
dkurkin/d54-control-ramp-tokens

Conversation

@handamade

Copy link
Copy Markdown
Owner

Per-size geometry for the four sized controls moves out of CSS Module literals into a shared --psi-control-* token family, fixing a divergence that shipped in production.

Spec: docs/superpowers/specs/2026-07-31-control-ramp-tokens-design.md
Plan: docs/superpowers/plans/2026-07-31-control-ramp-tokens.md

The defect

Button scaled its inline padding across the ramp (8/12/16/20) while Input and Select sat flat at space-8 at every size. At size={48} a Button inset its label 20px and an adjacent Input inset its text 8px — in every form Psi ships.

Nothing caught it because geometry wasn't tokenized: invisible to the manifest, guidance.json, the MCP, the DTCG export and the Figma plugin; unreachable by componentOverrides (D34); ungated by CI.

What changed

D54 — a control token-only family (the surface.ts posture from D51), 28 tokens, aliased per component as --psi-{component}-{size}-{prop}. That alias tier isn't merely preferred: psi/component-tokens-only allows a module to bind only its own component's tokens or a scale token, so --psi-control-* physically cannot be bound directly.

D55 — label and value ramps fork; text-18-28-regular added so the value ramp runs regular end to end; icon-leading buttons get an optical inset.

Visible changes

size Input/Select padding was
24 8 8
32 8 8
40 12 8
48 16 8

Input/Select at 48 also move mediumregular. Buttons with a leading icon gain the optical inset (12 [icon] 8 [label] 16 at size 40) and a ramped gap (4/8/8/8, was flat 6).

Text-only Buttons are pixel-identical — the icon inset is derived so container + text-inset reproduces the old padding exactly.

Acceptance gate for review

components-button--all-sizes must come back byte-identical. If that baseline appears in the changed set, the icon-inset derivation is wrong and the fix is the token values, not the baseline.

The anti-drift test is the cycle's real acceptance test: Input and Select must resolve to the same control-value-* token at every size, which is what makes this defect structurally unrepeatable rather than merely fixed.

Notes

  • Minor at 0.x, deliberately not a patch: ^0.7.x resolves to <0.8.0, so a patch would land the padding change on consumers' next install without an opt-in.
  • Auto-merge is not armed. VR baselines still need collecting, and the final whole-branch review hasn't run.
  • Out of scope, in the backlog: chevron size ramping (stays 12×12), Button icon sizing.

🤖 Generated with Claude Code

handamade and others added 11 commits July 31, 2026 18:37
Per-size geometry on Button/IconButton/Input/Select lives as literals in
CSS Modules, so it is invisible to the manifest, guidance.json, the MCP,
the DTCG export and the Figma plugin; unreachable by componentOverrides
(D34); and ungated by CI. That is why Input and Select shipped flat at
space-8 while Button scales 8/12/16/20 — at size 48 a Button insets its
label 20px and an adjacent Input insets its text 8px.

D54 introduces a `control` token family (surface.ts posture, D51) that
the four sized controls alias. D55 splits label and value ramps, adds
text-18-28-regular so the value ramp runs regular end to end, and gives
icon-leading controls an optical inset via :has().

Also gitignores analytics/ for local-only analysis reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
^0.7.2 resolves to <0.8.0, so shipping the Input/Select padding change as a
patch would land it on consumers' next install without an explicit bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The psi/component-tokens-only stylelint rule maps icon-button -> button in
its ALIASES table, so icon-button.module.css may only bind --psi-button-*.
IconButton therefore declares no ramp tokens of its own and binds
--psi-button-{n}-height. Token count corrects to 52 aliases / 80 total.

Also records that the same rule enforces the alias tier: --psi-control-* is
neither an own-component nor a scale token, so no CSS Module can bind the
family directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six tasks: text-18-28-regular combo, the control family, Button/IconButton
binding plus the optical inset, Input/Select binding plus the anti-drift
test, the guidance.json geometry block, and story/VR/changeset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Input and Select ran regular at 24/32/40 and jumped to medium at 48 only
because no sans 18-28-regular existed. Adding it lets the value ramp run
regular end to end.
Per-size geometry for Button/IconButton/Input/Select becomes data instead
of literals in CSS Modules. Token-only family, surface.ts posture (D51).
Label and value ramps fork on padding and font; height and gap are shared.
Component tokens do not reach the MCP — it bakes guidance.json and
resolved/*.json, and resolved carries only semantic theme tokens. Without
this an agent still could not ask what padding a 40px Button uses.

Adds a drift test (controller amendment) that mirrors guidance.geometry
against controlVars in control.ts, so a retuned ramp cannot silently
diverge from what guidance.json reports.
Size geometry moves from CSS literals onto --psi-button-{n}-* aliases of
the control family. Adds the D55 optical icon inset via :has() — a leading
icon sits one step closer to the edge than text. Text-only Buttons are
pixel-identical; ember's --psi-button-font override is preserved.
Both were flat at space-8 at every size while Button scaled 8/12/16/20 — at
size 48 a Button inset its label 20px and an adjacent Input inset its text
8px. Both now bind the shared value ramp (8/8/12/16), and the anti-drift
test asserts they resolve to the same token at every size.
@cursor

cursor Bot commented Jul 31, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
psi Ready Ready Preview Jul 31, 2026 5:25pm

14 baselines: 12 updated, 2 new. Every one traced to an intended change.

- input/select --all-sizes: the fix. Sizes 24 and 32 are unchanged in the
  diff and only 40/48 shift, exactly as the value ramp 8/8/12/16 predicts.
- field--default, dialog--form-dialog: contain Input/Select at 40/48 and
  inherit the padding change; labels and help text unchanged.
- typography--specimens (4 themes): the new text-18-28-regular combo
  inserts one row, shifting everything below it. Rows above are unchanged.
- button--icon-leading: new story. Renders the optical inset — the icon
  sits closer to the left edge than the label does to the right.

components-button--all-sizes is NOT in this commit, in either theme. That
absence is the acceptance gate: it proves container + text-inset reproduces
the pre-D54 padding, and that ember's --psi-button-font mono override still
resolves at every size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. guidance.json told agents a 40px IconButton insets 16px. It sets
   padding: 0 and binds only --psi-button-{n}-height. components.IconButton
   is now "height-only". That artifact exists to answer exactly this
   question, and it was wrong for one of four components — Task 5 wrote the
   block, Task 3 wrote the binding, neither reviewer saw both.

2. The :has(> svg:first-child) inset fails WRONG on trailing icons, not just
   safe on wrapped ones. :first-child ignores text nodes, so in
   <Button>Next<Icon /></Button> the svg is the first ELEMENT child: the rule
   fires and narrows the start (text) side while the icon keeps full padding
   — the inverse of the intent. CSS cannot tell the cases apart without a DOM
   signal, and adding one means the API change D55 exists to avoid. Accepted
   and documented in the spec and guidance.geometry.iconInsetLimits rather
   than silently shipped.

3. The spec claimed the anti-drift test makes the defect "unrepeatable". It
   gates the token layer only. The original defect lived in input.module.css,
   and nothing here gates CSS Modules: stylelint allows --psi-space-*, the
   scope gate skips geometry by design, and no test reads a module. Claim
   corrected; the real gate filed as a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@handamade
handamade merged commit e851be0 into main Jul 31, 2026
3 checks passed
@handamade
handamade deleted the dkurkin/d54-control-ramp-tokens branch July 31, 2026 17:32
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