Skip to content

feat(studio): inspector fields, toggles and sections on the shared primitives - #3774

Draft
miguel-heygen wants to merge 12 commits into
mainfrom
feat/studio-u10a-inspector-primitives-layout-text
Draft

miguel-heygen wants to merge 12 commits into
mainfrom
feat/studio-u10a-inspector-primitives-layout-text

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Lands the first inspector sweep PR (U10a: primitives, layout, text, sections, the 3D Transform heading) of the Studio design-system foundation. Stacked on the Renders sweep (#3772). Ratchet 470 to 468. Bundle +7.0 KB gzipped. Stops at a seam: the flat slider and select row keep their hand-rolled implementations and move in the next PR.

What

Lands U10a, the first of the inspector sweep PRs: the inspector's shared value
controls move onto the design system's primitives, and the doubled "3D Transform"
heading is gone.

  • CommitField is a boxed field. It wears the same fieldBase as Input,
    NumberField and Select, so the inspector's metric input and every other
    Studio field are one control with one look.
  • MetricField and DetailField put their label outside that box.
  • SliderControl becomes the shared Slider, SegmentedControl the shared
    Tabs, SelectField the shared Select, FlatToggle the shared Toggle.
  • FlatRow drops the underline it drew around the value; the field's own box is
    the boundary now, and the value tier tints it.
  • The Layout section stops printing a "3D Transform" label above a component that
    already renders one.
  • Section and the 3D Transform header expand on the duration-expand token.
  • Colour literals in the ratchet: 470 to 468, with two files reaching zero.

Also fixes a defect in the shared Toggle that this migration surfaced: Base UI
renders the switch as a <span role="switch">, so disabled:opacity-40 compiled
to a real rule that could never match and a disabled switch drew at full opacity
with a pointer cursor. It is data-[disabled]: now, with a test beside the
primitive that fails without the fix.

Why

Two requirements from the design-system contract.

R10 asks that inspector inputs read as inputs, with a boundary that differs from
their label. They did not: a value was bare text on the panel background, and the
metric input measured 17px high with no radius and no background. You could not
tell a value from its own label until you clicked one.

R9 asks that "3D Transform" render once for a selected element. It rendered twice:
the Layout section printed a static label directly above a component whose own
header carries the same words and is the one that collapses.

R8 and KTD7 want one implementation per control. The hand-rolled range input,
segmented strip and native <select> were three more. The segmented strip in
particular carried aria-pressed buttons with no keyboard handling, so the only
way through it was Tab, one segment at a time; on Tabs it answers arrow keys,
Home and End.

How

The public prop signatures of the migrated primitives do not change, so their
twenty-odd consumers across the inspector pick up the new look and the new
keyboard behaviour without a line of their own changing. That is what keeps this
PR small while the visible change is panel-wide.

Telemetry keeps its contract: one event per commit boundary, never per
intermediate value. Each migrated control passes the tracker the section already
holds as onTrack, and the assertions run through a real section rather than the
primitive alone, because the section is what holds the tracker.

data-flat-row-value is deleted rather than moved. The boxed field already
carries data-testid="inspector-field", so a second hook on the same element was
one more thing to keep in step.

Test plan

Studio suite, from packages/studio, with a capped worker pool:

  • bunx vitest run --poolOptions.forks.maxForks=4 — 440 files, 4845 tests, green.
    Baseline on the branch point was 440 files / 4841 tests, so the delta is the
    four tests added here and no test removed or weakened.
  • bun run typecheck — clean.
  • bun run --filter @hyperframes/studio build — succeeds.
  • bunx oxlint and bunx oxfmt --check on the changed files — clean.
  • bunx fallow audit --fail-on-issues — exit 0 against both the base branch and
    the trunk; no finding in a file this PR touches.
  • Studio load smoke, the CI job's own script against a local dev server —
    PASS: studio loaded with schema-valid API fixtures and no runtime errors.

New tests, each proven to fail against the unfixed code:

  • The Layout section renders "3D Transform" exactly once. Fails with the removed
    label put back.
  • Section expands on duration-expand. Fails with a hard-coded duration.
  • The migrated Select trigger classifies the way the native <select> did on
    both hotkey selector lists, asserted true-and-equal so two falses cannot agree
    and prove nothing.
  • The segmented control renders a real tablist and answers ArrowRight.
  • The shared Toggle wears its disabled look on the attribute Base UI sets.

Screenshots and computed styles, before and after, from the sweep's capture
script. The row this unit is judged on:

Control Height Radius Font size Background
Inspector metric input, before 17px 0px 11px transparent
Inspector metric input, after 28px 6px 11px rgb(22, 22, 24)

28px and 6px are what both Export buttons already measure, so the inspector field
now sits inside the same three-height, three-radius set the sweep is aiming at.
Every other row in the table is identical before and after.

Bundle, gzipped across the app's chunks: 1,571,013 bytes before, 1,578,200 after.
A delta of 7,187 bytes (about 7.0 KB) for the first inspector consumers of
Slider and Toggle, against the 100 KB budget for the set. Reported, not
trimmed.

Not covered

  • FlatSlider and FlatSelectRow keep their hand-rolled implementations.
    Both are large pointer-capture and native-select state machines with about
    thirty tests each pinned to their exact DOM, and converting them would have
    taken this PR well past a reviewable size. They are the next sweep PR's, and
    the flat inspector therefore still shows one underlined row (Weight) beside the
    boxed ones until then.
  • The remaining section families are untouched: effects, colour and colour
    grading, canvas menu, media, motion and overlays keep their own colours and
    controls. Those are the later inspector PRs.
  • Four colour literals in files this PR touches stay. One is an
    rgb(0, 0, 0) CSS fallback for a computed style, which is a browser default
    rather than a design value. The other three are shadow presets that have no
    matching token yet; adding tokens belongs to the unit that owns the token file.
  • A pre-existing flake in the screenshot capture, not fixed here. The canvas
    motion-path toggle appears in some captures and not others, on the base branch
    as well as this one (present in one of two base runs, absent in two runs here).
    Its visibility is gated on an async runtime probe of the preview iframe, so it
    races the screenshot. Anyone diffing the inspector screenshots should expect it
    and not read it as a regression.
  • This PR is stacked. Its own diff is 583 changed lines; a line-count check
    measured against the trunk will report the whole stack instead.

Boots Studio from source on a free port, walks six review states, writes a
PNG for each plus a markdown table of height, radius, font size and
background for a fixed selector list. The table is the before/after artifact
for the design-system sweep.

Adds a data-testid to the two Export buttons and the inspector metric field,
the three controls the table measures that carry no role or aria-label.
Studio moves from Tailwind 3.4 to 4.3.3, loaded by @tailwindcss/vite instead
of PostCSS. The JS config becomes an @theme block in studio.css carrying the
same studio.* and panel.* colors, with explicit @source globs so the gitignored
data/projects tree is never scanned.

The official upgrade tool renamed the utilities, but it rewrites bare words
inside any string, so its edits to event names, CSS property names, filter
function names, status enum values and prose were reverted by hand.

Nothing in Studio relied on the v3 default border color, ring width or ring
color, so the tool's compatibility shim was dropped rather than kept.
…erlay

Adds @base-ui/react 1.7.0 to Studio and runs the KTD6 dismiss spike on both
halves: a happy-dom regression test, and a real-browser run over the actual
DomEditOverlay booted through the design-shots harness.

Base UI registers its outside-press listeners on document in the capture
phase, so the overlay's bubble-phase stopPropagation cannot swallow them.
Both halves pass on the plain path, so no capture-phase wrapper is needed
and useContextMenuDismiss is left untouched.
Studio's design values lived in three places: a JS Tailwind preset, an
inline @theme block, and raw hex and pixel literals in markup and CSS.
theme.css is now the single owner. Tailwind v4 turns it into both the
utility classes and the CSS custom properties, so markup, plain CSS and
canvas code all read the same number.

- semantic colour palette (background levels, surfaces, borders, text
  levels, accent, selection, playhead, danger, media, container), radii,
  control heights, the micro type scale, font stacks, shadows, easings
  and motion durations
- the stock Tailwind palette is cleared with --color-*: initial, so a
  colour that is not a Studio token cannot be reached from markup; the
  entries Studio's markup still names are re-declared as deprecated
  aliases and removed once the sweep converts them
- motion durations ship as @Utility rules that zero themselves under
  prefers-reduced-motion, since Tailwind has no duration namespace
- Phosphor icons get one default size and weight, read off the theme at
  the app root; icons that pass their own still win
- published as @hyperframes/studio/theme.css; the JS preset export stays
  as a deprecated shim for v3 consumers, with a test that fails if the
  two drift apart
…pike' into feat/studio-u4-primitives-button-tabs

# Conflicts:
#	bun.lock
Four primitives that consume design tokens only, plus the configured class
merger they all share.

- ui/cn.ts: one `cn` instance whose config knows the two class families
  theme.css invents, the `text-step-*` type scale and the `ctl` control
  heights, so a caller's class replaces a primitive's instead of stacking
  with it and letting stylesheet order decide.
- Button and IconButton: native buttons on the three control heights, the
  radius scale, the semantic palette and the motion tokens. Four classes
  that resolved to nothing (rounded-button, shadow-btn-primary,
  bg-accent-red, ease-standard) are gone, which is why the Renders Export
  now has a radius.
- Tooltip: Base UI's tooltip, so the portal, the viewport flip and the
  collision shift stop being hand-rolled. role="tooltip", the id wiring and
  aria-describedby stay explicit here because the library does not add them.
- Tabs: Base UI's tabs, with roving tabindex, arrow keys, Home and End, and
  a data-tab-id the sidebar's focus restore looks up.

Every interactive look is also reachable through a data-preview-state
attribute that CSS alone reads, so a gallery can show hover, active and
focus without a pointer. A test pairs the two lists in both directions.

The class-resolution test compiles Studio's real stylesheet with every class
the primitives emit and fails on any that produces no selector. Tailwind
treats an undefined class as silence; that silence is what let four dead
classes sit in this file.
Tailwind silently drops a class it cannot compile, so `rounded-button`
renders as no radius at all and nothing goes red. Two tests close that:

- the token gate compiles Studio's entry stylesheet with every class the
  source claims and reports `file: class` for anything that produces no
  selector. Tailwind is the judge, so there is no allowlist.
- the hex ratchet counts colour literals per file against a committed
  baseline. It fails on a rise, and on a fall it prints the command that
  banks the lower number. The baseline is only written under a named
  flag, never as a side effect of a run.

The gate's first run found seven names the markup has always asked for
and no config has ever defined: rounded-button, shadow-btn-primary,
bg-surface-hover, bg-accent-red, ease-standard, text-2xs and two panel
backgrounds. Each is added to theme.css, the semantic ones by role and
the rest as deprecated aliases for the sweep to remove. It also found a
dead state class on the timeline clip, styled nowhere, which is deleted.
The header's view-mode toggle and the inspector's tab strip become real
Tabs, so both gain the roving tabindex, arrow keys and Home/End they never
had; undo, redo and Inspector become IconButton and Button; Export becomes
the primary Button at the medium size. Capture stays an <a download>,
wearing Button's recipe, because a button cannot save a file.

Colour literals in StudioHeader.tsx drop to zero: the brand gradient moves
into theme.css as two tokens and the Export's hand-written text colour
becomes text-bg-0. Ratchet total 475 to 470.
…imitives

The five value controls exist once, on Base UI, wearing the theme tokens.
Each is boxed with a visible border and a focus ring so an inspector value
reads as an input rather than as its own label.

Value writes happen at a commit boundary, never per keystroke or per pointer
move: one drag is one onCommit and one telemetry call. Right-click and Escape
abort a drag in flight and put the pre-drag value back.

The switch role joins the typing-target selector list, so a Toggle keeps the
keys the native checkbox it replaces owned.

No consumer moves onto these yet.
The four native selects become the shared Select, so no OS popup is left in
the panel and the four controls read as the same control as everything
around them. Disabled options survive the move: SelectOption gains a
disabled flag, which is what keeps a resolution the composition cannot
reach out of the keyboard's path while its label still explains why.

Export drops its own type size. cn resolved that override by dropping the
size recipe's step, which is what left the panel Export a type step below
the header's; the computed-style table now reads 28px / 6px / 12px for
both. Its two per-row siblings become IconButton with a Tooltip carrying
what the title attribute used to say.

The panel's colours move to the semantic tokens.
The inspector's value control is a box now, not bare text with an
underline drawn around it. CommitField wears the same fieldBase every
other Studio field wears, so the metric input measures 28px high with a
6px radius and a real border where it measured 17px, no radius and no
background. The label moves outside that box, which is the whole of R10:
a boundary that differs from the label.

SliderControl, SegmentedControl and SelectField become the shared Slider,
Tabs and Select. The segmented strip gains arrow keys, Home and End from
Tabs; the aria-pressed buttons it replaces answered no key but Tab. The
native select is gone, so no OS popup is left in the classic panel. Each
one still fires one telemetry event per commit boundary, now proven
through a real section rather than the primitive alone.

FlatToggle becomes the shared Toggle, and that migration found a defect in
the primitive: Base UI renders the switch as a span, so Toggle's
disabled:opacity-40 compiled to a rule that could never match and a
disabled switch drew at full opacity with a pointer cursor. It is
data-[disabled] now, with a test at the primitive that fails without it.

The Layout section stops printing its own "3D Transform" label above a
component that already renders a collapsible header with the same words,
so the heading appears once (R9). Section and the 3D header expand on the
duration-expand token, which zeroes itself under prefers-reduced-motion.

Colour literals: 470 to 468.
@miguel-heygen
miguel-heygen force-pushed the feat/studio-u13-renders-sweep branch from 4fe3390 to 142a68c Compare September 19, 2026 20:28
Base automatically changed from feat/studio-u13-renders-sweep to main September 19, 2026 23:01
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