feat(studio): shared Input, NumberField, Select, Slider and Toggle primitives - #3626
Draft
miguel-heygen wants to merge 10 commits into
Draft
miguel-heygen wants to merge 10 commits into
miguel-heygen wants to merge 10 commits into
Conversation
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.
…t' into feat/studio-u9-header-tabs-sweep
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.
This was referenced Sep 4, 2026
miguel-heygen
force-pushed
the
feat/studio-u9-header-tabs-sweep
branch
2 times, most recently
from
September 19, 2026 18:35
d104075 to
22ddd2f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,SliderandToggle, each built on Base UI and styled only fromtheme.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
fieldBaseinInput.tsxis the shared recipe: control height, radius, border, background, hover and focus states.NumberFieldand theSelecttrigger wear the same one, so the three cannot drift apart.SlidersplitsonPreview(every intermediate value, for the live canvas) fromonCommit(release, settled step, track press).onTrackfires besideonCommitonly.NumberFieldcommits on blur, Enter and a settled step.Inputcommits on Enter and blur, and Escape abandons the draft.cancel(), so an aborted gesture stops applying moves while the button is still down.onTrackcallback; 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 checkboxesTogglereplaces matched that list asinputelements. 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:
Test plan
packages/studio/src/components/ui/valueControls.test.tsx, 15 cases, plain react-dom plusactunder happy-dom:onCommitand one telemetry call;aria-checked;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
Menu,ContextMenuorPopover; those land alongside this one.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.