A clip's source length came from the first video, audio or image found inside
its element. For a composition that is media on the scene's own timeline, so a
scene holding a 1.8 s video could not be longer than 1.8 s: trimming its end by
a few pixels collapsed it to that length. A composition host no longer lends its
inner media to the clip; a plain wrapper around one video still does. An inline
composition still shows its inner video or image as its thumbnail.
* ci: editing a PR's text no longer cancels its running checks
CI, regression and Windows render re-run on pull_request edited (for base changes) with cancel-in-progress, so a body or title edit cancelled the running checks and the fail-closed Test aggregator went red on the cancelled run. Only a push or a base change cancels now; a text edit queues behind the running run.
* test(ci): read the workflows folder by path, not as a module URL
Fallow treated new URL('../.github/workflows/', import.meta.url) as an unresolvable import. Use import.meta.dirname and join, as scripts/check-skill-mirror.mjs does.
* test(engine): hold the download attempt deadline so slow runners cannot trip it
urlDownloader tests passed a real 1s deadline that a slow Windows attempt outlasted.
The deadline timer is now held; the one test that needs it fires it by hand.
* fix(engine): name the underlying error when a download fails, without its URL
* test(engine): fire the stalled-body deadlines by hand instead of racing a real 20 ms timer
* fix(studio-server): serve projects whose names contain @ % # & or ?
Five routes cut the decoded project id out of Hono's c.req.path, which leaves %40 %25 %23 %26 %3F
encoded, so the cut missed and files, preview assets, sub-compositions, thumbnails and waveforms
404ed or 403ed for those names. One helper now takes the sub-path by segment from the raw URL.
* fix(studio-server): thumbnails and waveforms stay inside the project for any name
The thumbnail route built its preview URL from the raw project id and path, so # ? and % broke it,
and neither route checked that the decoded path stays inside the project. Thumbnails now use the
composition guard and an encoded URL; waveforms use the read-only asset guard.
* fix(cli): play and present serve assets whose names contain @ % # & or ?
Both /composition/* routes cut a prefix out of Hono's c.req.path with no decode. They now share
studio-server's requestSubPath (renamed from projectSubPath and generalised to any route) with the
five studio-server routes, so one helper owns how a request path becomes a file path.
* fix(studio-server): a thumbnail request for the project folder answers 404
An empty thumbnail path resolved to the project directory and crashed reading it (500).
* fix(studio-server): read the thumbnail's composition with one open
Checking the path with stat and then reading it let the file change in between (CodeQL
js/file-system-race). One open now serves the type check, the mtime and the read; a missing file
still thumbnails from the preview as before, anything that is not a file answers 404.
A shadow reload that was not ready within one 15 s budget was discarded for
good: the stage kept the previous document, the edit never showed, and nothing
retried. On a busy machine that dropped deletes, trims and agent writes. The
shadow now keeps loading for up to three budgets before it is dropped, so a
slow load still lands and a load that never becomes ready still fails.
* fix(cli): stop check --at failing still frames as a frozen timeline
* fix(cli): warn when the running CLI is older than the project's pin
* fix(cli): keep judging spread samples when check --at is combined with other gates
* feat(studio): an embedder can change what the shortcuts panel lists
The panel read a private list, so an app that embeds PlayerControls could not drop a key Studio binds but it does not (bare S split). The list is now exported as DEFAULT_SHORTCUT_SECTIONS, PlayerControls takes shortcutSections, and the dispatcher names its bare F, S and R keys from the same module. Defaults unchanged.
* fix(studio): an embedder's shortcuts list may repeat a key or a section title
Rows were keyed by their key text and sections by title, so a relabelled list with a repeat hit React duplicate keys. The list is static, so rows and sections are keyed by position.
* fix(producer): report render progress from the first frame, at most four times a second
Capture loops reported every 30th (or 10th) frame, or every single frame, so a render sat silent through browser warm-up and the first 30 frames, then flooded on the sequential paths. One helper now owns the cadence for every capture stage: first frame, last frame, and every 250 ms in between. Browser GPU check, capture calibration and worker start-up each report their own step.
* fix(producer): always report the first captured frame and keep one telemetry code for browser start-up
The centre-anchor step ran on every scale change, including a window resize and a zoom set by a host, and from scroll 0 it always moved the view right, hiding 00:00 under the track header. A view at the start now stays there; a scrolled view still keeps its centre time.
NLEPreview always drew its picture 8px in from each side on a dark band, so an embedder that sizes the box to the film saw a smaller picture in a black frame. fillBox drops that inset from both the layout and the stage-size math. Default look is unchanged.
* fix(core): frame captures wait for a seeking video to land its frame
A paused seek set video.currentTime and returned; the seek-completion barrier resolved while the
video was still seeking, so a capture right after the first seek on a fresh preview painted the
previous frame. The media sync now joins each seeking video to that barrier until seeked, error,
emptied or abort.
* style(core): format the seek barrier test
* fix(core): a section whose root sets pointer-events:none stays pickable
* fix(core): the picker enables one sheet per hit test instead of mutating the DOM
* fix(core): the picker override lives in an adopted sheet, never in the saved document
* fix(core): the picker override wins over an author !important on a section root
* docs(core): name the picker override ceiling and why the fallback stays unlayered
* fix(core): a click-through overlay host keeps passing picks through
The override now targets section inner roots only, so a host the parent author set to pointer-events:none stays pass-through. Inner roots that were none before the override are never pick targets themselves, an inner root gets a unique selector instead of its bare tag, and a page with no pass-through root skips the override and its restyle.
* fix(core): picks reach content under a composition's own pointer-events:none root
* feat(runtime): data-vfx-chain backdrop capture and ref second sources
Interface v1.1's two remaining capture shapes, in the runtime.
`backdrop`: a host resolves its source from a sibling
`<canvas layoutsubtree class="hf-vfx-src" data-vfx-for="<host id>">`
before its own child wrapper, so the same kernel reads either its own
pixels or every layer below it — which element the wrapper holds is the
exporter's placement, not a def's declaration. `VfxEntry.capture` now
reports what was resolved rather than what the defs asked for.
The trap that shape creates: a `backdrop` wrapper lives OUTSIDE the host,
and children of a `layoutsubtree` canvas are never painted by the page
compositor. Skipping a hidden host the way a `self` host is skipped would
therefore delete every layer below the adjustment instead of passing it
through. A hidden backdrop host now still captures and KEEPS the bitmap,
painting no kernel.
`ref` params: a node's second source, captured into `u_src2` from the
wrapper the exporter emitted around the referenced element. Per node, not
per entry, and shared when two nodes name the same element. Every source
is drawn at the host's device size so `u_src`/`u_src2`/`.hf-vfx-out`
share one coordinate space, and each source's canvas gets its own
`paint` wait — paint records are per canvas. A source the clip runtime
has hidden captures EMPTY rather than waiting out a paint that cannot
come and then throwing "No cached paint record" every frame; an empty
matte is also what After Effects shows outside a matte layer's window.
Also: a `.hf-vfx-in` that measures 0×0 is now reported once instead of
capturing nothing in silence, and one that is not the canvas's immediate
child is refused at registration instead of throwing every frame. Both
are measured Chrome rules (vault `layoutsubtree-capture-rules`).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(runtime): luma-matte and noise two-source vfx kernels
`luma-matte` is the first def with a `ref` param: `out = src · coverage(u_src2)`
with Alpha / Alpha Inverted / Luma / Luma Inverted coverage. It is not one
After Effects effect but the runtime form of all of them — unsupported
track-matte sources, stencils, silhouettes, Set Matte — which the exporter
distinguishes by choosing what the host wraps and what `matte` names. `src`
is premultiplied, so one scalar multiply covers all four channels, and Luma
reads the matte over black, which is what a premultiplied texel already holds.
`noise` ports `ADBE Noise` from `AEFX_Noise.metallib`: Bob Jenkins' 1997
integer mix over `(x, y, seed32)`, the two glibc-style LCG rounds, and the
`0x7F80`/`0xFF` byte the IR XORs out of them — no table, no state, so it is
seek-safe by construction. Reading the metallib's struct metadata rather
than the deep dive's prose settles two of its open questions:
`ADBE Noise-0002` is `inUseColorNoise` (a checkbox), not a Uniform/Gaussian
popup, and the mystery seed floats are `inRandomSeed_High`/`_Low`, the two
halves of one 32-bit seed. What the CPU puts in those halves per frame is
still unprobed (plan Task 4.3), so the kernel seeds the low half with the
frame index; the tests assert distribution and seek determinism, not parity.
The shader declares `precision highp int` — the whole port rests on 32-bit
wraparound and a fragment shader's integers default to mediump, which ESSL
guarantees only 16 bits of.
Both defs declare `capture: "self"`, which says a texture is needed and
nothing about whose: `noise` on an adjustment layer is the same kernel, and
the runtime reads the `backdrop` placement off the DOM.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(vfx): browser checks for backdrop capture, luma-matte and noise
Task 2.6 Step 5 and Task 2.7's owed browser checks, folded into the
existing puppeteer harness: an identity backdrop kernel composites two
stacked blocks captured through a data-vfx-for sibling wrapper back
over themselves (PSNR >= 40dB vs a plain-DOM control panel); a backdrop
host outside its own clip window still keeps those blocks on the
wrapper canvas's own bitmap via capturePassThrough; luma-matte's four
modes are checked against alpha- and luma-varying matte strips at four
sample points each; noise is checked for amount=0 identity, same-time
determinism, and a changed pattern when its only seed (seek time)
changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(runtime): external displacement maps and visible ref sources
Two gaps the exporter side found in the `ref` plumbing.
1. `displacement-map` had no `map` ref param, so the exporter's
`params.map = "<element id>"` was dropped by `normalizeVfxParams` (it
iterates the def) and the node silently self-displaced. `map` is now a
ref param, bound to `u_src2` and selected by a new `u_hasSrc2` uniform
the runtime sets from whether the ref resolved. Refs gain
`optional: true` — empty `map` is the self-referential form every
corpus instance uses, while an empty `matte` stays a loud failure — and
a ref naming its own host resolves to self rather than capturing the
same pixels twice (on a backdrop host it would otherwise have read the
layers below as the map).
2. Interface v1.1 assumed a ref source is invisible in After Effects.
retro-wave `Logo Anim` layer 5 is layer 4's displacement map AND an
enabled, opaque layer, and the runtime cleared its wrapper's bitmap
after the upload, deleting it from the frame. A wrapper marked
`data-vfx-ref-visible` now keeps its bitmap and is captured at the
REF's own box instead of the host's, so it paints at its own size and
resolution. The three capture shapes are one `{ upload, keepBitmap }`
mode: texture only, bitmap only (a backdrop pass-through), or both.
Sampling is unaffected by the box choice, and the browser test proves it
rather than assuming it: `drawElementImage` scales the element into the
whole canvas, so a 320px-wide matte's four strips still land as four
40px bands in a 160px host. The v1 rule — a ref is scaled into the host's
box, not placed in composition space — is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(runtime): capture a visible ref while its host is hidden; review nits
Addresses PR #4330 review feedback (jrusso1020, CHANGES_REQUESTED).
Blocking: both the engine-mode and preview-mode capture paths treated
"host not paintable" as binary — a backdrop entry got a pass-through
capture, everything else was skipped outright, including a ref source
that lives on a different, independently-visible element (e.g.
retro-wave's Logo Anim layer, which is simultaneously layer 4's
displacement map and its own fully opaque visible layer). Adds
visibleRefSources()/captureVisibleRefsOnly() and a unified
FrameCaptureMode discriminator so a visible ref still gets captured
even while its own host is hidden, on both paths.
Non-blocking, addressed:
- luma-matte's mandatory ref that names its own host now resolves to
an explicit RefResolution {kind:"self"} and binds u_src2 to unit 0,
instead of relying on an unbound sampler's implicit default.
displacement-map's optional self-ref is unaffected (still folds into
{kind:"none"}, using its existing u_hasSrc2 shader branch).
- noise shader now enforces rgb<=a (valid premultiplied alpha) after
adding noise, independent of u_clipping — documented why u_clipping
is currently a permanent no-op on this runtime's 8-bit render
targets.
- added a hash-pinning test for hfJenkinsMix; documented, with a
brute-force proof, which one of the reviewer's suggested mutations
is unobservable within this fixture's reachable seed range.
- fixed two misplaced doc comments.
fallow flagged resolveVfxCapture/resolveRefSource as newly over the
complexity threshold after this diff; extracted resolveEmptyRefParam()
and captureHiddenEntry() to bring both back under.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(runtime): capture a visible ref while a hidden BACKDROP host passes through
Re-review of #4330 at b6d51bd9: the visible-ref-while-hidden fix only
covered non-backdrop hosts. captureHiddenEntry returned
capturePassThrough(entry) for a backdrop and never reached
captureVisibleRefsOnly; frameCaptureMode picked "passThrough" before
considering refs and sourcesForFrame("passThrough") was [entry.src]
alone. An adjustment layer whose displacement map is itself a visible
layer still lost that layer outside the adjustment's window.
- captureHiddenEntry runs the pass-through AND the visible-ref capture,
OR-ing the results.
- sourcesForFrame("passThrough") is the backdrop wrapper plus any
visible refs, and capturePaintedHost's pass-through branch also calls
captureVisibleRefsOnly.
- Tests for the backdrop+visible-ref shape in both engine and preview
modes (the reviewer's probe).
Also closes the non-blocking display:none item: isPaintableHost(src.inner)
missed a ref whose OWNING element is display:none, since display does not
inherit — the preview path waited out the 2 s ceiling, then failed with a
misleading 0×0 report. New isPaintableSource walks inner's ancestors for
display:none (visibility inherits, so one read suffices there); used at
both source-side sites. checkVisibility()/offsetParent are absent or
always-null in the unit harness's DOM. Test added; verified it fails on
the old inner-only check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(studio): ship component styles as their own stylesheet for embedders
Studio's component rules (timeline clips, loaders, range inputs, toasts and
the rest) lived only in the app sheet, so a page that embeds Studio's
components with theme.css got unstyled clips and dark-on-dark labels. They
move unchanged into components.css, exported as
@hyperframes/studio/components.css; studio.css keeps the app shell and
imports it, so Studio's compiled CSS has the same rules.
* test(studio): read moved component rules from components.css
* test(studio): catch class rules nested in the app sheet's layers and media blocks
* fix(scripts): retry a fresh GitHub attachment's transient 404 in the captures gate
Reproduced this week: the same attachment URL 404'd, then 200'd minutes
later with no edit to the PR in between, and whichever asset had been
attached most recently was always the one that failed. The gate already
retries 429/5xx with backoff, but treats every other status as final —
so a fresh upload lagging GitHub's own read-path failed the check on
attempt 1.
404 is now retryable, scoped to a GitHub attachment host (isAttachmentUrl)
so a real 404 elsewhere (a typo'd link, a deleted gist) still fails fast.
403 stays non-retryable everywhere, including on an attachment host — it
means something else (auth, a private-repo asset another workflow can't
see), not propagation lag.
Widened the retry window to match: 5 attempts, delays out to 60s, 3-minute
deadline (was 3 attempts / 45s) — the observed lag was up to ~3 minutes.
Tests: split the old combined 404-and-403 test; added the attachment-host
404 retry case and the non-attachment-host 404 still-final case; updated
the backoff test's attempt/delay assertions for the new constants. 73/73
green, oxlint/oxfmt clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(scripts): stop the captures-gate retry tests from sitting through the real backoff
Review on #4362: `runCli()`'s no-network preload stubs `fetch` but not time,
so the two CLI tests exercising a genuinely-undownloadable capture now sit
through the full 5-attempt/110s backoff per asset (two assets each) — 220s+
per test, pushing `bun run test:scripts` from 15s to 446s and `Test (support)`
(timeout-minutes: 10) to 10m0s with zero margin.
Preload now also collapses every `setTimeout` delay to the next tick, so the
retry logic itself still runs for real (same attempt count, same terminal
"could not download" outcome) — it just doesn't wait. 73/73 tests, 1.1s total.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(scripts): split isRetryableStatus to clear fallow's CRAP-score gate
CI's fallow audit flags `isRetryableStatus` at CRAP 30.0 (cyclomatic 5,
threshold 30.0) — pre-existing since the original commit, not something the
prior test-timing fix touched. Split into `isServerRetryable` (429/5xx) and
`isRetryableAttachment404` (404 scoped to the attachment host), each at
cyclomatic 2-3, composed by the still-exported `isRetryableStatus`. Same
retry decision, no behavior change. 73/73 tests, oxlint/oxfmt clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(studio-server): stop mislabeling two more read-route failure shapes
Follow-up to the stale-tree investigation this week — two more cases
where the read route answers with the wrong shape for what happened.
A path a listing (`walkDir`) can show but this route cannot serve, now
correctly reported:
- **Replaced by a directory.** `existsSync` passes, `readFileSync` throws
`EISDIR`, and Hono answers plain-text "Internal Server Error" — not
JSON, and not a reason. `statSync(...).isFile()` after the exists check
turns this into `404 { why: "not_a_file" }`, same shape as any other
missing-file 404 the client already handles.
- **A dangling symlink.** `isSafePath`'s `realpathSync` throws on a link
whose target does not exist, and (by design — see its comment) that
fails closed as unsafe, same as a real traversal attempt. Correct for
containment, wrong for the response: a broken link scans as an attack
when it is broken plumbing. Added `isDanglingSymlink`, checked only
after `resolveWithinProject` has already refused the path, so nothing
about what is permitted changes — only which `why` a refusal carries.
A symlink resolving to something real outside the project is still
`outside_project` 403, unchanged; pinned by test so this can't
regress into loosening containment.
Tests: dir-in-place → `not_a_file`; dangling symlink → `dangling_symlink`;
symlink-to-outside-project still → `outside_project` 403 (the guard the
new branch must not weaken). 102/102 in the touched suite, 247 green
across studio-server routes overall. oxlint, oxfmt, tsc clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(studio-server): stop dangling_symlink from leaking outside-project existence
Review on #4363: `isDanglingSymlink` ran on the *lexical* path even when
`resolveWithinProject` had already failed — so a path reached via `../` that
happened to be a dangling symlink out there, or a leaf reached through an
escaping directory symlink, came back `404 dangling_symlink` instead of `403
outside_project`. Worse: an in-project symlink pointing outside the project
got `404` if the outside target was missing and `403` if it existed — a
small oracle for "does this path outside the sandbox exist."
`isDanglingSymlinkInProject` now requires containment on both ends: the
link's own directory must resolve inside the project (`isSafePath(dir,
dirname(lexicalPath))`), and its target, read via `readlinkSync` and
resolved against the link's directory, must also resolve inside the project.
Only then does "target exists nowhere" get the new label — anything that
touches outside the project keeps the plain `outside_project` 403, with no
distinction based on what's actually out there.
4 new regression tests: a dangling symlink reached by `..` (403, not 404),
a dangling leaf behind an escaping directory symlink (403, not 404), and a
same-status check across a missing vs. existing outside target (both
`outside_project`, not one of each). files.pathSafety.test.ts 25/25,
files.test.ts 80/80, oxlint/oxfmt/tsc clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(studio-server): close a TOCTOU CodeQL flagged on the not_a_file check
CodeQL (js/file-system-race, high severity) on #4363: the new `not_a_file`
check stat'd `res.absPath` by path, then a separate `readFileSync` reopened
the same path — a directory-for-file swap between those two calls could
land a stale answer. Opens the file once instead and checks/reads through
that single descriptor (`fstatSync(fd)` then `readFileSync(fd)`), so the
check and the read are provably on the same inode; a swap after open can't
affect either. `openSync` on a directory still succeeds (POSIX allows
O_RDONLY there), so the `not_a_file` 404 still fires the same way; opening
a genuinely missing path still throws and hits the existing "optional" vs.
plain 404 branch unchanged.
105/105 in files.pathSafety.test.ts + files.test.ts, oxlint/oxfmt/tsc clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
The fix the cutover-dashboard investigation this week was building toward.
Proven 2026-09-23: `refreshFileTree` (useFileTree) is only ever called from
Studio's OWN file operations (create/delete/rename/upload/block install) —
never on an external change. An agent removing or replacing a composition
updates the preview and the SDK session (useExternalFileChangeCoordinator
already reloads both) but never the tree, so the file stays listed and
clickable forever. Opening it opens a session that can never resolve
(`reason: "absent"`), every edit through it silently takes the (also
failing) server path, and nothing tells the user why.
Two refresh triggers, belt and braces:
- `useExternalFileChangeCoordinator` gains an optional `refreshFileTree`
collaborator, called alongside `reloadPreview`/`reloadSdkSession` on
every accepted external change — closes staleness at the source, for
every consumer of the tree, not just the SDK read. Wired through
`useStudioExternalFileChanges` from `fileManager.refreshFileTree`
(already in scope there).
- `useSdkSession` gains `onAbsentRead`, called once per (projectId, path)
when a read reports `reason: "absent"` — the fallback for a change the
SSE never delivered (server restart, a missed watcher event). Guarded by
a ref-tracked set so a genuinely-gone file can't loop, cleared on
project change so a different project's identical path can still fire.
New: `sdkHandle.compositionMissing`, true for exactly that read result on
the currently active path, and `CompositionMissingBanner` — sibling of
`ProjectUnreachableBanner` (that one fires when the whole PROJECT is
unreachable; this one when the project is fine and this one file inside
it is not) — shown in App.tsx alongside it. The read failure alone is
silent; this is what tells the user their edits stopped landing.
Deliberately not touched: `activeCompPath` is never cleared when the path
leaves the tree — the graveyard page (`clearing-activecomppath-on-tree-
absence`) is binding, and its reasons still hold (`useAutoOpenRootComposition`
latches once; a null path never recovers).
Tests: coordinator refresh fires once per accepted change, not for a
self-write echo, and no-ops when the collaborator is absent (4 new).
useSdkSession: compositionMissing set/cleared, onAbsentRead once per path,
fires again for a different path, guard resets on project change, no-ops
without the collaborator (5 new). Banner render test (1 new). 25/25 in the
touched suites; 2830/2831 across studio hooks+components overall (the one
failure is a pre-existing happy-dom `window.confirm` gap in
ExternalFileConflictBanner.test.tsx, confirmed unrelated via git stash).
oxlint, oxfmt, tsc --noEmit clean.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(skills): push transitions reveal the incoming scene on every seek path
* fix(skills): keep the vendored push templates in step with css-push
* chore(skills): regenerate the skills manifest
* fix(cli): flag nowrap text that still sticks out of a container that does not clip
Cause: container_overflow only inspected containers that clip, so overflow:visible
removed the box from the check while nowrap text stayed wider than that box.
Rationale: a nowrap text child is still outside its container when the container
stops clipping. data-layout-allow-overflow still clears the finding.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(layout): ignore font-height spill and wrapping siblings
A container that does not clip was checking every child on both axes
once any child refused to wrap. Font-height spill and a wrapping sibling
then looked like overflow.
* fix(layout): skip an empty decoration next to a nowrap label
The nowrap-only check looked at whitespace and not whether the child
had text. An empty decoration inherited nowrap and was reported as overflow.
* fix(layout): flag nowrap text only when it is wider than the box
A narrow label that sits or slides outside a box that does not clip
was reported as overflow. Only a line wider than that box is overflow.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Cause: text_occluded walked from the covered text up to any ancestor with
data-layout-allow-occlusion, so a mark on a wrapper cleared text a sibling
node was still covering.
Rationale: the waiver still clears the finding when it is on the covered text
itself. A mark on an outer wrapper no longer does.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(studio): serve preview assets by URL instead of inlining them as base64
The Studio preview route serves project files under a base href, but both preview adapters
called the bundler with its self-contained defaults, so every image and font was inlined
once per reference. One exported option set now owns how a preview bundle is built.
* test(studio): cover poster, srcset and sub-composition preview asset URLs
Also types the Vite adapter bundler with BundleOptions, so the shared preview options are
checked against the real option set.
* fix(studio): tag a preview asset for revalidation only once it has settled
Preview assets are now served by URL, so the asset route serves every image and video. It tagged
them by mtime and size; two same-size writes inside one filesystem tick share an mtime, and copy
tools (cp -p, rsync) set mtime back, so a browser could keep stale bytes after an edit. The route now
tags a file by inode, change time and size, and only once its last change is three seconds old; a
younger file is served without a tag and refetched.
* fix(core): films whose animations end after the voiceover keep their length
The runtime treated any root timeline running more than 1 s past its media
as loop-inflated and, with no data-duration on the root, reported its length
as Infinity. Hosts that read only the runtime message, such as an
opaque-sandboxed preview, then showed a total of 00:00. Only a timeline at
endless-loop scale (7200 s, the length Studio already rejects) now counts
as loop-inflated.
* fix(core): the runtime reports a finite length for looping timelines
When the root timeline looked loop-inflated and the root had no
data-duration, the timeline message said Infinity even though the runtime
had already chosen the media window as the root's length. Hosts that read
only this message (Studio's player, opaque-sandboxed previews) then showed a
total of 00:00. The message now carries the length the runtime resolved.
Since the source-location mapping landed, building the whitespace-collapsed copy of a file grew a
string with += and called endsWith on it per character. V8 flattens that string on every call, so a
1.2 MB index.html cost ~31 s of CPU in every lint run, including the one preview runs before it
listens.
* fix(cli): add --vars refuses a value of the wrong type instead of writing it
* fix(cli): a refused --vars writes none of the item's dependencies either
* fix(cli): keep a file edited while add was still downloading the rest of the plan
* style(cli): shorten the --vars refusal comment
When the probed video stream duration is inflated (container duration
fallback includes a longer audio track), FFmpeg delivers far fewer
frames than expected — typically exactly 50% for stock clips whose
audio is 2x the video length. The coverage gate then aborts the
render because it sees 50% coverage against the inflated expectation.
After extraction, if FFmpeg delivered <=60% of the predicted frames,
correct `metadata.videoStreamDurationSeconds` to the actual video
extent. This reconciliation feeds through to the coverage check via
the same `metadata` reference, so `expectedFramesForVideo` sees the
true duration without any interface changes.
This is a corrective complement to the preventive nb_frames
cross-check in extractMediaMetadata — it catches cases where
nb_frames is unavailable (MKV, live streams, fragmented MP4).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* perf(cli): tell the preview about a file edit 30 ms after it lands, not 300
* perf(cli): keep bulk writes coalesced while lone saves go out fast
* style(cli): name the watcher delay
* perf(cli): throttle watcher flushes instead of switching to a long wait
When ffprobe omits the video stream's own `duration` field,
`extractMediaMetadata` falls back to the container duration
(`format.duration`), which includes the longest stream in the
container. Stock and looping clips commonly have an audio track
2x the video length, inflating `videoStreamDurationSeconds` and
causing the downstream coverage gate to expect twice the frames
FFmpeg can actually produce — the "50% cluster" failure pattern.
Cross-check with `nb_frames / fps` when the stream duration is
absent. If the frame-derived duration is >10% shorter than the
container, use it instead. This corrects the inflation at probe
time without requiring a second ffprobe pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(media-use): resolve brand logos from theSVG
Replace the svgl and simple-icons tiers of the `logo` cascade with a
single theSVG (thesvg.org) tier. theSVG ships 7,400+ official brand
SVGs with color, mono, light/dark and wordmark variants.
The tier fetches one manifest pinned to a glincker/thesvg commit on
jsDelivr, memoizes it per process, and matches entities locally by
normalized slug, title, or alias. That drops the hand-kept svgl alias
and simple-icons slug maps. GitHub org avatar and favicon stay as the
fallbacks for brands theSVG lacks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(media-use): sanitize fetched SVGs and gate theSVG by license
freezeUrl/freezeLocalFile wrote every fetched SVG to disk unsanitized;
the theSVG tier is now the sole SVG-returning logo provider, so an
inline script tag or on-event handler would land verbatim in a user
project. Route .svg destinations through the existing Figma sanitizer
(new @hyperframes/core/sanitize-svg subpath, synced via
package-subpaths).
thesvgMatch never checked theSVG's own license field: entries theSVG
itself marks unresolved (Proprietary, Unknown, Custom, TODO, or an
explicit no-redistribution-license string) could still resolve and
freeze into a project. Gate matches to the ten SPDX ids theSVG's
submission form accepts; a rejected entry falls through to a lower
ranked theSVG match, then to the github/favicon tiers.
Also caps the theSVG manifest fetch at 20MB, streamed, matching
freeze.mjs's existing cap discipline. It had a timeout but no bound.
* chore(skills): regenerate skills-manifest for media-use/motion-graphics
The original submission edited skills/media-use and
skills/motion-graphics docs without regenerating skills-manifest.json;
its own CI never ran (fork approval gate), so the drift went
unnoticed until this pass's first CI run caught it.
* fix(media-use): drop the sanitizer's workspace import, add parity test
freeze.mjs importing @hyperframes/core/sanitize-svg broke the
test-skills CI lane: packages/cli/src/media-use tests run with node
builtins only, zero install, by design (media-use is vendored
standalone into other projects, e.g. packages/desktop). Port the
sanitizer into media-use/lib/svg-sanitize.mjs instead, and drop the
now-unused core export.
Parity between the port and packages/core/src/figma/sanitizeSvg.ts is
enforced by a new test (scripts/check-svg-sanitize-parity.test.mjs,
run in the test:scripts lane, which does have an install) over a
hostile-SVG corpus, so the two cannot drift silently.
* fix(media-use): close three sanitizer bypasses, add fetch timeout
Independent review of the maintainer pass found the SVG sanitizer
(packages/core/src/figma/sanitizeSvg.ts, reused by media-use's own
port) was itself bypassable: an unquoted href/xlink:href survived,
inline style="..." attributes were never checked, and a href bound
through a renamed XML namespace prefix (xmlns:x=".../1999/xlink",
x:href=...) slipped past the literal "xlink:href" match.
Rewrote sanitizeSvg as a tag/attribute tokenizer instead of a set of
literal-spelling regexes: parse each tag's attributes in any quoting
form, resolve the local name after stripping any namespace prefix,
then drop by local name (any on* handler, any *href whose value is
not a #fragment or data:image/ embed, any style attribute containing
url(, @import, expression( or javascript:). Ported the fix into
media-use's svg-sanitize.mjs identically. Legitimate url(#id)
references on non-style attributes (clip-path, fill) are untouched.
Also adds a 10s AbortSignal.timeout to freezeUrl's fetch, matching
every other fetchMedia call in this package - it had a size cap but
no deadline.
New cases for all three bypasses in both sanitizeSvg.test.ts and the
parity corpus, each reproducibly failing against the prior head.
* fix(media-use): replace regex SVG sanitizer with a DOM allowlist
Two rounds of regex/tokenizer patches to sanitizeSvg found new bypasses
in the same shape each time (unquoted href, style= url(), aliased
xlink prefix; then presentation-attribute url(), unquoted '=' handling,
SMIL animate/set href rewrite, self-closing-slash tag corruption,
data:image/ navigation via <a>). Denylisting attribute names against a
hand-rolled tokenizer was the wrong approach regardless of how careful
the regex.
Rewrote sanitizeSvg (packages/core/src/figma/sanitizeSvg.ts) as a real
DOM walk (linkedom, already a dependency of core and cli) with an
allowlist: known-safe elements and attributes only, everything else
dropped by construction rather than pattern-matched as dangerous.
href/xlink:href (any namespace prefix) kept only for a same-document
fragment, or data:image/ off non-<a> elements. Any attribute value or
<style> text containing url() kept only when every url() argument is a
fragment; @import/expression(/javascript: dropped from style content.
Serializing from a real DOM means output is always well-formed, so the
self-closing-slash corruption class of bug is gone by construction.
media-use's own copy (packages/cli/src/media-use/lib/svg-sanitize.mjs)
can't import linkedom directly: its CI test lane runs bare `node --test`
with no install step, so even an external dependency fails to resolve,
not just a workspace import. It's now a generated file instead of a
hand-copy: scripts/generate-media-use-svg-sanitize.mjs bundles the core
source (esbuild, minified, zero remaining imports) and
scripts/check-media-use-svg-sanitize-generated.test.mjs fails the build
on drift. Verified the generated file runs with plain node in an empty
directory with no node_modules on the path.
Every payload from both independent-review reports is now a
table-driven test in sanitizeSvg.test.ts (46 cases) and the same corpus
in check-svg-sanitize-parity.test.mjs (39 cases) asserts core and the
generated media-use copy agree and each independently rejects every
non-local url(), javascript:, data: href on <a>, and on* handler.
Mutation-proved against the real pre-fix blob, not a synthetic case.
Sampled 56 real theSVG icons across all 7 collections to check the
allowlist doesn't over-strip legitimate content: found and fixed three
missing attributes (fx/fy, enable-background, a blanket data-*
allowance) and one real behavioral bug - <style> elements were being
dropped outright, silently unstyling any Illustrator-exported logo that
colors paths via CSS classes. Fixed by keeping <style> on the allowlist
and content-checking its text the same way as an inline style=
attribute, with a regression test using that exact shape.
Full suite after the fix: sanitizeSvg.test.ts 46/46, parity+drift tests
39/39, media-use suite 298/298, tsc/oxlint/oxfmt clean.
* fix(lint): ignore generated svg-sanitize.mjs bundle in oxlint
Minified esbuild output (packages/cli/src/media-use/lib/svg-sanitize.mjs,
generated by scripts/generate-media-use-svg-sanitize.mjs) trips oxlint's
correctness rules 504 times and fails `bun run lint`. Same shape as the
existing registry/blocks/**/*.iife.js exclusions: a generated, minified,
non-hand-edited bundle, not hand-written source.
* fix(format): ignore generated svg-sanitize.mjs bundle in oxfmt
Same generated esbuild bundle that needed an oxlint exclusion in the
previous commit also fails (minified, no formatting to
check against), cascading Preflight into Player perf, regression,
Windows render verification, and preview-regression on CI. Same
established pattern as the existing registry/blocks/**/*.iife.js and
skills/**/*.min.js entries in .prettierignore.
* refactor(media-use): reduce complexity flagged by fallow audit
isSafeAttribute (10 cyclomatic) and the parity test's aggregate-marker
check (11 cyclomatic, 26 cognitive, CRAP 132) both exceeded fallow's
per-function thresholds after the rebase pulled in a stricter baseline.
Extracted isXmlnsAttribute/isKnownAttribute as named predicates and
survivingUnsafeMarkers as a named per-output helper; no behavior change,
same guard order, same failure strings. Regenerated svg-sanitize.mjs.
* refactor(media-use): make the parity test's marker check table-driven
Fallow gives .mjs scripts no coverage credit, so its CRAP gate needs each
function at cyclomatic 4 or less. The extracted survivingUnsafeMarkers
still scored 9 (CRAP 90). The four regex markers are now a table, and the
data:image-on-<a> and non-local url() checks are small named helpers.
Same checks, same order, same failure strings.
* fix(media-use): close CSS-escape and image-set bypasses in sanitizeSvg
The url()/@import checks scanned raw text for the literal "url(" and
"@import". A browser decodes CSS escapes first, so fill="\75 rl(...)",
url\28...\29 and @\69mport all loaded an external resource, and
image-set("https://...") loads one with no url() at all. Reproduced in
headless Chrome: 8 of 8 payloads fired a request through the old code.
CSS can only load a resource through a function or @import, so CSS text
now passes only if it has no @import, every function is on a compute-only
allowlist (color, transform, filter and math functions), and every url()
is a same-document fragment, all checked on escape-decoded, lowercased
text. Same fix reaches media-use through the regenerated bundle.
Checked against all 13,384 files of the pinned theSVG corpus: 13,383
sanitize byte-identically; one drops an unreferenced id="XRP-(XRP)".
The parity oracle now fails on any surviving attacker host, so it no
longer shares the sanitizer's parsing blind spot.
* refactor(media-use): share one capped body reader for freeze and manifest
fetchJson copied freezeUrl's declared-length check and streaming size cap
line for line. Both now call readCappedBody in media-fetch.mjs, which both
already import; it has its own tests, since freeze's cap had none. The
freeze cap errors now read "freeze failed for <url>: ..." (no caller or
test matches the old wording). Also fixes freeze.mjs's comment, which
still called the sanitizer a lexical pass.
* style(media-use): format the parity test corpus
* fix(media-use): make the sanitizer's CSS scans linear on hostile input
The url() check used /url\(\s*(['"]?)([^)]*)\1\s*\)/ and the function
check /([-\w]*)\(/: on attacker padding these backtracked cubically and
quadratically (an unclosed url( plus 4,000 spaces took 20s; an 80,000
character word took 7.7s), and freeze runs the sanitizer synchronously on
bodies up to 256MB. Both are now single-pass indexOf scans (a million
characters in under 15ms), with the same verdict on ordinary input. A url()
value holding "(" is rejected, so a nested url( can't hide inside one.
A size test holds both payloads under 2s.
* test(media-use): size payloads that fail the old scans fast instead of hanging
* fix(media-use): stop the freeze timeout from aborting long downloads
The 10 s AbortSignal.timeout on freezeUrl also aborted the response body,
so any stock video, generated video or LUT that took longer than 10 s to
download failed. The timer now covers only the wait for response headers
and is cleared once they arrive; the 256 MB cap still bounds the body.
* refactor(media-use): trim logo-provider comments to the repo limit
The tier list and the thesvgMatch doc block ran past the four-line comment
cap that the merge check enforces. Same facts, shorter blocks.
* refactor(media-use): name the header wait and restore the variants note
Renames the freeze timeout controller to headerWait so it no longer reads
as HTTP headers, puts the theSVG variants fact dropped by the comment trim
next to the line that picks the default mark, and keeps thesvgMatch on the
file's JSDoc style.
---------
Co-authored-by: Imad Phelps <imad.phelps@heygen.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* fix(cli): keep hearing a file after an atomic save on Linux
* fix(cli): skip a subdirectory the watcher cannot open
* test(cli): pin that one unwatchable subdirectory leaves the project watched
* test(cli): assert the tree keeps its other watches when one subdirectory fails
* test(cli): cover the manifest directory and prefix-named siblings
* test(cli): reset fake directories per test and cover an unwatchable root
Studio's panels become cards on a dark shell, and each panel's tabs take the "Current" design: the open tab is a fill in the card's colour that slides to the tab you pick, only the open tab shows its icon and close glyph, and a strip that is too narrow scrolls with a soft edge instead of hiding tabs behind a menu. This replaces the pill, underline and close treatment that was on this PR before.
## Before


## After


Switching tabs (the fill slides in about 140 ms and the new icon fades in over 80 ms; the panel body switches at once):
https://github.com/user-attachments/assets/8d359ada-c9e1-4425-9c95-61d5d4f5271b
### Every label whole, and the tabs use the whole strip (2x)
An earlier head clipped the left strip's fourth tab next to empty strip space. That space was a 72 px slot (plus 4 px) held on every strip for the strip actions, even on strips whose panel is not focused and so draws no actions. Dockview sizes that slot by its content; this PR had pinned it. Now the slot takes room only where the actions are drawn, so an unfocused strip gives its tabs the whole width. Tab padding is 6 px with no gap between tabs, so the tabs also fit next to the actions. Labels, icons and close glyphs sit on the tab's centre in every strip.
Left strip at 1440 x 900, tabs width against the room before the action slot:
| | macOS | Linux (these captures) |
|---|---|---|
| Earlier head | 322 in 280, clipped | 312 in 280, clipped |
| Now, panel unfocused | 278 in 358 | 268 in 358 |
| Now, panel focused (actions shown) | 278 in 282 | 268 in 282 |



At 1280 x 800 the left column is narrower, and the pinned slot clipped Catalog next to the empty space. Room for the tabs there went from 241 px to 317 px:


Captured at the PR head (5fd6c7bd) from a fixture project (the `warm-grain` registry example) in headless Chrome on Linux, 2x, each state loaded fresh so Studio builds its default layout for that window. Before is `main`; the strip Befores use the earlier heads' `dock.css` (8079c635 at 1440, 8ed7ab69 at 1280) on the same build. The macOS column was measured live in Chrome on macOS. The paper theme only re-themes the timeline today, so the panels look the same in both themes.
## What changed
- **Cards on the shell.** Each dock group is a card with the only 1 px border (radius 10) and a 6 px gutter to its neighbours. The preview and timeline no longer draw a second rounded border inside it; the preview stage is an inset well. Dockview's tab dividers and separators are gone.
- **Tabs.** Tabs use the whole strip unless the panel shows its actions. Every label is whole at the default 1440 x 900 layout (6 px side padding, no gap between tabs), and labels and icons are centred in the tab. The open tab is a fill with rounded top corners, flush with the card body. Only the open tab shows its type icon and close glyph (the glyph appears on hover or keyboard focus, and its space is kept so the label never moves). Other tabs are labels.
- **One sliding fill per strip.** One element slides (transform and width, 140 ms ease-out) under the open tab. Reduced motion turns the slide and the icon fade off. Hover is instant.
- **Strip actions** (panel menu, maximise or restore, close group) sit on the active group only, at half opacity while the pointer is in the panel and full on hover or focus. They take room only on the strip that draws them, so the other strips give their tabs the whole width. The panel menu lists that side's panels as checkboxes, plus Reset layout.
- **Narrow strips** scroll sideways with a 12 px fade on the clipped edge. Dockview's overflow dropdown is off.
- Tokens: `--panel-shell-bg`, `--panel-card-bg`, `--panel-card-border`, `--panel-strip-bg`, `--panel-tab-bg-hover`, with contrast pairs registered.
## Timeline viewport gate
The first head of this rebuild (b20ff32a4) cost the timeline gate one frame on CI: interaction p95 33 ms on every run (first attempt 100 ms on three runs), against main's 20 ms. Main-thread traces were identical; the extra cost was compositing, from a fade mask on every tab strip. Masking only a strip that is actually clipped brought it back:
| Head | Interaction p95 per run (ms) | Frame p95 (ms) | Runs passing |
|---|---|---|---|
| main | 20, 20, 19, 20, 18 | 34 | 5/5 |
| b20ff32a4 (rerun) | 33, 33, 33, 33, 33 | 33 | 5/5 |
| 41302773 | 18, 20, 19, 20, 17 | 33 | 5/5 |
| eefaa839 | 18, 20, 18, 18, 20 | 17 | 5/5 |
| af4f2b9b (tab spacing), first attempt | 18, 79, 18, 116, 19 | 100 | 3/5 |
| af4f2b9b, rerun | 18, 16, 20, 18, 18 | 33 | 5/5 |
| 8ed7ab69 | 17, 16, 19, 24, 20 | 33 | 5/5 |
| 6a5edd89 | 33, 33, 33, 33, 33 | 17 | 5/5 |
From about 14:30Z `main` itself reads 33 ms on every run (three consecutive `main` CI runs), and CI tests this PR merged with the current `main`, so the 6a5edd89 row matches `main`'s level at that time.
The af4f2b9b first attempt had three runs at main's level and two single-run spikes; the same commit's rerun passed 5/5, and the change is tab spacing only.
## Tests
Each rule that could regress without anyone noticing has a test, and each test was proved by breaking the code once:
| Rule | Test | Break | Result |
|---|---|---|---|
| Icon and close glyph on the open tab only | `DockTabs.test.tsx` (dock.css applied, computed display) | delete the hide rule | 2 red |
| Actions on the active group only | `DockTabs.test.tsx` | drop the active-group check | 1 red |
| One fill per strip, following the open tab | `DockTabs.test.tsx` | fill stays at 0 / a second fill | 1 red each |
| One border per surface | `dockTabs.contract.test.ts` | add a strip border | 1 red |
| Only the slide and icon fade animate | `dockTabs.contract.test.ts` | add a hover transition | 1 red |
| Reduced motion turns them off | `dockTabs.contract.test.ts` | remove the reduced-motion rule | 1 red |
| Label and icon centred in the tab | `dockTabs.contract.test.ts` | drop the full-height rule | 1 red |
| Every tab label whole at 1440 x 900 on each strip while its panel is focused (the least room it gets), with 10 px for macOS's wider system font (CI renders a Linux fallback) | `scripts/studio-runtime-smoke.mjs` (CI "Studio: load smoke", real Chrome) | the "Ca" head's spacing; a spacing that fits on Linux but clips on macOS | red on both ("312px of tabs in 280px", "276px of tabs in 280px"); green on main and this head |
| Unfocused strips hold no room for actions they do not draw | `scripts/studio-runtime-smoke.mjs` | pin the action slot at 72 px again | red: "3 inactive dock strips hold width for actions they do not draw" |
| The shown tab stays whole when its strip narrows (its panel gains focus and the actions appear), comes back narrower than it was before it was hidden, or when a neighbouring tab closes and it moves | `DockTabs.test.tsx` | ignore each of those changes in turn | 1 red each; checked live at 1280 x 800: clicking into the left panel's body used to leave Catalog 37 px hidden, now it is whole |
| A strip the user scrolled by hand stays put when something else in the dock changes, when it widens, and through another panel's maximise and restore | `DockTabs.test.tsx` | reveal on every pass; on any width change; after a hidden spell | red each; checked live: a hand scroll of 20 px is still 20 px after a maximise and restore |
Run alone, each exits 0: `DockTabs.test.tsx` 5, `dockTabs.contract.test.ts` 5, `Dock.test.tsx` 14, `dockAccessibility.test.ts` 10, `PreviewPane.test.tsx` 1, `Menu.test.tsx` 10, `scripts/contrastRatchet.test.ts` passing.
## Independent review
An adversarial review (separate agent, mandate to refute) at 41302773, eefaa839, 8079c635, af4f2b9b and 8ed7ab69: **no blockers; one major, fixed.**
- MAJOR (af4f2b9b), fixed in 8ed7ab69: CI renders Studio's system font stack with a narrower Linux fallback, so the clip check could pass while a Mac clips. The check now keeps 10 px of room for the macOS font, and a spacing that fits on Linux but clips on macOS turns it red. The 8ed7ab69 review asked whether 10 px holds for every strip, not just the left one it was measured on. Measured macOS minus Linux tab width at 1440 x 900: left 10 px, right 6 px, Preview and Timeline 2 px each, so the left strip is the widest gap and 10 px covers all four.
- MINOR, left as is: the room check has no unit test of its own; it runs only in a real browser, and the smoke is its test (red on both broken spacings above).
- MINOR: a panel cannot be closed from the focused tab itself by keyboard. The keyboard path is focused tab, Tab to "Panel menu", Enter, uncheck the panel (checked live: closes that one panel; checking it again reopens it). The tab's close glyph is not focusable, the same as dockview's stock tab on main, because a tab cannot hold a focusable button.
- MINOR: the open tab is 16 px wider than on the design page (the close glyph's space is kept; see For Miguel).
- Resolved: a contrast pair cited stale line numbers.
## UX checklist
**Job:** someone editing a video moves between panels (Compositions, Assets, Code, Catalog; Design, Layers, Renders, Variables) many times an hour and needs to see which one is open and reach the next without hunting.
**One action:** click the tab of the panel you want.
| # | Question | Y/N | Evidence |
|---|---|---|---|
| 1 | First-time user knows what to do | Y | Tabs keep their labels; the open tab is joined to the body it controls (After captures) |
| 2 | Interactive things look interactive | N | Strip actions are hidden until the pointer is in the panel (Miguel's decision; see Traded off) |
| 3 | User's words, no internal ids | Y | Titles unchanged; controls named "Close Design", "Panel menu", "Maximize panel", "Close group" |
| 4 | Works like other tools | Y | Editor-style tabs with maximise and a panel menu |
| 5 | Guidance at the control | N | The three icon buttons have accessible names but no visible tooltip |
| 6 | Primary action reachable without reading | Y | The tabs are the strip |
| 7 | Exactly one primary action | Y | Tabs; actions are secondary and quiet |
| 8 | Counts match data | Y | Titles come from each panel through `Dock.Panel` (`Dock.tsx` setTitle) |
| 9 | Excluded items announce themselves | Y | Every label is whole at the default width (the load smoke checks it); a narrowed strip scrolls with an edge fade, and the panel menu lists every panel |
| 10 | Long names recoverable | Y | Tabs never truncate; the strip scrolls |
| 11 | Keyboard path | Y | Arrow keys move and activate tabs (existing); strip actions are buttons and show on focus |
| 12 | Nothing hover-only that must be acted on | N | Same as 2 |
| 13 | Refusals say what next | n/a | No refusals here |
| 14 | Refusal next to the thing | n/a | |
| 15 | Error prevented | n/a | |
| 16 | Can leave any state without losing work | Y | Closed panels reopen from Window or the panel menu; Reset layout restores the default |
| 17 | Destructive looks different | N | "Close group" uses the same x as a tab's close; it loses no work, and the Window menu reopens the panels |
| 18 | Empty state names what is empty | n/a | |
| 19 | Feedback within 400 ms | Y | The tab state and icon change in the same frame as the click; the fill arrives in 140 ms |
| 20 | Progress on waits | n/a | |
| 21 | Hand-built state survives reload | Y | Layout, open tab and maximised group persist per project (existing dock persistence) |
**Choice:** four tabs per side, one click each. **The end:** the panel body appears at once and the fill settles under the tab you picked.
**Not exercised:** a screen reader pass; touch input.
**Traded off:** hover-only strip actions (rows 2 and 12) and scroll-plus-fade instead of an overflow menu when a strip is narrowed are Miguel's decided design.
## For Miguel
- **Paper (light) theme:** Studio's paper block only re-themes the timeline today, so the panels are dark in both the Before and After paper shots. Panel paper values are Desktop's to set (design principles, section 7); this PR adds the token names Desktop can override.
- **Action slot:** the design review asked for the action slot to be reserved on every strip, so tabs never move when focus changes panel. That reservation is what clipped "Ca" next to empty space, so it now holds room only on the focused panel. The cost: when a strip that is already too narrow gains focus, its actions take 76 px. The strip then scrolls so the shown tab stays whole, and a neighbouring tab can go under the edge fade. At the default 1440 x 900 nothing clips either way.
- **Tab spacing:** side padding is 6 px (10 on the design page) and tabs have no gap. The design's 10 px would need 322 px on macOS; the left strip has 282 px beside its actions at 1440. A wider default left column would allow the design padding again. A narrower window scrolls the strip with the edge fade; per the decision there is no overflow chevron.
- **Close glyph space:** the open tab keeps its close glyph's space at rest, so it is 16 px wider than on the design page, where the label shifts on hover.
- **Not in this PR:** inspector section headers and the focused-card border step (later build steps).