Skip to content

feat(studio): shared Input, NumberField, Select, Slider and Toggle primitives - #3626

Draft
miguel-heygen wants to merge 10 commits into
mainfrom
feat/studio-u5-primitives-inputs
Draft

miguel-heygen wants to merge 10 commits into
mainfrom
feat/studio-u5-primitives-inputs

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Lands unit U5 (Input, NumberField, Select, Slider, Toggle) of the Studio design-system foundation. Stacked on the vertical-slice PR (#3624). No ratchet change; no color literal added. Bundle: about 0.7 KB gzipped as shipped (no consumer yet); cumulative since before the primitives about 53.2 KB, against a 100 KB budget for the primitive set (raised from the plan's 60 KB; recorded here).

What

Adds the five shared value controls to packages/studio/src/components/ui: Input, NumberField, Select, Slider and Toggle, each built on Base UI and styled only from theme.css. Adds [role='switch'] to the typing-target selector list.

Stacked on the header/tabs sweep branch. No consumer moves onto these primitives here; the inspector, timeline and Renders panels migrate in their own PRs.

Why

Studio's value controls exist several times over: two slider implementations, native <select> elements the app cannot style, and inspector fields with no visible boundary, so a value reads the same as the label next to it. One implementation per control is the point, and a field that looks like a field is the user-visible half of it.

The controls also carry a contract that is easy to lose in a reskin: a drag is one write and one usage event, not one per pointer move. Rebuilding on a library without pinning that down would have quietly multiplied both.

How

  • One box, three controls. fieldBase in Input.tsx is the shared recipe: control height, radius, border, background, hover and focus states. NumberField and the Select trigger wear the same one, so the three cannot drift apart.
  • Commit boundaries, not keystrokes. Slider splits onPreview (every intermediate value, for the live canvas) from onCommit (release, settled step, track press). onTrack fires beside onCommit only. NumberField commits on blur, Enter and a settled step. Input commits on Enter and blur, and Escape abandons the draft.
  • Right-click and Escape abort a drag in flight, putting the pre-drag value back. Base UI has no cancel API for a running drag, but every change event carries cancel(), so an aborted gesture stops applying moves while the button is still down.
  • Telemetry stays outside the primitives. Each takes an optional onTrack callback; consumers pass the design-panel tracker they already have. A shared primitive does not need to know the inspector exists.
  • role="switch" joins the typing-target list. The native checkboxes Toggle replaces matched that list as input elements. Without the new row, a shared toggle would have let a global shortcut claim Space instead of flipping the control, silently.

Two findings worth recording, both places where the plan's assumption did not survive contact with the library:

  • The "type letters into a number field" error state is unreachable. Base UI filters non-numeral characters at the keystroke, so the text never arrives. The reachable invalid cases are a lone sign character and an emptied field; both are tested, and both show the error boundary and commit nothing rather than committing zero.
  • Right-click abort is preserved, so the accepted-regression path was not needed.

Test plan

packages/studio/src/components/ui/valueControls.test.tsx, 15 cases, plain react-dom plus act under happy-dom:

  • a pointer drag across five moves produces exactly one onCommit and one telemetry call;
  • three fast arrow-key steps produce three commits and one telemetry event, through the real coalescing sink rather than a stub;
  • a right-click mid-drag reverts the value and commits nothing;
  • typing a number and pressing Enter commits it once and keeps the unit; an unparseable or emptied field shows the error state and commits nothing;
  • the select opens from the keyboard, moves its highlight and commits on Enter;
  • the toggle flips on click and on Space and reports aria-checked;
  • every class the five controls emit compiles against Studio's real stylesheet, with a deliberate non-resolving pair proving that check is not vacuous;
  • each control is classified by both hotkey selector lists exactly as the native control it replaces was, including the slider thumb still counting as a typing target so arrow keys step it.

Each of the three load-bearing tests was confirmed non-vacuous by breaking the implementation on purpose (moving the telemetry call onto the continuous change handler, and removing the new selector row) and watching them fail.

Also run: full Studio suite (441 files, 4836 tests, green), typecheck, build, oxlint, oxfmt, and the repo fallow audit. The screenshot capture and its computed-style table are unchanged, as expected while no consumer has moved.

Bundle size, gzipped JS plus CSS: this PR adds about 0.7 KB over its base. Cumulative growth since the primitive work began is about 53 KB, which is inside the agreed budget but leaves limited headroom for the remaining primitive PR.

Not covered

  • No consumer migration. Every existing slider, select, checkbox and inspector field is untouched, so nothing on screen changes yet.
  • No Storybook stories; the gallery is a separate PR.
  • No Menu, ContextMenu or Popover; those land alongside this one.
  • The scrub area on NumberField (drag a label to change a number) is not wired up. The inspector's existing scrub lives on its label component and moves with that consumer.
  • Range (two-thumb) sliders are not exposed. Nothing in Studio needs one.
  • happy-dom has no layout, so nothing here asserts a rendered pixel, a focus ring or popup placement. Those belong to the screenshot pass.

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.
@miguel-heygen
miguel-heygen force-pushed the feat/studio-u9-header-tabs-sweep branch 2 times, most recently from d104075 to 22ddd2f Compare September 19, 2026 18:35
Base automatically changed from feat/studio-u9-header-tabs-sweep to main September 19, 2026 19:49
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