6px between rows left little clearance above a row's value for the
promote-to-variable badge (now positioned above the row). Widen the
row gap to 10px.
Shrinking the wrapped control's width to make room for the badge (previous
commit) fixed the overlap but pushed the value left unnecessarily. Move the
badge to sit above the row instead, clearing the value without touching its
layout.
PromotableControl absolutely-positions its "◇ var" / "◆ {id}" badge over
the wrapped control without reserving any space, so on rows where the
value renders flush to the right edge (flat Font/Color rows) the badge
sat directly on top of the value text instead of beside it. Add a
right-padding gutter on the wrapper sized to each badge state, and cap
the bound chip to a fixed max-width so it always fits inside its gutter.
Stroke width committed border-width and border-style together from one
free-text field, so setting a style meant typing an exact CSS keyword
(e.g. "dashed") with no indication of which ones were valid — the row
also duplicated the discoverable Stroke style select directly below it.
Stroke width now only commits border-width; style changes go exclusively
through the existing select.
FlatRow lays out label…gap…value across a `justify-between` row, but the
shared CommitField input it wraps had no text-align, so its text hugged
the LEFT edge of the value's own (often much wider) right-hand box —
looking left-aligned relative to the row, out of step with FlatSelectRow
and FlatSlider, which already right-align.
Added an optional `align` prop to CommitField (default "left", preserving
the legacy panel's MetricField/DetailField layouts where label-then-value
sits inline and left reads naturally) and pass `align="right"` from
FlatRow. Left the Motion Timing row's Start/End/Duration cells alone —
those stack label-above-value in a grid, a different pattern from the
inline label…value row this fix targets.
New tests: FlatRow's input has `text-right` (not `text-left`); the legacy
MetricField's input keeps `text-left` (not `text-right`), pinning
CommitField's default so the shared component doesn't drift for the
panel that didn't ask for this.
Full studio suite (2645 tests) green; typecheck/oxlint/oxfmt clean.
Reported as "template variables are broken": binding an element's field to a
variable via the flat inspector's "◇ var" promote chip (or editing an
already-bound field's value) wrote the correct bytes to disk, but the
Variables tab kept showing the pre-edit value until the whole Studio page
was hard-reloaded.
Root cause: DesignPanelPromoteProvider deliberately opens its OWN SDK
session (`useSdkSession(projectId, selection.sourceFile ?? activeCompPath)`)
so that promoting inside a sub-composition binds the variable in the
sub-comp's own file, not the host's. For the common case — a top-level
element, same file as `activeCompPath` — this session is a SEPARATE
in-memory `Composition` instance from the shared one `VariablesPanel`
(Variables tab, Slideshow, etc.) reads. A persist through the promote
provider's session never fires the shared session's own "change" event.
Worse, the shared session's file-change listener runs
`isSelfWriteEcho(path, content)` to decide whether to reload — but
`sdkSelfWriteRegistry` is keyed by file path only, not by session instance
(its own doc comment assumes "the studio process has a single SDK session
lifecycle at a time"). It sees the promote provider's write registered
under the same path and concludes it's its own echo, permanently
suppressing the reload it actually needs.
Threaded `forceReloadSdkSession` (the same mechanism every other
server-side-write path in Studio already uses for exactly this "resync
after a write I didn't make myself" case) from App.tsx through
StudioRightPanel into DesignPanelPromoteProvider, and call it after every
successful promote/setDefault persist — unconditionally, not gated on the
promote target matching activeCompPath, since re-opening a file that
didn't change is a harmless no-op re-parse and a path-equality guard here
already produced one subtly wrong comparison (activeCompPath can be null
while the shared session still defaults to "index.html") before landing on
this simpler version. Verified live: editing a variable-bound field's
value now updates the Variables tab immediately, no reload required.
App.tsx crossed the 600-line file-size gate after threading the new prop;
extracted the tiny handleAddAssetAtPlayhead wrapper into its own
useAddAssetAtPlayhead hook (with a regression test) to bring it back under.
Full studio suite (2639 tests) green against a fresh main; typecheck/
oxlint/oxfmt clean.
Every editable value in the flat inspector (FlatRow's CommitField, the
Motion Timing row's Start/End/Duration cells, and every raw <select> —
Style/Text dropdowns, Grade's Custom LUT and Copy-grade-to scope) rendered
its underline/border only on hover (`border-transparent
group-hover:border-...` or no border at all). At rest a value looked like
plain static text, with nothing distinguishing it from a label — testers
reported not being able to tell which fields were editable.
Give each a dim-but-visible resting border (`border-panel-border-input/50`,
or `border-panel-accent/30` for the explicitCustom tier) that brightens on
hover/focus, instead of a fully transparent one. Purely visual — no
behavior change.
Full studio suite (2641 tests) green; typecheck/oxlint/oxfmt clean.
Review feedback on #2497 (Rames D Jusso) found a real gap: the exclusivity
this PR introduced only applied to the direct in-panel tab click, which
calls setExclusiveRightInspectorPane. Every OTHER caller that reaches
setRightPanelTab("design"|"layers") — element select (useDomSelection.ts),
closing block-params (App.tsx), the header Inspector button
(StudioHeader.tsx), and even this PR's own "!inspectorTabActive" entry
branch in handleInspectorPaneButtonClick — went through
trackedSetRightPanelTab's old unconditional additive
`{...panes, [tab]: true}`, reproducing the exact "both tabs highlight, only
one renders" bug this PR claims to fix. Confirmed via the reviewer's traced
repro: fresh boot, click Layers tab while no inspector tab is yet active →
rightInspectorPanes ends up {design:true, layers:true}.
Fixed at the reviewer's preferred choke point: trackedSetRightPanelTab
itself is now flat-aware, applying the same exclusive-radio update
setExclusiveRightInspectorPane does whenever STUDIO_FLAT_INSPECTOR_ENABLED
is on, falling back to the legacy additive update otherwise. This closes
the gap for every current and future caller of setRightPanelTab, not just
the one call site this PR touched.
New usePanelLayout.test.ts cases pin both directions: setRightPanelTab
stays additive under flat=off (legacy split-view behavior unchanged), and
enforces exclusivity under flat=on even when called directly (not through
the tab-click handler) — using the vi.doMock(manualEditingAvailability)
pattern already established in PropertyPanel.test.tsx for flag-dependent
module state.
Full studio suite (2643 tests) green; typecheck/oxlint/oxfmt clean.
The flat inspector split Layers and Design into a vertically-resizable
stacked pair whenever both panes were toggled on, mirroring the legacy
panel's layout. For the flat redesign this reads as two competing panels
crammed into one column; Layers should always render full-height by
itself there instead.
Gate the split-view branch behind !STUDIO_FLAT_INSPECTOR_ENABLED so it
still applies to the legacy panel, and fall through to Layers rendering
alone (the existing `layersPaneOpen` branch already does this — it just
never got reached previously because the split check ran first).
Also added setExclusiveRightInspectorPane (radio-style: selecting one pane
turns the other off) and use it for the Design/Layers tab clicks under the
flat flag, since leaving both panes independently toggleable would highlight
both tabs as "active" while only one actually renders.
New usePanelLayout.test.ts covers both the existing toggle behavior and the
new exclusive variant. Full studio suite (2634 tests) green; typecheck/
oxlint/oxfmt clean.
* feat(studio,cli): per-frame board comments, self-refreshing storyboard, status-aware preview landing
Per-frame comment boxes on the storyboard board batch into
.hyperframes/frame-comments.json (a resubmit wins per frame; unconsumed
comments on other frames are kept). Submitted-but-unconsumed comments
stay visible — a toolbar banner plus a per-tile echo — until the agent
consumes the file; the banner also says what to do next (reply anything
in the agent chat).
The board keeps itself current: GET /projects/:id/signature exposes the
watcher-cached project signature, the storyboard payload carries the
signature it was derived from, and the view polls at 2s (hidden tabs
skipped, re-checked on visibility), refetching in place with no loading
flash. Posters bake the signature into their URL so tiles fill in as
sketches land and a poster that failed mid-write retries on the next
version; the empty state upgrades itself when STORYBOARD.md appears,
and its handoff prompt now points the agent at the review loop and uses
the parser's real status vocabulary (outline, not planned).
preview lands the browser on the storyboard view while the board is the
review surface — any frame built, or pure planning (srcs declared, none
on disk yet) — and on the timeline once the video is assembled.
* feat(skills): the review loop — plan, sketch, build as one shared process
hyperframes-core/references/review-loop.md is the single source for the
three-pass collaborative review: the plan proposed on a live board
(§ 1), wireframe sketches marked built with one layout question (§ 2 —
real words on plain blocks, run no CLI; a confirmed board is itself a
valid deliverable when the user asked for a storyboard, not a video),
the build dressing confirmed layouts (§ 3, worker or inline), and the
final look (§ 4). Autonomous runs skip every gate and keep one question
before render.
The three narrative workflows' Steps 3/4/6 collapse to references plus
their sketch stand-ins (captured-asset blocks for product-launch-video,
plain code panels for pr-to-video); the confirmed-sketch handoff stays
in each frame-worker prompt. general-video plans on a board for
multi-scene narrative pieces in collaborative mode — its sketch pass is
layout-before-animation with the user watching. The router treats
"I want a storyboard" as a process request rather than a route, and
closes exploratory intake by recommending a route plus how the run will
review.
The supporting contracts land next door: the comments channel (silent
submit, one reply picks it up, check the file before the words) in
brief-contract § 1; the sidecar schema and the built status rung in
storyboard-format; the mode question asked first and alone in the three
workflows' Step 0.
* feat(media-use): user memory — remembered preferences and frozen recipes
Two tiers of memory on media-use's existing two-tier storage split.
Preferences (lightweight): confirmed brief answers — destination, aspect,
language, mode, voice, style preset — recorded to the project's
.media/preferences.json (committed, the team inherits it) and promoted
to the personal ~/.media/preferences.json once the same value is
confirmed in two different projects (a sightings ledger accumulates the
cross-project evidence user-side, since project files can't see each
other). prefs.mjs get/record; merge reads project-over-user; a changed
value restarts its provenance.
Recipes (heavyweight): one approved run frozen as a named, versioned
bundle — frame.md, the storyboard skeleton (structure kept: durations,
transitions, srcs, Video direction; statuses reset to outline; content
blanked to per-frame fill-ins naming the beat's role), and the confirmed
brief values. Named folders, not content hashes: re-freezing bumps
version and archives <name>@v<N>; a freeze is already confirmed, so it
promotes to the user tier immediately. recipe.mjs freeze/list/use, plus
resolve --type recipe --entity <name> delegating like grade/lut.
16 new node --test cases; the media-use lib suite is 168/168.
* feat(skills): wire user memory into the brief and the review loop
brief-contract § 2 gains Remembered defaults: read the merged
preferences before Round 2 and let a remembered value become the
recommended option with a receipt naming its source project. Memory
changes the default, never the question — every ask-marked field still
gets asked, and what the request says this time beats what was picked
last time. Record only what the user actually confirmed (a defaulted
voice nobody chose is not an answer; a "go" that accepts the
recommended defaults is). The first record announces itself once;
after that the receipts carry the reminder. In autonomous mode a
remembered value becomes the decided value, receipt included.
The three narrative workflows read the remembered defaults before
Round 2, record the confirmed answers at the Step 0 gate, record the
chosen preset at the Step 2 gate (pr-to-video excepted — its preset is
fixed), and fall back to the remembered voice when the request names
none. general-video's discovery reads the same defaults.
Recipes wire in at both ends: Step 0 checks for a matching recipe
before the mode question — one question, plural-aware, and adopting
one fills the brief, skips the design step, and drafts the storyboard
from the frozen skeleton while every review gate still runs. The
review loop's final look (§ 4) offers the freeze once after approval,
and the confirmation teaches the recall phrase — the name is something
the system reminds the user of, never something they must remember.
The router recognizes a named recipe or "like last time" as a route.
* docs(skills): the sketch pass names check, not the deprecated validate
* feat(skills): intent-layer references — process, route briefs, capability menu, BRIEF.md format
* feat(media-use): brief skeleton as the recipe's fourth artifact; flow/storyboard preference keys
* feat(skills): the intent layer conducts every brief — workflows execute BRIEF.md
* feat(skills): retire the mode preference key; sync catalog surfaces for intent layer
* refactor(skills): dedupe router vs intent-layer guidance — one owner per rule
* feat(skills): the design ask — own spec, pick by eye from showcases, or defer
* docs(skills): the design ask says the honest line on capture routes
* feat(skills): product-launch-video absorbs website-to-video as the tour angle
* refactor(skills): keep product-launch-video pristine — a tour is brief intent, not a pipeline branch
* feat(skills): production loop + genre lenses; general-video goes freeform (route yours, laws hold)
* refactor(skills): /hyperframes is the front door - route tables and scope lists leave the workflows
* docs(skills): review-loop pass across skill catalog
* fix(cli): pass project dir to openStudioBrowser in background-server path
* feat(skills): add pitch-round reference - verbalized sampling concept gate
* feat(skills): wire pitch round into intent layer - completeness triage + route eligibility
* feat(skills): editorial capability recommendations, handoff disciplines, menu-probe split
* feat(skills): pitches carry their machinery; source-only-formed requests pitch the telling
* feat(skills): companion goes director - ceiling treatment plus blueprint/rule citation discipline
* fix(scripts): sandbox npx-leak guard - private npm global prefix keeps npx on the branch CLI
* chore(skills): resync manifest hash after formatter pass reflowed general-video tables
* fix(skills): recipe freeze reads workflow from BRIEF.md; style_preset records require workflow scope
Two holes found by a live companion-run freeze: the agent-supplied --workflow
contradicted the run's actual workflow (recipe.json said faceless-explainer,
brief-skeleton said general-video), and the style_preset lookup missed because
the preference had been recorded under the bare key.
- freezeRecipe resolves the workflow from BRIEF.md frontmatter; the flag is a
fallback for briefless projects and a contradicting flag is ignored (noted).
- recordPreference refuses a bare style_preset — the scoped key is the only
writable shape; freeze tolerates legacy bare records via read fallback.
- review-loop § 4 / media-use SKILL / brief-format wording follow the machinery.
CI's file-size check (which diffs against origin/main, not per-commit like
the local lefthook gate) flagged useDomEditCommits.ts at 602 lines. Extracted
the standalone atomic-patch-batch helpers (formatUnsafeFieldList,
getErrorDetail, readErrorResponseBody, formatPatchRejectionMessage,
patchElementBatches, batchesAreInlineStyleOnly,
AtomicElementPatchConvergenceError) into useDomEditCommitsHelpers.ts — none
of them close over hook state, so this is a pure move. useDomEditCommits.ts
is now 451 lines.
Typecheck/oxlint/oxfmt clean; useDomEditCommits.test.tsx (28 tests) and the
full studio suite unaffected.
Fixes real bugs from two independent re-reviews (#2225 @ 65954c3804,
#2416 @ beaf4ffbf6):
- FlatTimingRow's pinRange committed a pinned start+duration range through
TWO sequential onSetAttribute calls. Each resolves domEditSelection fresh
from current hook state, so a selection change between the two awaits
could misdirect the second write at the newly-selected element instead of
the one being edited, and a failure of just the second call left the pair
half-applied (inconsistent inferred/explicit state). Added
commitDataAttributes/handleDomAttributesCommit (mirroring
onCommitAnimatedProperties's same-shaped fix for GSAP property batches):
one PatchOperation[] persist call against an explicit, caller-supplied
selection — not the "current" one — threaded through as the new optional
onSetAttributes prop. pinRange uses it when provided, falls back to the
old sequential behavior otherwise.
- Hide All silently dropped nested sub-composition children: a selection
inside a sub-comp with no timeline-store entry of its own resolves to a
virtual `sourceFile#domId` key (the fallback branch exists so the
expansion hook can later resolve it via clipParentMap), but
toggleTimelineElementHidden only searched the RAW store list, which never
contains that key. useTimelineElementVisibilityEditing now resolves
against useExpandedTimelineElements() instead, matching the track-based
toggle's existing approach — the expanded list synthesizes a real,
patchable TimelineElement (matching key/domId/sourceFile) for each visible
child whenever its host is currently expanded.
- Two composition hosts importing the same sub-composition collapsed to
the first one: findMatchingTimelineElementId ORed domId/selector/
compositionSrc matches with equal priority in a single per-element scan,
so `.find()` could stop at an EARLIER, unrelated host that merely shared
the compositionSrc, before the scan ever reached the correct domId/
selector match further down the list. Restructured to try domId, then
selector, across the WHOLE list first; compositionSrc-only matching is
now a true last resort for when neither identifies a specific element.
- FlatSlider's native pointercancel handler (a platform-level gesture abort
— scroll/touch takeover, pen leaving range) manually duplicated the
pointer-capture release logic instead of calling cancelDrag, so it never
reverted to the pre-drag value — leaving whatever intermediate position
the pointer last reached committed, unlike the Escape/right-click paths
added in the previous round. Now calls cancelDrag directly.
- useColorGradingController's flushPendingPersist read identityKeyRef.current
fresh at flush time rather than a value snapshotted when the edit was
scheduled. Defensive fix: added pendingPersistIdentityRef, set alongside
pendingPersistValueRef in commitColorGrading, read by flushPendingPersist
instead of the live ref — closes the gap regardless of how unlikely the
actual race is given the identity-cleanup effect's existing eager-flush
behavior.
Two prior findings re-verified as already fixed further up this same
Graphite stack (not re-fixed here, per established stack-order handling):
metadata-cache negative-caching (267cdfce1) and cross-file
selectionIdentityKey (6f40e03a1), both landing after #2225's reviewed head.
StudioRightPanel.tsx crossed the 600-line file-size gate after wiring the
new onSetAttributes prop through; extracted the inspector split-pane resize
handlers (previously inlined) into their own useInspectorSplitResize hook.
New regression tests: repeated-composition-host resolution, atomic vs.
fallback pinRange commit paths, pointercancel revert. Full studio suite
still at the known pre-existing 55-failure baseline, zero new regressions.
Typecheck/oxlint/oxfmt clean.
A fresh full-stack re-review checked 15 PR heads independently. Cross-
checked all 9 remaining claims against the actual current tip:
- #2120 (id/selector key qualification, Hide All no-op/race, variable
parity), #2121 (opacity-zero fallback), #2122 (GSAP preview sibling
resolution, scrub-label), #2124/#2126 (negative metadata cache), and
the keyboard-access half of #2121/#2186 were all already fixed by a
later commit in this same stack (65954c380, PR #2225) — the reviewed
heads predate it. Verified each in the current source rather than
taking the isolated-head review at face value.
- #2186's "no ESC/right-click cancel during drag" was the one claim that
held up: FlatSlider had keyboard arrow-key support but no way to abort
an in-progress pointer drag. Escape now reverts to the pre-drag value
and releases pointer capture; a right-click (contextmenu) during a drag
does the same instead of committing whatever position the pointer last
reached while the native context menu opens over the slider. Both go
through commitDraft (not just a visual reset) since the drag's leading-
edge commit in onPointerDown may already have applied an intermediate
value that needs actually undoing, not just hiding.
propertyPanelFlatPrimitives.tsx crossed the 600-line file-size gate after
this change; extracted FlatSelectRow into its own file, matching the
FlatToggle/FlatMaskInsetRows precedent from earlier in this stack.
New regression tests for Escape-cancel and contextmenu-cancel. Full
studio suite still at the known pre-existing 55-failure baseline, zero
new regressions. Typecheck/oxlint/oxfmt clean.
Fixes the four blockers from the #2416 re-review at head d6a40c38b:
- FlatSlider's onPointerUp calls releasePointerCapture() explicitly, which
fires lostpointercapture SYNCHRONOUSLY in real browsers — the prior
unconditional onLostPointerCapture resync ran mid-onPointerUp, flipping
draggingRef false before onPointerUp's own check, silently dropping every
normal drag-release's final commitDraft(). happy-dom doesn't replicate the
synchronous cascade, so this shipped without a failing test. Added an
explicitReleaseRef flag set right before each deliberate
releasePointerCapture() call so onLostPointerCapture can tell "our own
release, caller's logic already handles it" apart from a genuine external
capture loss. Added a regression test that monkey-patches
releasePointerCapture to reproduce the real-browser ordering.
- persistColorGradingValue read onSetAttributeLiveRef.current (reassigned
every render) instead of the callback live when the debounced edit was
scheduled — a timer for element A firing after a re-render for element B
would wrongly call B's callback with A's data. Removed the ref; the
callback is now an explicit parameter captured by commitColorGrading's own
closure (added to its useCallback deps) and threaded through to
persistColorGradingValue and flushPendingPersist.
- flushPendingPersist passed () => true as its isLatestAttempt checker,
bypassing the per-commit version guard entirely. Now calls
bumpDomEditCommitVersion(gradingVersionRef) like a regular debounced
commit, so a newer edit landing before the flushed write settles still
wins the race.
- The selection-identity cleanup effect stopped clearing statusTimersRef
during an earlier refactor — stale RUNTIME_STATUS_REFRESH_DELAYS timers
for an outgoing element could fire after switching selection and stamp
the new element's runtimeStatus with the old element's answer. Restored
the clear in the same effect cleanup.
Also gave the Custom LUT and "Copy grade to" scope <select> controls
aria-labels — both had their visible text in a sibling span/text node, so
neither had an accessible name.
Full studio suite still at the known pre-existing 55-failure baseline
(variablePromoteIntegration, useGsapPropertyDebounce, sdkCutover(Parity),
sdkResolverShadow), zero new regressions. Typecheck, oxlint, oxfmt clean.
Fixes three of the adversarial findings from the third #2416 tip
re-review:
- Grade rollback was identity-scoped but not attempt-scoped: two edits on
the SAME element (e.g. drag Exposure, then Contrast, before Exposure's
persist settles) could have the earlier edit's late completion stamp
confirmedGradingRef with its now-superseded value, or revert `grading`
out from under the newer optimistic edit. Added a monotonic per-commit
version via the existing bumpDomEditCommitVersion primitive (the same
one the DOM-attribute commit runner uses for the identical race) —
persistColorGradingValue now checks both identity AND "is this still the
latest attempt for this element" before applying any effect.
- The render-phase identity-reset block consumed shared mutable state
(clearing the pending-persist timer, reading and nulling
pendingPersistValueRef) directly during render. Adjusting STATE during
render this way is React's documented pattern and safe to repeat, but
consuming a ref this way is not: if React discarded/interrupted that
specific render before it committed, the timer would already be
cancelled and the pending value already nulled, with no corresponding
effect ever running to compensate, silently losing the edit. Replaced
with the idiomatic pattern for "clean up a per-identity resource when it
changes" — a useEffect keyed on identityKey whose CLEANUP performs the
cancellation/flush. A cleanup only ever runs for the effect instance
that actually committed, closing the gap entirely. The render-phase
block now only performs pure, idempotent state resets.
- FlatSelectRow's Preset row passes label="" (the visible "Preset" text is
a sibling span, to avoid rendering it twice) which left the underlying
<select> with no accessible name at all. Added a dedicated `ariaLabel`
prop, distinct from the visible `label`, so a caller can supply a name
without a duplicate visible label.
Also hardened FlatSlider's lostpointercapture handling: it now resyncs
the draft directly from a latestValueRef immediately, instead of only
clearing the dragging flag and waiting for the separate [value]-keyed
effect to notice — closing a narrow ordering gap where a value change
arriving while still dragging, followed by capture loss with no further
render, could otherwise leave the knob stuck.
propertyPanelFlatPrimitives.tsx crossed the 600-line file-size gate after
these changes; extracted FlatToggle (and its tests) into their own files,
matching the FlatMaskInsetRows precedent from an earlier commit in this
stack.
New/updated regression tests: same-element version race, Preset select's
aria-label. Full studio suite still at the known pre-existing 55-failure
baseline, zero regressions.
Fixes two of the three adversarial findings from the second #2416 tip
re-review; the third is a pre-existing runtime-protocol gap, explained in
the PR thread rather than patched here.
- The Grade rollback added in the previous commit could never fire through
the real Studio callback: runDomEditCommit (the shared commit runner used
by every data-attribute commit, not just Grade) catches persist failures
internally and always resolves, reporting outcome only via its own
onError side effect. A caller awaiting the promise never sees a
rejection, so the revert-on-reject logic was dead code against the
actual app. Added an optional onSettled(ok) callback to
DomEditCommitRunnerConfig (purely additive — every existing caller that
doesn't pass it is unaffected) and threaded it through
commitDataAttribute -> handleDomAttributeLiveCommit -> the
onSetAttributeLive prop type (now accepts an optional 3rd argument) ->
useColorGradingController, which now drives the revert from the real
signal. The promise-rejection path stays as a fallback for any other
implementation of onSetAttributeLive that rejects instead.
- Selection flushing performed a real side effect (writing the outgoing
element's pending edit) during the render-phase identity-reset block.
Adjusting STATE during render (comparing against a ref) is React's
documented pattern, but it doesn't license actual I/O — React can invoke
render more than once per commit, which could double-fire or misorder
the write. The reset block now only enqueues the flush (a pure ref
write); a new effect keyed on the identity performs it after commit.
- Async persist completions (both the onSettled callback and its promise-
rejection fallback) now capture the identity key the attempt was made
for and check it against the CURRENT identity before touching
confirmedGradingRef/grading/runtimeStatus. Without this, a persist that
settles after selection has moved on to a THIRD element could clobber
that element's freshly-reset state with a result that belongs to an
element no longer selected.
Not fixed here: the runtime Grade target (HfColorGradingTarget, used by
core's resolveTarget to find the DOM element inside the preview iframe)
has no source-file/composition-scope discriminator, matching the same gap
selectionIdentityKey had before this stack — but fixing it means changing
a wire-protocol type shared across core/player/studio and the legacy
ColorGradingSection too. hfId (checked first, before id/selector) is
minted uniquely per element at parse time in the common case, so this is
a narrow residual risk for hfId-less same-selector elements across
different source files, not a regression introduced by this stack.
Flagged as a follow-up in the PR thread.
New/updated regression tests: real onSettled(false) path (distinct from
the promise-rejection fallback), and a stale in-flight persist settling
after selection has moved on twice more. Full studio suite still at the
known pre-existing 55-failure baseline, zero regressions.
Fixes the Deepwork tip re-review's four remaining blockers plus its
additive findings:
- selectionIdentityKey: add sourceFile as a 5th identity component. The
same local id/selector can legitimately recur across different
composition files (host vs. an inlined sub-composition, or two unrelated
sub-comps) — without sourceFile, those collided onto the same identity
key and reused stale controller state across a selection change that
should have reset it.
- useColorGradingController: flush (not discard) a pending Grade edit when
selection changes before the 350ms debounce fires. The prior fix
correctly stopped it from landing on the WRONG (new) target, but
cancelling outright silently dropped the user's in-flight edit instead of
writing it to the element it was authored for — using the
onSetAttributeLive closure captured for the outgoing render, which
(via commitDataAttribute's own useCallback deps) is still bound to the
outgoing selection.
- useColorGradingController: revert to the last confirmed-good grading when
a persist rejects, instead of leaving the optimistic (never-actually-
saved) value showing indefinitely. Tracks a separate
confirmedGradingRef, updated only on a successful persist.
- FlatSelectRow: disable the reset button when the row itself is disabled
(it previously ignored disabled entirely, same class of bug as the
FlatSlider reset button fixed earlier) and give the underlying <select>
an aria-label from the row's label text.
- FlatSlider: handle lostpointercapture the same as pointercancel — capture
can be lost without either firing first (another element steals it, or
the browser reclaims it for a scroll/touch gesture), which previously
left the dragging flag stuck and the knob permanently unable to sync to
external value changes.
New regression tests for all of the above; full studio suite still at the
known pre-existing baseline (55 failures unrelated to this stack).
Fixes issues raised in the Deepwork re-review of #2120-#2190 that weren't
covered by #2225's earlier fix pass:
- useColorGradingController: reset grading/compare/mediaMetadata state (and
cancel pending persist/status timers) when selection changes to a
different element — this hook is called unconditionally on every render
(unlike legacy ColorGradingSection, remounted via a selectionIdentityKey
React key), so switching selection reused the previous element's state.
- useColorGradingController: stop permanently caching a non-OK
/media/metadata response as null — a transient server error poisoned the
HDR banner for that asset for the whole page lifetime.
- FlatSelectRow: preserve a valid authored value outside the preset list
(e.g. mix-blend-mode: difference, an arbitrary object-position) instead of
silently misrepresenting it as the first preset — touching the control
would overwrite real persisted state.
- FlatSlider: the throttled trailing commit now reads onCommit through a
ref updated every render instead of closing over it at schedule time — a
caller whose onCommit spreads other current state (Grade's per-detail
commits) could otherwise have a delayed commit revert whatever the user
changed on a different control in the same 40ms window.
- FlatSlider: flush a still-queued trailing commit on unmount instead of
dropping it, and disable the reset button when the slider itself is
disabled.
- FlatSlider: add touch-action: none to the track so touch drags don't
compete with page scroll.
- FlatColorGradingAccessory: clean up the compare-hold's window listeners
on unmount, not only on release — switching selection mid-hold used to
leak them.
- Align (flat Text): re-clicking the option already visually active for a
logical start/end value no longer rewrites it to the physical left/right,
preserving RTL semantics.
- FlatSegmentedRow: give every option an accessible name and aria-pressed
state — two visually-identical glyph buttons (upright/italic "A") had no
way to be told apart by assistive tech.
- PropertyPanelFlat: the panel body falls back to its own scroll when the
collapsed group headers alone exceed the available height, so groups
can't become permanently unreachable in a short pane.
New regression tests for all of the above; full studio suite at the known
pre-existing baseline (55 failures unrelated to this stack).
A pure debounce resets its timer on every pointermove, so a real drag
(events faster than 40ms apart) never commits until the pointer pauses
or lifts — killing live preview updates mid-drag. Throttle with a
leading-edge commit + trailing flush instead.
Only onPointerDown was wired, so dragging the knob/track only ever
committed the initial click position — nothing tracked the pointer
after that. Uses the Pointer Capture API (setPointerCapture on
pointerdown, onPointerMove while captured, release on pointerup) so
the value follows the cursor continuously during a drag, matching how
the legacy native <input type="range"> control behaves for free.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The track's visible line was only 2px tall, and pointerdown was bound
directly to that thin element, making it hard to grab. The hit area is
now 20px tall (a wrapping div) with the visible line rendered as a
thin decorative child, centered inside it — the ratio math only reads
left/width so click accuracy is unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a fast (120ms) CSS entrance animation for flat inspector accordion
group headers/body, gated to the group actually toggling (not derived
from remounting alone) to avoid a Chromium reflow quirk that otherwise
replays the animation on untouched collapsed siblings.
Collapsed group headers render in fixed, non-scrolling document flow
above and below the open group; only the open group's own content
scrolls, in a dedicated region. Also fixes the flat inspector footer's
missing background.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The flat inspector rendered its new Style/Grade groups AND the legacy
ColorGradingSection/StyleSections components a second time below them,
visibly doubling every control. Remove the now-redundant legacy render
call sites (and their now-unused imports) from PropertyPanelFlat.tsx;
those components stay intact for the legacy (flag-off) PropertyPanel.
FlatTextSection's multi-field branch (textFields.length > 1) now renders
FlatTextLayerList (Task 5) + the existing single-field FlatTextFieldEditor
for the active field, tracked via new local activeFieldKey state that
resyncs (useEffect) when the active field disappears from props. This
retires the legacy TextSection delegation entirely for that case; the
TextSection import is removed from propertyPanelFlatTextSection.tsx since
nothing else in the file referenced it.
Also updates propertyPanelSections.test.tsx and PropertyPanel.test.tsx,
which exercised/documented the old multi-field-falls-back-to-legacy-
TextSection behavior in comments and test titles — reworded to describe
the new flat path (assertions were already compatible and still pass).
Flag for reviewer: hideOwnHeading on the legacy TextSection component
(propertyPanelSections.tsx) was added in an earlier plan specifically for
this now-removed call site. It has no remaining consumer after this task
lands (PropertyPanel.tsx's legacy caller doesn't pass it). Left in place
per brief instruction — not deleting unilaterally, since that's a scope
decision for whoever reviews this task.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review proved the existing test didn't catch a broken stopPropagation
by temporarily removing it and confirming the suite still passed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review of the pin-aware group list refactor flagged the test name
claiming the group "closes" on unpin — it doesn't assert that, and
structurally the group re-opens (togglePin never touches openGroupId).
Retitled to describe only the return-to-stack behavior actually tested.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reads/writes the per-element-kind pinned-groups map added to
studioUiPreferences in the prior task, read-modify-writing the whole
map since writeStudioUiPreferences only shallow-merges top-level keys.
Also adds a not-busy click assertion for the Apply button so
onApplyToScope is exercised end-to-end, not just its disabled state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>