fix(studio): colour hex editing and shortcuts panel dismissal - #2844
Conversation
…me percentages A ruler press with no pointer movement settled the playhead at t=0 instead of the clicked time. handlePointerUp replays pendingClientXRef, which only the pointermove path wrote, so a plain click fell back to the ref's initial 0 and overwrote the correct pointerdown seek. Seed the ref on pointerdown. The keyframe retime move branch also returned the raw quotient while the resize branch rounded to 3dp, so values like 74.81203007518799% landed in the user's source and churned the diff on every drag. Round at the point of computation so the no-op test and the written value agree.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 7be28e4.
Both fixes are tightly scoped and the abstractions are the right ones. The hex-draft split (updateColorDraft(value, source) with the hex branch skipping the canonical stamp-back, plus the new resolveColorGestureValue guarding gesture emits behind ^#[0-9a-f]{6}$) is a clean way to keep the hex input as the sole author of its own text while editing — the raw hex string flows through the gesture transaction as latest, the picker path still gets its RGB round-trip, and alpha is preserved from draftColorRef.current.alpha on the hex path so partial hex edits don't drop alpha.
Swapping the hand-rolled ShortcutsPanel dismiss for useContextMenuDismiss is the right consolidation — it joins the three existing consumers (CanvasContextMenu, KeyframeDiamondContextMenu, TrackGapContextMenu) so the panel now inherits the same capture-phase pointerdown + preventDefault-on-canvas-gesture handling that the rest of the editor already has. role="dialog" without aria-modal, with an inline comment explaining why (non-modal disclosure, focus not trapped), matches WAI-ARIA authoring practices exactly. The 6 new ShortcutsPanel tests cover all three focus positions on Escape + the preventDefault-on-pointerdown edge case + no-re-render-on-idle-Escape — thorough.
Concerns
- The "incomplete pending hex on outside-click" test only asserts
onCommitisn't called — not that the input value reverts.propertyPanelColor.test.tsx:129covers the commit no-op but not the visible-state restoration. The restoration path does work in the code (settle()atuseInspectorGestureTransaction.ts:35callspreviewRef.current(active.before)before the commit; that runsresolveColorGestureValue(before)→ picker source →updateColorDraft(before, "picker")→setHexDraft(toHexColor(...).toUpperCase()), so hexDraft reverts). But the test doesn't assert it. This mirrors the Escape test at:146which explicitly re-opens the panel and assertsopenHexInput(host).value === "#224466". Same assertion would harden the outside-click case against a future silent regression where the input keeps#12AB3on-screen while the color model reverts. Trivial to add.
Nits
- The
#22CC66→#2222CCregression case atpropertyPanelColor.test.tsx:112is exactly the shape of production defect that specification-only tests miss. Nice catch to include it. useContextMenuDismiss.tsdocblock is load-bearing — it's the kind of comment that saves future readers from wondering why we listen on capture-phase pointerdown AND mousedown. Keep it.
What I didn't verify
- Manual testing of the two shortcuts-panel-during-canvas-gesture failure modes (marquee-start with panel open, then outside click that gets preventDefault'd), and the hex-field alpha preservation via the picker's alpha slider (
propertyPanelColor.tsx:190-196looks correct, but I didn't repro). - Sibling PR #2843 interactions — reviewed the two as a stacked pair.
— Review by Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed at 7be28e4.
Six real bugs, six tight fixes. The hex-draft split, the useContextMenuDismiss swap, and the role="dialog" without aria-modal are all the right shape. Alpha preservation through draftColorRef.current.alpha on the hex path (propertyPanelColor.tsx:203), the source-normalized sourceValue: formatCssColor(colorFromCss(value)) at :220 so Object.is comparisons in the gesture transaction don't spuriously fire from string-form drift, and the capture-phase pointerdown listener that survives the canvas's preventDefault — those choices are careful. I read Rames's review before writing this and won't repeat it; my focus is the one class of user input that the new resolver silently drops.
Concerns
-
P1 — 3-digit hex shorthand (
#F00,#000,#FFF) is silently rejected.resolveColorGestureValueatpropertyPanelColor.tsx:196uses^#[0-9a-f]{6}$— six hex digits only. ButparseCssColoratcolorValue.ts:36-45explicitly accepts the 3-digit shorthand (shortHexbranch). So typing#F00walks this path:handleHexChange("#F00")→setHexDraft("#F00"),previewColorGesture("#F00").- Resolver: source=hex, regex fails on 3 chars → returns
null→ outeronPreviewno-ops. Swatch stays black. - Outside-click →
settleColorGesture. Latest="#F00", before="rgb(0, 0, 0)". Rollback preview restoreshexDraftto"#000000"(via the source=picker branch).onCommitruns with"#F00"→ resolver rejects → no commit. - Net: user typed a valid CSS shorthand, saw it appear in the input, clicked outside expecting red, got nothing. No error surface.
Same outcome on Tab-blur, which is a behavioural loss vs the OLD
onBlurpath (which calledparseCssColor(normalized)directly and would have committed the 3-digit form). The existing "backspace to#3" test atpropertyPanelColor.test.tsx:104-110iterates through"#33333" → "#3"and asserts the input keeps each intermediate string — which is what fixes the snap-back, good — but the 3-digit form here is only ever an intermediate on the way to fewer chars, never a terminal commit state. There's no test that asserts#F00(or#333as a completed shorthand) commits asrgb(255, 0, 0)(orrgb(51, 51, 51)).Fix (single line): broaden the gate to accept both hex forms parsed by
parseCssColor:if (source === "hex" && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(nextValue)) return null;
Then add a paired test covering the shorthand commit-on-blur path — the 3-digit case is genuinely common designer input (
#000,#FFF, primaries), and CSS supports it.
Nits
useContextMenuDismissattaches its Escape listener todocumentunconditionally inuseContextMenuDismiss.ts:777, so every Escape press throughout the editor invokescloseShortcuts=setShowShortcuts(false). React'sObject.isshort-circuits the re-render (the Profiler-based "does not re-render when Escape is pressed while closed" test atShortcutsPanel.test.tsx:145-158proves this — nice test), so it's functionally fine, but the old effect gated onshowShortcutsand avoided the callback entirely. Minor: not blocking, worth a comment if you want future readers to know the deduplication is load-bearing.aria-controls={shortcutsPanelId}atShortcutsPanel.tsx:554is always set on the trigger, but the referenced element only exists in the DOM whenshowShortcuts=true. Screen readers handle a dangling reference gracefully in practice; a stricter reading of the ARIA spec would toggle the attribute conditionally. Not worth blocking on.- The
role="dialog"withoutaria-modalon a non-modal disclosure is a defensible-but-debatable ARIA choice. The inline comment atShortcutsPanel.tsx:575-578documents the intent well enough that any future reader who wants to argue forrole="menu"or no role at all has the receipts. Good.
What I didn't verify
- Manual repro of the
#F00shorthand path in a running studio — traced through code, but a live check would confirm no other path picks up the shorthand later (e.g. the picker's own opening event doesn't retroactively re-parsehexDraftand commit). - Alpha preservation on the hex path when the current alpha is < 1 and the user types a fresh 6-digit hex — read as correct at
propertyPanelColor.tsx:200-204, not exercised at runtime.
Verdict
COMMENTED — B+. The three shipped fixes are correctly designed and the test suite covers the key regressions; the 3-digit hex gate is one line and worth landing before merge. Not a blocker to the enterprise/holdout gate — feature is opt-in on studio side and the shorthand loss is a UX degrade, not a crash.
— Review by Via
The 24x24 WCAG 2.5.8 overlay sits on a wrapper that outranks the diamonds, so on a segment narrower than 24px it overhung them and won their hit test at fit zoom. Gate the overlay on the clear span between the two diamonds and let the button keep its 16x16 box below that. Also renames the pointer target suite to say it asserts the classes that produce the size, not the measured geometry, which happy-dom cannot see.
…ide press Split hex-draft ownership so the hex input is the sole author of its own text while editing (updateColorDraft no longer stamps a canonical hex back over every keystroke), fixing snap-back on backspace and the silent wrong-colour clobber on non-repeating hex values. Route hex typing through the shared gesture transaction so outside-click and Escape settle/cancel it like the other inspector fields, instead of relying on a private onBlur commit that never fires once the panel unmounts on outside-click.
Swap the panel's hand-rolled bubble-phase mousedown listener for the shared useContextMenuDismiss hook, which adds Escape support and fixes outside-click dismissal when a canvas gesture (e.g. marquee start) calls preventDefault on pointerdown, which otherwise suppresses the mousedown compat event entirely. Also wires up dialog ARIA (role, aria-modal, id/aria-controls) between the trigger and panel.
The panel does not trap focus and leaves the rest of the editor operable, so aria-modal would tell assistive tech the whole app is inert while it is open. role=dialog plus aria-controls and Escape is the correct non-modal disclosure shape.
The gesture resolver gated on six digits while parseCssColor accepts both lengths, so #F00 previewed as nothing and committed nothing. The old onBlur path parsed it, making this a behavioural loss rather than a pre-existing gap. Also asserts that an incomplete hex is restored on outside-click, not merely left uncommitted.
7be28e4 to
86f633f
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Re-reviewed at 86f633f (R1 was 7be28e4).
Delta
One new commit new to this PR: fix(studio): accept 3-digit hex shorthand in the colour field. (The R2 diff also carries #2843's roomForFullTarget commit downstack — noted, reviewed on #2843 already.)
R1 → R2 verdict
- R1 outside-click restoration test gap → RESOLVED.
propertyPanelColor.test.tsx:151"does not commit an incomplete pending hex on outside-click" now assertsopenHexInput(host).value === "#224466"after the outside click — mirrors the Escape test's shape and closes the assertion gap. Also added a targetedcommits a 3-digit hex shorthand on outside-clicktest at:147covering the new#F00path. Trace verified:settle()→previewRef.current(active.before)reverts hexDraft via the picker-sourceupdateColorDraft,commitRef.current(active.latest)no-ops because the resolver returns null on#12AB3.
New — 3-digit hex shorthand acceptance
Well-caught side-bug. parseCssColor at colorValue.ts:39-48 already accepts shorthand (#F00 → RGB via parseInt(r+r, 16) per channel), so the old ^#[0-9a-f]{6}$ gate in resolveColorGestureValue was silently dropping ordinary designer input while the pre-refactor onBlur used to accept it. The regex update to ^#([0-9a-f]{3}|[0-9a-f]{6})$ is minimal, and the "live-previews only a hex length that parses, 3 or 6 digits" test now covers all four states (3, 4-and-5-suppressed, 6, revert-to-3).
Mid-typing states like #F0 / #F000 correctly stay silent through the resolver. Alpha preservation via draftColorRef.current.alpha is unchanged, so a shorthand-typed value keeps whatever alpha the picker held.
Clean second round.
— Review by Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
R2 — verified at 86f633f9
Delta since R1 (aa281164 → 86f633f9)
Four commits, one of which is the direct R1 response:
7f0cadcb— dropsaria-modalfrom the shortcuts popup (correct for a non-modal disclosure).86f633f9— accepts 3-digit hex shorthand in the gesture resolver.
propertyPanelColor.test.tsx grew from a two-case file to a full hex-editing suite (10 cases), and ShortcutsPanel.test.tsx is a new 158-line file.
R1 findings — verified
-
P1: 3-digit hex silently dropped on outside-click / Tab-blur → RESOLVED.
resolveColorGestureValueatpackages/studio/src/components/editor/propertyPanelColor.tsx:192now gates on/^#([0-9a-f]{3}|[0-9a-f]{6})$/i, which is exactly the accept-set ofparseCssColorincolorValue.ts:41-59(shortHex+hex, no 4-/8-digit alpha branch). Direct tests:"commits a 3-digit hex shorthand on outside-click"(#F00 → rgb(255, 0, 0)) and"live-previews only a hex length that parses, 3 or 6 digits"(asserts 4/5 digits stay silent, 3 and 6 preview once). The commit message explicitly cites the pre-existingonBlurpath as evidence this was a behavioural loss rather than a pre-existing gap — that framing matches what I flagged. -
Nit:
useContextMenuDismissEscape fires unconditionally; React bail-out is load-bearing → ADDRESSED via test.ShortcutsPanel.test.tsx:"does not re-render when Escape is pressed while closed"uses<Profiler>to assertonRenderis called exactly once after a closed-state Escape. The bail-out is now guarded against a future refactor that would break identity ofsetShowShortcuts(false). -
Nit:
aria-controls={shortcutsPanelId}on the trigger references an id that only exists whenshowShortcuts=true→ UNCHANGED. Still the case atShortcutsPanel.tsx:152. This is a widely tolerated ARIA pattern (aria-controls may point to a target that appears when the control is invoked) and the new"connects the trigger to the dialog with aria-controls"test only asserts the open state. Not blocking — flagging so it stays visible.
Rames' R1 ask for a stronger outside-click restoration assertion is also satisfied: "does not commit an incomplete pending hex on outside-click" reopens the input and asserts input.value === "#224466" (the original), not merely onCommit not called.
Fresh adversarial pass at head
parseCssColorat head accepts onlytransparent, 3-digit hex, 6-digit hex, andrgb()/rgba(). The new regex covers both hex forms. No 4-/8-digit alpha branch to leak.sourceValue: formatCssColor(colorFromCss(value))re-normalizes on every render; assumes the transaction hook snapshots at gesture-start rather than tracking identity every render. Behaviour tests pass, so the assumption holds; noting the coupling for future changes touseInspectorGestureTransaction.useContextMenuDismisslisteners are registered document-wide for the lifetime of the mountedShortcutsPanel(open or closed) — capture-phase pointerdown + mousedown + keydown. All three firesetShowShortcuts(false)unconditionally on outside events; correctness rides on React'sObject.isbail-out. Cheap in practice, and now covered by theProfilerre-render test above.- The Escape branch in
useContextMenuDismissdoes notpreventDefault/stopPropagation, so pressing Escape while another surface owns the intent (e.g. text-field edit) still lets that surface see the event. Good.
No P0/P1 at head.
Verdict
APPROVED, grade A. R1 P1 is fixed at the parser boundary rather than papered over, the fix ships with directly-named regression tests including the 4-/5-digit silence guard, and both nits are either mechanically addressed or now protected by explicit assertions.
— Review by Via
The base branch was changed.
…ex-shortcuts-panel fix(studio): colour hex editing and shortcuts panel dismissal

What
Two Studio panels that would not take or keep user input correctly.
Bugs fixed
updateColorDraftstamped a canonical hex back over every keystroke, so pressing backspace immediately restored the character.onBlurthat never fires, because the panel unmounts on outside click before blur is delivered.mousedownlistener never ran, because a gesture such as a marquee start callspreventDefaultonpointerdown, which suppresses themousedowncompat event entirely.#F00was silently dropped. Found in review of this PR's own fix.parseCssColoraccepts both 3 and 6 digit hex, but the new gesture resolver gated on 6, so shorthand neither previewed nor committed. The oldonBlurparsed it, so this was a behavioural loss rather than a pre-existing gap.aria-modalon a non-modal popup. The panel does not trap focus and leaves the rest of the editor operable, soaria-modaltold assistive tech the whole app was inert while it was open.Why
Bugs 1 to 3 mean the colour field cannot be used by typing at all, which is the only way to enter an exact brand colour. Bug 5 leaves a panel stuck open over the canvas mid-gesture.
How
Split hex-draft ownership so the hex input is the sole author of its own text while editing, and route hex typing through the shared gesture transaction so outside click and Escape settle or cancel it like every other inspector field.
Replace the shortcuts panel's hand-rolled listener with the shared
useContextMenuDismisshook, which already handles Escape and thepointerdownpreventDefault case. Wirerole=dialogplusaria-controlsbetween trigger and panel, which is the correct non-modal disclosure shape.Test plan
Studio suite green on this branch: 2997 passing, 0 failures. Typecheck, lint and format clean.
Not covered
parseCssColoraccepts, 3 and 6 digits. 4 and 8 digit alpha hex is out of scope becauseparseCssColordoes not accept it either, and alpha is owned by the panel's own slider.