Skip to content

feat(studio): shared menu, popover and value-control primitives - #4191

Merged
miguel-heygen merged 4 commits into
mainfrom
feat/studio-menu-popover-inputs
Sep 19, 2026
Merged

miguel-heygen merged 4 commits into
mainfrom
feat/studio-menu-popover-inputs

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

Studio gets the shared Menu, ContextMenu, Popover, Input, NumberField, Select, Slider and Toggle primitives, built on the Button, Tabs and Tooltip primitives that already landed, and the package entry now exports them. Nothing in Studio uses them yet: a host app that mounts Studio pieces in its own tree (the desktop app) and the next sweeps (Renders panel, inspector, timeline toolbar) build on them.

What changes

  • Menu family and Popover: an open menu with shortcut hints and a danger item, a context menu, radio rows, and a popover; one popup surface so every popup looks and dismisses the same way.
  • Value controls: a text input, a number field with a unit, a select, a slider that previews while dragging and commits on release, and a switch; one shared field style (fieldBase) so they all match.
  • Keyboard: the new controls are added to the "typing target" lists so single-key shortcuts (play, split) do not fire while a slider, switch or menu row has focus.
  • Package entry: cn, the Menu family, Popover, Input, NumberField, Select, Slider, Toggle, fieldBase, fieldText and their prop types are exported from @hyperframes/studio.

Shape

Before

Studio today: the inspector's Media section uses hand-rolled controls (select, toggle, sliders), and there is no shared menu, popover or value-control set.

before

After

The new primitives rendered together on the dark theme from a throwaway gallery page (not committed): input, number field, select, slider, switch, an open menu with a danger item, and an open popover. No Studio screen uses them yet.

after

Verification

  • Studio ui and styles tests, 95 passed in 11 files, including the class gate and the hex ratchet (miga, exit 0); tsc --noEmit exit 0 for packages/studio; oxlint 0 warnings; comment-check clean against main.
  • Independent review of the diff: no blockers; replay code identical to the source commits apart from the tone default.
Area Status
Replayed code identical to #3625/#3626 apart from the tone default Audited
Every class resolves; no hex or raw px in the new components Audited (class gate, hex ratchet, grep)
New typing-target rows cannot swallow a shortcut in an existing editable Audited (consumers cited)
Roles and accessible names on Menu, Slider, Toggle, NumberField, Select Audited (reading)
Desktop's use of Popover and fieldText resolves through the package entry Audited
Behaviour with a real consumer wired in Not exercised (no consumer yet)

One menu chrome and one popover chrome, on Base UI, wearing the theme
tokens. Dismissal is plain Base UI with no capture-phase wrapper: its
outside-press listeners are registered on document with capture set, so
the canvas overlay's bubble-phase stopPropagation cannot starve them.

Menu items carry an optional mono shortcut hint, a disabled state and a
danger tone. ContextMenu opens at the pointer. Popover shares the
surface, radius and open motion but owns no item semantics, so a text
field inside it keeps its own keys.

Also adds [role='menuitemradio'] to the playback-shortcut ignore list.
The hand-rolled speed menu spelled its rows as buttons, which that list
already matched; the shared radio item is a div with the same role, and
nothing else in the list matched it, so an open speed menu would have
leaked arrow keys and Space to the player.
…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.
… string reading as a class

Main's comment gate rejects long comment blocks, and the class gate read the
MenuItem tone default as a Tailwind class. The prop is optional instead.
A host mounting Studio pieces in its own tree imports them from the package:
cn, Menu family, Popover, Input, NumberField, Select, Slider and Toggle.
@miguel-heygen
miguel-heygen marked this pull request as ready for review September 19, 2026 19:19

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

APPROVING at ab8afaa1 — new-primitives-only scope confirmed; fold is byte-identical to source PRs; two additive selector rows are covered by KTD13 parity tests

Stacked on hf#4181 (c610d891) and hf#4186 (e505dcc5). Isolated hf#4191's own contribution against e505dcc5 (author-scoped delta above the sibling squashes).

Scope — new primitives + tests + 2 additive selector rows, nothing else

13 files, +1789/−0. Every touch verified:

  • New (10): Input.tsx, NumberField.tsx, Select.tsx, Slider.tsx, Toggle.tsx, Menu.tsx, Popover.tsx + Menu.test.tsx + valueControls.test.tsx (~900 lines of tests). Add-only, no existing files touched.
  • Barrel exports (2): packages/studio/src/components/ui/index.ts (+22 exports for the new primitives) and packages/studio/src/index.ts (+23 package-root exports). Pure additive.
  • Selector-list rows (2): typingTarget.ts (+1 selector: [role='switch']) and playbackShortcuts.ts (+1 selector: [role='menuitemradio']). Both add-only, no removals.

Grep for imports of the new primitives outside packages/studio/src/components/ui/: only packages/studio/src/index.ts (the package barrel). No in-tree consumer wired in. Miguel's "no call sites changed yet" claim holds.

Fold parity — replay is byte-identical to source PRs

hf#3625 (Menu family) and hf#3626 (value controls) are both still OPEN. I compared each file in #4191 at the pre-trim commit (f897548f7) against the source PRs' heads (05961175 / 7263c927):

  • IDENTICAL: Menu.tsx, Menu.test.tsx, Popover.tsx vs hf#3625
  • IDENTICAL: Input.tsx, NumberField.tsx, Select.tsx, Slider.tsx, Toggle.tsx, valueControls.test.tsx vs hf#3626

All 9 replayed files are blob-SHA-identical to their sources. Nothing dropped in the fold.

The trim commit — pure comment shrink + one semantic change

0a04fffd5 trims 235 lines from 10 files. I scanned every non-blank/non-comment diff line in the affected files:

  • Menu.tsx: ONE non-comment change — removed tone = "default" default value:
    -export function MenuItem({ shortcut, tone = "default", className, children, ...props }...)
    +export function MenuItem({ shortcut, tone, className, children, ...props }...)
    Consumer code uses tone === "danger" && itemDanger, so undefined === "danger" is false — semantically identical to "default" === "danger". The reason for the fix: the class gate was reading the string default as a Tailwind candidate. Making tone optional stops that without changing runtime behaviour.
  • Input.tsx, NumberField.tsx, Popover.tsx, Select.tsx, Slider.tsx, Toggle.tsx, playbackShortcuts.ts: zero non-comment/non-blank diff lines. Pure comment trim.

Space-key / typing-target — verified by mutation-red parity test

valueControls.test.tsx:485 ("hotkey classification (KTD13)") is the discriminator. It pairs the native reference on the left with the primitive on the right:

["native-checkbox", host.querySelector('[role="switch"]')!],
...
for (const [nativeId, primitive] of pairs) {
  expect(isTypingTarget(primitive)).toBe(isTypingTarget(native(nativeId)));
  expect(shouldIgnorePlaybackShortcutTarget(primitive)).toBe(shouldIgnorePlaybackShortcutTarget(native(nativeId)));
}

Native <input type="checkbox"> matches the existing input selector → typing target = true. The <div role="switch"> primitive would NOT match any pre-PR selector → typing target = false → parity fails. Removing the new [role='switch'] line in typingTarget.ts breaks the test. Same mutation-red property for [role='menuitemradio'] in playbackShortcuts.ts (asserted at Menu.test.tsx:326).

Toggle also has a direct Space test at valueControls.test.tsx:461: focus → keydown/keyup{key:" "}expect(flips).toEqual([true, true]). That's the positive path; KTD13 is the guard proving a global shortcut can't steal it.

PR-body follow-ups (author-declared, non-blocking)

  • Select builds its own popup instead of using popupSurface — deferred hygiene.
  • Menu rows are not typing targets for app-level plain-key hotkeys (matches the old menus' behaviour).
  • No test for Input keeping a half-typed draft when value changes from outside.

None of these gate the merge; all are marked "not changed here" in the PR body.

CI

Studio and player captures and Test both show FAILURE at the earlier run, both superseded by later re-runs (per [[feedback_check_runs_api_returns_stale_runs.md]] — statusCheckRollup keeps stale rows). Latest state: 45 SUCCESS + 22 SKIPPED, 4 IN_PROGRESS (CI/Test re-run, Windows studio-core, Windows studio-engine-cli, Smoke). No live FAILURE. Stamping on code merit per [[feedback_ci_is_reference_not_gate]]; hf-oss require_last_push_approval=true means my APPROVE binds to the head; merge waits on the IN_PROGRESS lanes settling.

Stamp mechanics

reviewDecision: REVIEW_REQUIRED, mergeStateStatus: BLOCKED. APPROVE binds to ab8afaa1dc145a437ae3bec118ab17fb0c605cb3. Miguel miguel-heygen on trusted stamp list. Note this branch is stacked on hf#4181 + hf#4186 (both my approvals at their current heads); once those merge, hf#4191 rebases to their squash-commits.

— Review by tai (pr-review)

@miguel-heygen
miguel-heygen merged commit 3ec26b5 into main Sep 19, 2026
72 of 85 checks passed
@miguel-heygen
miguel-heygen deleted the feat/studio-menu-popover-inputs branch September 19, 2026 19:33
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.

2 participants