Skip to content

Scale spacing and sizing decisions from shared concepts - #10

Merged
ntucker merged 5 commits into
mainfrom
spacing-and-sizing-pass
Jul 26, 2026
Merged

Scale spacing and sizing decisions from shared concepts#10
ntucker merged 5 commits into
mainfrom
spacing-and-sizing-pass

Conversation

@ntucker

@ntucker ntucker commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

A rendered audit of the Storybook fixtures — screenshots plus computed geometry, not source reading — turned up two classes of problem: the same relationship spelled differently in each composite, and values that were defensible in isolation but wrong once seen at real size.

The shared concepts

mediaScale (packages/silk-core/src/recipes/mediaScale.ts) owns the media edge, the media-to-text gap, and the name/meta type roles for anything pairing leading media with a text column. Identity, MediaObject, Comment, and Notification had been choosing all three independently, and had already drifted into using two different gaps for the identical 40px case.

Two things follow from it. Avatars move off the space scale onto fixed px, because density remaps whitespace — correct for gaps, wrong for a face. Compacting a layout was shrinking the person in it. And fallback initials scale with the circle rather than sitting at a pinned 14px, which had them nearly filling the small avatar and looking lost in the large one.

measure is now a typography token: a character count in semantic.measure, delivered as --silk-measure-* in ch, with a measure="none" | "prose" axis on Text. Line length is a property of the type, not of the layout column, so Container's widths were the wrong home and a per-composite max-width hook was heading for one narrow decision per composite.

Field gained orientation="horizontal". The root becomes a two-column grid: the control auto-places into column one, and the label, description, and error pin to column two. That last part is the reason it earns an axis rather than a doc note — the description lands under the label that explains it, not under the checkbox. Reuses RadioGroup's existing axis name, so it costs no new vocabulary.

Fixes

  • Composites inherit the Card padding default instead of hardcoding an inset. ProfileCard and SettingsPanel were at 20px, PostCard at 16px, and Notification nested a 16px Box inside a Surface; stacked in a column their content edges ragged. All five cards in the feed now share one content edge.
  • CommentThread nests replies inside the parent comment's content column, so indentation equals the media column automatically and every level renders its own rail. Previously it computed the indent with an inline style — the last rung of the ranked escape hatches — and drew a rail only at depth 0, making anything past one level invisible.
  • StatGroup's value role and inter-stat gap scale with size (20px value and a 24px gap at md, up from 16px and a hardcoded 16px).
  • EmptyState binds its title to its description and separates the action, with a measure cap on the copy.
  • Stack/Inline/Grid reset UA padding as well as margin, so asChild onto a <ul> or <dl> stops leaking. This is what let the thread drop its inline list reset.
  • Surface is a block box, so asChild onto an <a> no longer breaks the border; the Switch thumb is circular at any track size.
  • Fixture rhythm: nav grouping where the within-group gap equalled the between-group gap, card titles abutting their captions, a separator with identical space on both sides that therefore didn't section, and a Progress bar that read as a second track belonging to the slider above it.

Test plan

  • yarn test green across all three workspaces; yarn typecheck clean
  • 14 new regression tests covering the invariants above
  • Mutation-checked: reverted the ProfileCard padding and the Avatar sizing, confirmed the corresponding tests fail, then restored
  • Every change verified against rendered output via the audit harness
  • The measure cap verified against the built stylesheet — 65ch resolves to 595px and caps a 900px column, measure="none" stays uncapped
  • Reviewer: restart yarn docs before checking Storybook. The theme module regenerates its variables from a dependency, and Linaria's dev cache does not invalidate on that; the built output is correct.

Notes

scripts/spacing-audit.mjs is included — the Playwright harness that produced the geometry and screenshots behind every claim here.

Not addressed, deliberately: md checkbox, radio, switch, and the toast close button are 20px, under the WCAG 2.2 target minimum of 24px. It is a real gap, but resizing them changes the proportions of every form in the system and deserves its own decision rather than riding along on a spacing pass.

Made with Cursor

A rendered audit of the Storybook fixtures found the same relationships
spelled differently in each composite, and several places where the value
a component picked was wrong once it was seen at real size.

Introduce `mediaScale`: one table owning the media edge, the media-to-text
gap, and the name/meta type roles for every composite that pairs leading
media with a text column. Identity, MediaObject, Comment, and Notification
had been choosing these independently and already disagreed, using two
different gaps for the identical 40px case.

Avatars move off the space scale onto fixed px from that table. Density
remaps whitespace, so anything on the space scale shrinks with it — right
for gaps, wrong for a face. Fallback initials now scale with the circle
instead of sitting at a pinned 14px.

Add `measure` as a typography token (a character count, delivered as
`--silk-measure-*` in `ch`) with a `measure` axis on Text. Line length is
a property of the type, not of the layout column, so Container's widths
were the wrong home for it.

Add `orientation="horizontal"` to Field. The root becomes a grid so the
control leads its label and the description aligns under the label rather
than under the control — the shape a boolean control needs, which every
call site previously had to hand-assemble.

Also:
- Composites inherit the Card padding default instead of hardcoding an
  inset, so siblings in a column stop ragging against each other.
- CommentThread nests replies inside the parent's content column, so every
  level renders its own rail and indents without depth arithmetic. Removes
  an inline `style`, which the escape-hatch ranking puts last.
- StatGroup's value role and inter-stat gap scale with `size`.
- EmptyState binds its title to its description and separates the action.
- Stack/Inline/Grid reset UA padding as well as margin, so `asChild` onto
  a list stops leaking.
- Surface is a block box; the Switch thumb is circular at any track size.
- Fixture rhythm: nav grouping, card title gaps, separator sectioning, and
  a Progress bar that read as a second slider track.

Adds `scripts/spacing-audit.mjs`, the Playwright harness used to verify
all of the above against actual rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Jul 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 029c56a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@reactive/silk Minor
@reactive/silk-docs Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment thread packages/silk/src/components/Field.tsx
ntucker and others added 2 commits July 26, 2026 14:23
Drop unused context/API surface, share size axes with mediaScale, and make the audit script reuse a page with proper cleanup.

Co-authored-by: Cursor <cursoragent@cursor.com>
The horizontal grid pinned only direct data-field-* children to column 2, so
a Stack or Inline around the description or error auto-placed into the control
column even though Field already treats those wrappers as transparent when
wiring aria. Match wrappers holding a slot via :has(), and pin the label's row
so the control stays beside it regardless of child order.

Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3654e13. Configure here.

Comment thread packages/silk/src/components/CommentThread.tsx Outdated
ntucker and others added 2 commits July 26, 2026 14:47
Comment renders anything in its replies slot through CommentReplies, a railed
stack with reply padding. CommentThread bundled the continue button into that
slot, so a comment at maxDepth with nothing nested inline drew a rail beside a
lone button — the button used to sit outside the railed stack before replies
moved into the content column.

Give Comment a footer slot for a trailing affordance that belongs to the
comment rather than to its replies, and route the button there. The content
column is already a Stack gap="1" align="start", so the slot needs no wrapper
or padding of its own, and the button's align-self class was inert.

Also collapse the reply-count suffix, which was computed twice behind mirrored
typeof checks on continueLabel.

Co-authored-by: Cursor <cursoragent@cursor.com>
Field.Root's data-orientation was matching Slider's ancestor selectors and
forcing vertical track height:100%, which resolved to 0 on a horizontal Field.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ntucker
ntucker merged commit 2b03511 into main Jul 26, 2026
3 checks passed
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