* fix(studio): report a rejected read as a network reason, not an open failure
`stage: "open"` is documented as `openComposition` threw — an unparseable
composition, or OOM. Every event in that bucket on 0.8.56 and 0.8.57 carries a
fetch-rejection message instead: "Failed to fetch" (28 users / 11 users), "Load
failed", "NetworkError when attempting to fetch resource.".
The cause is one missing catch. `readProjectFileOptional` awaits `fetch` with no
try/catch, so a rejected request escapes the function and is caught by the
effect's outer `.catch` — the handler written for `openComposition`. The read
path has a five-member reason union precisely so this class of failure is
diagnosable, and the one case that never produces a response was the case
outside it.
That made the largest SDK-session failure class in Studio (2.9% of session users
on 0.8.56, 2.3% on 0.8.57) unaddressable: you cannot fix a network failure while
it is reported as a composition that failed to parse.
Now `readProjectFileOptional` catches its own rejection and returns
`reason: "network"`, so it lands in `stage: read` with everything else, and
`stage: open` means only what its comment says.
Telemetry only. Nothing branches on the stage and no user-facing behaviour
changes. Fifth split of this shape, after `ineligible_operation` (v0.8.49),
`stage: read` (v0.8.51) and `block_detail` (#4214).
Co-Authored-By: Claude Code <noreply@anthropic.com>
* test(docs): point the catalog tab-row test at the page gutter #4238 moved the inset to
`catalog-detail.test.ts` asserted the exact strings that #4238 deleted:
`.hf-ve-tabs-row { margin: 20px 0 0 16px; }` and `.hf-ve-body { padding: 2rem 0 0 16px; }`.
That PR removed both insets deliberately — its own comment says "the page gutter is
the page's job, and a second 16px here put everything below the stage on a
different left edge from the title, install block and stage above it" — and
restored the gutter on `#content-area` in `docs/custom.css` instead. The test was
left asserting the old mechanism, so main has been red on it since, unnoticed
because the `Test` workflow does not run for a docs-only change.
The invariant did not change: this content is still inset from the page edge. Only
the mechanism did, so the test now asserts both halves of it — the two rules carry
no inset of their own, and the gutter standing in for them is still present, at both
its values. Asserting only the first would keep passing if the gutter were deleted
outright, which is the regression the test exists to catch; both mutations were
checked and both fail.
Co-Authored-By: Claude Code <noreply@anthropic.com>
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Bump @puppeteer/browsers to ^3.2.2 and puppeteer-core to ^25.10.0 so Chrome spawns detached only off Windows (upstream puppeteer #15415). Lock regenerated on top of main. Fixes#4028.
A tab whose project the running Studio cannot resolve failed every composition
read with a 404 and said nothing. `useSdkSession` recorded the failure to
telemetry and returned, so the editor looked normal while every edit silently
took the server path instead of the SDK — nothing landed, and nothing said
why. The `out-fb1` timeline sat in that state for an hour and a half, through
a reload that passed mount validation.
The banner names both sides: "This Studio is serving X. This tab was opened
for Y." That is the whole truth in the host that produces these 404s. The
CLI-embedded Studio serves exactly one project, and `hyperframes preview`
reuses port 3002 when the folder matches, so starting it on a different
folder takes the port out from under an open tab — and the project the old
tab was pointed at is untouched on disk. "No longer available" would be
false: nothing was lost, and the same timeline shows the project return.
With several projects served (the Vite host) a rename cannot be told from a
deletion, so that wording claims no cause.
`reportReadFailure` answers which project a failed read identifies. Only a 404
does; a 5xx, a dropped request, an unexpected body or an empty file say nothing
about which project the server serves, so none of them claim it.
Also fixes a smaller lie on the same path: `useFileTree` parsed the 404's JSON
body without checking `r.ok`, so the failure took the success branch and
recorded an empty tree — and an empty provenance snapshot for a project that
was never read — which then travelled out in bug reports.
The 404s continue; this ends the silent part, not the failure.
Co-authored-by: Claude Code <noreply@anthropic.com>
The mount path has validated its hash since v0.8.52. The hashchange path did
not — it adopted whatever id the URL offered, so changing projects walked
straight around the mount fix and re-armed the same failure: every later
/api/projects/<id>/... request 404s for the life of the tab, including the
composition read that opens the SDK session.
Same tri-state as mount: ok adopts, unknown adopts (a transient check failure
must not rewrite a hash that is probably fine), missing falls back.
Three details that are not obvious from the diff:
- **The race guard reads the hash, not a counter.** This effect re-registers on
every projectId change, so a `generation` in that closure resets and an
in-flight verdict from the previous registration would read as current —
adopting a project the user already navigated away from. The hash cannot go
stale, so it is the ground truth to compare against after the await.
- **The fallback re-fetches the project list.** tryConnect has `data.projects`
in scope; this path does not, and caching the mount value would be stale in
exactly the long-lived-tab case this exists for.
- **Both rewrites use replaceState.** `location.hash =` pushes, leaving the dead
id one Back away — and Back would then re-validate, fall back, and push again,
forever. The mount fallback had this bug too; fixed here as well.
Also emits the hashchange verdict so the `unknown` branch — which deliberately
keeps the hash and is invisible from outside — is countable.
Verified: 7 mutants, 7 caught, including adopting without validating, collapsing
unknown into missing, trusting the verdict over the live hash, memoizing the
fallback list, and pushing on either rewrite. Full studio suite 5,323 passed.
Co-authored-by: Claude Code <noreply@anthropic.com>
Two telemetry gaps, both pure instrumentation with no behaviour change.
`tab_id` — distinct_id identifies a browser, not a page. Several
investigations stalled on "two tabs, or one tab switching?", because a burst
of events alternating between two project hashes is indistinguishable from a
user moving between projects, and `session_start` fires once per page load so
counting it cannot tell them apart either. One id per page load, from one
shared module, attached by BOTH transports (`studio:*` and `studio_*`) — those
two families already drifted once on how `agent_runtime` is encoded.
Deliberately not persisted. Storage would survive a reload and quietly turn
this into a tab-session id, changing what every query means, and blocked or
cleared storage would make the property intermittently absent — which reads as
"old client" rather than "storage unavailable".
`project_hash_validated` — a tab that keeps its hash because the check itself
failed ("unknown") is currently indistinguishable in production from one that
never ran the check. Different causes, different fixes, one silent bucket.
Emits `stage` (mount | hashchange), `outcome` (ok | missing | unknown), and
the HTTP status when the server answered.
Verified: 8 mutants, 8 caught — including dropping tab_id from either
transport, regenerating it per read, persisting it to storage, emptying it in
the no-DOM fallback, and collapsing "unknown" into "missing". Full studio
suite 5,314 passed.
Co-authored-by: Claude Code <noreply@anthropic.com>
* fix(studio): reloading the preview swaps in a ready copy instead of blanking
* fix(player): report painted once assets are ready and the loader has faded out
assetsready fires when the loader fade starts, so a consumer promoting on it can
expose a raised or fading panel. painted fires once per document load after the
fade completes, with a getter for late listeners; it resets on each load.
* fix(studio): promote the shadow preview only when painted, under one wait budget
Studio waited for ready plus two frames, which can land while the player's loader
panel is still up or fading. It now also waits for the player's painted event.
The shadow no longer arms the 5s adapter give-up, so a slow but live load fails
only at the single 15s budget. Set-destination availability follows the live
iframe across a swap.
* feat(studio): edit a clip's speed ramp with a rate lane and presets
* fix(studio): keep the live preview attribute in sync with what gets persisted
* fix(cli): snapshot picks the source frame through a rate lane and up to 10x
hyperframes snapshot clamped the constant playback rate at 5 and ignored a
rate lane in data-automation, so a sped-up or ramped clip showed the wrong
frame. It now reads the rate through the runtime's own reader and maps time
with the shared source-time function.
* docs(skills): teach speed ramps as a rate lane in one reference page
Adds the speed-ramps reference, corrects the 0.1 to 5 rate range to 0.1 to 10
and the 'speed ramps are not supported' lines in the skills, and regenerates
the skills manifest.
* docs(skills): say what fixes a ramped clip's length and how the render approximates pitch
* fix(studio): hide the speed preset until the clip has a known duration
A preset on a clip with no data-duration was stretched over a made-up one
second. The panel now offers presets only when the duration is known.
* docs(skills): fix the last stale ramp line in the editing recipes and the slice-cap wording
* feat(studio): a host can make the preview read-only
Gates the shared geometry commit funnel (offset/size/rotation), so the
inspector's manual X/Y/W/H/rotation fields refuse the same as canvas
drag/resize/rotate. The flat inspector's fields render disabled with a
host-supplied reason. Fixes a hooks-order crash on deselect found in review.
* refactor(studio): derive the read-only preview flag from context, not a synced store
EditorShell synced its readOnlyPreview prop into a zustand store via a raw
useEffect behind an eslint-disable of no-restricted-syntax. Replace it with
a PreviewReadOnlyProvider context computed fresh from the prop every render:
Tier-1 consumers (genuine EditorShell descendants) read it via useContext;
Tier-2 consumers (useAppHotkeys, useGestureCommit, the
useDomEditSession/useDomEditCommits/useDomGeometryCommits chain), which run
in App.tsx above EditorShell, take it as an explicit parameter threaded from
StudioApp's own prop instead. Adds a host-mount test proving EditorShell
gates its descendants from its own prop with no StudioApp/App.tsx involved.
* fix(studio): keep preview hotkeys context explicit
* fix(studio): close read-only interaction escapes
* fix(studio): preserve timeline ownership in read-only preview
* fix(studio): rollback read-only preview work
* fix(studio): keep hotkey ownership on the active surface
* fix(studio): format preview overlay and strengthen split test
* fix(studio): clear preview gate findings
* refactor(studio): share preview test fixtures
* feat(studio): the timeline's colours read CSS custom properties instead of literals
The timeline's surfaces, hairlines, ruler text and clip colours were hard-coded
hex/rgba, so a host with a light chrome saw a black slab. The 24 values move
into new --timeline-* tokens in theme.css (the same mechanism Button and Menu
already use), holding today's exact values, so a host themes the timeline by
overriding a token instead of the object staying opaque.
* fix(studio): route timeline clip colours through theme tokens
* chore(studio): bank the colour-literal baseline
* fix(studio): preserve timeline clip token defaults
* fix(studio): increase default timeline clip contrast
* fix(studio): keep timeline hover brighter
* test(studio): stabilize property panel flag coverage
`edit_blocked` is the largest interaction failure in Studio: 575 users in 14
days, 17% of everyone who touches an editing control, and 41% of them are
blocked on their first or second attempt. Only 30% ever commit anything
afterwards.
Almost all of it is one message. "This animation is computed at runtime. Edit
the animation in the Code tab." covers ~460 of those users and is thrown from
seven different places — runtime-dynamic provenance, unresolved keyframes, an
unresolved selector, a geometry commit over unresolved source, and three
live-tween-without-a-source-match cases across position and rotation. The
telemetry carried only that string, so the classes were indistinguishable.
The copy is also only literally true for the first. The rest are parser or
source-matching limits where the animation may be plain authored source, and
we send the user to the Code tab to rewrite something that could be fine.
Adds a `detail` beside the coarse reason, set at each of the seven origins and
emitted as `block_detail`. Telemetry only; nothing branches on it, and the
user-facing copy is unchanged. Which cause dominates decides whether the fix
is better copy, a parser gap, or an Unroll-style recovery path — and guessing
that without the split is what this is meant to avoid.
`directEditOutcomeForProperties` rebuilt blocked outcomes from `reason` alone,
so it dropped the detail for the three causes that flow through it; it now
carries it. Mutation testing caught that, not review.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(studio): undo waits for in-flight hand edits before consuming history
* docs(studio): trim comments to the 3-line house rule
* feat(studio): export timeline editing, undo/redo, conflict banner and TimelinePane for a host
* docs(studio): trim comments to the 3-line house rule
* refactor(studio): simplify exports pass
* docs(studio): trim comments to the 3-line house rule
* fix(studio): resolve canEdit targets against expanded timeline rows
* docs(studio): trim comment to the 3-line house rule
* fix(studio): guard razor-split-all and the audio-group attribute write
* fix(studio): gate the audio-group attribute for sub-composition-only members
* fix(studio): keep blocked timeline edits truthful
Refresh cached permission checks from the current host policy, gate auto-grouping before live patches, and restore optimistic attribute previews when a guarded commit is refused.
* fix(studio): publish desktop compatibility exports
Expose range selection and the canonical loader from the built Studio package while retaining timeline editing seams.
* fix(studio): gate timeline drop writes
Route asset, file, and composition drops through the shared edit permission gate before any side effect.
* fix(studio): preserve release-owned style exports
* fix(studio): clear file and Fallow gates
* fix(studio): keep the carve gate and range slice under the size budgets
* style(studio): format playerStore
`studio:unhandled_promise_rejection` was dominated by two AbortError rows
("signal is aborted without reason", "The user aborted a request."), about
10,000 events across 265 users per week, every one with a stack rooted in
the thumbnail scheduler's `release()`.
The scheduler is correct: every promise on its abort signal is awaited, and
a Chrome 153 reproduction with the real bundle confirms that releasing a
lease mid-fetch produces no unhandled rejection on its own. The leak comes
from browser extensions that wrap `window.fetch` in the main world
(Requestly and Poper Blocker are two seen in field stacks) and derive a
promise from each request without a rejection handler. Chrome rejects that
derived promise with our abort reason, so the stack points at `release()`.
Injecting such a wrapper reproduces the field rows exactly. Among users
with both this leak and a "Failed to fetch" error, 69 of 72 have an
extension frame inside `window.fetch`; among the rest, 13 of 308.
An AbortError rejection is a cancellation we asked for, so record it as a
sampled `abort_rejection_filtered` counter instead of an error. The filter
sits before the ERROR_CAP check: a scroll burst previously spent the
50-event cap in under a second and dropped every real rejection for the
rest of the session (116 users hit the cap in the last 7 days).
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(release): a stale local tag can no longer silently widen the changelog
`release:prepare` creates `vX.Y.Z` locally at the branch commit, then the
publish workflow creates the same tag at the merge SHA. After a squash merge
those are different commits and the local one is not an ancestor of main, so
`git describe` cannot reach it and falls back to the previous release without
saying so. `git fetch --tags` does not help: it refuses to move a tag that
already exists locally, and only `--force` does.
On the v0.8.52 cut this produced a `v0.8.50...v0.8.52` draft that re-listed
about a hundred already-shipped commits. The changelog is user-facing, and
nothing in the output said the baseline was wrong.
Adds a guard in the drafter: after resolving the baseline, fail if a stable
tag exists locally that sits strictly between it and the release. `describe`
only ever returns a reachable tag, so a tag in that range is proof it skipped
one. The message names the tags and prints `git fetch origin --tags --force`.
`--from` still overrides, for a deliberate baseline.
Also prints the captures-gate hatch with the release PR instructions. Every
release bumps packages/studio and packages/player, which
scripts/check-pr-captures.mjs watches, so it demands before/after media for a
version bump; its own `## No visible change` section is the intended answer
and applies to a 4-line package.json diff.
Verified end to end by moving a local tag onto the pre-squash commit of
v0.8.51: the drafter fails with the fix, and drafts normally once restored.
Six mutants of the range check are all caught by the tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(release): guard the baseline inside its resolver
`resolvePreviousTag` only runs when `--from` was not supplied, so testing
`!options.from` at the call site repeated a condition the `??` already
expressed. Folding the guard into the resolver drops that branch, which had
pushed `createDraft` to a CRAP score of exactly 30.0 and failed the audit.
The assert sits outside the try/catch on purpose: `fail` exits rather than
throwing today, and this keeps a catch from ever swallowing the guard if that
changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A host mounting Studio's header reuses HyperframesLogo and buttonSizes for
matching chrome, and imports the compiled stylesheet as a fixed-name package
export instead of a hash that changes on every rebuild.
* feat(studio): every Studio panel is a dock panel and the shell moves onto it
EditorShell, App.tsx, the left and right panel columns, and the
inspector hooks now render through Dock.Panel instead of the old
fixed-width sidebars and split-pane inspector. usePanelLayout becomes
a thin facade over the dock store; fitPanels, the resize divider and
the split-inspector hook are dead and removed.
* refactor(studio): drop dead inspector flags and stale fallow entries after dock migration
Collapse inspectorButtonActive into inspectorPanelActive, remove unread
layers/design active fields, un-export unused prop types, flatten a nested
ternary, and point fallow ignores at the renamed/deleted files.
* fix(studio): keep right-panel behaviours the dock migration dropped
Block params dismiss on selection or when Design is hidden, the slideshow
panel can be closed, the selection outline follows the inspector intent,
caption editing focuses Design, and a URL slideshow tab opens once the
panel exists.
* refactor(studio): name the inspector-open state once in the header
* fix(studio): right-panel behaviours survive selection, restore and reopen after the dock migration
Block params stay open over an element that was already selected, caption mode wins over block params, the slideshow panel waits for the dock, the Renders tab title survives a reopen, each side column remembers the tab it last showed, tab switches are reported only when the tab changes, and the e2e thumbnail selector follows the dock.
* docs(studio): describe the dock layout and point comments at the renamed right panels
* fix(studio): block params survive a preview reload of the selected element, and panel titles are covered
The dismissal compares the selected element by its target key instead of object identity, because a preview reload rebuilds the selection for the same element. A closed and reopened panel keeps its custom title, with a test, and the e2e selector is formatted.
* fix(studio): block params leave with any other tab pick, and a restored slideshow placement survives loading
Block params are now dismissed by the tab setter the way their old tab was, including a re-pick of the same element, instead of by comparing selections. The slideshow panel is only closed after a file that was a slideshow stops being one, so a restored layout is not discarded while the file loads.
* refactor(studio): drop the duplicate inspector-open computation
The rebase onto main left two identical inspectorButtonActive/inspectorOpen
constants after a conflict resolution; only the first was still referenced.
* chore(studio): allow the dock migration deletions in the guard
StudioLeftSidebar.tsx, LeftSidebar.tsx and its storage test,
TimelineResizeDivider.tsx, useInspectorSplitResize.ts and fitPanels
are replaced by the dock; the CI deletion guard needs each named
with its reason.
* style(studio): oxfmt the deletion-guard entries
* fix(studio): rename Timeline's aria-label so it does not collide with its dock panel wrapper
Every dock panel wrapper now carries aria-label=<title>, matching the "Timeline"
title for the timeline panel. Timeline.tsx also set aria-label="Timeline" on its
own content div, so `document.querySelector('[aria-label=\"Timeline\"]')` found
the dock wrapper first and the timeline viewport gate CI check timed out waiting
for an attribute the real content div actually carried. Renamed to "Timeline
track view" and updated every consumer: the production diagnostics selector,
its tests, and the e2e viewport-gate script.
* fix(studio): restore inspectorButtonActive as a real prop after a rebase regression
Rebasing PR2 onto main replayed an older local commit whose diff did not
textually overlap main's newer inspectorButtonActive prop, so git applied
it with no conflict marker and silently reverted the header to computing
the value itself instead of receiving it. StudioHeader.dom.test.tsx already
asserts the prop is respected; restoring it makes that test meaningful
again. App.tsx now computes it once (panelLayout.rightCollapsed is already
the dock's effective collapse state, no separate railed-by-width case) and
passes it down.
* chore(studio): wrap a long doc comment in useRightPanelIntents
* feat(studio): the Renders panel on the shared primitives
The four native selects become the shared Select, so no OS popup is left in
the panel and the four controls read as the same control as everything
around them. Disabled options survive the move: SelectOption gains a
disabled flag, which is what keeps a resolution the composition cannot
reach out of the keyboard's path while its label still explains why.
Export drops its own type size. cn resolved that override by dropping the
size recipe's step, which is what left the panel Export a type step below
the header's; the computed-style table now reads 28px / 6px / 12px for
both. Its two per-row siblings become IconButton with a Tooltip carrying
what the title attribute used to say.
The panel's colours move to the semantic tokens.
* docs(studio): shorten two Renders-panel comments to fit the comment gate
* fix(studio): keep the renders-export test id the merge silently dropped
* fix(studio): type the Renders test mock against StartRenderHandler, not vitest 4's loose default
* fix(studio): type each mock declaration, not just the mountRenderQueue param
Same shape as fix/regression-docker-build-timeout: bun install can print a tarball error and hang instead of exiting (run 35468046055).
This Dockerfile ships a real prod deploy path, so a reviewer should look twice.
* fix(ci): bound the regression image's bun install so a hang cannot eat an hour
bun install printed a tarball error then hung instead of exiting (run 35468046055).
Timeout+retry the install and cap the whole build step at 20 min.
* fix(ci): correct the build step's stated normal duration
Review measured 18 real samples at 2m28s-3m40s (median ~3m), not 5-8m.
Also: neither hang hit the 60-min timeout; both were manually cancelled.
* feat(core): add an inlineAssets option to skip base64 asset inlining
A same-origin caller can now keep every font/image src as its authored path, not a data: URL.
Default true, unchanged for every existing caller.
* fix(core): also stop inlineAssets: false from inlining LUTs
The same bug (a path that reads back as data: URL) applied to LUT references too.
Composes with the pre-existing inlineColorGradingLuts flag; default is unchanged.
The project id in the location hash is user-supplied and outlives the project
it names: rename the folder, or open a bookmark from a project that is gone,
and it points at nothing. useServerConnection trusted it unconditionally, so
every later /api/projects/<id>/... request 404'd for the life of the tab —
including the composition read that opens the SDK session. Studio then fell
back to the server path for every edit, and the resolver shadow never ran
either, so nothing recorded why.
Telemetry, 24h after the read-reason split shipped in v0.8.51: 120
`stage: read` / `http_error` 404 reads across 5 users, about 24 each, none
recovering. On this route a 404 has exactly one origin — resolveProject
returned null — since the other failure modes answer 403 and the GET does not
set mustExist.
Validates through /api/projects/:id, which calls the same
adapter.resolveProject the file routes use. Matching against the
/api/projects list instead would be wrong twice over: that list omits session
ids, which resolve fine, and skips project dirs without an index.html.
The check is tri-state on purpose. Only a definite 404 counts as missing; a
rejected request or a 5xx keeps the hash, so one network blip cannot rewrite
a valid deep link out from under the user.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(studio): move undo and redo into the timeline toolbar
Undo and Redo now live in the flat timeline toolbar next to Select,
Razor and the rest of the CapCut-style tools, instead of the header.
Same actions, shortcuts and disabled states, with a shortcut-aware
tooltip; disabled reads clearly dimmer than enabled at a glance.
The header's right side is now segmented: Capture and Inspector share
one rounded, bordered group with a hairline separator and one control
height and radius, Inspector fills when on, and Export stays the lone
separate primary action. Below about 1000px Capture drops to icon-only.
* test(studio): tighten the Inspector fill check and cover Undo/Redo hotkeys
An exact class token now proves the fill, since bg-hover also names a hover
variant that was always present. Undo and Redo at their new toolbar spot
get the same hotkey-classification proof the header used to carry for them.
* fix(studio): undo and redo lead the timeline toolbar, left of select
Miguel's call from the mockups: undo/redo read as the first tools reached
for, ahead of selection and razor, matching where his reference places them.
* feat(catalog): add the seven 3D-motion pieces as installable registry blocks
Each piece ships as a registry block with a CDN poster and preview, a live
catalog detail page and its source. Scripts travel inside the catalog payload
(shared vendor libraries fetched once, item scripts inline, the one HDR as a
data URI) because the docs host only serves fonts, images and JSON.
NO_ESSAY_COMMENTS_OK: the long comment blocks are in vendored libraries and
the ported frost source under registry/blocks, not hand-written here.
HF_MAX_NONLFS_KB=6000: four hosted texture PNGs under docs/public/catalog/items
are runtime assets the docs host must serve as files.
fallow skipped locally: it audits against origin/main and this branch is
stacked on an older main.
* chore(catalog): regenerate gallery data and nav after rebasing onto main
* fix(catalog): read the install manifest through the bounded project reader
* fix(catalog): a script the inliner missed fails the payload instead of shipping a dead URL
* refactor(catalog): the leftover-script check reuses the reference finder
* fix(catalog): frost and glass textures load from the CDN and the iteration presets are gone
* chore(catalog): regenerate the frost and glass payloads and nav
* fix(catalog): frost and glass textures are hosted through the registry url field, not hardcoded
* chore(catalog): regenerate the frost and glass payloads against the hosted texture URLs
* fix(catalog): the hosted-reference rewrite refuses a bare name it cannot safely replace
* ci(catalog): verify changed catalog payloads in headless Chrome and check the docs catalog
* feat(catalog): every item page follows the shared layout with a Tune panel and tabs
* fix(catalog): let the texture-mask item host its masks so its tile plays live
* chore(catalog): regenerate item pages, gallery data and the texture-mask payload
* chore(catalog): regenerate item pages after merging main
* fix(catalog): declared-only variables get the Tune panel, Code and Docs tabs capped
* feat(catalog): give the blend-difference caption a demo scene so its tile plays live
* chore(catalog): live payload for the blend-difference caption
* perf(catalog): heavy 3d pieces compile and draw before ready, not on the first visible frame
* feat(catalog): the avatar promo card hosts its videos, audio and fonts on the CDN
* feat(catalog): the avatar promo card tile plays live from its CDN-hosted media
* chore(catalog): the avatar promo card plays live, no allowlist left
* chore(catalog): shorten two comment blocks
* chore(catalog): trim first-party comment blocks to the comment-check limits
* chore(catalog): shorten two frost source comments
* fix(catalog): the Docs tab renders as prose again, and the detail page honours reduced motion
Frame-mode pages carry no prose wrapper, and the detail component sat inside
not-prose, so the Docs slot's headings, paragraphs, links and lists had no
styling at all. The slots now carry their own prose classes and the chrome
around them keeps not-prose.
The recorded clip also gets the reduced-motion guard the docs-snippet check
requires, and the generator reads stability from the source metadata type so
the scripts typecheck passes.
* fix(catalog): docs-tab paragraphs render as blocks
Mintlify emits paragraphs as inline spans, so two paragraphs in the Docs slot ran together.
* fix(ci): drop the duplicate docs_catalog job output
git's line-based auto-merge silently duplicated the outputs key when
main and this branch each added it on non-adjacent lines during the
rebase; oxfmt's yaml parser rejects the repeated map key.
* fix(catalog): close four CodeQL alerts in the frost source
* chore(catalog): allowlist the avatar-promo-card CDN asset deletions, format sync-docs-catalog
check-no-main-deletions.mjs was rejecting 11 local mp4/font files this branch
already deleted in favor of CDN hosting; scripts/sync-docs-catalog.mjs had one
line over the 100-col print width from an earlier edit in this branch.
* fix(catalog): detail tabs use docs prose spacing, Install first; previews skip no-WebGPU runners
* fix(catalog): the docs tab scrolls with the page instead of a capped inner box
* refactor(catalog): split the payload asset and verify scripts into single-purpose helpers
* refactor(catalog): split the preview generator into single-purpose helpers
* ci(catalog): fail when the committed catalog differs from generator output
The docs host serves the committed catalog and nothing regenerates it, so a stale
copy shipped silently. The catalog payloads job now generates into a temp dir and
compares it with the committed tree.
* chore(catalog): regenerate docs/public/catalog with the refactored scripts
One full regeneration from an empty output tree with the current scripts. The tree grows
from 58.4 MB to 65.1 MB. Two hosted assets are about 2 MB each (an avatar png and a wav);
the same bytes are already committed under assets/, so HF_MAX_NONLFS_KB is raised for
this commit only.
* fix(catalog): run the catalog script tests under the runner they are written for
The rebase put three node:test files in the vitest half of test:scripts, where they report
"No test suite found". They now sit in the node --test list.
* fix(catalog): a WebGPU item without an adapter shows its recorded clip and says why
* ci(catalog): the drift check runs from a committed fetch mirror with no network
Generation now serves every font and CDN script fetch from scripts/catalog-fetch-mirror and fails
when a fetch is not in it, even if the caller swallowed the error. CATALOG_FETCH_MIRROR=record
refreshes the mirror from the live network. The docs_catalog filter also watches packages/core,
packages/producer and the mirror, and the 13 re-hashed font files are listed in ALLOWED_DELETIONS.
* chore(catalog): clean up the temp font cache and sort the mirror index without a locale
* fix(catalog): hide Tune when the clip stands in, time out the adapter request, share one check
* fix(catalog): the adapter probe survives a sync throw and a hang; test the clip guard
* fix(catalog): keep the adapter probe inside the component, Mintlify evaluates only exports
* fix(catalog): run the detail and page generator tests in CI; no Tune flash while probing WebGPU
* docs(catalog): credit vendored libraries and make each SKILL.md list what the block installs
CREDITS.md names every vendored dependency with its upstream and licence, and flags the two leaf
textures whose origin is still to be confirmed. Each SKILL.md Files table now comes from the block
manifest and its install and render paths point at compositions/<name>/. A test checks that every
path a SKILL.md names is a manifest file that exists.
* fix(catalog): a declared WebGPU item passes verify by its manifest tag, not by console wording
verify-catalog-payloads forgave a missing adapter only by matching the markup and the message, which
library-reported errors and widgets never satisfy. The webgpu tag in the item manifest is now the
owner: a tagged item ignores its page and console errors, an untagged one gets no forgiveness, and a
marker payload with no html is listed instead of crashing the run.
* docs(catalog): correct the credits per block and guard the mount and render paths
CREDITS.md states the three.js version and vendored-or-CDN status per block, adds d3-delaunay,
Clipper on the CDN and the Google Fonts loads, and lists every texture with no known origin.
The SKILL.md test now also checks the mount and render paths against the manifest composition.
* test(catalog): read each block manifest once in the SKILL.md file test
The two checks shared a copied manifest read, which fallow flagged as a clone with a high score.
* fix(catalog): commit the hosted item files a gitignore rule kept out, and check git not the disk
The compositions/ and *-ad/ ignore rules matched folder names under docs/public/catalog/items,
so 33 hosted fonts, textures and marks were generated but never committed and would 404 on
the docs host. The rules now have a negation for that tree and the files are added.
The drift check read the checkout, where git-ignored files still exist, so it passed locally
and failed only in CI. It now unpacks HEAD with git archive and compares that.
* fix(catalog): a tile keeps its poster until the composition has painted
* fix(catalog): a tagged WebGPU item forgives only the adapter-absent error, and test the wiring
The manifest webgpu tag now decides whether an item may lack an adapter, and the error wording
decides which lines are that absence. Any other page or console error on the item, and every
failed request, still fails. The per-item decision is one exported function the tests drive,
marker payloads are parsed by a tested helper, and the SKILL.md variable counts are checked
against their tables (cuboid-carousel said forty-nine for forty).
* fix(catalog): the pinned group's posters load eagerly at high priority
* fix(catalog): type the tile reveal test so the scripts typecheck passes
* fix(catalog): stop the adapter signature matching an unrelated failed adapter request
failed to request .*adapter also matched errors about other adapters. It now matches only a
request for the WebGPU adapter, so a tagged item cannot hide an unrelated failure behind it.
* test(catalog): a tagged item still fails on an error about some other adapter
* feat(catalog): the install command sits above the preview, as on the live catalog
* fix(catalog): find the install slot through Mintlify's child wrapper
* fix(docs): catalog copy buttons keep their size when the label swaps to Copied
* refactor(docs): name the shown copy label once in the catalog copy button
* fix(docs): primary catalog button keeps a readable label on hover and after copy
* fix(docs): inset the catalog tab strip and tab content to match the boxed panels
* feat(studio): host apps can place overlays on the preview and restyle its stage
* feat(studio): stage background also recolours the preview panel; document the host layer
* fix(studio): preview consumers read the live iframe from one shared owner
* fix(studio): clear the shared preview iframe when the studio unmounts
The store outlives StudioApp, so a remount would start from the previous mount's detached iframe. The owner hook now writes null on unmount.
* fix(studio): skip the preview-iframe notify when setIframe repeats the current value
setIframe always merged a new state object, so zustand notified raw
subscribers even when the iframe reference had not changed. Bail out
by returning the same state reference on a no-op set.
* feat(studio): header and inspector tabs on the shared primitives
The header's view-mode toggle and the inspector's tab strip become real
Tabs, so both gain the roving tabindex, arrow keys and Home/End they never
had; undo, redo and Inspector become IconButton and Button; Export becomes
the primary Button at the medium size. Capture stays an <a download>,
wearing Button's recipe, because a button cannot save a file.
Colour literals in StudioHeader.tsx drop to zero: the brand gradient moves
into theme.css as two tokens and the Export's hand-written text colour
becomes text-bg-0. Ratchet total 475 to 470.
* docs(studio): shorten the header and inspector tab comments to fit the comment gate
* chore(studio): bank the lower colour-literal baseline for StudioHeader
* fix(studio): arrow keys only move focus when the inspector strip toggles panes
* chore(studio): allow deleting PanelTabButton, replaced by the shared Tabs primitive
* refactor(studio): move file undo/redo into one history-actions hook
* refactor(studio): simplify history-actions hook
* test(studio): cover history-actions ordering and motion-file reads
* test(studio): type the history-actions test mock
* feat(studio): shared Menu, ContextMenu and Popover primitives
One menu chrome and one popover chrome, on Base UI, wearing the theme
tokens. Dismissal is plain Base UI with no capture-phase wrapper: its
outside-press listeners are registered on document with capture set, so
the canvas overlay's bubble-phase stopPropagation cannot starve them.
Menu items carry an optional mono shortcut hint, a disabled state and a
danger tone. ContextMenu opens at the pointer. Popover shares the
surface, radius and open motion but owns no item semantics, so a text
field inside it keeps its own keys.
Also adds [role='menuitemradio'] to the playback-shortcut ignore list.
The hand-rolled speed menu spelled its rows as buttons, which that list
already matched; the shared radio item is a div with the same role, and
nothing else in the list matched it, so an open speed menu would have
leaked arrow keys and Space to the player.
* feat(studio): shared Input, NumberField, Select, Slider and Toggle primitives
The five value controls exist once, on Base UI, wearing the theme tokens.
Each is boxed with a visible border and a focus ring so an inspector value
reads as an input rather than as its own label.
Value writes happen at a commit boundary, never per keystroke or per pointer
move: one drag is one onCommit and one telemetry call. Right-click and Escape
abort a drag in flight and put the pre-drag value back.
The switch role joins the typing-target selector list, so a Toggle keeps the
keys the native checkbox it replaces owned.
No consumer moves onto these yet.
* refactor(studio): shorten the primitives' comments and stop a default string reading as a class
Main's comment gate rejects long comment blocks, and the class gate read the
MenuItem tone default as a Tailwind class. The prop is optional instead.
* feat(studio): export the shared primitives from the package entry
A host mounting Studio pieces in its own tree imports them from the package:
cn, Menu family, Popover, Input, NumberField, Select, Slider and Toggle.
* feat(studio): dock sashes and tab strips work from the keyboard and side columns yield on narrow windows
Every dockview sash is a focusable separator (role, aria-orientation, aria-label);
arrow keys along the split axis resize the neighbouring groups by 16px (Shift 64),
clamped by the panels' minimum sizes, by replaying a pointer drag so dockview's own
clamping applies. Tab arrows now activate the tab they land on and wrap; Home/End
jump to the first and last tab.
Narrow-window rule: the preview keeps its 360x200 floor first; side groups' minimum
width is clamp((viewport - 360) / 2, 120, 200) and they shrink to their defaults when
it drops below 200. Below 600px the dock overflows and clips instead of squeezing the
preview. Measured at 560px: before, the inspector column sat fully off-screen at
x 560-760; after, columns are 120/360/120 and reachable.
* refactor(studio): simplify side group zone lookup in dock layout
* fix(studio): dock side-fit spares the preview group, follows panel moves and is covered through the shell
A side panel tabbed into the preview group no longer lowers the preview minimum width, moving a panel re-applies the side minimums, a project switch disposes the previous observer, and the dock shell tests now cover the sash and resize wiring.
* fix(studio): pin the preview floor on every group that holds the preview
A group that held the preview kept a stale side-column minimum after a drag, so the preview could shrink below its floor. Every group with a preview tab is now constrained to the preview minimum explicitly.
* fix(studio): every dock group gets a width minimum, shortcuts keep their meaning on tabs and sashes
The side-fit now rewrites the minimum of every group each time, so a group that stopped holding the preview or gained the timeline never keeps a stale value. Alt, Ctrl and Meta arrows are left to the browser. The shell test now proves the resize observer watches the dock.
* test(studio): the dock shell tests cover panel drags and sash labels
* refactor(studio): share the dock test harness and drop an unused export
The sash decorator is internal, the bare dockview setup and the persisted-group lookup used by several dock tests live in one place each.
* refactor(studio): shorten the side-minimum comment to one line
* fix(cli): keep the contact sheet png test within its budget on slow runners
sharp's bundled Pango/Fontconfig backend enumerates the OS font
directories on first text render and builds an on-disk cache before it can
draw the sheet's labels. On Windows CI that first-render scan is
unbounded (measured 12s-103s here for otherwise-millisecond work), which
is what the 60s ceiling was really absorbing. Point Fontconfig at an
empty, per-run config on win32 so label rendering has nothing to scan,
and bring the budget down to reflect the real raster work.
* test(cli): keep the createContactSheet timing label to measure the Windows font fix
Diagnostic: the Windows job on this branch prints createContactSheet's wall time
with the one-font Fontconfig override, to compare against 3.7-18.4s without it.
* test(cli): temporary diagnostic, time the first svg and first svg text render
* test(cli): temporary diagnostic, first text render in a child process
* test(cli): set the Windows Fontconfig file in the vitest config, before workers start
* test(cli): drop the contact sheet timing diagnostics
* fix(cli): the vitest Fontconfig dir is created with an unpredictable name and removed on exit
* feat(studio): shared Button, IconButton, Tooltip and Tabs primitives
Four primitives that consume design tokens only, plus the configured class
merger they all share.
- ui/cn.ts: one `cn` instance whose config knows the two class families
theme.css invents, the `text-step-*` type scale and the `ctl` control
heights, so a caller's class replaces a primitive's instead of stacking
with it and letting stylesheet order decide.
- Button and IconButton: native buttons on the three control heights, the
radius scale, the semantic palette and the motion tokens. Four classes
that resolved to nothing (rounded-button, shadow-btn-primary,
bg-accent-red, ease-standard) are gone, which is why the Renders Export
now has a radius.
- Tooltip: Base UI's tooltip, so the portal, the viewport flip and the
collision shift stop being hand-rolled. role="tooltip", the id wiring and
aria-describedby stay explicit here because the library does not add them.
- Tabs: Base UI's tabs, with roving tabindex, arrow keys, Home and End, and
a data-tab-id the sidebar's focus restore looks up.
Every interactive look is also reachable through a data-preview-state
attribute that CSS alone reads, so a gallery can show hover, active and
focus without a pointer. A test pairs the two lists in both directions.
The class-resolution test compiles Studio's real stylesheet with every class
the primitives emit and fails on any that produces no selector. Tailwind
treats an undefined class as silence; that silence is what let four dead
classes sit in this file.
* feat(studio): export Button, IconButton, Tabs and Tooltip from the package root
* docs(studio): trim comment blocks to pass the no-essay-comments gate
* refactor(studio): use native CSS.escape in the button class-resolution test
* refactor(studio): share the ui test mount helper and flatten the Button leading slot
* fix(studio): keep the tooltip open on a disabled control
* fix(studio): give the tooltip trigger no layout box and anchor to the child
* fix(studio): anchor the tooltip through a ref so it adds no render
* fix(studio): button children lay out with the consumer gap
* test(studio): keep the no-icon label and shortcut layout assertion from the standalone fix
* docs(studio): shorten the Tooltip header comment to fit the comment gate
* test(studio): the Button vacuity guard names tokens that stay undefined