Skip to content

fix(studio): editor panel UX — commit safety, keyboard a11y, wired BlockParamsPanel - #1965

Open
vanceingalls wants to merge 1 commit into
mainfrom
studio-ux-4-editor
Open

fix(studio): editor panel UX — commit safety, keyboard a11y, wired BlockParamsPanel#1965
vanceingalls wants to merge 1 commit into
mainfrom
studio-ux-4-editor

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Property-panel / editor-component fixes from the studio UX review — headlined by wiring the Block Params panel, whose controls previously edited local state and went nowhere (critical dead surface).

Changes

BlockParamsPanel actually commits (critical):

  • Literal-substitution rewrite of the installed block composition file via the file-manager context (registry params are metadata-only defaults; block HTML hardcodes literal values, so substituting the previously-applied value is the correct apply mechanism). Debounced 300ms, not-found guard, saving/saved/error status line, preview reload, disabled state without file access, empty-params state. Known follow-up: not yet in ⌘Z history (recordEdit isn't reachable from this panel).

Commit-field safety:

  • Escape-to-cancel on every CommitField (restore + blur); rejected input reverts the draft instead of displaying a value that was never applied; MetricField scrub survives pointercancel/capture loss.

Destructive-edit guards:

  • Solid↔Gradient toggle restores the last authored gradient (was: exploration destroyed it). Grading preset apply merges manual adjustments (was: wiped 9 sliders). Failed font import no longer commits the family (was: composition named a font that never loaded). Upload/LUT/font failures show inline errors (were silent unhandled rejections).

File tree:

  • Context menu: role="menu", arrow-key nav, focus in/restore. Invalid rename keeps the input open with an inline error (was: silently cancelled the whole operation). Folder delete says "and everything inside it" and anchors at the invoked row (was: generic copy pinned to the panel bottom).

A11y / affordances:

  • Gradient stop dots are draggable and no longer insert duplicates when clicked; "Maximum 6 stops" reason shown. Font rows render in their own typeface + ArrowUp/Down/Enter combobox nav. Toggles get role="switch"; sections aria-expanded; sliders labeled; keyframe nav buttons labeled; hit targets expanded to ≥24px (chevrons, diamonds, +file/+folder); resize/rotate handles get hit insets; Transform3DCube is keyboard-rotatable; color picker manages focus; transform-commit jargon reworded to user terms; copy toast fires only after the clipboard promise resolves.
  • Ease bezier readout is paste-editable (Enter commits, Escape reverts).
  • Empty states for FileTree, GsapAnimationSection, BlockParamsPanel.

Verification

  • vitest: 1189 pass / 0 fail (SnapToolbar / DomEditOverlay / KeyframeNavigation suites included); oxlint/oxfmt/tsc clean

Stack

PR 4/7 of the studio UX-review fixes.

🤖 Generated with Claude Code

@miga-heygen miga-heygen 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.

TITLE: fix(studio): editor panel UX — commit safety, keyboard a11y, wired BlockParamsPanel

VERDICT: Approve (comment-only per workflow)

SUMMARY

This is solid, careful work across 27 files that addresses three intertwined concerns — data-loss prevention (commit safety), keyboard accessibility, and wiring BlockParamsPanel to actually persist edits. The PR description accurately reflects what the code does; nothing is hand-waved. CI is green (vitest 1189/0, preflight, preview parity all passing).

PONYTAIL

Approach quality: Strong. The PR correctly identifies that block params were a dead surface (editing local state that went nowhere) and fixes it with literal substitution against the composition file. The commit-field Escape-to-cancel pattern is consistent and well-factored. The a11y improvements (role="menu" + arrow nav on context menus, role="switch" on toggles, aria-expanded on collapsibles, combobox nav on font search) are real, not cosmetic. Gradient stop dots becoming draggable is a good UX win. The lastGradientRef for Solid↔Gradient toggle preservation is a thoughtful touch.

Risk assessment: Low. The biggest moving part — BlockParamsPanel's content.split(previous).join(nextValue) substitution — is inherently greedy (replaces ALL occurrences of the literal, not just the param binding site). The PR acknowledges the recordEdit gap as a follow-up. No new external dependencies. No state shape changes that would affect other PRs in the stack.

FINDINGS

[medium] BlockParamsPanel: greedy literal substitution may over-replace

packages/studio/src/components/editor/BlockParamsPanel.tsx

The substitution content.split(previous).join(nextValue) replaces every occurrence of previous in the entire file, not just the one corresponding to this param. If a block's HTML contains the same literal string in multiple places (e.g. a color #ffffff used as both a param default AND in a comment or another element), the substitution will corrupt unrelated content.

This is partially mitigated by the fact that previous tracks the last applied value (not the original default across all params), so collisions shrink over time as values diverge. But it's a real risk for freshly-installed blocks where multiple params share a common default like #000000 or 100.

Consider: could the registry encode a more specific anchor (line number, attribute path, or a sentinel comment like <!-- hf:param:key -->) so the substitution targets a single binding site? Even a content.replace(previous, nextValue) (first-match-only) would be safer than split/join (all-match).

[low] BlockParamsPanel: debounced commits can race on rapid param switching

If the user edits param A, then within 300ms switches to param B and edits it, the debounce timer for A is cleared (line ~if (commitTimerRef.current) clearTimeout(commitTimerRef.current)). The A change is silently dropped because there's a single commitTimerRef shared across all params.

For a v1 this is acceptable (rapid cross-param editing is rare), but a per-param timer map would prevent the edge case.

[low] BezierReadout: key={bezierText} forces remount on every external change

packages/studio/src/components/editor/EaseCurveSection.tsx

Using key={bezierText} means the BezierReadout component fully remounts whenever the parent's bezier text changes (e.g. picking a preset ease). This resets the internal draft state, which is the intended effect — but it also discards in-flight user edits if an external change arrives while typing. A controlled useEffect sync on bezierText would be gentler, though for this use case the interaction conflict is unlikely.

[nit] Gradient stop dots: role="slider" without tabIndex

packages/studio/src/components/editor/propertyPanelFill.tsx

The gradient stop dots get role="slider" + aria-valuenow/min/max, which is excellent, but the <div> has no tabIndex={0} so it's not keyboard-reachable. A keyboard user can't focus or drag these stops. Consider adding tabIndex={0} and arrow-key handlers to complete the interaction (similar to how Transform3DCube was wired up in this same PR).

[nit] Font combobox: data-font-option-index lookup uses querySelector instead of ref

packages/studio/src/components/editor/propertyPanelFont.tsx

The document.querySelector(\[data-font-option-index="${next}"]`)call for scrolling the active option into view works but bypasses React's ref system. In a portal or multi-instance scenario this could target the wrong element. AlistRefon the scroll container +children[next]` would be more robust.

[positive] Escape-to-cancel on CommitField is well-structured

The escapedRef flag to skip the blur-commit path when Escape triggers blur is a clean pattern that avoids the usual "Escape fires blur which re-commits" footgun. The scheduleResync to re-align the draft after rejected input is also a good catch.

[positive] Context menu focus management

Saving document.activeElement before opening and restoring on cleanup is correct. Arrow-key navigation with Home/End is a complete implementation. The role="menu" + role="menuitem" wiring follows WAI-ARIA menu pattern.

[positive] Error surfacing on previously-silent failures

Font import, LUT import, image upload, and clipboard copy all now show inline errors or toasts instead of swallowing promise rejections. This is a significant UX improvement.

DIFF_STATS

+627 / -145 across 27 files. Heaviest files: BlockParamsPanel (+102/-11), FileTreeNodes (+90/-20), EaseCurveSection (+68/-7), propertyPanelFont (+49/-10), propertyPanelPrimitives (+45/-15), propertyPanelFill (+42/-2).


Review by Miga

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Additive review on PR 4/7.

Audited: packages/studio/src/components/editor/BlockParamsPanel.tsx, propertyPanelFill.tsx, FileTreeNodes.tsx, and the editor commit/a11y surfaces at head 8f0c6f3.

The commit-field Escape handling, pointer cancel coverage, and context-menu keyboard work all look directionally good. I’m holding the PR on the block-param write path below.

blocker: packages/studio/src/components/editor/BlockParamsPanel.tsx:61 uses content.split(previous).join(nextValue) to persist a param edit. That replaces every matching literal in the installed composition file, not the selected block param occurrence. The panel only receives compositionPath plus param defaults; it has no block instance id, source range, inserted snippet, or param binding metadata, and no later PR in the stack narrows this. So if a default value like a color/string appears elsewhere in the block file, editing one param silently mutates unrelated CSS/text. That is a data-corruption failure mode, not just polish.

Miga already flagged the over-replace risk; the additional gap is that the current data contract cannot identify the intended occurrence, so the safe fix needs either a real target contract from block installation/metadata or a guard that refuses ambiguous matches before writing. A minimal stopgap would be to require exactly one occurrence of previous and use a single replacement, with a test covering duplicate literals. A better fix is to persist params through explicit placeholders/metadata instead of literal search.

Verdict: REQUEST CHANGES
Reasoning: The PR turns a previously dead editing surface into a write path that can corrupt unrelated content when default literals repeat. It needs targeted/guarded persistence before this stack should merge.

— Magi

@vanceingalls
vanceingalls force-pushed the studio-ux-4-editor branch 2 times, most recently from 90b25e2 to b89c8ac Compare July 6, 2026 06:35
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

@miguel-heygen The block-param write path is reworked at head b89c8ac27 (the split/join was replaced before your review landed; this head also adds the test you asked for):

  • Token-boundary matching#0c0c0c can no longer rewrite part of #0c0c0cff (lookaround regex, escaped literal).
  • Occurrence-count guard — the param's site count is captured on its first commit; any later commit whose current value matches a different number of sites (i.e. collides with unrelated content) refuses to write and surfaces an explicit error instead. This is the guarded stopgap you outlined — stricter than exactly-one since multi-site defaults (e.g. vfx-magnetic's 2× --bg-color) are legitimate.
  • Per-param debounce + serialized commits — a second param's edit no longer cancels the first's pending commit, and commits are chained so concurrent read-modify-writes can't drop each other.
  • New BlockParamsPanel.test.tsx — covers duplicate literals with substring safety, the collision-refusal path, and the cross-param debounce race (3/3 pass).

Agreed the durable fix is real param binding metadata from block installation — left as the follow-up noted in the PR body.

🤖 Generated with Claude Code

Base automatically changed from studio-ux-3-shell to main July 6, 2026 23:56
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Rebased onto main after #1964 merged (new head 1fad64d82). The block-param guard + test from the earlier fix are intact through the rebase:

  • token-boundary match + occurrence-count guard (refuses to write when the value collides with unrelated content) — BlockParamsPanel.tsx:67-72
  • per-param debounce + serialized commit chain
  • BlockParamsPanel.test.tsx (9 tests: duplicate literals, substring safety, collision refusal, cross-param race)

Rebase also reconciled two editor files against main's newer versions: color-grading (kept main's collapsible LUT UI + grafted in the import spinner/error) and media-section (kept main's Cutout block + re-applied the slider aria-labels). tsc clean, 358 editor tests pass.

@miguel-heygen re-requesting review — the write-path blocker should be resolved.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed at c0ef6b8 (post-fallow-audit rewrite).

Layering on top of Miga's Approve-with-nits and Magi's CHANGES_REQUESTED at 8f0c6f3. Miga's structural read is right; Magi's blocker at their SHA was real and load-bearing. This review has two jobs: (a) verify Magi's blocker at current HEAD, (b) second-pass lens on top.


🔴 Magi's blocker: MITIGATED at HEAD, but the core hazard survives on first commit

Between Magi's review (8f0c6f3) and current HEAD (c0ef6b8b), Vance replaced content.split(previous).join(nextValue) with a token-boundary regex + occurrence-count invariance guard (BlockParamsPanel.tsx:53-75). Two real improvements land:

  1. Token boundaries: (?<![-\w#])<previous>(?![-\w]) correctly prevents #0c0c0c from clobbering the first six chars of #0c0c0cff (verified against the new test at BlockParamsPanel.test.tsx:99-112).
  2. Divergence guard: if the file's occurrence count of previous changes between commits (e.g., something external appeared with the same literal), the second write is refused with a clear error.

But Magi's original data-corruption scenario is not fully closed. The guard captures expected = matches on the FIRST commit and doesn't fire until matches !== expected on a LATER commit. On the first commit for a given param, ALL matching occurrences are unconditionally replaced. BlockParamsPanel.tsx:67 (const expected = (expectedCountRef.current[key] ??= matches);) is the tell — first-call sets expected to whatever count exists, then matches !== expected is false → mass write proceeds.

Concrete failure scenario (independent of Magi's phrasing):

  • Composition file contains Block A (<circle fill="#FFFFFF" ...>) AND some unrelated adjacent element <rect fill="#FFFFFF" ...> (either a sibling block, a design comment, or a header decoration).
  • Block A's registered --fill-color default is #FFFFFF.
  • User opens Block A's params panel, changes fill to #000000.
  • First commit: matches = 2, expected initialized to 2, matches !== expected is false → content.replace(matcher, "#000000") rewrites BOTH the circle AND the rect. Silent unrelated mutation. This is exactly Magi's failure mode.
  • Subsequent edits then ride the mass-replacement (expected is now 2, matches stays 2 because both moved together), so the panel keeps silently mass-writing until an external hand-edit causes divergence.

The new test at BlockParamsPanel.test.tsx:99-112 actually bakes this behavior in as intentional (.a { background: #0c0c0c; } .b { border-color: #0c0c0c; } — both replaced on first commit, described as "both belong to the param"). But the panel has no source-range or block-instance metadata to distinguish "belongs to this param" from "coincidentally the same literal in adjacent content" — the two are indistinguishable at the string level. The PR body's own line acknowledges this: "registry params are metadata-only defaults; block HTML hardcodes literal values, so substituting the previously-applied value is the correct apply mechanism" — this is the right diagnosis and the wrong fix.

Extended fix guidance (Magi's "targeted metadata or ambiguity guard + test" is directionally right; here's the concrete shape):

  • Minimum viable: refuse first-commit writes when matches > 1, surface "'${previous}' appears N× in ${compositionPath} — edit the file directly to disambiguate". Legitimate cases where the same literal is used twice within one block (the test's background + border-color case) become one-time manual edits, which is annoying but not data-corrupting. Cost: 3 lines of code + one test flip.
  • Ideal: persist a data-param-<key>="<value>" attribute on the block's root element at install time; the panel writes into the specific element's attribute, not the file's global text. This is the "real target contract" Magi named. Bigger refactor, right long-term shape.
  • Middle ground: confirm-on-first-write UX ("found N places matching #FFFFFF — replace all?"). Better than silent, cheaper than a metadata rewrite.

The current guard is a correct safety net for FUTURE drift; it does not protect against the initial multi-occurrence write that Magi flagged. I'd hold at 🔴 until the first-commit ambiguity is addressed.


🟠 No telemetry on the new error surface

commitState.tone === "error" (BlockParamsPanel.tsx:185-189) surfaces failures to the user but doesn't emit to packages/studio/src/telemetry/events.ts. If the ambiguity guard fires in production, or if writeProjectFile fails, ops has no way to dashboard the rate. This whole panel is a first-of-its-kind write surface (literal substitution against composition files) — the "how often does this actually work" question has no answer. Suggest a trackBlockParamCommit({ tone, path, key }) on each state transition, especially the error tones.


🟡 blockName is declared in BlockParamsPanelProps (line 7) but not destructured or used

Dead prop in the interface. Either destructure and use it (e.g., in an ARIA label), or remove from the interface. Not a blocker.


🟢 Confirmed good

  • escapedRef pattern in propertyPanelPrimitives.tsx:20-107 is applied consistently on CommitField. The range-slider onBlur at line 295 doesn't take Escape, which is correct — sliders are mouse-first, keyboard revert isn't the expected model.
  • Per-param debounce timers + serialized commit chain (BlockParamsPanel.tsx:44,88-96) correctly preempts two race classes Magi didn't call out (cross-param cancel + concurrent read-modify-write).
  • Flush-on-unmount (BlockParamsPanel.tsx:117-126) captures the latest values closure by reassigning flushRef.current per render — that's the right shape for useLatest.

Verdict: 🔴 with concrete extended-fix guidance above. Magi's blocker is the right call; the fallow-audit rewrite improves the picture but doesn't close the failure mode named in the review.

Review by Rames D Jusso

@vanceingalls
vanceingalls force-pushed the studio-ux-4-editor branch 2 times, most recently from 0c01be8 to f328775 Compare July 10, 2026 06:58
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Addressed @james-russo-rames-d-jusso's review at head f32877520:

🔴 First-commit multi-occurrence write — the guard now closes the initial-write hole, not just future drift. On a param's first commit, if the value matches >1 site, the panel no longer silently mass-replaces — it shows a confirm prompt ("appears N× in this block — Replace all / Cancel") and only writes on explicit accept. Single-match commits stay silent (the safe common case). This is the confirm-on-ambiguity middle ground you named; refusing outright would've broken every block that legitimately uses its color in 2 CSS rules. BlockParamsPanel.tsx:83-101. Test rewritten to assert the confirm flow (BlockParamsPanel.test.tsx, 3/3 pass).

🟠 Telemetry — added trackBlockParamCommit({ tone, blockName, key }), fired on every terminal transition (saved / error / confirm), so the commit success/refusal rate is dashboardable. telemetry/events.ts.

🟡 Dead blockName prop — now consumed (threaded into the telemetry above).

Also rebased onto current main; DomEditOverlay reconciled against main's DomEditRotateHandle extraction + crop-outline work — re-applied the rotate tabIndex={-1} and handle hit-target insets onto main's structure. tsc clean, 394 editor/telemetry tests pass, fallow clean.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

REQUEST_CHANGES

The first-commit hazard is still not closed. BlockParamsPanel now asks for confirmation when previous matches more than once, but “Replace all” still executes content.replace(matcher, nextValue) across every matching occurrence. The new regression test explicitly clicks Replace all and asserts both sibling literals change, so it blesses the original corruption mode rather than preventing it.

Because the panel has no occurrence metadata, the safe behavior for an ambiguous first commit is to refuse/no-op with an actionable error (or add truly targeted binding metadata). Please add a regression asserting that a duplicate literal does not write/mutate an unrelated sibling; do not make the test approve Replace all as the normal path. CI is green, but this remains a blocker.

@vanceingalls

Copy link
Copy Markdown
Collaborator Author

@james-russo-rames-d-jusso You're right — the confirm flow still executed the mass replace and the test blessed it. Fixed properly at head 1b41329da: the panel now refuses an ambiguous first commit instead of gating corruption behind a click.

  • Refuse, don't confirm — if the param's current value matches more than once, commitParamNow no longer writes at all. It sets an error ("'X' appears N× in {path} — the panel can't tell which one belongs to this parameter, so it won't risk changing unrelated content. Edit the file directly to disambiguate.") and returns. Only a unique single occurrence is rewritten automatically. The Replace-all path, the confirm state, and expectedCountRef are gone. BlockParamsPanel.tsx:54-92.
  • Regression test you asked for"refuses a multi-occurrence value and never mutates the unrelated sibling": content has the default on .block AND on an unrelated .unrelated element; asserts no write and the file is byte-identical (sibling untouched). The old test that clicked Replace-all is deleted. Added a companion "writes a unique occurrence with token boundaries" (the safe path + hex8 non-corruption) and kept the later-collision refusal. 4/4 pass.

Accepted the usability tradeoff you called: a block that legitimately uses one literal in two rules now needs a manual edit. Safe-but-annoying over convenient-but-corrupting. The targeted binding-metadata approach (install-time data-param markers) remains the right long-term shape and is out of this PR's scope.

tsc clean, 395 editor/telemetry tests pass, fallow clean.

@miga-heygen miga-heygen 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.

Re-review at 1b41329d — BlockParamsPanel first-commit ambiguity

Verdict: LGTM 🟢 — the blocker is closed.

What changed since f3287752

The "Replace all" confirmation UX is gone. The panel now refuses any write when the target literal matches more than once (BlockParamsPanel.tsx:93–100):

if (matches > 1) {
  setCommitState({
    tone: "error",
    message: `"${previous}" appears ${matches}× in ${compositionPath} …`,
  });
  return;
}

This is exactly the "minimum viable" fix shape that all three prior reviews (mine, Magi, Rames) converged on — refuse ambiguous first-commits rather than risking silent sibling mutation. Only a unique, single-occurrence literal is safe to rewrite automatically; everything else gets an actionable error directing the user to edit the file by hand.

Test coverage

Four tests cover the critical paths:

Test Scenario Assertion
refuses a multi-occurrence value Default #0c0c0c appears in .block AND .unrelated writeProjectFile never called, content untouched, "appears 2×" shown
writes a unique occurrence #0c0c0c unique; #0c0c0cff must NOT match Single write, hex8 preserved (token boundary)
refuses once a previously-unique value later collides First commit works (unique), second refused when new value matches existing content Only one write total
keeps a pending commit for one param Two params edited within debounce window Both params written (per-param timers, not shared)

The first test is the regression Magi asked for — it proves a duplicate literal does NOT mutate an unrelated sibling. The old test that blessed "Replace all" on coincidental matches is replaced.

Other improvements in this push

  • Telemetry: trackBlockParamCommit({ tone, blockName, key }) on every save/error path — addresses Rames' 🟠 about no observability on the new write surface.
  • Dead prop fixed: blockName is now destructured and used (telemetry), closing Rames' 🟡.
  • Per-param debounce timers: commitTimersRef is a Map<string, ...> — editing param B no longer cancels param A's pending commit.
  • Commit chain serialization: concurrent read-modify-write on the same file is prevented.
  • Flush on unmount: closing the panel within the debounce window no longer silently drops the last edit.

All CI checks green. No remaining blockers from my side.

Review by Miga

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review at current head 1b41329da8a8bfea5db218673600e199b9e1c8a5. Duplicate-literal ambiguity is now refused before mutation, with regression coverage proving sibling nodes remain unchanged. Required checks have no failures/pending checks; no unresolved current threads. Approved.

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.

4 participants