Commit Graph
37 Commits
Author SHA1 Message Date
LING DUANandClaude Opus 5 f29bbc4daa feat: sample declared interactive state before classroom questions (#1508)
* feat: sample declared interactive state before classroom questions

* test: exercise generated publication example through the iframe reader

* chore(generation): bump package version for observation prompt contract

* fix: keep interactive state optional on insecure HTTP origins

* fix(playback): keep component picking and separate state from reference

A declared state interface replaced the component picker with a forced
whole-area `#experiment` reference, removing the per-component selection
and outline that `main` already ships. Sampling was also gated on the
reference selector, so the only way to obtain state was to give up the
selection.

Reference identity and area state are now independent request-scoped
evidence items:

- `handleToggleElementPick` always arms the picker again, so a scene that
  declares the interface keeps main's per-component selection, outline,
  and send-time clearing.
- `sampleInteractiveState` follows the current Scene instead of the draft
  reference, so an unreferenced follow-up still reports current facts and
  never re-creates or extends a reference.
- The Host carries area state with or without a component reference. The
  evidence header names both identities and refuses to present area facts
  as properties of the referenced component.
- `metadata` is absent when only area state travels, so no element
  identity and no Spotlight authorization can be derived from it, and the
  accepted-reference receipt stays driven by explicit references only.

Review follow-ups in the same change:

- Client sampling follows `NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED`.
  An ungated packet turned an ordinary Pi question into a 400 while the
  reference feature was disabled.
- A Scene that declares the interface always receives an availability
  boundary, including when the browser produced no packet at all. It is
  reported as `not-sampled` rather than the previous `no-interface`,
  which was a false statement about an activity that does declare one.
  Courseware without the interface keeps its unreferenced behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(generation): register the observation snippet as a packaged asset

The interactive-observation snippet is referenced by all six widget
content templates but was never added to the packaged-asset manifest, so
the asset test and the golden scene prompt both failed.

- `SNIPPET_IDS` now lists `interactive-observation`, restoring both the
  "exactly the generation-owned templates and referenced snippets" check
  and the "every referenced snippet is packaged" cross-check.
- The interactive system-prompt snapshot is re-pinned. The change is
  purely additive: the snippet is appended to the simulation template.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(pi): stop injecting state constraints while the feature is disabled

The route rejects a request that carries a reference or a state packet
while `NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED` is off, but an ordinary
question carries neither. It still reached the Host, and a Scene that
declares the state interface then received the full page-state block —
several kilobytes of constraints about evidence the deployment can never
sample.

The Host now returns before building that note when the feature is off.
A route-level regression asserts that neither the Director prompt nor the
Child prompt gains `PAGE-REPORTED STATE` in that configuration; disabling
the guard makes it fail with exactly that symptom.

Also reopens the composer before the unreferenced follow-up in the
classroom browser spec. An accepted answer may close it, which made the
assertion flaky without changing the behaviour under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(pi): decouple reference Scene from state freshness and bound assembled evidence

Cross-review found two defects in the request-scoped state evidence.

The reference's Scene was folded into the sample's staleness test. The packet
is already bound to the current Scene by the identity check above it, so a
valid current-Scene sample was being discarded as `stale-sample` purely because
the student's component reference came from an earlier Scene. Reference and
area state are independent evidence items; freshness is a property of the
sample alone. With the coupling gone the two can now disagree on Scene, so the
note says so explicitly rather than letting the model attribute area facts to a
component that may not be on the current Scene.

The assembled evidence had no stated output budget. The static component packet
is bounded to 24,000 code points upstream, but that bound covers the static
packet alone; the note and the escaped observation JSON were appended without a
recheck. Escaping `<` for the prompt expands one code point into six, and `<` is
legal in a label or a fact value, so a packet the Host accepts could assemble to
149,385 code points. The budget is now declared as the static bound plus the room
the note frame needs, which is what makes the degradation terminate. Over budget,
the state body drops whole to an explicit `unavailable` statement: truncating the
JSON would emit a broken packet, and thinning a `complete` relation set would turn
an exhaustive set into a false one. The relationship summary degrades with it, so
the prose never asserts COMPLETE over a body that is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(pi): keep slide references independent of activity state

* refactor(interactive): simplify declared state and unify iframe preparation

Accept any JSON report within byte and depth budgets, without generated field
requirements or relationship-completeness semantics. Keep publishState and an
optional rendered result in the generation guidance.

Prepare the observation responder through patchHtmlForIframe and let the pool
own document identity, preserving state across placeholder remounts. Settle
sampling failures locally and align browser/server nesting limits.

Cover permissive JSON delivery, resource limits, lifecycle, legacy behavior,
and real renderer remounts with focused regression tests.

* fix(generation): publish automatic activity changes with clear positions

* fix(interactive): report missing legacy scope as no interface

* fix(interactive): guard sampling capabilities and bind scopes lazily

* chore(generation): bump version after main integration

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:17:30 +08:00
DDLandwyuc b735609e14 fix(playback): stop superseded scene engines (#1510)
Detach and stop the active playback engine before asynchronous scene teardown so switching to a non-playable scene cannot leave narration, timers, or effects running.\n\nRecheck engine ownership after lecture-session creation in manual and auto-play paths, close stale sessions, and reject progress callbacks from detached engines. Add regression coverage for all three races.

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-16 06:02:27 +08:00
LING DUANandwyuc ebf665f316 feat(playback): reference static GenUI components (#1281)
* feat(playback): reference static GenUI components

* feat(playback): gate courseware references

* fix(pi): bound Interactive source HTML before parsing

* fix(pi): bound interactive reference HTML processing

* fix(playback): close interactive reference review gaps

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-11 19:16:10 +02:00
85ef52d599 feat(media): write generated media through the asset pool under server-backed persistence (#1392)
* feat(media): store generated media in the asset pool when persistence is server-backed

With server-backed persistence the document is durable and shared, but
generated media stayed in the producing browser: the document kept its
gen_img_* / gen_vid_* placeholder and narration kept a browser-derived audio
id. Every new browser that opened such a course re-ran generation for every
slide, and it never converged, because the address of the generated bytes was
never written back into the document.

Under server-backed persistence only, the classic generation chain now stores
bytes in the asset pool first and writes the id the pool allocated into the
document.

- The client bootstrap configures the asset seam alongside the document and
  runtime seams: an HttpAssetStore over the persistence endpoint carrying the
  same credentials the document store carries, marked server-backed. The seam
  preflight now covers all three, so a failure still cannot half-configure
  persistence.
- Image, video and TTS generation commit in one fixed order: provider, pool,
  document, local cache, task. A reference reaches the document only after put
  returned an id, so a document can never name bytes that were not stored. A
  failure before the write-back leaves the placeholder with the provider called
  exactly once; the retry happens on the next owner load.
- The write-back is a per-slot rewrite through mutateDocument, which re-reads
  the current document under the per-stage lock, so it cannot clobber a newer
  scene. The open course is refreshed with the same rewrite without being
  marked dirty.
- "Has this already been generated?" is answered by the document (the slide
  exists and no longer holds the placeholder) instead of by this browser's task
  table.
- The classroom's resume effect fails closed on ownership: only a resolved
  owner starts generation, so a viewer opening a shared course spends nothing.
- The local media and audio tables become a per-tab cache. A failed cache write
  costs a re-download, never the media.

Browser-only mode is unchanged: every new call site sits behind the
server-backed gate, the local tables stay authoritative there, and placeholders
stay in the document.

Rendering and export needed no changes. HttpAssetStore.resolve mints an object
URL exactly as the browser store does, and the export byte resolver was already
pool-first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(classroom): make the generation owner gate a three-outcome rule and apply it everywhere

The gate refused everything but a resolved owner, which read a sidecar that
answered "no ownership fact exists for this course" as a reason to block. That
is the answer a deployment without the sidecar's server-side prerequisites
gives for every course, and the answer a course with no ownership record gives:
in both, there is nobody the operator's budget needs protecting from, and
refusing strands the course's own author behind a question that can never be
answered.

Ownership is now four states over the sidecar's three outcomes. A definite
answer splits into owner and not-owner. An absent record is its own answer,
ownerless, and generation proceeds — the behaviour such a deployment had before
the gate existed. Only the absence of an answer, a transport failure or a load
that has not asked yet, stays unresolved and fails closed: "we could not ask"
must never be read as "nobody owns this". One mapper turns a sidecar result
into that state, and one predicate decides on it.

The workbench classroom pane runs the same resume effect and had no ownership
input at all, so a viewer opening a shared course there could still spend the
budget. It now asks the sidecar once per course, in parallel with its load and
feeding only the generation gate, so its read-only and edit behaviour is
unchanged. The shared progressive-load policy carries the gate for it, with
both new inputs required rather than defaulted so a future caller cannot omit
them into an open budget. Its stale comment claiming ownership could not be
expressed here is corrected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): make the write-back survive autosave, arrive before the scene does, and never leak

Independent reviews of the write-back found three ways a durable document could
still end up naming a placeholder, and two ways the gate that protects the
operator's budget could be walked around.

An autosave round captures the store synchronously and writes that capture, so
a round already in flight when a rewrite landed wrote the placeholder straight
back over the allocated id, and nothing marked the store dirty again to correct
it. The rewrite now marks the units it changed, which leaves a corrective flush
queued behind the stale one; re-saving a scene that already holds the id is
idempotent, losing the id is not.

Media is generated from outlines in parallel with scene content and usually
finishes first, so the slide that will carry the placeholder does not exist yet
and the write-back has nothing to rewrite. That was the ordinary path, not a
tail case, and its result was discarded: the task was marked done, the scene was
added afterwards with its placeholder intact, and a second pass in the same run
could call the provider again. The allocation is now held under the placeholder
— which also answers the skip test, so nothing pays twice — and applied when
that scene is committed, before its first save. One complete pass now leaves no
placeholder behind.

A failed commit used to abandon what it had already allocated. A poster upload
that failed threw away a stored video and sent the retry to submit the most
expensive job in the system again; a rejected write-back left registry rows that
name bytes nothing references, which the byte collector cannot reclaim because
it only collects blobs no row names. A poster failure now costs the poster, and
a write-back that reached nothing reclaims what it allocated. A partial write is
left alone, because the document already names it.

The ownership gate is fail-closed again. Treating the sidecar's 404 as
permission was wrong: the client cannot tell "this course has no owner" from
"this deployment told me nothing", so a visitor who opened a shared course could
bill the operator. The root cause was the sidecar itself, which gated on the
agent runtime although every persisted course has an owner regardless — the
persistence route resolves one for every request. It now gates on server
persistence, so the configuration that made 404 the universal answer has real
ownership facts to report, and the gate can refuse everything but a named owner.

Retry affordances answered to no gate at all. A viewer of a shared course with
one failed image was shown a Retry button that called the provider. Both retry
entry points and every surface that draws them now read one shared permission,
so what is offered and what is allowed are the same value.

Also: narration regeneration no longer pretends it can replace bytes behind a
live id — the exclusivity proof that would allow it is refused by construction
once references leave the browser, so it forks to a fresh id and says so; the
"already generated" test lets a finished deck answer from the document alone,
since scene order stops identifying an outline once slides are inserted or
deleted; stored assets record a specific media type rather than a generic
transfer type; the pane no longer asks the sidecar in browser-only mode; and the
funnel's docstring now states what the per-stage lock actually guarantees, which
is same-browser serialization and not a cross-browser compare-and-swap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): park allocations in the deciding turn, never reclaim on an ambiguous write

A delta review of the write-back found the first-pass fix still had a window,
and the reclamation it added could delete media the document already names.

The allocation was parked after an awaited local cache write. A scene committed
in that window reconciled against a registry that did not hold it yet, so the
document kept the placeholder — and the entry recorded a moment later then
answered the skip test as "already handled", so nothing could correct it. Parking
now happens inside the write-back, in the same synchronous turn as the decision
that nothing could take the reference; no await separates the live check from the
park. Allocations parked by an earlier pass are handed to their slides at the
start of the next one, before anything decides what still needs generating, so a
held allocation whose scene has since arrived becomes a rewrite rather than an
answer.

Reclaiming on a rejected write was unsound: a rejection does not prove the server
did not apply the write, so deleting the asset could break the scene that now
names it. The funnel decides instead, and says so: it reclaims only when no store
write was ever issued and nothing took the reference. Anything else is placed if
its slide exists and parked if it does not, so the next pass reuses the bytes
instead of paying for them again. When a write fails after part of it landed, the
live store is brought up to the document before the error is rethrown — otherwise
the next ordinary flush would overwrite the half that did land, with the ids
deliberately not reclaimed.

Parked allocations are now cleared with the course. Classic placeholders are
reused across runs, so one surviving an interrupted run would be handed to a
different slide of the next deck: the previous picture, on a slide whose provider
was never asked. Both classroom surfaces clear the arriving course, the deletion
cascade clears the deleted one, and clearing the database clears them all.

Two more ways generation could start without asking the gate are closed. An
overlapping pass — an outline retry re-enters generation with every outline while
the first is still working — re-requested elements whose provider call was
already in flight; a task that is not done is an answered request, not an
unanswered one. And narration regeneration in the timeline editor called the TTS
provider and allocated a pool asset with no ownership check at all; it now reads
the same permission, which withholds both the per-line and whole-timeline
controls and refuses the call.

Finally, a pane opened during the stage-link availability gap recorded the
sidecar's 404 for a course that was moments from existing and never asked again,
leaving the real owner locked out of generation until it remounted. Ownership is
re-fetched once the document becomes available; the gate stays closed until an
answer arrives, so asking again can only open it for someone entitled to it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): make the asset routes reachable, and stale snapshots harmless

A full-branch audit found that the deployment this project documents could not
store a single generated asset, and that several routes into durable storage
could still write a placeholder over a reference that had already landed.

The persistence route sent asset requests through the development
authenticator, which refuses outright in a production build that has not
explicitly opted into it — and the documented server-persistence recipe
produces exactly that build. Every store and every read answered 401, so images
and video failed on every slide while re-billing the provider on each retry, and
a narration failure stopped the deck at its first slide. Assets live in one
shared partition by design, so there was never anything per-caller for that
authenticator to decide: the route now resolves the asset principal itself,
alongside the owner it already resolves for documents. Runtime sessions are
genuinely per-learner and keep the development authenticator until real session
verification replaces it. And narration that cannot be stored no longer fails
its scene: the line stays unvoiced and retryable, which is what an image that
cannot be stored does to its slide.

Placeholders could also come back from behind. A queued autosave's snapshot, an
editor-history entry replayed by an undo, the departing save a course switch
flushes — each captures content at its own moment, and any of those moments can
predate a write-back. Point fixes at each producer would leave the next producer
to rediscover the bug, so the check lives at the write boundary every producer
passes through, and the allocation record it consults now outlives the parked
queue: a placeholder whose rewrite landed long ago is exactly the case it
catches.

Two ways generation could be lost or repeated are closed. A pass now claims the
elements it will reach and releases them however it ends, so an overlapping pass
stands down while an aborted one strands nothing — previously its tasks stayed
`pending` and every later pass skipped them with no retry control to recover
them. And the media abort controller is aborted before being replaced, so a
superseded pass stops calling providers instead of running on for a course the
user has left.

The remaining two are narrower. The workbench pane asks for ownership only after
a document load succeeds, and after every later one, mirroring the page route:
the load is what creates the ownership row the first time a course is opened, so
asking beforehand asked about a course that did not exist yet and locked its
author out for the mount. And the ownership gate on the timeline editor now
withholds narration regeneration alone; listening back to existing narration and
seeing whether a line has any spend nothing and stay available.

Known limitation, unchanged and now stated plainly in the comments that used to
point at it as a solution: nothing reclaims an unreferenced pool asset. The
registry sweep is written but not wired up, and the byte collector only reclaims
blobs no registry row names, so every narration regeneration and every abandoned
allocation leaves storage behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): gate asset mutations, and make claims and allocation records survive a handoff

Opening the asset routes opened all of them. Reads and allocations are meant to
be as open as document reads and creates already are, but no authorization hook
was supplied, so the handler's default admitted PUT and DELETE too — and those
scope by principal key alone, which is one shared constant. Any caller who
learned an id, and a document read hands out every id its slides name, could
overwrite or destroy another author's media. Mutations now require the
deployment's credential, which in a production build without the development-auth
opt-in means they are refused outright; reads and allocations stay open. The
route comment says what the posture is and what it is not: the deployment-level
fence is the access code, and no per-principal quota is configured. The client's
own reclaim is best effort to match — losing an argument about deleting an asset
must not cost a task its retry, and the bytes are left for server-side
reclamation.

The pass claim could not survive the handoff it was written for. A retry aborts
the live media pass and starts its replacement in the same synchronous block,
long before the aborted pass's cleanup runs, so the replacement saw every element
still claimed, collected nothing, and returned — leaving each unreached element
at pending with nobody coming back for it and no retry control to recover it,
which is the exact failure the claim was introduced to prevent. A claim now
carries its pass's signal and is retired the moment that signal aborts, and a
pass releases only claims it still owns, so a late unwind cannot take its
replacement's work. Claims are also acquired at the single point every request
passes through, so a single-task retry participates too — previously a retry
awaiting its provider was invisible to a pass starting alongside it and both
called it.

The allocation record could outlive the bytes it named. It was written before the
write-back attempted anything and survived the reclaim that followed a failure,
so when the slide finally arrived the write boundary stamped a deleted id into
the document — and the placeholder it replaced was gone, which reads as already
generated and stops anything from retrying. The record is now written only where
the allocation is retained, and forgotten wherever a reclaim removes the bytes,
including the narration rollback path.

The tests follow. The route test drives the real storage handler against an
in-memory registry instead of a stub, so it can see what the resolved principal
is then allowed to do; the handoff test performs a real abort mid-pass rather
than starting from an already-aborted signal; and the guards that could only
assert file layout now assert the property they care about, or have been replaced
by behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): make media passes serial per course instead of tracking element ownership

Three rounds of per-element claims each produced a new way to lose an element.
Whole-pass reservations swallowed a Retry for an element the same pass had
already failed, leaving it pending with the affordance gone. Retiring a claim by
its signal freed an element whose commit was still uploading, so the replacement
pass paid for it twice. A claim held for a failed element stranded its retry. The
bookkeeping is the defect: every refinement of "who owns this element right now"
answered the question at a moment when the answer was already stale.

Passes for one course are now serial. A replacement aborts its predecessor, as
before, and then waits for it to settle before collecting. That removes the
question entirely: a commit already under way finishes — its bytes stored and its
reference written, so the new pass sees a resolved slide and skips it — and an
element the aborted pass never reached is still a placeholder and gets collected
like any other. The claim set, the reservations, the signal retirement and the
identity-checked release are all gone.

The task table is consulted for one thing only: an element that is generating
right now is a single-element retry running alongside the pass, and taking it too
would pay twice. Pending is deliberately not a skip reason — it means a pass once
intended to reach an element, which an abandoned pass leaves behind with nobody
acting on it, and reading that as answered is what stranded elements before. A
retry runs concurrently with a pass, because a pass never revisits an element it
has processed, and it re-reads the task after its own await and refuses before
touching it: marking first and refusing afterwards destroyed the failed state
that draws the affordance.

Browser-only mode is back to exactly what it was. The abort is now conditional,
the waiting does not apply, and the original status-based skip is restored
verbatim. Two baseline lines remain changed in each of the two files, and both
are behind a server-backed fork whose else-branch is the original.

Two smaller things. The allocation record becomes visible when a write goes on
the wire rather than when the round trip ends, and the write boundary reconciles
under the document lock rather than before it — a save queued during a write-back
was otherwise captured with the placeholder and, for a course the user had left,
had no corrective flush to follow. And the comments that said a refused reclaim
leaves its bytes for server-side reclamation were wrong: nothing collects them,
because the registry entry still names its blob and the sweep that would remove
it is not wired up. They now say the bytes leak.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): make a deferred pass re-earn its right to run, and bound the commit it waits on

Serializing passes moved their body out of the block that launched them, and
three things followed from that.

A pass now wakes when its predecessor settles, which can be after the user has
left the course. It enqueued before it looked at its signal, into a task table
keyed by element id alone — and placeholder ids are not unique across courses,
which is why the classroom clears that table on arrival. So a departing course's
pass seeded the arriving course's table with tasks carrying the wrong stage id,
and a Retry routes by that id: the reference went into the wrong document. A pass
now re-validates after the wait, before touching anything shared.

The same lateness broke the skip test. `documentSkipIndex` answers only while the
live store is on the pass's stage, and returning nothing put the collection loop
on the browser-only rule — a silent demotion from "the document is the authority"
to "this browser's task table is", on exactly the path where that table has just
been cleared. Every element the predecessor had committed was collected again,
paid for again, and its second write-back found no placeholder to rewrite, so its
bytes were parked where nothing will ever reference them. In server-backed mode
an unreadable document now means the pass stands down.

And waiting was unbounded. A commit is uncancellable: the asset client takes no
signal, and a document write cannot be half-undone. One stalled upload therefore
froze the course's media generation for the session — the replacement never
collected, the element sat on a skeleton that draws no Retry, and only a reload
recovered. The pass's signal is now threaded into the media proxy fetch, and the
commit is bounded by a deadline. The deadline is on the wait, not the work: the
commit carries on, and if it lands late the document simply ends up correct,
while the element becomes retryable and the queue moves on.

The tests that were meant to pin the previous round were not sensitive to it.
Two asserted end states where the mechanism only changes ordering, and one of
them rigged the document read so the assertion held whether or not the pass had
waited; a third covered half of what it claimed. They now observe the ordering
directly — nothing is issued while another pass for the course is working; in
browser-only mode a second pass reaches its provider immediately — and the
reconciliation under the document lock has a test that fails when it moves back
outside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* revert(media): drop the commit deadline and the abortable download

The deadline bought less than it cost. Abandoning a commit after two minutes
makes the element retryable while the real commit is still running, so a Retry
starts a second commit for the same placeholder against the first: two provider
calls, two allocations, and whichever lands second stamps its result over the
other's task by element id. The allocation record is keyed by placeholder, so the
loser's cleanup erases the winner's record, and the write boundary then puts the
raw placeholder back into the document. That is the overlap serial passes were
built to remove, reopened through the one door serialization never covered.

So a stalled commit holds the course's media queue until it settles or the page
is reloaded, and that is written down rather than papered over. The wait is
unbounded on purpose: every ceiling on it turns out to be a way of running two
commits for one element.

Threading the pass signal into the download was also a mistake, in the other
direction. The provider call that produced the URL has already been billed, so
cancelling the download throws away work that is paid for — and the shared proxy
cache records a cancelled request as a transient failure against that URL, which
after three of them blocks it for every consumer in the session. Browser-only
mode never asked for this: it had no way to observe an abort there, which is
exactly why the bytes were kept. The signal is gone from the download again, and
`fetchAsBlob` is byte-for-byte what it was before this branch.

The regression guard for the stranded-element rule is restored alongside the
timing test that was meant to supersede it. It catches a different rule — a task
left pending being read as answered — and nothing else does: making the pass skip
pending leaves every other suite green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): stop asking the pool for refs it never issued, bound it, and adopt cached bytes

Four things a deployment found once this was running for real.

A reference this application mints itself — a generation placeholder, a derived
narration key — was never in the pool, because the pool allocates every id it
holds. Asking anyway used to be an IndexedDB miss; once the pool is server-backed
it is a request that answers 404, one per element per load, forever on a course
that still holds placeholders. Every lease and probe now checks first. The check
is a negative test on shapes this application owns, not an id validator: the
pool's id domain stays unconstrained, and anything that is not one of ours is
still asked about.

The asset store can bound how much one principal holds, and enforces it inside
the write transaction, but nothing ever passed the number. It does now, with a
default rather than an opt-in: allocation is reachable by any caller a
deployment admits, and with one shared principal an unbounded store is
unbounded database growth with no operator-visible brake.

Refusing asset mutations to unauthenticated callers was not enough, because
every authenticated caller resolves to that same shared principal — so
authentication decided nothing, and any signed-in visitor could delete any id
they learned. Since this branch began storing media the registry is the only
copy a course has. Replacing and deleting are now refused to everyone, and the
browser no longer tries: an entry nothing references waits for server-side
reclamation instead. What a browser must still do is forget its own record of an
allocation that reached nothing, or a later save would stamp an id the document
has no reason to trust.

And a course generated before any of this holds placeholders in its document
with its bytes only in the author's browser. Those bytes are paid for, so the
author's next load converts them — stored to the pool and written back through
the ordinary commit path, with no provider call — instead of buying them again.
A row that records only a hosted URL is treated as absent: that URL is the
provider's address, not something a document may hold.

One renderer expectation moved with this. An untracked placeholder used to paint
as pending on first render because asking the pool left a lease in flight; it
settled to disabled a moment later either way, and now says so from the start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): surface a full store as a refusal and convert legacy narration

A quota refusal reached the browser as HTTP 500 with a generic message, which
reads as a transient failure: the element kept a Retry that would pay a
provider again and be refused again. The store raises the contract's own error
and the handler maps it to 507, but the store answering a request is not always
built by the same bundle as the handler -- the persistence provider is reached
from the route bundle and from instrumentation, which is why its state lives on
a Symbol.for global -- and `instanceof` is false across that boundary while the
declared code is still right. Classify on the code as well as the class, and
make the code a permanent, persisted refusal in the browser: recorded locally so
it survives a reload, shown as "storage is full", and refused by the retry entry
point so a stale button cannot buy a second generation. Every other storage
failure stays retryable.

Convert what a pre-server-backed course still holds. Generated media is adopted
under either key this application has used for it -- the placeholder, and the
allocated id of a course converted once and later rolled back -- instead of only
the first. Narration is converted by a load-time pass over the open course's
speech actions, since nothing re-enters generation for an action that already
has an id: bytes to the pool, id written back through a funnel that mirrors the
media one, owner-only and server-backed-only. A line whose bytes are in no
browser is left alone rather than re-synthesized.

Also: the pool guard is now a positive `ast_` test rather than an enumeration of
the shapes we mint (imports never reach the pool, so this is safe in both
modes); the slide ref collection is an exported pure function so its four lease
sites are covered behaviourally; ASSET_QUOTA_BYTES treats every spelling of zero
as opting out and refuses a malformed value at startup instead of falling back;
the abort signal is re-checked after the cache read, before an uncancellable
commit; and the unused `removeAsset` and pool `replace` surfaces are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* chore(storage): release 0.29.1

The asset HTTP handler now recognises a store refusal by the contract code it
declares as well as by its class, so a quota refusal raised in another module
realm answers 507 instead of 500. Same contract, stricter recognition, no API
change: a patch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): make a full store recoverable and adoption course-safe

Narration adoption read the local audio row by its derived key alone. That key
carries no stage id and the table is keyed by id alone, so two courses can mint
the same one -- a PPTX import numbers its scenes and actions deterministically,
which gives every imported deck's first slide `tts_s1_speech-scene-p1`. Locally
a collision only means one course plays another's clip in one browser; adopting
it wrote that clip into the shared document permanently, for every device and
every visitor. A row that names a course is now adopted only into that course,
and a row from before that column existed only when the text it recorded is the
text of the action being converted.

A full asset store was made permanent last round, which was wrong three times
over: it overwrote the refused bytes with an empty blob -- on the conversion
path that row is a course's only copy of its own media -- it kept sending the
rest of the deck to a provider against a ceiling it already knew was reached,
and it left no way back once an operator raised that ceiling. A full store is
neither the content's fault nor the configuration's, so it is now its own case:
the bytes are kept, the pass stops at the first refusal, and the element shows
the reason together with a Retry that re-attempts the upload from those bytes.
Nothing retries automatically, so no one is re-billed.

The narration write-back now reaches the write boundary every producer of a
durable write passes through, not only the dirty mark: adoption never deletes
the derived row, so a snapshot that reverts the rewrite is adopted again on the
next load and allocates a fresh asset every time. Adoption is also mounted by
both classroom surfaces rather than one, takes the course's abort signal, and
re-validates that this browser still has the course open before each write.

ASSET_QUOTA_BYTES is validated from instrumentation, where the README and the
docstring already claimed it was: its only other consumer is lazy and memoised,
so a malformed ceiling let the process boot and then failed every persistence
request, documents and runtime included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): remember a full store per course, and never lose retained bytes

A stopped pass left the elements it never reached as placeholders with no
persisted record -- deliberately, since nothing was attempted for them. But
that left the next load with no reason not to try: it called a provider for the
next placeholder and was refused at exactly the same point, once per reload,
indefinitely. A full store is not a property of any slide. It belongs to the
deployment and changes for reasons the document knows nothing about, so it is
now remembered once per course in the browser's device KV. A pass that finds
the marker stands down before spending anything and leaves every placeholder
its "storage is full" state and its Retry; the first upload that succeeds
clears it and the next pass runs normally.

Narration adoption latched per course so it runs once per load, and the latch
outlived the abort that leaving a course performs. On a surface that stays
mounted across switches -- the workbench pane is one component for every course
it shows -- owner course A, visitor course B, then back to A skipped exactly
the clips the abort had cut off, and nothing else converts them. The latch is
released with the abort now, and a course adopts one run at a time so a
re-entry cannot hand a clip a second allocation while the previous run's
uncancellable tail is still settling.

A quota-blocked element retried into a network error or a 500 lost the bytes
that were kept for it: the retry deleted the row before attempting the upload
and wrote no replacement for an error carrying no structured code, so the next
retry went back to a provider for media this browser had a moment earlier. The
row now survives until an upload succeeds, the failure handler keeps whatever
bytes the attempt was given, and the retry asks the question a pass asks --
does this browser already hold bytes for this element -- rather than reading an
error code that a second failure has already overwritten.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XypYpLBtk8DB5nT5jyqZ5D

* fix(media): adopt real legacy narration, queue re-entries, report attempt outcomes

Narration adoption admitted a stage-less row only when the text it recorded
matched the action being converted. Both of those columns were added to the
local audio table by the very change that moved narration onto allocated ids,
so a row still carrying a derived key has neither: the rule refused every real
pre-allocation course and passed only on fixtures built from post-allocation
rows. What the row cannot say, the key can. A derived key names two clips only
when two courses share a scene order and an action id, and an action id repeats
only when something other than the generator minted it -- an import numbers
them by slide position. So a key built from a generated action id is adopted on
that basis, a key an import could have reproduced still needs matching text,
and a row that names another course is refused however unique its key looks.

Handing a re-entering caller the adoption run already in flight undid the latch
release it was paired with: that run is bound to the signal the departure just
aborted, so it stops at its next clip while the caller -- which has the course
open and a live signal -- is told the work is done, and an effect replayed as
mount, cleanup, mount adopts nothing at all. A later caller now waits for the
uncancellable tail and scans again, which costs a lookup on a course that has
nothing left and finishes the clips the abort cut off on one that does.

One attempt at an element now reports both facts its callers need instead of a
bare boolean: whether the store refused it for room, and whether bytes actually
reached the store. Leaving a course clears the task table, so a retry that
landed afterwards read "no failed task" as success and deleted the row holding
the only copy of the media. Nothing is inferred from that table any more.

Reading the localStorage property can throw where storage is denied by policy,
typeof included, so the availability check moved inside the guard: this metadata
is best-effort, and a rejection here strands a generation pass that has already
enqueued its tasks.

A retry is never blocked by the per-course "store is full" marker, but a retry
that is refused again re-sets it, and adoption now reads and writes the same
marker rather than issuing one refused upload per clip on every load. The two
canvas element renderers and both thumbnail renderers show the reason beside
the Retry, so a full store does not look like an ordinary failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(media): probe a full store instead of standing down, and pair notices with a Retry

Narration adoption was given both halves of the per-course "the store is full"
marker last round: it stood down when the marker was set, and it set the marker
when its own upload was refused for room. Those halves are only safe together
if something can lift the marker, and for adoption nothing could. It has no
affordance of its own, it stood down before reaching its own clear, the media
pass returns before its marker gate when there is nothing to generate -- so a
narration-only deck, or one whose slides are already satisfied, painted no
storage-full element and offered no Retry -- and narration generated rather
than adopted allocates directly rather than through the media commit. The
course's cached narration was then lost for good, where before it converted on
the first load after the ceiling was raised.

The gate is a probe now. A marked course attempts exactly one clip per load:
refused, it stops and the marker stands, which costs what standing down cost;
stored, it lifts the marker and finishes the course. Adoption spends no
provider money, so the whole cost of probing a store that is still full is one
refused upload. Generated narration lifts the marker too.

The three surfaces that gained a failure notice last round drew it for any
failure with a reason, including the one refusal that is reachable without
server-backed persistence, so a browser-only deck painted something it had not
painted before. The notice is drawn beside a Retry and nowhere else, which is
what it was added for and what leaves browser-only output unchanged. Both are
now asserted through the render harness the surface matrix already had.

A caller arriving while a rescan is queued shares it rather than appending
another. One rescan converts whatever the run in flight left and every later
one would find an allocated id on every action, so a chain bought nothing and
turned a single stalled upload into a course that never adopts again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(media): treat a refusal for room as a fact about one clip, not the deck

The asset store checks each write against the headroom it has left, so a store
that refuses a long opening clip can still hold every short clip behind it.
Narration adoption assumed the opposite: it broke the deck at the first refusal
and then re-attempted that same first clip on every later load, because the
document names it first. A deck whose longest clip exceeds current headroom
therefore never converted the clips that would have fit, with no affordance to
recover it -- the state the probe was introduced to remove, reached through a
narrower door.

An unmarked load now attempts every clip, skipping the ones that do not fit,
and remembers the condition only if the load ends with clips it still could not
store. A marked load spends its single upload on the smallest clip left rather
than the first one named: that is the clip that answers the question the marker
asks, because if the smallest does not fit nothing does. The media pass keeps
stopping at its first refusal, and for a reason adoption does not share --
every element it attempts costs a provider call.

A rescan several callers share took the newest caller's signal, and the newest
caller is not necessarily the one still there: a surface that opened a course
and closed it again would stop work a surface still showing that course was
waiting for, and that surface is latched, so it would never ask again. The
shared run now takes a signal that is aborted only once every caller has left.

The comment claiming the shared rescan contains a stalled upload was wrong --
the rescan is chained off the run in flight, so a stalled upload leaves every
caller pending exactly as a chain would. It claims the bounded queue it
actually provides, and the stall is recorded as a limitation.

The failed-state containers took their stacking classes unconditionally, so
markup differed in browser-only mode even though nothing moved on screen. Those
classes are applied only when there is a notice to stack, and the tests assert
the exact class attribute rather than a substring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(media): stop narration adoption writing the media pass's store-full marker

The marker means "do not call a provider for this course". A path is entitled
to write it only if its own refusal cost a provider call, and narration
adoption's refusals cost nothing: it uploads bytes this browser already holds.
The store also checks each write against the headroom it has left, so a clip
that does not fit says nothing about whether a slide's image would. Adoption
was writing it anyway, and one over-long narration clip was therefore enough to
stand a course's entire image pass down on every later load -- on a store that
had just accepted adoption's other clips. The author could still recover each
element by hand, every load, for ever.

Three rounds of narrowing this seam produced a finding each time, so it is
removed rather than narrowed again. Gone: the marker read, the single-clip
probe, the smallest-clip selection, and the up-front read of every row into an
array -- which also retires a sampled-then-stale flag and the retention of a
whole deck's blobs for the length of a run, and returns the loop to streaming
one row at a time.

Adoption's rule is now that every load attempts every clip it holds, once; any
failure skips that clip and the load continues. The noise the coupling was
meant to avoid does not arise, because after the first load the clips still
outstanding are exactly the ones that did not fit -- normally none, or one.

A successful write still clears the marker, and that is a different kind of
statement: a write that went through is a fact this run established, where a
refusal is an inference about what some other write would cost. For a course
whose media needs nothing, adoption and generated narration are also the only
paths that can establish it.

The failure module still documented the deck-wide premise this contradicts. It
now says what is true: the check is per write, and the media pass stops the
deck as a judgement about cost rather than about certainty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(media): bound a full store's cost from the store's own arithmetic

Removing the store-full marker from narration adoption removed its bound too,
and the code then asserted the bound was unnecessary. It is, on a store with
room for most of a deck. On the store the whole mechanism exists for -- the
ceiling reached, nothing fitting -- the outstanding set after every load is the
entire deck, so a thirty-clip course posted thirty full blobs on every load,
indefinitely. Each of those is not a cheap refusal: the bytes are uploaded, the
server hashes the whole payload, and only then takes a per-principal lock and
sums every entry that principal owns before saying no.

The bound needs no flag, no key and nothing carried between loads. The store
asks whether `used + addedBytes` exceeds the ceiling, and `used` only grows
while a run is uploading, so a clip refused for want of room implies every clip
at least that large is refused for the rest of that run. The run keeps the
smallest size it has been refused and skips anything no smaller without
uploading it; a smaller clip is still attempted, because it may fit. A deck the
store refuses entirely now costs one upload per successive size minimum instead
of one per clip, and a deck it has room for costs nothing extra, because
nothing is refused. Only a refusal for room lowers the bar: a dropped
connection says nothing about how much room there is.

The deck-wide certainty premise the failure module retracted last round still
stood verbatim at the site that implements the stand-down. Both copies now say
the same thing: the check is per write, and the pass stops the deck as a
judgement about cost rather than about certainty.

The comment on adoption's marker clear now names its price. Narration of a few
hundred bytes fits in headroom an image does not, so a proven write can let the
next pass buy one more image that is refused again -- bounded at one, and the
price of the alternative being a course whose media never generates again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(media): state the adoption bound exactly, and stop three comments describing the old rule

The comment introducing the in-load bound gave its cost as "at most a handful,
and the first load pays the most". Neither clause is a property of the rule. A
clip is skipped only when something no larger was already refused, so a
fully-refused deck costs one upload per successive size minimum in document
order: one when the clips grow, about ln N for an arbitrary order, and one per
clip when they only shrink -- a long opener followed by terser lines is exactly
that shape. And no load is cheaper than the first, because the bound resets per
run and a refused clip stays outstanding. The comment now says that, and points
at what would make it exactly one for any ordering: the store returning its
remaining headroom in the refusal's existing details channel, which the server
leaves empty today.

Two other comments still described the previous rule -- "attempts every clip it
holds, every load" -- one of them twenty lines above the paragraph that
introduces the bound, in the same block. Both now say what the code does.

The bound's soundness is worth stating where a maintainer will look for it:
quota is charged at full length with no discount for a duplicate, the sum it is
checked against joins entries to blobs so the collector cannot lower it, the
check takes a per-principal lock before summing, and replace and delete are
refused to every browser. Nothing a run can do makes room appear inside it.

One test installed a row implementation and replaced it wholesale a few lines
later, so the first was dead and the survivor dropped the text the first clip's
import-shaped key needs for the ownership rule -- it passed on the coincidence
that the fixture's default text is the action's. Merged into one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(media): keep a Retry from re-buying parked media, and state the store seam once

Five findings from an inline review.

The standalone classroom route asked the ownership sidecar once per load and
recorded only stage ownership when that ask failed. Every non-answer fails
closed, so one transient 5xx left the genuine author with no resume, no Retry
affordance and no legacy narration converted for the rest of the load, with
nothing to change it short of a reload. The failure now records the fail-closed
answer explicitly -- an answer an earlier load established must not outlive the
failure that replaced it -- and an unresolved answer is asked for again, a few
times over a few seconds. A real answer, however unwelcome, is final.

A Retry could pay a provider for media the pool already held. When the bytes
are stored and only the write-back fails in a way that keeps the allocation, it
is parked and no local row exists, because that row is written only after a
successful write-back. Retry now reads the parked queue exactly as the pass
does and re-attempts the write-back: it re-keys the task done when the document
takes it, leaves the entry parked when the slide still does not exist, and
stays failed and retryable when the document refuses again.

Object URLs a parked allocation owns are revoked when the entry is dropped. The
commit path leaves them alone while the entry is parked, because it is then the
only thing holding bytes this tab can render, so a course switch or a stage
deletion was pinning the whole blob for the life of the tab. An entry a slide
has already taken is left alone: the task table is displaying those URLs.

The fallback lookup for cached bytes is a stage-scoped scan, and the keyed
lookup misses for every row the commit path writes, so a pass was materializing
and sorting the course's whole media table once per element. One scan per pass
now, built on the first miss. It is sound and not merely cheaper: an element
asks only for its own placeholder, and every row a pass writes carries the
placeholder of the element that wrote it.

"The store accepted a write, so it is not out of room" was enforced at three
call sites under slightly different conditions, which made it a convention the
next pool write path could silently break. It is stated once, in putAsset, for
the course whose bytes it just stored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-09-11 19:38:08 +08:00
9fe650d994 fix(quiz): resolve AI answer keys to option values before grading (#1328)
* fix(quiz): resolve AI answer keys to option values before grading

AI-generated quiz answer keys are unreliable: the model may write the
correct answer as option CONTENT ("(6, 2)"), as a LETTER ("A"), or as a
formatting variant (full-width chars, extra spaces) of either. Grading
compares exact option values, so a learner picking the correct option
was still graded incorrect whenever the key was written as content or
in a different format.

- generation: normalizeQuizAnswer now resolves every answer variant to
  the matching option value (NFKC + whitespace-stripped + letter-prefix
  canonical matching against option values and labels); unknown answers
  pass through untouched
- grading: resolveAnswerKeyToValue retro-fixes already-generated courses
  whose stored keys hold content variants, mapping them to option values
  when exactly one option matches canonically; ambiguous keys are left
  alone instead of being silently re-pointed
- tests: letter/content/full-width/trailing-space/multi-answer/unknown
  cases + end-to-end grading with a content-variant key

* chore(generation): bump version to 0.3.2 for answer-key fix

The repo requires every publishable @openmaic package change to ship with
a version bump (CI check-package-version-bumps).

* style(generation): prettier-format package.json after version bump

* fix(quiz): use resolved answer representation in review renderers; prettier

- QuestionCard + quiz-view review modes highlight the option canonically
  matching the stored answer key (answerIncludesOption), so persisted
  content-variant keys highlight correctly after the grading fix
- prettier formatting on touched files

* test(quiz): guard possibly-undefined answer in multi-choice test

* fix(quiz): narrow answer-key matching to exact unique alignment; project review UI from the same resolver

Per review: matching policy narrows to the DSL option-identity contract.
- Generation: exact value or unique exact label alignment; unknown and
  ambiguous keys stay unresolved (fail closed). Prompt example unified to
  the DSL object-options + value-answer shape.
- Grading: persisted keys resolve via the same exact alignment; the
  resolver returns the option's actual value when exactly one option
  matches canonically-or-exactly; ambiguous keys stay untouched.
- Review UI: QuestionCard and both quiz-view review modes highlight via
  the same resolver projection (answerIncludesOption(q, optionValue)).
- Removed the broader equivalence rules (NFKC, whitespace stripping, case
  folding, wrapped/prefixed-letter probe) — semantic synonyms such as
  正交/垂直 remain uncovered, hence Refs #1326.

* fix(quiz): resolve answer keys for persisted keys only; align quiz template with the DSL

- grading: compatibility resolution (label -> value) now applies to the stored
  answer key only. A learner submission is compared as submitted, so an option
  label can no longer be accepted as a different option's value.
- quiz-content/user.md: replace the string-options/`correctAnswer` example with
  the DSL shape the system prompt defines ({ label, value } options and
  `answer` as an array of option VALUES), so generated keys are values.
- tests: regression that a label submission grades incorrect while the same
  question's value submission grades correct.

* test(generation): update scene-prompt golden snapshot for the DSL-shaped quiz template

The golden test pins the rendered user prompt for every scene kind; the
quiz-content template now emits the DSL shape, so the snapshot follows.

* fix(quiz): read label-stored correct answers in the editor's option rows

`quiz-edit-ops.toRows` decided correctness with raw value equality, so an
existing label-keyed quiz showed A/B as correct (the review UI and the grader
both resolve labels) while every row read as incorrect internally — and the
next option edit or reorder rebuilt `answer` from those rows and silently
dropped the key.

- `toRows` now uses the same resolved projection (`answerIncludesOption`).
- `normalizeQuizAnswer`'s docstring now describes the exact-only behavior it
  has had since the review: formatting variants are not normalized and
  ambiguous entries pass through untouched.
- Regressions: an unrelated option-label edit and an option reorder both keep
  a label-stored answer, and a multiple-choice toggle preserves it. All three
  fail against the previous raw comparison.

---------

Co-authored-by: CrazyPandaP <CrazyPandaP@users.noreply.github.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-10 23:28:20 +02:00
wyuc dfebbcf33f perf(classroom): speed up classroom loading (media hydration, sidebar thumbnails, media range requests) (#1276)
* perf(classroom): defer non-priority media blob hydration off the load path

Entering a classroom awaited the full mediaFiles restore — one object URL
per restored image/video blob — before the loading gate opened. For
video-heavy courses that is hundreds of MB of IndexedDB materialization
blocking first paint.

Split the restore into two phases:

- The awaited phase now builds metadata-complete task entries but only
  creates object URLs for failed rows (none needed) and for media
  referenced by the scene the classroom opens on (persisted cursor, else
  the first scene). buildRestoredMediaTasks gains an optional
  shouldHydrateBlob predicate, defaulting to eager hydration so existing
  callers are unchanged.
- Remaining blob-backed records hydrate in the background, chunked over
  requestIdleCallback (setTimeout fallback). Each deferred task stays
  'done' — so generation resume never re-runs it — but carries no
  objectUrl yet, which the media resolution state machine already renders
  as a pending skeleton until the URL lands.

Background hydration is guarded per record: a task that was replaced
(classroom switch, regeneration, retry) or that belongs to another stage
is skipped and its freshly minted URLs are revoked immediately.

* perf(classroom): lazy-render slide thumbnails in the playback sidebar

The playback scene sidebar mounted a full SlideCanvas for every slide
scene the moment the classroom opened (and every off-screen video element
opened a preload="metadata" fetch), because SlideThumbnail's existing
visible prop was never passed.

Extract the editor nav rail's near-viewport IntersectionObserver hook to
lib/hooks/use-near-viewport.ts and gate the sidebar's slide thumbnails
through it: only scenes within 200px of the viewport render the live
canvas; the rest show SlideThumbnail's existing placeholder until scrolled
near. The placeholder keeps the same box size, so gating never shifts
layout.

The shared hook now starts hidden instead of eager: with the previous
eager-initial state, opening a long deck still mounted every canvas for a
frame before the observer could flip off-screen items off. The observer's
guaranteed initial delivery flips near-viewport items within a frame; the
no-IntersectionObserver fallback (e.g. jsdom) defers to a microtask so
the effect never synchronously re-renders.

* perf(media): lazy image decoding and HTTP Range support for classroom media

Renderer: BaseImageElement's <img> now carries loading="lazy" and
decoding="async", so thumbnail-heavy surfaces (playback sidebar, editor
nav rail, course cards) no longer fetch and decode every slide image up
front. In-viewport images are unaffected — the browser fetches them
immediately. slideToPng forces eager loading inside its permanently
off-screen snapshot tree, where lazy images would otherwise never fetch
and exports would capture blank slides.

Server: GET /api/classroom-media/[classroomId]/[...path] now answers
single byte-range requests with 206 Partial Content (Content-Range,
Accept-Ranges, correct Content-Length), enabling progressive playback and
seeking for hosted video/audio instead of downloading whole files. Suffix
ranges are supported; unsatisfiable ranges get 416 with the full size;
unsupported units or multi-range sets fall back to the plain 200 full-body
response, which is always a legal answer. Existing caching headers are
kept on every response shape.

* chore(renderer): bump version to 0.1.4 for image lazy-loading change

* fix(classroom): guard deferred hydration against restarted tasks and bound idle waits

A deferred record is only ever 'done' without an objectUrl; a task that
regeneration or retry restarted passes through pending/generating, so skip
those instead of attaching stale persisted bytes. Also give the idle
scheduling a timeout so a busy main thread cannot starve hydration.

* fix(classroom): stop superseded deferred hydration before minting URLs

A restore epoch captured at apply time now gates each idle chunk: loading
another classroom or reloading the same one invalidates any older hydration
loop, so it neither keeps scheduling work for an abandoned classroom nor
attaches bytes read by an older load to a newer load's tasks.

* fix(classroom): keep 416s uncached and classify element-keyed media as priority

A 416 with public immutable caching can poison the media URL for later
valid requests, so range errors now send Cache-Control: no-store. Priority
classification also collects the opening scene's media element ids, since
task lookup binds records keyed stage:<elementId> even when the slide slot
carries a different opaque ref.

* fix(classroom): tie deferred hydration liveness to the classroom load token

The restore epoch only advanced when the next load reached apply, so an
abandoned classroom kept hydrating during the next load's storage/network
phase. Compose the epoch with the load's isCurrent (load token plus effect
cleanup) so navigation stops the loop at the next idle boundary.

* fix(classroom): classify legacy-recovered media before deferring it

Legacy singleton video recovery assigns a placeholderRef only at the end of
the task build, so a record keyed by an allocated id without placeholderRef
was deferred even when it backs the opening scene's gen_vid_* element. Run a
metadata-only build first to learn each record's effective ref and classify
against it, keeping the first visible page's legacy video eager.

* fix(action): wait for deferred video bytes before starting play_video

executePlayVideo treated status done as immediately playable, but a deferred
restore is done without an objectUrl: the renderer shows a skeleton, no
<video> exists, and the later hydration never retriggers play, leaving the
action stuck until the safety timeout. Readiness now follows the renderer's
contract (only done-with-bytes is playable) at the initial check, the
subscription exit, and the post-subscription recheck; the failed skip applies
whether or not a wait happened.

* fix(classroom): include the stage whiteboard in priority media refs

The stage-level whiteboard stays open across standalone classroom switches,
so its media can be visible before any scene is. Classifying it as deferred
left visible whiteboard media pending behind idle hydration chunks.
2026-08-30 03:48:04 +08:00
LING DUANandwyuc 9a28ca39d5 feat(chat): add single PPT element references to Pi (#1224)
* feat(chat): add PPT element references to Pi

* fix(chat): include chart values in reference routing

* fix(chat): harden slide element reference evidence

* fix(playback): clear element reference on scene navigation

* fix(pi): preserve element grounding across child retries

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-29 14:38:15 +08:00
wyucandClaude Fable 5 04621578de release: OpenMAIC 1.0.0 — the agent workbench (#1228)
* feat(storage): add an agent-session store with PG backend and layered contracts (#1163)

* feat(storage): add agent-session store with PG backend and layered contracts

* test(storage): avoid BigInt literals for pre-ES2020 root typecheck

* fix(storage): close agent-session store review findings

* docs(storage): align hook ordering and contention-probe claims with the code

* ci: run on the agent-workbench integration branch

* chore(storage): bump to 0.5.0 for the agent-session store

* fix(storage): carry replay compaction across page boundaries

* feat(agent): add the driver model contract and stage route dialect (#1165)

* feat(agent): add the driver model contract and stage route dialect

* fix(agent): validate route context windows and clarify dialect precedence

* feat(agent): adapt the agent-session store and runtime foundations (#1167)

* feat(agent): adapt the agent-session store and runtime foundations

* feat(agent): resolve request owner identity via an anonymous cookie

* docs(agent): document the opt-in compaction default and harden edge cases

* feat(agent): add the background session runner (#1169)

* feat(agent): add the background session runner

* feat(agent): wire the runner into startup behind feature flags

* fix(agent): stop clean interruptions from consuming the attempt budget

* fix(storage): charge the attempt budget for abandoned leases but not clean parks

* docs(storage): document the attempt-charging contract and decouple its tests

* feat(agent): add agent session and owner event streams (#1170)

* feat(agent): add agent session and owner event streams

* fix(agent): close the session-existence oracle and document the owner seam

* feat(agent): add agent session lifecycle routes (#1171)

* feat(agent): add agent session lifecycle routes

* fix(agent): validate session-create input and preserve the owner cookie on errors

* refactor(storage): drop the unused active-stage API from the agent-session contract (#1174)

* refactor(storage): drop the unused active-stage API from the agent-session contract

Tools address stages explicitly on every call, so the store keeps no
mutable session-level stage pointer. Removes resolveActiveStage and
setActiveStage from the store interface, their PG implementations, the
active_stage_changed lifecycle event, the session_active_stage owner
event variant, and the contract tests pinning them. The active_stage_id
column and the DDL check constraint stay untouched for schema
compatibility.

* chore(storage): bump @openmaic/storage to 0.7.0 for the contract removal

* docs: document the agent runtime configuration surface (#1176)

* fix(agent): repair orphaned and late tool results across interruption boundaries (#1180)

* fix(agent): repair orphaned and late tool results across interruption boundaries

A crash, shutdown, or provider failure can leave the durable transcript with
tool calls that have no result, or with results ordered illegally for the
provider. Three failure modes were fixed:

- Orphaned tool calls: a run that died between an assistant tool-call frame
  and its result left a dangling call in the entry tree. Resume no longer
  synthesizes and persists receipts for it: interrupted results are a
  read-time provider view owned by a shared read-boundary repair, which
  returns the original array for a healthy transcript and never mutates the
  tree.

- Late parallel results: a parallel tool can finish while pi unwinds an
  aborted assistant frame, leaving result(A), assistant(aborted), result(B)
  in durable order. Strict providers reject non-contiguous results, so the
  read-boundary repair moves existing results next to their owning assistant
  frame (in call order), omits incomplete unwind frames, and synthesizes
  receipts only for genuinely missing calls.

- Interrupted calls at the write boundary: a call still in flight when the
  run winds down (shutdown, lease loss, cancellation, provider failure) had
  no receipt at all. The runner now tracks in-flight calls from their
  assistant frames and, before the terminal flush, appends an
  interrupted-result receipt for each still-orphaned call through the same
  attempt-fenced write chain, so a lease-stealing zombie never writes and
  the next claim sees a provider-safe transcript.

* test(agent): pin the runner wiring for interruption-boundary tool repair

* feat(agent): add neutral tool foundation libraries (#1184)

* feat(agent): register a web_search tool on the session runner (#1185)

* feat(storage): add a per-session URL trust gate (#1186)

* feat(agent): add the skills system (#1189)

* feat(agent): add the skills system (builtin directories and durable user skills)

* fix(storage): serialize the user-skill quota check-and-insert per owner

Two concurrent creates at the 50-skill boundary both counted 49 rows and
both inserted (READ COMMITTED, no lock), overshooting the quota contract.
The create transaction now takes a per-owner pg_advisory_xact_lock first,
and the same-name idempotency check runs before the count check so an
at-least-once retry of the create that committed as the owner's 50th row
still returns its durable receipt instead of a quota error. The 23505
backstop is retained for writes that do not take the lock.

* fix(agent): share unstorable-character validation and align skill lookup

* feat(agent): add session materials and a fetch_url tool behind the URL trust gate (#1190)

* feat(agent): add session materials and a fetch_url tool behind the URL trust gate

* fix(agent): harden session material fetching

* feat(storage): add an ownership scope to stage documents (#1191)

* feat(agent): add material read and search tools (#1192)

* feat(agent): add stage read and patch tools (#1194)

* feat(agent): add page generation and deck editing tools (#1198)

* test(storage): keep the PG contract suite order-independent (#1200)

* fix(agent): revoke deleted-session URL authority and reject private ISATAP endpoints (#1199)

* fix(storage): revoke deleted session URL authority

* fix(ssrf): reject private ISATAP endpoints in strict fetches

* chore(storage): bump to 0.11.1 for the session-URL authority fix

* feat(agent): add roster and voice registration tools (#1201)

* feat(agent): add folder organisation tools (#1202)

* feat(api): add stage and material HTTP routes (#1203)

* feat(workbench): add the client data layer (#1204)

* feat(workbench): add the client data layer

* docs(workbench): write the ported comments in English

* chore(edit): remove the in-editor agent panel (#1210)

* chore(edit): remove the in-editor agent panel

* style: apply prettier formatting

* fix(agent): report the runtime as unusable without a database (#1207)

* fix(agent): report the runtime as unusable without a database

* style: apply prettier formatting

* feat(agent): add image, video and pptx import tools (#1211)

* feat(workbench): add the agent chat surface (#1205)

* feat(workbench): add the agent chat surface

* docs(workbench): write the ported comments in English

* fix(workbench): label the folder and rename tools on the timeline

* fix(workbench): label the roster and voice tools on the timeline

The reconciliation test iterates every tool the runner registers and
requires a display label of its own. The roster and voice-clone tools
(list_voices, set_roster, clip_audio, register_voice) reached the
integration base with the roster/voice-registration tools but never
gained presentation rows, so they fell through to the default branch
and rendered their wire names. Port their rows from the reference
implementation (labels and i18n keys verbatim) and extend the
reconciliation allowlist with ROSTER_TOOL_NAMES and
VOICE_CLONE_TOOL_NAMES, so a future tool cannot enter the product
without a label.

* feat(agent): add the material extraction lifecycle (#1212)

* feat(storage): add material extraction lifecycle

* feat(agent): execute queued material extraction

* style: apply prettier formatting

* style: satisfy prefer-const in the extraction runner

* test: give material fixtures the extraction lifecycle fields

The media-tools slice and the extraction lifecycle slice were each green
in isolation but never compiled together: the lifecycle made derivedFrom
and extraction required on AgentSessionMaterial while the media-tool
fixtures predate them.

* chore: remove stray task notes

* fix(workbench): label the extraction lifecycle tools on the timeline

* feat(workbench): add the workspace shell (#1206)

* feat(workbench): add the workspace shell

* docs(workbench): write the ported comments in English

* i18n(workbench): align workspace keys across locales

* fix(workbench): adopt the landed data layer and label the extraction tools

- replace the sibling-slice seam stubs with the real data-layer modules
- drop ambient declarations now shadowed by landed files
- port timeline labels for the extraction lifecycle tools from the reference
- align the new i18n keys across all locales

* ci: retrigger

* feat(api): folder routes, stage-meta viewer surfaces, and the material upload contract (#1215)

* fix(storage): restore capability-based stage access

* fix(api): bind document access to request owner

* fix(agent): restore three-state stage access on the tool layer

Port probeStageAccess and the three-state StageAccess (owned / foreign /
missing / tombstoned) and gate every stageId-bearing stage tool on an owned
probe, mirroring the reference per tool:

- move_to_folder, rename_stage, read_stage_outline refuse a non-owned stage
  with the single not-yours message before touching the store.
- The course/DSL toolset and the roster toolset are wrapped by
  withOwnerStageAuthorization: read_stage, patch_stage, grep_stage and every
  writer refuse a foreign stage with the same message and refusal shape.
- Scene preview keeps its own probe and its own refusal text, and is
  registered beside the course toolset (never double-gated).
- The runner injects one probe factory at the three call sites.

Tests: the dsl cross-owner test premise (a foreign stage is readable by id)
encoded an invented capability-read policy that the reference does not have
at the tool layer; it now asserts foreign read/patch/grep are all refused
while the owner still reads. Curriculum cross-owner assertions were already
the reference's and now pass with the probes in place.

* docs: correct per-file test counts in the fidelity report

* test: fix type errors in stage-access fidelity test

* test: adapt media-tool and gate suites to the owner-scoped store seam

* feat(api): add owner-scoped course-folder HTTP routes

Port the reference implementation's /api/folders family (list, create,
rename, delete with ungroup/remove modes, and folder membership) onto the
owner-bound document store, replacing its provider-based auth with the
existing withRequestOwnerId / owner-scoped store seams.

The storage package's folder store grows the pieces the routes need:
DocumentFolder.order (schema column + max+1 assignment + ordering),
renameFolder, deleteFolder(mode) with captured member ids, and
setStageFolder(stageId, folderId | null) with idempotent un-filing.
FolderNameError moves into folder-name-validation.ts (stage-storage re-exports
it, keeping import sites intact).

Every route gates on the configured agent runtime (plain 404 when off or
unconfigured), keeps the reference's machine codes and envelopes, and is
covered by gate tests plus a behavior suite.

* feat(api): add stage-meta viewer surfaces for the classroom

Port the reference implementation's viewer-facing stage state — can-edit /
collected / published / generation-complete — on top of the stage-access
base (stage_meta + tombstones). stage_meta gains published_at and
generation_complete columns plus a stage_bookmarks table; the reference's
deployment-specific origin/claimed_at columns are stripped.

New gated routes: GET /api/stage-meta/[stageId] (per-viewer facts, 404 for
absent/tombstoned, never returns the owner id), GET /api/stages/[id]/status,
POST generation-complete / publish / unpublish (owner-only), POST
/api/bookmarks. The resolver lives in lib/server/stage-access.ts.

Wiring: a fetchStageMeta client with the reference's three-outcome contract,
stage-store isOwner/isBookmarked/readOnly fields (upstream single-user
defaults, no-op until the sidecar answers) plus setViewerAccess, the
classroom apply path computing readOnly = !(isOwner || isBookmarked), the
Stage editability gate, and a sidecar probe after each classroom load. A
sidecar 'absent' answer keeps the editable default here because the
classroom also serves local-only courses; server writes stay owner-enforced.

* feat(api): port the reference material upload contract

Rewrite POST /api/materials to the reference implementation's upload shape
so the workbench uploader (uploadWorkbenchMaterial, which posts no session
id and expects a flat 201 view) works unchanged: owner-scoped upload with
mime normalization/validation (415), per-class size caps checked on the
declared content-length and the streamed body (413), empty body (400),
quota (429), sha256 reserve->store->finalize lifecycle with abandon on
failure, flat { materialId, originalName, bytes, mime, extraction } 201,
and an x-request-id echo.

Adds the owner-scoped material library (owner_material table + quota +
24h lazy sweep, bytes in the host's asset registry as the neutral
replacement for the reference's object-storage byte path) and the material
cap configuration. The session-scoped GET list is left as-is; the
reference's owner-material extraction worker is not ported (the branch's
session-material extraction lifecycle already covers extraction).

Gate tests now cover all 23 persistence routes across the three runtime
env states; the materials behavior suite pins the new contract.

* feat(media): add an optional local ffmpeg media extractor (#1213)

Adds a local ffmpeg/ffprobe pipeline as a second media extraction
provider behind the extractor registry, ported faithfully from the
reference implementation: duration probing, keyframe-safe chunking,
per-chunk ASR with timeout and deadline budgets, and timestamped
transcript assembly.

- Availability probing feeds the registry's candidate selection: the
  provider simply is not a candidate when ffmpeg/ffprobe are absent.
- With neither ffmpeg nor a cloud provider configured, extraction fails
  with an actionable message naming both enablement paths.
- Media materials route through the same extraction lifecycle and lease
  fence as documents; no parallel queue.
- Tests inject the executable resolver so the missing-ffmpeg path is the
  default-tested one; the real pipeline test is skip-if-unavailable.
- @openmaic/storage 0.13.0 -> 0.14.0 (media routing in the material
  lifecycle surface).

* feat(storage): per-scene monotonic revisions via database triggers (#1214)

* feat(storage): per-scene monotonic revisions via database triggers

Restore the reference implementation's freshness granularity: a
per-scene monotonic revision maintained by database triggers, so every
writer (HTTP routes, agent tools, jobs, manual SQL) bumps it without
application cooperation.

- Companion revision tables + trigger functions in the storage package's
  idempotent schema bootstrap, with the lock-order invariant, pg_notify
  wakeup and the suppression switch for batch writers.
- ensureDocumentSchema gained a dollar-quote-aware statement splitter.
- The freshness and manifest routes serve per-scene revisions.
- Mutation-verified: dropping the triggers turns the revision tests red.
- @openmaic/storage 0.13.0 -> 0.14.0.

* fix: forward the freshness manifest through the owner-bound store

* feat(workbench): add the Pro entry points and preserve the mode-transition semantics (#1208)

* feat(workbench): add the Pro entry points

* feat(workbench): preserve Pro mode transition semantics

* fix(workbench): drop ambient declarations shadowed by landed slices

* fix(workbench): drop ambient declarations shadowed by the landed shell

* feat: port workspace shell sibling modules

Port the 16 leaf modules the Pro workspace shell imports but that were only
ambient-declared, replacing the compile-time bridge with real implementations
adapted from the sibling-slice reference: pure workbench helpers (session
title, rail tab, course-chat bootstrap, created-course tabs, course-tabs
memory, workspace navigation, pane navigation, pro-edit sizing,
existing-course minting, first-message session), the neutral brand context and
course-rename server API, the server-action session delete, the home
discovery hook, the classroom pane host with its load-policy leaf, the theme
toggle and floating-layer owner, plus the floating-layer-owner wiring the
dialog/dropdown/tooltip portals stamp.

Also add the workbench-shell locale copy for all 12 locales, port the
reference tests for the ported modules, and drop
types/workbench-sibling-slices.d.ts now that every declaration has a real
implementation.

* docs: keep ported comments in English and deployment-neutral

* docs: announce 1.0.0 and refresh the feature overview (#1216)

* docs: announce 1.0.0 and refresh the feature overview

* docs: finalize 1.0.0 README after feature merge

* fix(agent): control-plane routes answer 404, not 500, without a database

The agent control-plane routes gated only on the runtime flag, so an
enabled-but-unconfigured deployment (flag on, DATABASE_URL empty)
answered 500 from a store that cannot connect. Gate them on the
configured check instead, matching the stage/material routes: the
whole surface is cleanly absent until both the flag and the database
are present. The status probe keeps reporting both bits.

* test: mock both runtime gate exports in the control-plane route suites

* fix(agent): abort in-flight TTS on cancel and bound each provider request with a timeout (#1217)

The generate_tts / scene-tts path checked the runner's AbortSignal between
actions but never created the provider HTTP requests with it, so a session
cancel left a hung synthesis fetch in flight until a restart repaired the
tool result. Thread the signal end-to-end: TTSModelConfig carries an
optional signal, generateTTS combines it with a per-request timeout
(TTS_REQUEST_TIMEOUT_MS, default 30s, ported from the reference runtime's
TTS bounds) via AbortSignal.any, and every provider fetch (openai, azure,
glm, qwen incl. voice-clone + audio download, voxcpm, minimax, doubao,
elevenlabs, lemonade) is created with that signal.

A timeout now fails the tool call with TTSRequestTimeoutError (a clear
retryable error) instead of wedging the session; a caller cancel propagates
as the interruption so the runner settles the session as cancelled without
a restart.

Tests: hung-provider simulation rejects at the timeout with the retryable
error; abort mid-flight aborts the captured request signal and surfaces the
interrupted shape; removing the signal wiring makes the abort tests fail
(red), restoring them turns green.

* fix(workbench): PG-mode home listing via owner stages; keep the interrupted terminal course card (#1218)

Finding 1: with server persistence on, listStages resolved to the generic
GET /api/persistence/documents listing, which the capability model
deliberately answers 403 FORBIDDEN_DOCUMENTS for (reads by id, listings
owner-only). The home/workspace library now lists through the owner-scoped
GET /api/stages surface (same anonymous-owner cookie the workbench uses)
when server persistence is enabled; the server-side 403 is untouched.

Finding 2: a run interrupted (session_interrupted) and repaired
(session_resumed) that ends cancelled before agent_end stranded its pending
classroom sightings, so the timeline's terminal card lost the course the
answer produced. session_end (cancelled) now flushes the pending sightings
into the same course card set agent_end paints, before the stopped caption.

* chore(workbench): remove the bookmark concept and the saved-courses drawer (#1219)

* chore(classroom): remove the bookmark ('collected') concept entirely

The stage-meta viewer port introduced a bookmark surface (stage_bookmarks
table, POST /api/bookmarks, the isBookmarked sidecar field, and a
readOnly rule that let a saved course stay editable). The product has no
such concept, so remove it as a closure:

- delete the /api/bookmarks route and the stage_bookmarks table plus its
  query helpers from the persistence bootstrap
- drop isBookmarked from GET /api/stage-meta/[stageId]
- simplify the classroom read-only rule to readOnly = !isOwner across the
  sidecar client, ownership signal, classroom load, stage store and the
  classroom page
- keep publish/unpublish, generation-complete, isOwner and isPublic
  exactly as they were
- update the gate and stage-meta route suites and the README mentions

The workspace rail's Bookmark glyphs and comments describe the upstream
saved-courses (favorites) section, which is driven by isOwner and renders
no collect affordance; they are kept as unrelated homonyms.

* chore(workbench): remove the saved-courses drawer UI

The first pass removed the bookmark data model but kept the rail's
"Saved courses" drawer, judging it a separate surface driven by
`isOwner === false`. The home/workspace listing is owner-scoped, so that
flag can never occur: `allSaved` is permanently empty and the drawer
(plus the collapsed-rail Bookmark mini-button) is a dead affordance.
Remove it: the SavedDrawer component and its mount, the savedOpen /
savedSection state, the allSaved / matchedSaved derivations, the 'saved'
variant of the course-list renderers, the mini Bookmark glyph, the
drawer-only CSS, and the drawer's i18n keys from all 12 locales. The
courses tab is now exactly one folders tree.

The authored/favorites split in workspace-tree.ts goes with it; the tree
module no longer reads `isOwner`. The discovery course type keeps the
field — the shell still reads it for read-only gating.

Upstream has no collect concept; the drawer could only ever render empty
here. The reference implementation HAS this drawer (its favorites come
from its account system), so this removal is a deliberate upstream
product decision, not a fidelity bug.

* fix(workbench): restore the attach entry, add the rail settings entry, pin all three entry points (#1221)

* fix(workbench): restore the composer attach entry by gating it on the live runtime

The AttachButton's rollout probe read a `materialsEnabled` field that this
branch's /api/agent/runtime never answers (the materials routes gate on the
runtime itself, like the stages), so the gate could never pass and the attach
button never rendered — the Pro launch and chat composers showed only the
@-mention and enhance glyphs.

Substitute the field with the runtime's `enabled` value, which IS the upload
action's precondition: POST /api/materials answers 404 whenever it is false,
so the render condition now equals the action precondition (no dead button).

The button's label (`proMode.attach`) is a user-visible string that becomes
visible again; port the reference implementation's own translations verbatim
into the 11 locales that still carried the Chinese copy.

* feat(workbench): add the settings entry to the rail's bottom-left cluster

The reference's rail foot carries a cluster of utilities (its saved-courses
drawer, the language switcher, the display toggle). This branch removed the
drawer — it could only ever render empty here — and the product decision is
to fill that freed spot with the settings entry.

Add a settings trigger to the foot cluster (expanded rail, beside the
language and display toggles, and on the collapsed strip) and mount the
model/provider SettingsDialog in the rail, wired to the trigger. It is the
same dialog the classic home opens from its header pill; the workspace had no
settings entry of its own, so nothing is duplicated within a surface.

* test(workbench): pin the restored upload, attach, and settings entry points

Covers the three restored entry points:

- the courses-tab upload control: rendered beside the course name filter,
  wired to the discovery hook's ZIP import trigger, disabled while an import
  runs, and gated by the same condition as its action (the courses tab);
- the composer attach control: an actual render of AttachButton under both
  probe answers (visible when the runtime says the upload path is live,
  hidden otherwise), its mounts in the launch and chat composers, the
  branch's runtime-field substitution in the probe, and the reference's own
  `proMode.attach` copy in all 12 locales;
- the settings entry: the trigger in the rail's foot cluster (expanded and
  collapsed), beside the language and display toggles, opening the
  SettingsDialog the rail mounts.

* chore(config): the Pro workbench flag implies the MAIC Editor gate (#1223)

A workbench build without the editor toggle has no way to edit a course:
enabling NEXT_PUBLIC_PRO_WORKBENCH_ENABLED while forgetting
NEXT_PUBLIC_MAIC_EDITOR_ENABLED produced exactly that split-brain bundle.
The workbench IS Pro mode, so its flag now implies the editor gate; the
standalone flag remains for deployments that want the classroom editor
without the workbench. Documents both flags in .env.example.

* fix(agent): wake SSE tails and the runner on durable deltas (streaming fidelity) (#1222)

The Pro workbench chat did not stream: the session/owner SSE routes polled
the durable event log on a 5s/30s clock with no wakeup, so message_update
deltas (written at 150ms cadence) reached the browser in poll-sized blocks
and the thinking strip only mounted after the whole reasoning text had
accumulated.

Port the reference's LISTEN/NOTIFY delta path:

- storage: add in-transaction wake hooks (onSessionEventAppended,
  onOwnerEventAppended, onCancelRequested) so a host queues pg_notify in
  the same transaction as the durable append; align readEventsAfterForReplay
  to rank the bounded page so the first delta after the cursor is always
  kept (the live tail can never starve). Bump @openmaic/storage to 0.18.0.
- app: port the process-wide event-notify bus (dedicated LISTEN client,
  self-check probe, reconnect backoff; notify through the storage
  transaction surface), wire the store hooks, subscribe both SSE routes
  before the initial read with the reference's initializing gate, and give
  the runner one {kind:'session'} subscription whose wake runs the cancel
  check and the message drain. Polls stay as the lossy-NOTIFY backstop.
- lifecycle: start/stop the bus from instrumentation.

Tests: storage hook + compaction contract; route wakeup latency; runner
wakeup wiring with a fake agent; bus unit tests; PG contracts proving a
real append wakes the routes and a live SSE route forwards a message_update
on the wakeup, and that a rolled-back append never wakes. Also fix the
pre-existing park-attempt-budget PG test TRUNCATE (missing CASCADE against
newer FK tables).

* fix(storage): asset writes self-deadlocked against pooled PostgreSQL (#1225)

* fix(storage): refuse the non-transactional byte-write deadlock configuration

A byte store whose plain write() runs on its own pooled connection cannot be
invoked from inside a registry write transaction: after the transaction has
claimed the blob-row lock, that write blocks on the lock the transaction just
took while the transaction waits on the write - a self-deadlock PostgreSQL
cannot detect (one side is idle in transaction).

There is no lock-safe ordering for such a writer: bytes must be written after
the row claim (writing before it lets the collector delete the bytes while the
upsert waits), and any second-connection write after the claim is the
deadlock. The configuration is therefore detected and refused:

- AssetByteStore gains writesOutsideRegistryDatabase?: true, declaring that the
  layer's plain byte operations cannot contend for the registry's row locks.
- PgAssetStore refuses put()/replace() up front (and defends coordinatedWrite)
  when the byte store has no writeWith and does not declare the flag, throwing
  a clear configuration error before any row is claimed.
- The collector mirrors the guard on its delete path (deleteWith or a declared
  out-of-registry layer, else a configuration error).
- The object store declares the flag (its out-of-transaction write remains
  legitimate); the in-registry PostgreSQL byte column provides writeWith /
  deleteWith instead.
- Write transactions (put/replace/remove) set SET LOCAL lock_timeout = 30s so
  any future lock-contention variant fails loudly instead of hanging.

Bumps @openmaic/storage to 0.18.0.

* fix(persistence): forward the transactional byte methods through the lazy asset byte-store wrapper

The no-bucket case of lazyAssetByteStore returned a bare { write, read,
delete } and dropped writeWith/readWith even though the underlying
PgAssetByteStore has them. The registry's hasTransactionalWriter duck check
then failed and put() fell back to the byte store's own pooled connection,
which blocks forever on the blob-row lock the registry transaction just took
when the bytes live in the same PostgreSQL - the production self-deadlock.

The no-bucket layer is statically PgAssetByteStore, so its transaction-pinned
methods are forwarded eagerly (typed against the real signatures via
PgForwardedByteStore). The bucket case keeps its lazy-probing semantics: no
transactional writer exists there, the signed-URL method stays absent or lazy
exactly as documented, and the wrapper now declares writesOutsideRegistryDatabase
so the registry may run the plain write inside its transaction.

New tests pin the wrapper's transactional capability red-to-green and assert
put()/resolve() route byte traffic through the transaction-pinned queryable.

* fix(home): cap the generate-prep ingest drain at 3s so Generate never waits the full server budget

The classic home flow's Generate click drained in-flight ingests for the full
15s server budget. Cap the wait at GENERATE_DRAIN_CAP_MS (3000ms, documented as
a UX bound) and reuse the existing timeout fallback: sources that miss the cap
proceed on the legacy byte path and each late-resolving id is released.

* chore(storage): bump to 0.19.0 over the concurrently landed 0.18.0

* fix(agent): bound every tool call with a timeout; never resurrect a cancelled session (#1226)

* fix(agent): bound every tool call with a global timeout and settle it on cancel

A tool await that neither resolves nor rejects wedges the session forever:
the lease keeps heartbeating and the driver never reaches its next cancel
checkpoint. Race every tool execution (in buildAgent) against a hard budget
(OPENMAIC_AGENT_TOOL_TIMEOUT_MS, default 10 min, per-tool overrides for
known long runners) and against the caller's AbortSignal, so even a
signal-ignoring await cannot keep a cancelled session running.

On timeout the call rejects with AgentToolTimeoutError; the agent loop turns
the rejection into a structured error tool-result the agent can retry or
proceed from, and the abort signal is delivered to the tool's in-flight work
through a derived controller. Zombie-tool updates after settlement are
dropped.

* fix(storage): never re-lease a cancel-requested session; settle it as cancelled on claim

The claim scan treated a session with cancel_requested_at set as a normal
claim candidate: after a restart it re-leased the same session for attempt
N+1 and resumed generating despite the pending cancel. claimNextSession now
settles such candidates as cancelled under the claim lock (status cancelled,
attempt reset, lease and cancel request cleared, terminal session_end event
and owner projection) instead of leasing them, then keeps scanning.

Bump @openmaic/storage to 0.18.0.

* docs: takeaway-style 1.0.0 announcement with bilingual guide links

The 1.0.0 head is now a short takeaway block — badge links to the
official user guides (English and Chinese), five one-line highlights,
and pointers into Features and the workbench setup section — instead of
six dense paragraphs. The detailed provider-neutrality and freshness
notes move into the Features workbench section, phrased database-
neutrally (the announcement no longer names a specific database).
Release date corrected to August 27.

* fix(workbench): restore editor chrome, mode transition, streaming, materials, mentions, folders (#1229)

* fix(workbench): wire workspace folder routes

* fix(editor): restore reference workbench chrome

* fix(workbench): persist composer materials and course refs

* fix(workbench): preserve live reasoning frames

* fix(persistence): back off failed streaming saves

* chore(workbench): retire stale slice seams

* test(editor): cover element pin layer

* chore(storage): bump to 0.21.0 for the user-message ref/material fields

* chore(editor): translate ported code comments to English

* fix(agent): fence durable tool writes and consume cancel requests atomically (#1230)

* fix(agent): enforce provider force-off in agent tools and scrub vendor identity from tool results (#1231)

* fix(materials): serialize per-owner quota reservations and make crashed uploads reclaimable (#1232)

* fix(editor): resolve dock-bar i18n keys, remove dock height drag, wire element referencing (#1233)

* fix(workbench): send the opening session message exactly once with refs intact (#1234)

* feat(editor): port timeline TTS preview single-flight and voice-all state latching (#1235)

* fix(media): restore the reference classic media chain (#1236)

* fix(import): adapt imported PPTX canvas size so decks render without overflow (#1237)

* fix(editor): complete element referencing — renderer DOM contract and GenUI picking aligned with the reference (#1238)

* test(providers): reconcile the provider-config vendor-token debt count after the main merge

The integration line's AK/SK fallback for the managed document provider adds
occurrences that main's allowlist snapshot predates. Same mixed-composition
debt category the group already documents; no new vendor behavior.

* test(providers): reconcile vendor-token debt counts with the integration line

The main-merge brought main's neutrality-guard snapshot next to integration
features it predates (media-extractor fallback chain, local voice-profile
deletion semantics, the enabled-TTS helper). Same debt categories the guard
already documents; counts updated to the guard's own tally and two grouped
entries added. No new vendor behavior.

* fix(agent): carry reasoning through the completions dialect so the thinking strip renders (#1239)

* feat(skills): add Feynman and spiral curriculum methods (#1240)

* feat(agent): port missing reference tools and skills (parity audit) (#1241)

* feat(media): retire asset-registry wiring; media and materials follow the reference byte model (#1242)

* fix(classroom): center adapted canvases in the stage and send back navigation home during generation (#1243)

* feat(settings): skill management with real list, download, delete, and upload (#1244)

* feat(settings): skill management section with real list, detail, and zip download

* feat(skills): owner skill delete and upload across storage, API, and settings

* fixup! feat(settings): skill management section with real list, detail, and zip download

chore: neutralize a reference note in the settings header comment

* fix(media): persist origin-independent classroom-media references from the agent runtime (#1245)

* feat(editor): float the insert toolbar in the outer frame with collapse (#1246)

The insert strip was bounded to the slide card, so it could only ever sit on
top of slide content: the card's overflow clipped it and it could not be parked
in the padding beside the slide. Move it into the studio frame the element
picker's panel already roams (CanvasOverlayPortal + the frame selector), so both
canvas overlays share one bounding container and their handles behave the same.
While picking, the strip rises over the picker and goes inert, which is the
z-order CANVAS_OVERLAY_Z already documents.

Add a fold beside the grip: the chevron collapses the strip to that grip row and
back, with the buttons unmounted rather than hidden. The fold is session-local
state owned by EditShell, next to the drag offset, so a surface swap keeps it;
nothing is persisted. Expanding a strip parked at the bottom edge re-clamps
through the same bounds rule the keyboard move uses.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(workbench): align the chat timeline's left edge with the composer (#1247)

* fix(agent): fence session claims while an ask_user question is outstanding (#1248)

* fix(agent): settle-time rescue tracks real delivery instead of a count offset (#1249)

* fix(persistence): migrate owner_material to oss_key and drop legacy asset_id (#1250)

* docs(readme): surface the 1.0.0 user guide badges at the top (#1253)

* fix(workbench): show newly created folders in the sidebar without reload (#1254)

* docs(readme): add the release version prefix and drop the opt-in framing

* fix(workbench): single-source the chat gutter so timeline and composer share a left edge (#1255)

The transcript and the composer each established their own column: their own
`px-*` gutter and their own `mx-auto w-full max-w-*` centering wrapper. Equal
padding values were never enough, because the two columns are centered inside
different containing blocks — the transcript's is a scroll container, whose
content box is narrower than the composer footer's by the scrollbar's width:

  transcript text left = pad + (pane - 2*pad - scrollbar - measure) / 2
  composer box  left   = pad + (pane - 2*pad             - measure) / 2

The padding cancels out of the difference and what remains is `-scrollbar/2` at
every padding value, so the transcript sat half a scrollbar to the left of the
composer and tuning the two paddings against each other could not move it.

The column is now established once, by the nearest common ancestor of both
(`chatColumn`), and the scroll viewport and the composer footer are siblings
inside it that add no horizontal inset of their own. The cap carries the gutter
on top of the 760px reading measure, so the text column keeps its width. The
handed-over question row drops the padding that indented it past the agent's
prose; framed rows keep their own inner padding, which is what a card's border
sitting on the column edge means.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(workbench): lock pane-embedded classroom to edit mode (#1256)

The workspace right pane painted the full learning chrome — speed control,
play button, learner avatars, mic bar — for a course the agent had just
created, then flipped to edit once the first scene landed.

resolveStageChromeMode treated playback as the DEFAULT branch for a hosted
classroom, so every shortfall fell into it: a course whose tab opens at
stage_link time has no scenes yet, so currentSceneId is null and
isHostedSceneEditable is false. A folded pane parked the playback root
behind the fold and cross-faded it out over the pane on unfold, and a
failed editor chunk dropped into playback permanently.

Lock it at the pane instead of defaulting per entry path:

- WorkbenchPanelProvider — the single element that mounts a classroom into
  the workspace — publishes editPinned (visible && !playback). Every entry
  path passes through it, so none of them decides.
- The hosted resolution can no longer degrade to playback. Start Learning
  (workbenchLearning, new input, split out from pane visibility) is the one
  door; everything else resolves between the neutral loading shell and edit.
- Stage's chrome dispatch is exhaustive on chromeMode, so the playback root
  is no longer the else-branch of a condition about the current scene.

No flicker: chromeMode is resolved during render, and preloadEditor now
answers synchronously (isEditorPreloaded) so a remount with the chunk
already registered paints edit on the first frame. A failed import is no
longer cached forever, so the lock cannot strand the pane.

Standalone classrooms keep their stored mode unchanged.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 19:26:25 +08:00
wyuc 7dc51fa0de fix(editor): allow multi-tab edit mode by dropping the cross-tab edit lock (#1161)
* refactor(editor): extract enterEditMode from the Pro toggle

* fix(editor): drop the cross-tab edit lock

* chore(i18n): remove the multi-tab conflict strings

* docs(store): attribute order-rewrite safety to the regen lease
2026-08-24 12:27:35 +08:00
LING DUAN 2cbe4ae293 feat(pi): add Native Child whiteboard tools (#1152)
* feat(pi): add native whiteboard tools

* style(pi): format native whiteboard tests

* fix(pi): clarify whiteboard CAS handoff

* fix(whiteboard): reconcile runtime projection on open
2026-08-21 18:07:49 +08:00
0ef3a1d17e fix(editor): prevent class roster cards from shrinking (#1135)
Co-authored-by: tmt <tmt@MacBook-Air-4.lan>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-18 14:25:23 +08:00
wyuc e15780835a feat(media): convert legacy references to allocated asset ids (#1101)
Lands epic #1007 part 2(c): the app-side reference converter, the `audioUrl`
removal, and the `DSL_VERSION` bump as one delivery unit.

`convertDocumentAssetRefs` walks a loaded document (slide canvases, whiteboards,
video-manifest keys, speech actions) and rewrites legacy handles to allocated
pool ids: placeholders and `audioId`s with local bytes are ingested, a co-present
`audioUrl`/`audioId` pair collapses to one asset, a definitively dead URL
converts to an emptied reference, and a transient failure keeps both handles for
a later retry. Unavailable bytes leave the document untouched for a later open,
and a converted document returns by identity with no writes.

Conversion runs lazily on document open and at fetch time for server-generated
classrooms, with conversion and the first document save sharing the per-stage
document lock: once the document commits it owns every allocation, and every
failure exit rolls its pass ledger back. Legacy URL probes are bounded by a
per-probe timeout and a shared 60-second pass budget.

`SpeechAction.audioUrl` is removed from the DSL and every consumer reads the
converted shape; the server classroom generator keeps its derived-id + serving-URL
pair as an explicitly typed pre-conversion transport, consumed before persistence.
`DSL_VERSION` becomes 0.2.0 with the first real ladder entry, and
`@openmaic/dsl` goes to 0.9.0 per the release rule.

Deferred follow-ups are tracked in #1129: production principal derivation with
document/asset ownership aligned, conditional document writes, and hoisting the
aggregate probe budget into the export and video-timeline paths.

Refs #1007
2026-08-15 17:38:04 +08:00
xuyuanwei678 c38da84ef6 feat(renderer): decouple editor UI from the app (#1072)
* docs: define renderer editor task 1 integration

* docs: plan renderer editor task 1

* feat: gate renderer slide editor

* feat: adapt renderer editor intents

* feat: connect renderer editor to slide surface

* test: complete renderer intent shape fixture

* fix: restore renderer editor action previews

* fix: render editor alignment guides

* perf: smooth renderer element dragging

* feat: align renderer hidden element behavior

* feat: add renderer canvas host commands

* feat: add renderer canvas context interactions

* test: cover renderer context menu interaction

* fix: preserve renderer canvas interaction semantics

* docs: design renderer text editing

* docs: plan renderer text editing

* feat(renderer): add ProseMirror text core

* feat(renderer): add rich text editor controller

* feat(renderer): render editable text in place

* feat(renderer): enter text editing on click

* feat: connect renderer rich text editing

* feat: finalize renderer text edit lifecycle

* fix: stabilize renderer text edit commits

* fix(renderer): move elements from selected borders

* docs: design renderer editing UI text toolbar

* feat(renderer): publish editing UI contracts

* feat(renderer): add controlled text format toolbar

* style(renderer): format editing UI contracts

* fix(renderer): address Task 2 review findings

* fix(renderer): handle zero font sizes in toolbar

* feat(renderer): add editing UI color picker

* fix(renderer): address Task 3 color picker review

* fix(renderer): commit native color input changes

* fix(renderer): coordinate native color picker interaction

* feat(renderer): anchor text toolbar to canvas elements

* feat(renderer): compose editable canvas with text UI

* feat(editor): use renderer text toolbar UI

* fix(renderer): make editing UI tests type-safe

* fix(renderer): harden editing UI integration

* feat(renderer): add editable text UI

* feat(renderer): add image and shape editing

* feat(editor): extend renderer editing controls

* feat(editor): add renderer chart insertion

* docs: design renderer latex editor

* docs: plan renderer latex editor

* feat(renderer): add latex editor contract

* feat(renderer): add latex editor dialog

* feat(renderer): compose latex insert and edit UI

* feat(editor): integrate renderer latex editing

* fix(editor): consolidate latex toolbar actions

* fix(renderer): show toolbar hover labels

* test(renderer): cover localized latex tooltips

* chore(renderer): hide latex sample palette

* feat(renderer): add video editing controls

* docs: define renderer audio editing

* feat(renderer): render audio elements

* feat(renderer): add audio editing UI

* feat(renderer): add preview audio controls

* feat(editor): add renderer element clipboard

* feat(renderer): decouple editor UI from app

* chore(renderer): release 0.0.6

* fix(renderer): clear editor CI gates

* fix(editor): adapt renderer canvas to media resolution API

* fix(i18n): complete French editor labels

* fix(renderer): hide toolbar vertical overflow

* chore(renderer): address review cleanup

* chore(docs): remove planning artifacts from pr

* feat(editor): add transactional core package

* chore(editor): ignore local test cache

* build(editor): publish core entrypoint

* refactor(editor): route renderer changes through transactions

* refactor(editor): split renderer editing into editor package

* ci(editor): register editor package for release

* docs(editor): document package dependencies

* ci: rerun validation after editor release registration

* test(editor): validate deletion against existing element

* fix(editor): address transaction and package review findings

* fix(editor): validate transactions and restore editor styles

* fix(editor): protect required updates and react styles

* fix(editor): validate required transaction fields

* fix(editor): prevent insert popovers shifting canvas

* refactor(editor): own renderer editing capabilities

* fix(editor): harden editing transaction boundaries

* refactor(editor): modularize editor UI integration

* docs(editor): harden third-party integration

* fix(editor): preserve video resize aspect ratio

* fix(editor): discard text draft before deletion

* fix(editor): preserve renderer picking and line presets

* chore(editor): remove unused pick-layer import
2026-08-10 19:56:24 +08:00
ceb05adb41 feat(media): allocate generated assets through the registry (#1007 part 2, step b) (#1039)
* feat(media): establish shared asset ownership primitives

Introduce global browser asset-pool ownership, asset-reference collection, stage reclamation planning, and lease-based URL access. Carry allocated media identity through storage and generation boundaries with regression coverage.

* fix(media): enforce safe resolution across every consumer

Route image, video, thumbnail, presentation, and video-export consumers through one resolution state machine. Prevent opaque allocated or generated references from reaching render and export sinks, with fallback and ownership tests.

* fix(media): protect document-owned assets across mutations

Allocate pool bytes before compatibility writes and document commits, then roll back uncommitted generations safely. Preserve document ownership across edits, retries, imports, speech generation, scene changes, and stage deletion.

* fix(media): scope retries and tighten ownership guard

Scope retries to the target scene and slide and refuse ambiguous shared-reference mutations. Expand retry rendering coverage and keep direct pool URL resolution behind the shared lease owner.

* fix(storage): avoid nested lock during stage cleanup

Execute prepared reclamation plans against an explicitly deleted document so the compatibility cascade cannot re-enter the per-document lock. Cover deletion of stage-owned media rows even when the document has no references.

* refactor(media): confine reclamation to stage deletion

Remove inline pool and compatibility-row cleanup from element, speech, scene, and audio replacement flows. Keep whole-stage reclamation behind explicit stage ownership, preserve stage-less legacy audio rows, and document deferred document-truth sweeping.

* fix(media): close retry and resolution gaps

Restore shared source tasks after successful forks, scope retries across both whiteboard locations, and wait for parallel TTS workers before rollback. Resolve background media through import, rendering, and PPTX export paths while keeping retry controls visible over last-good bytes.

* test(media): execute the consumer safety matrix

Replace source-substring checks with resolver seam execution across all six UI consumers, stage hydration, and both exporters. Pin each rollback layer independently and harden the ownership guard against aliased pool imports.

* chore(packages): publish the additive DSL field

Bump the DSL patch version for the optional speech-action field. Keep the transitional reclamation policy app-owned and document the legitimate transaction rollback removals there.

* fix(media): make retry rollback task-safe

Delay allocation task re-keying until final document reconciliation succeeds, restore shared source tasks on failed forks, and require exact stage-whiteboard targets before falling through from a missed scene.

* fix(media): clear private assets and refresh leases

Delete the asset-pool database during the confirmed local-data wipe. Notify the app lease layer after same-id replacement so mounted consumers re-resolve current bytes without reaching into storage internals.

* test(media): pin closure safety guards

Exercise the CSS allocation boundary, unfiltered legacy-row ownership, delete-and-undo byte survival, and real distinct consumer seams. Restore the video-only manifest overwrite condition and share the direct video resolution hook across both element variants.

* test(media): narrow rollback element assertion

Narrow the reconciled slide element to an image before checking its source so the rollback regression remains type-safe under the full root compiler configuration.

* fix(media): close lease refresh races

Gate the first batch publication by unique resolved refs, then publish every replacement snapshot without mutating the prior React state object. Serialize invalidation behind pending releases, evict rejected refreshes, and register replacement observation at the pool boundary.

* fix(media): reopen pool after clear failures

Always evict the singleton once its store has been closed, including blocked and failed database deletion paths. Report blocked deletion as deferred and prove a later write uses a fresh live store.

* fix(media): isolate shared retry progress

Track forked regeneration under the selected element until it receives a fresh asset identity, leaving the shared source task and bytes untouched. Surface targeted failures through renderer task lookup and skip the redundant fork reconciliation lock.

* fix(media): close final asset retry gaps

Keep blocked asset clears fail-loud until a successful retry, with actionable settings guidance.

Clear failed shared-fork state across durable and live key spaces, and pin renderer lookups, lease publication identity, and committed-ref rollback protection.

* fix: preserve actionable retry failures

Localize the blocked cache-clear recovery hint across every supported locale and pin the deferred-error mapping.

Retain durable fork failure rows while retries run, deleting them only after successful generation so unstructured failures survive reload.

* fix(media): hydrate legacy stored video thumbnails

Home-page recent-video thumbnails regressed for legacy Dexie mediaFiles
rows keyed by gen_vid placeholders: the reworked hydration resolved the
row's bytes through the sealed resolver but never surfaced the stored
blob (and its poster) as object URLs for the preview card, so the CI
recent-video-thumbnail e2e specs found no visible element.

Hydration now materializes legacy stored video rows into blob URLs for
both the element src and poster while keeping the resolver invariants:
opaque refs still never reach a DOM src, and concrete addresses are
never blanked. Unit pins cover the seam so the vitest suite catches this
class without a browser.

* fix(media): resolve sole restored legacy video

Classroom playback restored tasks by exact document media references. Legacy gen_vid references can outlive the key used by the one persisted video row, leaving the player on a placeholder even though bytes were restored.

Select the sole completed stage video only for legacy sequential refs after exact and reconciled matches. Keep exact failures authoritative, refuse ambiguous candidates, and cover success, ambiguity, and failure precedence in unit tests.

* fix(media): scope legacy video recovery

Decide restored legacy video recovery once from the complete document and record it through the shared task lookup consumed by playback, editing, and resolved slides.

Keep ambiguous documents as placeholders, apply the same decision to thumbnail hydration, and preserve exact failure precedence.

* fix(media): exclude claimed video recovery tasks

Model restored legacy recovery as a two-pass match across document video elements and task rows. Remove tasks claimed by exact, targeted, or placeholder lookup before applying the sole-candidate fallback, and cover the ownership/cardinality matrix.

* fix(media): unify video element resolution

Centralize source, task, poster, and legacy recovery decisions for every video consumer. Ensure direct URLs win over opaque refs and play_video waits on element-targeted retry tasks.

* refactor(media): route legacy recovery through resolver

Let document-aware consumers request legacy video recovery through the unified element binding API, keeping thumbnail hydration on a single decision path.

* fix(media): preserve import refs and prefer pool bytes

Recognize unambiguous extensionless relative media addresses during classroom import. Resolve allocated export and thumbnail assets from the shared pool before falling back to lagging compatibility rows.

* fix(export): preserve concrete video sources

Route PPTX video elements through the shared media binding resolver so an unresolved opaque reference cannot replace a playable source. Complete browser and persisted-store cleanup when asset-pool deletion is deferred, while retaining distinct hard-failure behavior.

* fix(media): fork retries without exclusive ownership

Enumerate logical asset owners across every persisted document before allowing global pool replacement. Thread explicit targets through fresh-id rewrites and cover cross-document aliases plus unreadable ownership.

* fix(media): guard global asset reclamation

Share a fail-closed persisted-document liveness check between stage deletion and retry replacement. Preserve cross-document pool aliases while deleting stage-owned compatibility rows and cover enumeration failures.

* fix(media): cover complete slide asset references

Route slide media traversal through a shared mutable slot contract so backgrounds participate in export, thumbnail hydration, collection, and rewrite lifecycles. Snapshot complete surviving-document refs once per reclamation and preserve manifest-only owners while retaining fail-closed behavior.

* fix(media): preserve exclusive retry asset ids

Allow targeted retries to replace exclusively owned pool assets in place. Keep shared and unprovable ownership paths on fresh allocations, and pin production-shaped retries plus compatibility-row cleanup.

* fix(media): revalidate asset bindings at completion

Recheck repository-wide ownership before replacing generated media and fork scoped retries when exclusivity changed. Route video export selection through the unified resolver and keep concrete posters independent of task state.

* fix(media): count unflushed owners and broadcast replacements

The completion-time exclusivity proof read only the persisted document, but
slide duplication updates the Zustand aggregate synchronously and schedules
persistence behind a debounce. A retry finishing inside that window saw a
single persisted owner and replaced the bytes behind a reference the
duplicate also held. The proof now also counts owners in the live stage
snapshot when that snapshot represents the stage being retried, so an
unflushed duplicate forks instead.

Same-id replacement notifications were realm-local, so a second tab showing
the same classroom kept its lease pinned to the superseded blob URL. The
notification now travels over a BroadcastChannel; each receiving realm runs
its own observers against its own pool, so a spoofed message can at most
force a re-resolve. A missing or failing channel never fails the replacement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(media): bind replacement listeners and spare shared audio rows

A realm that only renders never sends a replacement, so binding the channel
from the sender path left passive tabs deaf to peers. Binding now happens
where the observer is registered, when the asset-pool module loads, and the
receiving realm resolves its own pool lazily so a cleared or unavailable
pool degrades to the next resolve instead of throwing.

Observer notifications ran under Promise.all, so a rejection surfaced after
BrowserAssetStore.replace had already committed and turned a durable success
into a reported failure. They are settled individually now; the callback is
wrapped because a synchronous throw would otherwise escape before allSettled
sees the array.

audioFiles rows are keyed globally by audioId, so deleting a stage removed
the sole row for an id a surviving document still referenced — playback and
both export paths read that table directly and cannot fall back to the
preserved pool blob. Rows are now filtered against surviving references,
while a failed enumeration still withholds only the irreversible pool
removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(media): serve replaced bytes from the pool everywhere

Unknown survivor liveness deleted every planned audioFiles row. Those rows
are keyed globally by audioId, and playback plus both export paths read the
table directly, so losing one is as irreversible for them as removing the
pool entry. Unknown liveness now preserves the rows too, leaving bounded
garbage for a later pass that can prove exclusivity.

The earlier pool-first change covered PPTX, video collection and thumbnail
hydration but missed classroom ZIP export, which still serialized the stale
compatibility row after a lagged same-id replacement, shipping media the
classroom no longer renders.

Auditing every direct reader of the media and audio tables surfaced the same
gap in playback: speech regeneration also replaces bytes under a stable id
and does not roll the pool back when the compatibility write fails, so the
player kept serving superseded narration. It now resolves the pool first and
falls back to stored rows for legacy and imported audio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(audio): replace exclusively owned speech clips in place

regenerateSpeechAudio accepted the action's audioId but always passed
undefined as replaceAssetId, so regenerating an exclusively owned
pool-backed clip allocated a new asset every time, rewrote the action and
orphaned the previous pool entry and compatibility row until stage
reclamation — contradicting the stable-id path generateAndStoreTTS already
implements for media.

Ownership is now established before synthesis, and the rule itself moved to
the shared reference module so media retries, poster replacement and speech
regeneration consume one implementation instead of restating it. An
exclusively owned clip keeps its id and has its bytes replaced; a shared
clip, a legacy id with no pool entry, or unprovable ownership still gets a
fresh allocation so other holders keep their audio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(media): drop the import left behind by the ownership move

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(media): resolve audio pool-first and fence peer realms

Stable-id TTS regeneration commits replaced narration to the pool before the
audioFiles mirror write, so a failed mirror leaves the row stale. AudioPlayer
already resolved pool-first, but audioObjectUrl, collectAudioFiles and the
video timeline dependencies still read Dexie directly and would serve the
superseded clip. All allocated-audio readers now share one resolver, with
Dexie kept as the fallback for legacy and imported rows.

The exclusivity proof modelled unflushed owners in the active realm only, so
another tab duplicating the same asset during its save debounce could still
be observed as a single owner and have its bytes replaced globally. A peer's
pending state cannot be read across realms, so presence is probed instead:
any realm holding the stage forces the fork path.

A deferred asset-pool deletion no longer reloads the page. The database is
still on disk and the guidance asks the user to close the other tab and
retry, which the reload discarded. The decision moved into a shared helper so
the rule is pinned rather than living inline in the component.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(media): fail closed when presence cannot be probed

The presence helper documented that an unanswerable probe must count as a
peer, but every unavailable path — no BroadcastChannel, a constructor that
threw, a send that failed, and the window before the pool's asynchronous
binding completes — returned false, so the ownership proof cleared a single
local owner and replaced globally shared bytes in place.

Probing now returns present, absent or unknown, and only a probe that was
actually sent and went unanswered is absent; the ownership decision treats
unknown exactly like present. The pool declares its binding intent
synchronously so a probe issued during the load-time window waits for the
bind instead of concluding that presence is unavailable, and releases that
gate if the import fails.

Coverage reaches the write boundary: with presence unknown, a
production-shaped targeted retry forks to a fresh id instead of calling
replace, and the original bytes stay intact for a peer's unflushed owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(dsl): bump to 0.6.3 after the release dedupe took 0.6.2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(i18n): add the deferred-clear guidance to fr-FR

The locale landed on main after this branch added the key, so the alignment
check flagged it as the one missing translation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-08-06 18:49:34 +08:00
杨慎 6f6b6eca07 fix(editor): localize Pro mode right rail labels (#1023)
* fix(editor): localize pro mode right rail labels

* fix(i18n): translate AI editor title

* fix(i18n): localize Korean roster classmate label
2026-07-31 05:30:02 -04:00
b4e3145c90 feat(roster): single-source the generated agent roster on the stage document (#994)
* feat(roster): single-source the generated agent roster on the stage document

The generated agent roster used to live in two places at once: the stage
document carried generatedAgentConfigs, while a per-stage IndexedDB mirror
(db.generatedAgents) was the actual read authority — and the only home of
each agent's voice binding. The split caused real losses: voices vanished on
device switches, exports, and shares (the mirror never travels), the load
path trusted a device-local table over the portable document, and roster
edits persisted through a bespoke debounce with no beforeunload flush.

Make the stage document the single source of truth:

- Contract: add optional voiceConfig/voiceDesign to GeneratedAgentConfig in
  @openmaic/dsl (VoiceDesign moves into the contract; the app re-exports it).
  Additive optional fields — not a breaking serialized-shape change, so
  DSL_VERSION stays put per the version policy.
- Writes: remove every mirror writer (registry saveGeneratedAgents, the
  stage store's debouncedSaveAgents bypass, classroom-load hydration, import
  direct writes). setStageAgents now only marks the stage dirty for the
  shared persistence scheduler plus synchronous in-memory registry/selection
  mirrors; generation and import embed the roster on the stage.
- Reads: classroom load hydrates the registry from the loaded stage's
  generatedAgentConfigs; export reads the stage roster directly.
- Lazy migration: the mirror is retained read-only. On load, a roster (or
  voice fields) missing from the document is backfilled from the mirror,
  committed onto the in-memory stage, and persisted by the next flush —
  idempotent, and gated on the current stage id after every await so a
  classroom switch cannot leak a stale roster into the registry.
- Deletion: deleteStageData discards the deleted stage's pending
  persistence work so a queued flush cannot resurrect the document.
- Round trip: classroom ZIP manifests now carry the voice fields, so
  export/import preserves agent voices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(roster): harden deletion, migration, selection, and ingress paths

Review hardening on top of the roster single-source change:

- Deletion vs in-flight persistence: deleting a stage now registers a
  session-scoped tombstone (lib/utils/deleted-stages.ts) before the cascade
  starts. Every persistence landing point checks it — the scheduled flush and
  the departing-stage retry drop their snapshots, and the aggregate/incremental
  saves re-check under the document lock, including the incremental path's
  full-save fallback that would otherwise rebuild a deleted document from the
  in-memory snapshot. A deletion that fails before the document is removed
  lifts the tombstone so the still-existing stage keeps persisting.
- Lazy migration termination: a fruitless legacy-mirror probe (nothing to
  merge — e.g. server-generated rosters, which are voiceless by design) is
  remembered per stage for the session instead of re-querying the mirror on
  every load forever. Successful merges are not memoized so a failed flush is
  retried on the next load. Docstrings now state the real lifecycle.
- Selection provenance: setStageAgents no longer overwrites a user-set agent
  selection with the full roster. Stage-derived selections still track the
  roster; a user-set auto selection is only narrowed to surviving agents
  (newcomers are not auto-selected, the user-set flag is untouched), and a
  user-set preset selection is left alone — matching restoreAgentSelection.
- Mirror hygiene: deleteStageData clears the deleted stage's rows from the
  legacy roster mirror (best-effort), and the mirror table's docs now describe
  its actual access pattern (migration reads + deletion hygiene).
- Registry persistence: the agent registry's persist snapshot partializes
  generated agents out of localStorage, making the stage document the roster's
  only durable home; the rehydration merge filter remains as defense in depth.
- Ingress validation: imported manifest voice fields are structurally
  validated (malformed bindings dropped per field, the agent survives), and
  applyGeneratedAgentsToRegistry validates providerId against the known TTS
  provider registry instead of casting, treating unknown providers as "no
  bound voice".
- Contract: drop the speculative AgentVoiceConfig.modelId — an audit of every
  roster producer, current and historical, found none that ever emitted it.
  Contract fields are added once a producer exists, not before.

New tests cover the deletion tombstone at both the store and storage layers
(including the missing-destination full-save pin and the lock-race re-check),
the fruitless-probe memo, the selection-provenance matrix, persistence
exclusion of generated agents, and voice-field sanitization on import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(roster): close tombstone lifecycle, probe-memo, and selection-intent gaps

Second hardening pass over the deletion/migration/selection fixes:

- Tombstone lifecycle: a deleted classroom id can legitimately come back
  (deletion is client-side only). Explicit (re)creation points now lift the
  tombstone — server-copy restore in applyClassroomStageAndScenes and backup
  restore in importDatabase — so edits after a same-session restore persist
  again, while in-flight flushes stay fenced.
- Failed deletion now restores the dirt it discarded: deleteStageData
  snapshots the pending map plus the in-flight round before tombstoning, and
  re-marks it when the delete fails with the document still present.
- Legacy-mirror probe memo now distinguishes a FAILED read (null, retried on
  the next load) from a confirmed-empty mirror (memoized), so a transient
  IndexedDB error cannot suppress migration for the whole session.
- Incremental save tail (currentScene KV row + chat sessions) is tombstone-
  fenced like the aggregate path, and both paths re-check the tombstone
  immediately before every write, with comments scoped honestly for the
  lock-free (no Web Locks) best-effort LWW fallback.
- Roster-edit selection mirror: an empty intersection now falls back to the
  full roster and clears the user-set flag (matching restoreAgentSelection's
  length > 0 gate), and a user-set selection that equals the pre-edit full
  roster keeps tracking the roster wholesale so newcomers are not excluded
  forever by the AgentBar auto-toggle snapshot.
- TTS/ASR provider lookups use Object.hasOwn instead of `in`/bare indexing,
  so prototype-chain keys ('toString', 'constructor', ...) no longer pass the
  provider whitelist or resolve to Object.prototype members.
- Documented the per-tab limit of the tombstone set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(roster): replace the boolean deletion tombstone with a deletion-generation model

A boolean tombstone cannot express the deletion lifecycle once a deleted id
legitimately comes back (server-copy restore on revisit, backup import): after
the restore lifts the flag, a pre-delete flush still in flight is
indistinguishable from a post-restore edit and can overwrite the restored
document with pre-delete content. Two rounds of point fixes kept leaking
variants of this race, so this restructures the fence instead.

deleted-stages.ts now keeps a per-stage monotonic deletion epoch plus a
deleted flag. markStageDeleted bumps the epoch; unmarkStageDeleted clears
only the flag and never rewinds the epoch. Every persistence path captures
the epoch at the moment it captures the data it will write (flush-round
snapshot, departing-stage snapshot, aggregate save entry) and re-checks
"captured epoch is current AND not deleted" immediately before each
individual write. Invariants:

- A write captured before a deletion can never land after it, even when a
  same-id restore has lifted the deleted flag (the captured epoch is
  permanently stale).
- A write capturing data after a restore observes the current epoch and
  persists normally.
- A failed deletion lifts only the flag; the restored pending changes are
  re-queued as descriptors, so their flush re-captures current store state
  under the current epoch and is neither dropped nor a stale replay.

Also fixed in the same restructuring:

- Incremental tail writes (currentScene, chats) each re-check independently;
  a delete landing while the first tail write is awaiting now fences the
  chat write instead of riding the earlier check.
- Deleting a classroom evicts it from the warm in-memory store, and
  loadFromStorage treats a warm-but-deleted stage as not loaded (discarding
  the ghost), so navigating Back to a deleted classroom reaches the
  server-restore path instead of rendering an editable ghost whose every
  edit is silently dropped.
- importDatabase records each document's pre-import deletion state and
  reinstates it when a failed import rolls the document back, so an
  outstanding flush cannot recreate the rolled-back document.
- Docstrings on the failed-deletion restore path now state that only dirt
  captured before the deletion is restored; edits refused during the
  deletion window are not part of the snapshot.

New tests cover the epoch invariants at the storage layer (pre-delete rounds
dropped across restores on the incremental, aggregate, and tail paths;
post-restore captures landing; strictly increasing epochs), the scheduler
layer (departing-retry delete+restore straddle; restored dirt flushing under
the current epoch), the warm-ghost restore driven through the real
loadFromStorage, and the import lift/rollback pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(deletion): report fenced drops, gate ghost eviction on cascade settlement

Edge hardening around the deletion-epoch fence; the epoch model itself is
unchanged.

- An epoch-stale drop is now reported, not disguised: saveStageData /
  saveStageDataIncremental return a distinct 'stale-dropped' status (entry
  check, every in-mutation re-check, and both tail-write re-checks) instead
  of a shape identical to success. saveToStorage reacts by skipping all
  success bookkeeping - no chatSnapshot rebind to a snapshot that never
  landed, no pending-dirt clearing - and returns false, keeping its
  "true means verified write" contract honest. persistDirtySnapshot keeps
  its existing (correct) semantics explicitly: a drop has nothing to retry.
- Warm-ghost eviction is settlement-gated: the deletion cascade records an
  in-flight bit (begun by deleteStageData, settled in its finally), and the
  deleted-warm branch of loadFromStorage keeps the warm state while the
  cascade is unsettled - a delete can still fail before removing the
  document, in which case the warm state plus the restored pending dirt is
  the only copy of the pre-delete edits. Once settled, the ghost discard
  and server-restore path behave as before.
- Read-side landing re-check: loadFromStorage re-checks isStageDeleted
  after hydration, immediately before set(), mirroring the write-side
  "re-check immediately before landing" discipline - a lock-free
  mid-cascade read can no longer re-materialize a ghost classroom.
- clearStoreForDeletedStage re-checks isStageDeleted at eviction time, so a
  same-id restore completing inside the cascade-tail window is never wiped.
- capturedEpoch is now a required parameter on both storage entry points:
  the default (call-time capture) silently reopened the capture-point/
  validation-point split for future callers; the type system now enforces
  the pairing. All production callers already passed it explicitly.

New tests cover the dropped-status contract, the cascade in-flight
lifecycle (including both failure shapes), keep-warm during an unsettled
delete with end-to-end pending restore after a failed delete, settlement-
then-eviction on success, the eviction guard against a tail-window restore,
and the read-side hydration re-check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(deletion): settle the deleted-warm load by awaiting the cascade outcome

Hardening around the deletion settlement gate introduced previously; the
epoch model is unchanged.

- The deleted-warm branch of loadFromStorage no longer completes the load
  while the deletion cascade is undecided (which exposed a classroom whose
  edits were refused without being restorable, and left a successful
  deletion's eviction with no follow-up reload). It now parks on a per-stage
  settlement promise (stageDeletionSettled) and branches on the outcome:
  a failed delete keeps the warm, restored classroom; a successful delete
  falls through to a full cold load so the server-restore path recovers the
  route; a navigation that moved on during the park is stopped by the load
  token guard. The eviction (clearStoreForDeletedStage) deliberately stops
  claiming a new load token so the parked load stays current - ghost
  re-materialization is fenced by the read-side deleted re-checks instead.
  No deadlock: a parked load holds no document lock, and the cascade never
  waits on a load.
- 'stale-dropped' now propagates through the debounced flush path:
  persistDirtySnapshot returns the sentinel instead of an empty failure set,
  and startFlushRound skips the chatSnapshot rebind on it (mirroring
  saveToStorage) - a falsely rebound baseline would let later chat saves
  no-op-skip chats that never landed and corrupt cross-tab conflict
  detection. Pending-map clearing keeps its empty-failure semantics
  (restored descriptors mint fresh revisions).
- The cascade in-flight state is a counter (begin++/settle--) instead of a
  boolean, and deleteStageData is single-flight per stage (a concurrent
  second call joins the first cascade), so overlapping deletions can never
  expose an undecided cascade as settled.
- The StaleDroppedSave docstring no longer overpromises: a tail
  saveCurrentScene can land after the cascade's clearCurrentScene; the
  orphaned cursor row is ignored by the load path rather than removed by
  the cascade.

Net +8 tests: overlapping-cascade counting, settlement-promise resolution
(success and failure), concurrent double-delete single-flight join, the
parked mid-cascade Back in both outcomes plus the navigation-away guard,
and the fenced flush round keeping an honest chat baseline end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(deletion): re-verify store identity on keep-warm resume, re-run joined deletes after a mid-cascade restore

Three hardening refinements on the deletion/persistence fencing:

- loadFromStorage's parked keep-warm branch now mirrors the success
  path's store-identity guard: if a tokenless writer (e.g. a database
  import) replaced the stage during the park, a failed deletion falls
  through to the cold load instead of reporting another classroom as
  the kept warm state. The ghost-discard block is correspondingly
  gated on the deleted flag so the fall-through does not mislabel a
  surviving document as a ghost.

- deleteStageData: a joined call carries its own deletion intent. When
  the first cascade fulfills but a same-id restore lifted the deleted
  flag in the meantime, the joined caller now runs one fresh cascade
  against the restored document instead of reporting the pre-restore
  outcome. Exactly one re-check per call (no recursion); a rejected
  first cascade still propagates unchanged to every joined caller.

- Narrowed the failed-delete restore-ownership comments: the restore
  covers only the pending map plus an in-flight flush round's dirt;
  a departing-stage snapshot is outside that capture and is fenced
  and dropped by design. Comment-only, no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(roster): gate deleted-warm handling on stage identity, keep an explicit empty roster authoritative

Final-audit fixes, three items:

- loadFromStorage keyed its deleted-warm settlement handling (park /
  keep-warm / ghost discard) behind the scenes.length > 0 shortcut, so a
  zero-scene warm ghost of a deleted classroom skipped it entirely: the
  cold load found no document, the ghost stayed in the store, and the
  classroom loader's server-fallback gate (!getCurrentStage()) never ran
  — the silently-uneditable trap this branch exists to close, in its
  scene-less form. Deletion handling now keys on stage identity alone,
  before any scene-count shortcut; the non-deleted warm skip still
  requires scenes.

- rosterNeedsLegacyFallback collapsed "roster field absent" (document
  predates roster persistence) with "explicitly persisted empty roster".
  A future writer persisting [] on a device whose read-only mirror still
  held stale rows would have its emptied roster resurrected on every
  load. The full-lift branch now triggers only when the field is absent
  (undefined); an explicit [] is authoritative and never probes the
  mirror. No current writer produces [], but the read side no longer
  depends on that invariant.

- The GeneratedAgentConfig docstring claimed the voice fields are not a
  breaking change; that only holds for this codebase's tolerant
  structural validators. The generated stage.schema.json sets
  additionalProperties: false, so consumers pinning an older published
  schema artifact reject documents carrying the new fields. The
  docstring now states both sides honestly.

Tests: a zero-scene deleted warm ghost is restored through the real
loadFromStorage (fails pre-fix), an explicit empty roster does not
resurrect stale mirror rows (fails pre-fix), and the
rosterNeedsLegacyFallback unit matrix covers undefined vs [].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(deletion): re-queue aggregate persistence when a failed deletion is recovered

Direct aggregate saves (saveToStorage: generation completion, server-restore
hydration) run outside the pending map and the flush round, so the deletion
path's pre-delete dirt snapshot cannot describe them. When a deletion bumped
the epoch while such a save was in flight, the save was correctly fenced
('stale-dropped') — but if the deletion then failed before removing the
document, the recovery only re-queued the snapshotted scheduler descriptors.
The fenced aggregate-only content stayed memory-only with no retry anywhere,
and a later reload lost it.

The failed-deletion restore now merges a full re-mark of the aggregate
(structure / stage / outline / currentScene / chats plus a descriptor for
every current scene) into the recovery set, still guarded on the store
holding the stage. Because the flush recaptures the CURRENT store state under
the CURRENT epoch, this necessarily carries whatever the fenced aggregate
save held — and, for the same reason, edits refused by the scheduler during
the deletion window, which previously stayed memory-only as well; the scope
docstrings now say so instead of declaring them excluded.

New integration coverage drives the real store scheduler and the real storage
layer end to end: a genuinely fenced in-flight saveToStorage (generation
completion) plus a pre-removal deletion failure ends with the completion flag
durable on the next flush; a deletion-window scene edit survives the same
way; and a successful deletion re-marks nothing (control). Both recovery
tests fail against the previous restore logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-27 14:39:19 +08:00
3eea9dc542 feat(runtime): complete the #869 learner-data cutover — quiz + playback onto RuntimeStore (#955)
* feat(runtime): persist quiz attempts in RuntimeStore

* fix(runtime): coalesce quiz draft snapshots

* fix(runtime): recover concurrent quiz attempts

* fix(runtime): handle quiz completion races

* fix(runtime): commit quiz review atomically

* fix(runtime): dedupe concurrent quiz writes

* fix(runtime): drop stale quiz drafts

* fix: harden quiz runtime review recovery

* fix: serialize quiz attempt identity

* feat: read quiz state from runtime store

* fix: persist quiz retries before resetting

* fix: preserve authoritative quiz outcomes

* fix: preserve legacy quiz retries during cutover

* fix: reconcile legacy quiz snapshots safely

* fix: drain rollover quiz write queues

* fix: drain completed quiz retry queues

* fix: reuse concurrent quiz retries

* fix(quiz): preserve drafts across abrupt reloads

* fix(quiz): recover empty retry sessions

* fix(chat): abort stalled runtime state reads

* fix(quiz): expose queued phases to readers

* fix(quiz): retain concurrent writer tails

* fix(quiz): canonicalize retry branches

* fix(quiz): keep retry rollovers monotonic

* fix(quiz): validate skipped retry siblings

* fix(quiz): close read cutover races

* test(classroom): cover legacy quiz summaries

* fix(quiz): reset async consumers on scene changes

* fix(quiz): close scene transition windows

* test(pbl): cover launch freshness guards

* fix(quiz): close cutover concurrency gaps

* fix(quiz): harden retry and context freshness

* fix(quiz): reject malformed legacy answers

* fix(quiz): validate legacy answer values

* test(quiz): cover legacy multi-answer migration

* fix(merge): retire dead ChatRequestTemplate.storeState after quiz read cutover

Main's three call sites built static storeState blocks that runAgentLoopFn
never consumed (it always rebuilds fresh state via getStoreState); the quiz
read cutover replaced that callback with the async two-phase RuntimeStore
read, leaving the template field with zero consumers. Drop it.

* feat(storage): conform HTTP/PG backends and reference server to RuntimeAppendOptions

The quiz write path's expectedLastSeq / sessionTransition / RuntimeAppendConflictError
semantics existed only in the browser backend; server-backed deployments would
silently accept conflicting appends and leave completed sessions active. Forward
the options over the wire, detect conflicts atomically under the PG transaction,
map them to HTTP 409 RUNTIME_APPEND_CONFLICT, and rematerialize the typed error
client-side so quiz retry logic works across every backend.

Co-authored-by: Codex <codex@openai.com>

* fix(chat): Pi single requests build storeState via the async runtime quiz read

Pi bypasses runAgentLoop's per-iteration getStoreState and serializes the
request template straight to /api/chat/pi, which rejects bodies without
storeState. Extract the fresh-snapshot builder (async RuntimeStore quiz read
with the scene-transition guard) and call it on the Pi path too.

* ci: whitelist the runtime-data-cutover integration trunk for PR checks

* feat(runtime): playback cutover — cursor in KV, discussion facts in RuntimeStore (#956)

* feat(runtime): cut playback over to the runtime layer — cursor in KV, facts in RuntimeStore (#869)

The fourth and last runtime family. Consumed-discussion facts become
append-only 'playback' records folded into a set at read (at-least-once
appends, no conflict machinery); the resume cursor is device-scoped
last-write-wins KV per the amended #779/#869 split. sessionStorage keeps
same-tab priority; KV takes over on fresh tabs/reloads. The dead Dexie
playbackState machinery is retired, with a one-time lazy migration of any
legacy row (cursor half + facts half) before deletion, and stage deletion
now clears both the KV cursor and any unmigrated legacy row.

Co-authored-by: Codex <codex@openai.com>

* test(runtime): include playbackState in the stage-delete db mock

---------

Co-authored-by: Codex <codex@openai.com>

* fix(playback): persist discussion facts on every consumption path (#957)

* fix(playback): persist discussion facts on every consumption path (final-review P0+P1s)

- The engine now publishes a progress snapshot the moment a discussion is
  consumed (join / skip / unselected-agent auto-skip). onProgress otherwise
  fires before the discussion action executes and a discussion is the scene's
  last action, so the fact never reached persistence.
- Reads fold records across ALL playback sessions in the learner partition
  (mergeLearner deliberately preserves same-kind sessions from both keys).
- Legacy migration appends only not-yet-durable facts, so an interrupted
  migration resumes instead of dropping the tail.
- recordConsumedDiscussion reports durability; the component drops failed ids
  from its observed set so a later progress tick retries (at-least-once).

* test(e2e): live verification of the playback persistence chain

Seeds a deterministic stage straight into the Dexie DB, starts the lecture
via the canvas overlay, and asserts the full chain: discussion auto-skip
appends a discussionConsumed record to maic-runtime, the device cursor lands
in KV, and both survive a fresh browsing context (empty sessionStorage).

* refactor(playback): consumed-discussion state is volatile by decision — cursor-only persistence (#959)

Product ruling on #869's fourth family: playback learner state is front-end
ephemeral UX, not learner data. A re-shown proactive card auto-skips, joined
discussions' content already lives in chat runtime records, and no replay
export / analytics consumer exists — so durable facts bought nothing over
in-memory + same-tab sessionStorage. Drop lib/playback/runtime.ts and the
RuntimeStore facts wiring; keep the device-scoped KV resume cursor (the half
with real UX value), the engine's consumption-time progress snapshot (cursor
freshness), and the legacy Dexie retirement (cursor half migrates, row
deletes, consumed ids are dropped).

* fix(review): P3 pair from cross-review — scene-id boundary + sessionTransition 400 (#966)

* fix(review): scene-id boundary for quiz context + 4xx for malformed sessionTransition (P3 pair)

Review findings on #955: didActiveSceneRemainUnchanged compared the active
scene by object identity, so a store update reallocating the scene during
the async quiz read dropped the learner's graded answers from that turn's
request — the scene id is the real boundary. The records route now
classifies a malformed sessionTransition as a validation failure instead of
letting the store's throw surface as a 500.

* fix(playback): superseded-engine cursor guard + migration write-window recheck

Second-vendor review of the #959 shrink (requested after the cross-review
noted it had single-vendor coverage) found: an engine orphaned by a scene
switch during async lecture resume could pass the idle-only recheck, be
resurrected, and publish its old scene's progress over the new scene's
debounced cursor — the resume continuation now requires identity with the
installed engine, and onProgress drops snapshots from superseded engines.
The legacy cursor migration also rechecks KV immediately before its write
so a concurrent tab's newer cursor cannot be overwritten and orphaned by
the legacy-row delete.

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Codex <codex@openai.com>

* fix(review): approval follow-up P3 nits (#967)

* release: v0.3.1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): approval P3 nits — ISO gate on sessionTransition, dead mocks, corrupt-timestamp guard

- The records route's sessionTransition guard now requires an ISO
  updatedAt (isIsoTimestamp), matching the sibling PATCH /status route
- Dead vi.mock factories for the deleted playback-storage module dropped
- A corrupt legacy playback timestamp falls back to 'now' instead of
  wedging migration into a permanent re-throw that disabled resume

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:28:04 +08:00
bb8d4f30e7 feat(chat): add experimental Pi classroom runtime behind a flag (#914)
* feat(chat): add experimental Pi classroom runtime

* fix(chat): harden Pi whiteboard action sequencing

* style(chat): format session retirement flow

* style(playback): format async scene transition

* fix(chat): retire actions on session errors

* fix(chat): close Pi runtime lifecycle gaps

* fix(chat): avoid replaying historical whiteboard actions

* fix(chat): prevent Pi structured-output residue from leaking and inflating turns

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(chat): expose whiteboard element/line ids and bound code-line context

Surface each element's id (and code line ids) in the Pi whiteboard context so
later child agents can target wb_delete / wb_edit_code, and cap the rendered
code lines with a shared character budget so a code-heavy board cannot grow the
prompt without bound. Return only the current turn's whiteboard ledger from the
Pi director loop instead of carrying history forward across requests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(chat): bound Pi whiteboard code context

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(chat): prioritize newer persisted whiteboard code in budget

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(chat): enforce whiteboard boundaries across Pi sessions

* feat(chat): refine soft-closing discussion controls

* fix(chat): make whiteboard clearing model-directed

---------

Co-authored-by: apple <apple@GigideMacBook-Air.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: LING-6150 <duan.lin@northeastern.edu>
2026-07-17 11:22:02 +08:00
cc5a6ab152 feat(agent): edit_elements — NL → EditIntent vertical (#895) (#896)
* feat(agent): add edit_elements NL → EditIntent vertical (#895)

Give the editing surface its first real consumer: natural-language
per-element style/geometry edits flow through the same EditIntent
vocabulary as pointer gestures, with an all-or-nothing validation gate
and one undo entry per instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent): harden edit_elements against CR findings (#895)

Re-validate intents at apply time (atomic refuse on missing/locked ids),
type-check prop values, require an open edit session for undo, clamp line
stroke width correctly, enforce group cohesion, nest shape text chrome,
and surface refuseReason on the tool card.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent): close remaining edit_elements CR residuals (#895)

Feed apply outcomes into next-turn history and post-run chat corrections,
phrase tool success as proposed-not-applied, and tighten gradient/filters/
enums/per-type value validation plus label-less shape text refusal.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(agent): derive edit_elements prop validation from the DSL schema (#895)

Three review rounds each found a hand-written validator disagreeing with
the DSL contract (a gradient pos range pinned at [0,1] against the
percent contract, a textType enum matching the DSL in neither direction,
per-type ownership gaps like vertical-on-shape and color-on-everything).
Replace the class instead of the instances:

- The gate now loads the dsl-generated JSON schema
  (@openmaic/dsl/schema/scene.schema.json) and validates every proposed
  prop value against the target element type's subschema via a compact
  schema-subset checker — per-type ownership, enums, and structured
  shapes (outline/shadow/gradient/filters) come from the single source
  of truth and stay correct as the DSL evolves.
- Explicit policy layers stay on top: the content/identity blocklist
  (schema would allow what policy forbids), geometry clamps (coords,
  min sizes, line stroke width), and a numeric-range overlay for what
  plain number types cannot express — gradient stop pos is a percent in
  [0,100] (the previous [0,1] check refused canonical gradients and the
  unit test pinned the wrong contract), fontSize/lineHeight/radius
  bounds, filter value length, stop-count cap.
- Consequences: color is refused on types that do not render it, with
  prompt guidance steering text color to defaultColor and shape body to
  fill; fill on charts is now allowed (the schema has it; the inventory
  already advertised it); textType accepts the full DSL enum.
- Apply-time revalidation also refuses element-type drift via a
  gate-time targetElementTypes map carried in the tool details.
- Refusal reasons render as visible card text, not only a hover title.
- Test pins added for all of the above plus selection scoping, coord
  bounds, non-finite rotate, and empty batch/props.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent): harden the schema-derived gate — fail closed, assert schema source, unify numeric clamping (#895)

- A schema construct the subset checker cannot judge (unresolved $ref,
  malformed anyOf, unsupported keywords) now refuses instead of
  accepting — the gate fails closed on the unknown.
- A module-load assertion verifies the loaded dsl schema carries every
  definition the gate maps and a sane TextType enum, so a stale or
  missing build artifact fails loudly with a rebuild hint instead of
  validating an outdated contract silently.
- Numeric-overshoot convention unified and documented: structural,
  enum, or ownership mismatches refuse (teaching the model the
  contract); numeric overshoot on a valid prop clamps (matching gesture
  behavior) — opacity now clamps like geometry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent): localize edit element refusals (#895)

* fix(agent): preserve partial element edit semantics (#895)

* fix(agent): prevent stale element edit application (#895)

* fix(agent): preserve live canvas edit invariants (#895)

* fix(agent): close element style contract gaps (#895)

* fix(agent): reject non-persistent element styles (#895)

* fix(agent): preserve rigid group geometry (#895)

* fix(agent): tolerate decimal group translations (#895)

* fix(agent): constrain line height edits (#895)

* fix(agent): reject stale or shadowed element edits (#895)

* fix(agent): close renderer contract gaps (#895)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-14 18:24:35 +08:00
wyucand杨慎 6f7ffa8302 chore: tighten GitHub issue intake (#921)
* chore: tighten issue intake

* fix: address issue intake review feedback

* test: preserve scene selectors in pro mode

---------

Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-14 15:44:57 +08:00
wyucand杨慎 40ff80ab63 feat(editor): add draggable insert toolbar (#912)
* feat(editor): add draggable insert toolbar

* fix(editor): improve toolbar accessibility

* fix(quiz): update empty-state guidance

* fix(settings): sanitize retired toolbar state

* fix(editor): keep hints from blocking controls

* fix(editor): refine toolbar and hint interactions

* fix(editor): preserve toolbar position across scenes

---------

Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-14 11:57:51 +08:00
danishsshaikh d00795094c feat(playback): add action-level navigation (#843)
Squashed from #843. Adds Notes click-to-jump action navigation and action-level playback resume/restore, with visible toolbar sentence controls removed after review.
2026-07-08 00:02:39 -04:00
wyucandClaude Opus 4.8 6b93980f82 feat(editor): redesign the narration timeline (action picker + inline insert) and enable it for interactive/PBL scenes (#834)
* feat(editor): enable narration timeline for interactive/pbl scenes

* docs(editor): fix stale timeline-gating comment in EditChromeRoot

* i18n(editor): add action-picker + addAction strings

* feat(editor): pure pickerOptions filter for the action picker

* feat(editor): ActionPicker popover (shared by header pill + inline +)

* feat(editor): header add-action pill opens ActionPicker; drop drag-chips

* feat(editor): inline + insert between cells opens ActionPicker

* style(editor): reskin SpeechClip + TTS footer to Pro Mode v2

* style(editor): align discussion dash tint to neutral primary token

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(editor): reskin CueMarker (bound label) + DiscussionClip + NodeDot

* fix(editor): complete picker i18n locales, label inline insert button, use primary token

- Add edit.timeline.addAction and edit.picker.{title,speechDesc,spotlightDesc,laserDesc,discussionDesc}
  to zh-TW, ja-JP, ko-KR, ru-RU, pt-BR, ar-SA locale files, mirroring the
  existing en-US/zh-CN structure.
- Give the inline "+" insert button in ActionsBar's DropZone an accessible
  name (aria-label + title) by threading an insertLabel prop from the root
  component, which already has the translator.
- Replace the raw bg-violet-500 active-divider glow in DropZone with the
  bg-primary token.

* chore(editor): drop dead drag-to-add path and orphaned i18n keys, explain disabled discussion option

* style: format picker-options with prettier

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 03:26:38 -04:00
wyucandClaude Opus 4.8 0b1304fe0c fix(editor): show per-line loading while the batch "regenerate all TTS" runs (#830)
Clicking the batch "regenerate all TTS" control (the timeline's "voice all"
button) only set a global in-flight flag and spun the button; each speech
line's status row (preview / regenerate) kept showing its old status, so the
batch gave no per-line feedback — even though the single-line regenerate path
shows a "generating" state.

Track the ids being regenerated in ActionsBar and pass a `regenerating` flag
down to each SpeechTtsBar; while set, the line shows the generating state
(spinner + disabled buttons) and the single-line regenerate is disabled (no
concurrent double-regen).

To avoid a ~1-frame flash back to the "not voiced" status at batch end (the
parent flag clears before the async audioExists re-check resolves), each line
latches a `batchPending` flag on the rising edge of `regenerating` (adjusted
during render, not in an effect) and clears it inside its own audio re-check
effect. The clear is guarded by `!regenerating` (and `regenerating` is an
effect dep, so a stale pre-batch check is cancelled at batch start), so only
the batch-end re-check clears the latch. The re-check is wrapped so an
IndexedDB rejection still clears the latch (the row can never wedge in the
generating state), and ttsRefresh is always bumped in the finally so every
line re-checks at batch end, including the all-fail case.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 03:05:51 -04:00
wyucandClaude Opus 4.8 6538e718ff feat(editor): in-editor authoring of classroom agents (Stage-level roster) (#816)
* feat(editor): agent roster edit operations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(editor): dedupe agent.delete lookup + cover history cap/color/teacherCount

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editor): materialize agent roster from presets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(editor): cycle prepended-teacher index + document branch-1 as-is

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editor): stage store viewMode + agent roster persistence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editor): useAgentRoster controller hook

Implements the AgentRosterController hook that bridges the store, history
stack, and agent-ops layer for the agents authoring view.

- Materializes roster from stage on mount via materializeRoster + resolvePreset
- Holds AgentRosterHistory in React state; exposes add/update/remove/reorder
- Guards LAST_TEACHER errors (caught, no-op) in update + remove
- Exposes SurfaceHistory-shaped history object for undo/redo wiring
- Syncs history.present to setStageAgents via useEffect on every change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editor): Agents view UI components

Adds AgentsView, AgentRosterGrid, AgentCard, AgentInspector, AvatarPicker.

Layout: full-height panel with a scrollable grid of agent cards on the left
and a fixed inspector panel on the right (opens when a card is selected).

- AgentCard: avatar + name + role badge + persona excerpt; ◀/▶ reorder
  buttons; delete button disabled + titled when canRemove=false (last teacher)
- AgentRosterGrid: responsive grid with trailing [+ 添加角色] add tile
- AgentInspector: name input, role select (teacher/assistant/student),
  AvatarPicker over AGENT_DEFAULT_AVATARS, persona textarea capped 2000 chars
- AvatarPicker: grid of AGENT_DEFAULT_AVATARS with violet selection ring
- AgentsView: composes the hook + grid + inspector; renders its own
  CommandBar (title="Agents", history=hook.history) with leading/trailing slots

Styling follows quiz surface conventions (rounded-2xl cards, zinc palette,
violet accent on selection, same FOCUS ring treatment for inputs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editor): [Slides]/[Agents] view toggle wired into Pro-mode chrome

- CommandBar: add optional `leading?: ReactNode` center slot for the toggle
- EditShell/Frame: thread new `commandLeading` prop down to CommandBar
- EditChromeRoot: read viewMode/setViewMode from useStageStore; build a
  ViewModeToggle ([幻灯片] / [角色] segmented control) passed as commandLeading
  to both EditShell (slides mode) and AgentsView (agents mode)
- When viewMode==='agents', render AgentsView instead of EditShell; leftRail
  and bottomRail are naturally absent since AgentsView owns its own layout
- When viewMode==='slides', existing EditShell path is unchanged
- CommandBar history in agents mode binds to useAgentRoster().history;
  title = "Agents"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): functional state updates in useAgentRoster + last-teacher role feedback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): break infinite render loop in Agents view persistence effect

Remove `stage` from the useEffect dep array in useAgentRoster — setStageAgents
mutates stage (new object ref), which was triggering re-render → effect → loop
(React error #185). setStageAgents already no-ops when stage is null
(lib/store/stage.ts:287), so the `if (stage)` guard is also removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): persist agent roster edits to registry + stage snapshot

- Add generatedAgentConfigs to StageRecord (database.ts) so Dexie can
  round-trip it through db.stages.
- Include generatedAgentConfigs in saveStageData whitelist (stage-storage.ts)
  so editor reloads restore the user's roster edits.
- In saveToStorage (stage.ts), call saveGeneratedAgents(stageId, configs)
  after the stage snapshot write; this is already debounced (500 ms) via
  debouncedSave, so db.generatedAgents + the in-memory registry are synced
  without writing on every keystroke. saveGeneratedAgents clears-then-
  bulk-inserts, so deleted agents are fully removed from the registry.
- Add 3 timer-driven tests that verify saveStageData carries
  generatedAgentConfigs and saveGeneratedAgents is called with the
  correct payload after the debounce fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(editor): scope agent-registry sync to real roster edits (cross-review)

- FIX A (P1): remove saveGeneratedAgents from shared saveToStorage; add
  dedicated debouncedSaveAgents called only from setStageAgents — scene
  advances (setCurrentSceneId etc.) no longer churn db.generatedAgents
- FIX B (P2.1): add isDirtyRef guard in useAgentRoster so opening the
  Agents tab without editing does not persist/rewrite the roster; ref is
  set in applyOp, add, undo, redo before any setHistState mutation
- FIX C (P2.2): coalesce consecutive agent.update ops on the same
  agent+fields into one history entry so undo steps over a whole edit
  rather than each keystroke
- Tests: add regression guards asserting saveGeneratedAgents is NOT
  called on setCurrentSceneId or plain saveToStorage, IS called after
  setStageAgents (new dedicated debounce path)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(editor): clone preset agents to fresh ids on materialization (cross-review P1)

* refactor(editor): move agent roster into right-rail tab beside Edit with AI

The [幻灯片][角色] top toggle + full-canvas AgentsView swap is replaced
by a tabbed right rail (RightRailTabs) with "Edit with AI" and "角色"
tabs. The slide canvas stays visible in all modes; agents are edited in
the 角色 tab of the right rail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(editor): restyle agent panel to Design B accordion (课堂阵容)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(editor): cross-review — persona draft sync, scoped preset re-id, AI-tab gating, dead-code cleanup

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(editor): avatar click expands card, editable-name affordance, add-button copy

* fix(editor): cross-review — drop blur-level undo coalescing, sync playback selection to edited roster

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(editor): prettier format agent panel files (CI fix)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 02:04:28 -04:00
wyuc 5daf3e5a6e fix(editor): keep emptied/zero-action scenes playable, bind outline by stable id, surface incomplete content (#814)
Editor empty-state robustness: dwell on zero-action scenes (engine + PlaybackChromeRoot) so emptied/blank slides stay playable; bind a scene's outline by stable outlineId (reorder/insert/duplicate-safe, allOutlines in scene order); clear the cue preview on glyph unmount; surface incomplete content calmly (amber dashed frame on clips, amber dot on blank outline titles + rail, neutral N/M-ready generate gate). Cross-reviewed (Codex + Claude), 5 rounds to a clean pass.
2026-06-30 05:57:26 -04:00
wyucandClaude Opus 4.8 b93434ae2c feat(agent-edit): multi-session conversation history for the AI editor (#801)
* feat(agent-edit): multi-session conversation history for the AI editor

The "Edit with AI" panel kept only one thread per stage in localStorage, and
"New conversation" deleted it, so previous conversations were unrecoverable.

Add a per-stage session list backed by IndexedDB (Dexie v12 `agentEditSessions`):

- "New conversation" now archives the current session and starts a fresh one
  instead of deleting it; a history popover in the panel header lists past
  sessions (auto-titled from the first user message) to switch back to or delete.
- One-time migration of the existing single-thread localStorage entry.
- The active session id is remembered per stage so a refresh restores it,
  including a just-cleared empty session (clean slate survives reload).
- saveSession preserves the original createdAt and tombstones deleted ids so an
  in-flight save can't resurrect a deleted session; switching stages drops the
  previous thread synchronously so it can't leak into the new stage.
- Sessions are pruned to a soft cap per stage and removed when a course is
  deleted.
- i18n for all 8 locales; unit tests for title derivation and the store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(types): drop redundant Window.SpeechRecognition augmentation conflicting with @assistant-ui/core

@assistant-ui/core's speech adapter declares Window.SpeechRecognition and
webkitSpeechRecognition globally as SpeechRecognitionConstructor. Three files
(prompt-input, use-audio-recorder, use-browser-asr) re-declared them as `any`,
which conflicts (TS2717) once that adapter's types are pulled into the program,
and the constructed instance then lacks the event handlers the code sets
(TS2339/2551). Rely on the global declaration and cast the instance to the local
rich shape. Takes `tsc --noEmit` back to zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:08:23 -04:00
杨慎 a93f875044 fix(agent): keep edit runtime alive across read-only scenes 2026-06-28 11:23:18 -04:00
wyuc a88ee3d119 feat(editor): add discussion authoring to the script timeline (#798)
Add an append-only 'Add discussion' control + inline editor (topic/prompt/initiating agent) to the script timeline, respecting the action-parser invariant that a discussion is the scene's last action and at most one per scene. Yellow terminal-anchor styling; agent picker (with avatars) sourced from the selected agents the playback engine gates on. Runtime unchanged.
2026-06-26 04:21:35 -04:00
wyucandClaude Opus 4.8 81dae36280 fix(editor): stop dumping raw tool-failure blob in AI edit tool cards (#785)
A failed tool card rendered the tool result's full text inline. For
framework schema-validation failures that text is a large blob — the
validation message plus the entire received-arguments JSON, including
the full interactive-page HTML — which floods the Edit-with-AI panel.

Remove the inline failure body. The card is non-expandable by design (a
single status row), so failure surfaces through the amber ✗ status mark
and its hover tooltip; the model also restates the actionable reason in
its own reply. Drops the now-unused `failText` prop from ToolCard and its
wiring at the three call sites, and refreshes the stale "surfaced inline"
doc comments.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:14:00 +08:00
wyuc 7cb129183d chore(packages): publish the @openmaic/* SDK family to npm (#778) (#780)
* chore(packages): publish the @openmaic/* SDK family to npm (#778)

Prepares the @openmaic/{dsl,renderer,importer} family for its first npm
publish, and moves the SDK packages onto the @openmaic scope.

Why the scope move: the @maic org name is unavailable on npm (an unscoped
`maic` package already holds the name), so @maic/* is not claimable. @openmaic
matches the project name, the scope is free, and the repo already ships an
@openmaic/docs package — so the SDK family now lines up with that convention.

- rename @maic/{dsl,renderer,importer} -> @openmaic/* across packages, the
  workspace glob, the package dir, and all import sites; lockfile regenerated
- renderer: add publishConfig (public, registry.npmjs.org) — was missing, so
  a scoped publish would default to the wrong registry / restricted access
- importer: add a files allowlist (dist, README, LICENSE) and drop the
  fragile .npmignore blacklist that shipped src; add an exports map so ESM
  consumers resolve dist/index.js instead of falling back to the .cjs main
- all three: add a prepublishOnly build (+ test/typecheck) guard so a publish
  can never ship a stale or empty dist
- add a tag-triggered publish workflow with npm provenance, pinned by name to
  the three @openmaic packages so the vendored forks (mathml2omml, pptxgenjs)
  are never published

Refs #778, #720 (Phase 1).

* fix(packages): address cross-review on the @openmaic publish prep

Cross-review (Claude /code-review + codex) on this PR surfaced:

- renderer's advertised CJS entry was broken: it keeps @openmaic/dsl external
  and imports a runtime enum from it, but dsl is ESM-only (no `require`
  condition), so `require('@openmaic/renderer')` would throw
  ERR_PACKAGE_PATH_NOT_EXPORTED. Make renderer ESM-only: drop the `.cjs`
  rollup output, `main` now points at the ESM build, and the `require`
  conditions are removed from `exports`. (importer is unaffected — it bundles
  dsl, so its CJS build still works.)
- prepublishOnly re-ran the test suite during `pnpm -r publish`, so a flaky
  test after dsl had already published gave a non-atomic partial release.
  Reduce prepublishOnly to a build-only guard (never ship stale/empty dist)
  and move the real test/typecheck gate into the workflow, before any publish.
- document that an @openmaic/* tag publishes the whole family via `pnpm -r`
  (pnpm skips already-published versions); the tag is a release marker, not a
  per-package gate.

Verified: dsl + renderer + importer build; renderer emits ESM only (0 .cjs),
all exports entries resolve; `npm pack` ships dist + README + LICENSE with no
src leak; frozen-lockfile passes.

Refs #778.

* style: reflow @openmaic/dsl type imports past print-width after rename

The @maic -> @openmaic rename lengthened two single-line type imports past
prettier's 100-col width; prettier --check flagged them. Pure formatting.

Refs #778.

* docs(importer): mark @openmaic/importer browser-only (cr-loop accepted limitation)

codex cross-review flagged that the published @openmaic/importer throws
`XMLHttpRequest is not a constructor` when loaded in a pure Node process —
its rollup build is browser-targeted (`nodeResolve({browser:true})` + a
browser pdf.js build). The app only consumes it client-side ('use client'),
so this is by design. Document it as an accepted limitation: prominent
browser-only note in the README and a `browser` field in the manifest.

Refs #778.
2026-06-24 15:12:23 +08:00
wyucandClaude Opus 4.8 dcf8a63333 fix(generation): make outline type changes take effect (interactive/PBL no longer downgraded to slide) (#772)
* feat(generation): changeOutlineType total constructor for valid type switches

Switching a scene outline's type in the editor only flipped `type` and left
the new type without its required config, so applyOutlineFallbacks silently
downgraded interactive/pbl back to slide. changeOutlineType returns a new
outline that strips foreign per-type config and seeds the target type's
required config from the shared fields, valid by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(generation): keys for interactive widget kind and PBL project config

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(editor): make outline type changes take effect with config forms

TypePill now builds a valid outline via changeOutlineType (replace, not
partial-merge) so a type switch drops stale config and seeds the new type's
required config — no longer silently downgraded to slide by applyOutlineFallbacks.
Adds InteractiveConfigDisclosure (widget kind + concept) and PblConfigDisclosure
(topic / description / target skills), mirroring QuizConfigDisclosure, so users
can configure interactive and PBL scenes that previously had no input UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(editor): move type-specific config under the type pill

The quiz/interactive/PBL config disclosure rendered as a detached chip at the
bottom-left of the card, reading like a stray key point. Pin it to the
bottom-right of the key-points row instead, so it sits directly under the
TypePill it configures (type top-right -> its config bottom-right, same edge),
with key points flowing on the left.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(editor): cascade type config as a connected segment left of the type pill

Replace the standalone config chip with a cascading control in the header: the
type-specific config (quiz count / interactive widget kind / PBL project) renders
as a themed segment joined to the left of the TypePill, the two clipped into one
rounded pill split by a hairline divider. Reads as a single 'config › type'
cascade and groups the config with the selector it refines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(editor): address cross-review — preserve widget config, drop stale fields

- changeOutlineType: preserve an existing procedural-skill widget config
  instead of replacing it with a seeded simulation (codex P2 — task-engine
  fields were silently dropped on Interactive re-select).
- InteractiveConfigDisclosure.setWidgetType: reset widgetOutline to the shared
  concept only, so switching widget kind drops the previous kind's fields
  (claude P2 — language/gameType/etc. leaked across kinds).
- i18n locale-coverage test now also checks ko-KR and pt-BR.
- Drop a redundant WidgetType cast; add tests for procedural-skill preservation
  and pbl targetSkills dedupe/cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(editor): round 2 cross-review nits — procedural-skill label + skill a11y

- InteractiveConfigDisclosure: when an outline carries a preserved
  procedural-skill widget, surface it as the current (selected) kind with its
  own label instead of mislabeling it 'Simulation', and make a same-kind
  re-select a no-op so its task-engine fields are never silently clobbered.
- PBL target-skill remove button uses a dedicated removeSkill aria-label.
- Add widgetProceduralSkill / removeSkill i18n keys (8 locales) + test coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(generation): make re-selecting the current outline type a no-op

changeOutlineType rebuilt the outline on every type-menu click, so re-selecting
the already-current type dropped fields it doesn't re-seed — legacy
interactiveConfig, or a partial pblConfig whose projectTopic is empty — turning a
harmless click into data loss. Short-circuit same-type selections to return the
outline untouched (codex P2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(format): prettier --write files left unformatted on main

These 14 agent/edit files (not part of this PR's feature) fail the repo-wide
`prettier . --check` in the required Lint job — they landed on main unformatted,
turning main's CI (and therefore this PR after a branch update) red. Formatting
them here unblocks the required check; pure formatting, no logic changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lint): suppress intentional set-state-in-effect in reasoning timer

The reasoning timer does a deliberate one-shot setState in its effect to show
the right elapsed value before the first 1s interval tick. main's CI fails the
required Lint job on the new react-hooks/set-state-in-effect rule here; suppress
it with a justification (no behavior change). Unblocks the required check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:48:07 +08:00
wyuc 1d1ce80e04 feat(maic-agent): editor-agent line (v0) — Pro-mode "Edit with AI" (#777)
Promote the editor-agent line to main: read-then-act editor agent (read_scene_content, regenerate_scene, regenerate_scene_actions, edit_interactive_html), a routable maic-agent model stage, the agent sidebar UI, interactive-scene runtime-error capture, and the timeline/script editor. Cross-reviewed and smoke-verified; see #777.
2026-06-23 16:56:37 +08:00
wyucandClaude Opus 4.8 13b42206f6 fix(generation): don't regenerate a deleted slide on a finished deck (#769)
Deleting a slide in Pro/Edit mode removed it from `scenes` but left its
entry in the persisted `outlines` plan. On the next classroom mount the
resume-generation pass treats the now-missing `order` as a pending
outline and regenerates the slide, appending it as the last page.

Rather than prune outlines by `order` (which is rebalanced by Pro-mode
insert/duplicate/reorder, so it is not a stable key and would mis-target
or break delete-undo), gate resume on a persisted `generationComplete`
flag:

- The flag is recorded (alongside outlines, in the stageOutlines record)
  when generation finishes — both generateRemaining completion paths, the
  retry-to-completion path (markGenerationCompleteIfDone), and the
  already-materialized-at-mount path — and restored on load. It is written
  only after a verified scene flush, so it never outruns the deck's scenes.
- A finished deck is frozen for editing: loadFromStorage no longer derives
  pending placeholders from orphaned outlines, the classroom resume effect
  skips generateRemaining, media resume is filtered to materialized
  outlines, and isCourseComplete honours the flag — so a deleted slide
  neither regenerates nor leaves a phantom pending page.
- Decks generated before the flag existed self-heal on load (every outline
  has a scene ⇒ inferred complete). deleteScene additionally records
  completion when deleting from a deck that is complete right now, so the
  "Course complete" end page and resume-suppression survive even when the
  flag was never persisted (e.g. edited without a reload).

`outlines` are never pruned, so order-rebalancing and delete-undo are
unaffected. Genuinely interrupted generations (not complete) still resume.

Fixes #768

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:23:17 +08:00
wyucandClaude Opus 4.8 4dce1f7414 refactor(types): import the slide DSL directly from @maic/dsl; drop the shim (#738)
#707 left lib/types/slides.ts as a thin `export * from '@maic/dsl'` shim so the
~100 existing `@/lib/types/slides` import sites kept working unchanged. Point
them at `@maic/dsl` directly and delete the shim, so the app consumes the
package contract with no indirection and the legacy module path is gone.

Pure module-specifier migration — every symbol imported from
`@/lib/types/slides` was already a re-export of the same `@maic/dsl` symbol, so
behavior is identical. Also redirects the two `lib/types` relative importers
(`./slides`), the e2e fixture's relative import, and the dynamic
`import('../types/slides').Slide` type alias in stage-storage; merges the now
same-module import pair in slide-edit-elements.

Verified: tsc --noEmit clean, eslint clean, vitest 804/804, next build OK,
e2e (Playwright) 19/19.

Part of #720 (Phase 1).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:04:06 +08:00
cdc04b7670 fix(interactive): keep-alive iframe pool — interactive scenes no longer reload on remount (#619) (#629)
* fix(interactive): keep-alive iframe pool so interactive scenes don't reload on remount (#619)

Interactive scene iframes reloaded on every remount — Pro mode toggle,
scene switch and back, any PlaybackChromeRoot remount — because the
<iframe srcDoc> was rendered inline in the scene subtree and React's
unmount (or a DOM re-parent) makes the browser drop and re-run the doc.

Persist the iframes in a stable host at the Stage root, outside the
mode-swap/scene subtree, keyed by sceneId:

- lib/store/interactive-iframe-pool: per-scene entries (srcDoc/src, content
  hash, tracked rect, visibility), LRU-bounded (CAP=3). Re-mounting with an
  unchanged hash keeps the existing content reference, so the iframe is
  never re-set; only a content-hash change rebuilds it.
- InteractiveIframeHost: mounted once in Stage, portals one persisted
  <iframe> per entry to document.body, positioned over each scene's rect.
  Hidden (visibility, never display:none) in edit mode and whenever the
  placeholder is gone. Owns the widget-iframe postMessage registration.
- InteractiveRenderer: now a placeholder that registers content, marks the
  scene active/visible, reports its rect via a rAF loop, and hides (not
  evicts) on unmount.

Mode toggle, back-and-forth scene switching, and re-renders now hit the
cache with zero reload and zero flash. widget-iframe messaging API is
unchanged; no change to the playback engine / SSE / edit-lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): interactive iframe keep-alive across Pro toggle + scene switch (#619)

Seeds an interactive scene whose widget holds click-counter state, then drives
the two remount triggers (Pro mode toggle, scene switch and back). A
MutationObserver scoped to the keep-alive iframe's title asserts the element is
never removed or recreated across either trigger, and the in-iframe counter
state is preserved — i.e. zero reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(interactive): address review — widget callback timing, fullscreen, content compare, rect gate (#619)

Code-review round-1 fixes for the keep-alive iframe pool:

- PlaybackChromeRoot: resolve the widget postMessage callback lazily at send
  time instead of capturing it once at engine construction. The iframe (and its
  registration) now lives in the keep-alive host, which registers a commit after
  the engine is built — so eager capture got null on a scene's first visit and
  silently dropped every widget action.
- InteractiveIframeHost: portal target follows document.fullscreenElement so the
  iframe stays inside the presentation-fullscreen subtree (requestFullscreen runs
  on the stage, not body) instead of vanishing. Known tradeoff: entering/exiting
  fullscreen re-parents the iframe and reloads it once — accepted over an
  invisible widget during presentation.
- InteractiveIframeHost: reset the pool when the host unmounts (classroom switch)
  so a new classroom doesn't retain the previous one's resident iframes.
- InteractiveIframeHost: only show the iframe once its rect has a real measured
  size (width>0 && height>0), avoiding a 0x0 flash at the viewport origin before
  first layout.
- pool: drop the djb2 hash; compare srcDoc/src by value (string === is value
  equality, so an equal-but-new remount string still hits the keep-alive path).
  Removes the hash-collision-skips-rebuild hole and simplifies the store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(interactive): owner-token visibility so stale cross-fade cleanup can't blank the iframe (#619)

cosarah reproduced: after toggling Edit mode on an interactive scene and
returning to playback, the scene flashed its preserved state then went
permanently blank. Root cause is a visibility race, not a reload — the document
stayed alive. Visibility was keyed only by sceneId, so during Stage's
AnimatePresence mode cross-fade the outgoing PlaybackChromeRoot's placeholder
cleanup (hide) ran after the incoming placeholder had already shown the same
scene, hiding the live iframe.

Give each placeholder mount a visibility-ownership token (useId). The pool
records the owner on claim(); release() only clears visibility when the caller
still owns it, so a stale cleanup no-ops instead of blanking a newer instance.
The host now shows iff entry.owner !== null.

- store: replace visible boolean + show/hide with owner + claim/release.
- interactive-renderer: owner = useId(); claim on mount, release on unmount.
- e2e: after returning from Pro mode, wait out the cross-fade and re-assert the
  iframe stays visible with its state (guards the blank-on-return regression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-06-01 23:42:24 +08:00
wyucandClaude Opus 4.8 47d28145b4 feat(maic-editor): slide surface — MAIC Editor v0 (epic #562) (#615)
* feat(maic-editor): framework primitives + edit StageMode (#564)

* feat(maic-editor): framework primitives + edit StageMode

Phase 1 framework foundation for the MAIC Editor (RFC #547,
tracking #560). Plumbing only — no UI consumers ship in this
sub-PR; the EditShell chrome and slide surface registration
land in follow-ups.

- StageMode gains 'edit' alongside 'autonomous' | 'playback';
  setMode resets canvas selection when leaving 'edit'.
- <Stage> auto-exits 'edit' whenever the current scene becomes
  uneditable (no scenes / pending generation / no current
  scene) so a follow-up Pro toggle can never strand the user
  in an empty edit shell.
- SceneEditorSurface contract + tiny registry under lib/edit/
  so each SceneType plugs in a surface without the shell
  importing surfaces directly. Surfaces declare CanvasComponent,
  useSurfaceState(), insert palette items, floating actions,
  commands, and (reserved for AI) inline coach hints.
- Slide kernel (lib/edit/slide-ops.ts): immutable, history-aware
  operations covering slide-update, element add / update /
  updateMany / delete / deleteMany / reorder / duplicate / align /
  removeProps, and text content edit.
- Slide element factories (lib/edit/slide-edit-elements.ts) for
  default text / shape / image elements + HTML <-> plain-text
  helpers.
- i18n: stage.editCourse + stage.doneEditing across all 6
  locales, consumed by the header toggle in the next sub-PR.
- Vitest coverage for the slide kernel (operations + history)
  and the edit-mode store transition (entry + canvas reset on
  exit).

PBLRenderer's mode prop is widened from the literal pair to
StageMode so <SceneRenderer> (which already passes StageMode)
type-checks. The prop is unused inside the renderer.

* style(maic-editor): apply prettier to lib/edit + tests/edit

CI runs on PRs to main only, so the prettier check did not fire
for this PR's target branch — applying formatting locally before
the merge train reaches main avoids a follow-up style commit.

* ci: also run on PRs targeting feat/maic-editor-v0

The MAIC Editor lands as a series of stacked sub-PRs against
the long-lived feat/maic-editor-v0 branch. Without this entry,
none of those sub-PRs get a CI gate — style/lint/type/test
regressions only surface when feat/maic-editor-v0 finally
merges back to main, at which point fixing them is a lot more
disruptive than catching them per sub-PR.

Push trigger is intentionally left main-only: nobody pushes
directly to feat/maic-editor-v0, every change arrives through
a PR that now runs the gate.

* fix(maic-editor): address kernel review on #564

Addresses cosarah's review against #561 scope:

Important:
- element.align now uses the canonical lib/utils/element.ts geometry
  helper instead of a forked copy. The local fork ignored
  PPTLineElement start/end and rotation, so bounds were wrong for
  lines and rotated elements.
- Cap slide-edit history at MAX_HISTORY = 50; drop oldest on overflow.
- Narrow slide.update patch to Partial<Omit<Slide, 'elements' |
  'animations'>> via a new SlideMetaPatch alias, so element /
  animation collections can only be mutated through their dedicated
  ops.
- element.add throws on id collision; element.duplicate throws when
  idMap is missing entries or when new ids would collide with
  existing elements.
- scene-editor-registry dev-warns on overwriting a *different*
  surface for the same SceneType (HMR re-register of the same
  instance stays silent); add unregister() for HMR cleanup and tests.

Minor:
- Unify on structuredClone over JSON.parse(JSON.stringify(...));
  inside immer's produce, un-proxy with current() first.
- Skip history push when produce returns the same content reference
  (true no-op detection). element.delete / deleteMany pre-check
  membership so their unconditional filter assignments don't break
  the ref-equality signal.
- Drop redundant cloneSlideContent calls in undo/redo/push paths;
  immer's structural sharing already guarantees immutability of the
  produced output. createSlideEditHistory keeps its defensive clone
  since the initial value comes from outside immer.

Coverage:
- Extract auto-exit predicate into lib/edit/stage-mode.ts so the
  policy can be unit-tested without rendering <Stage>.
- New tests: every align direction, line / rotated element align,
  no-op paths for update/delete/reorder/removeProps/text/align,
  element.add index clamping + id collision, element.duplicate
  default offset + contract errors, history future cleared after
  branching, history capped, registry register/unregister/HMR-safe
  re-register, and the auto-exit predicate.

371 vitest tests pass (was 335). tsc/lint/prettier/i18n/build all
green locally.

* fix(maic-editor): close kernel escape hatches (subagent CR follow-up)

Two defense-in-depth fixes flagged by independent review after the
prior commit:

- element.duplicate now deep-clones the source via
  structuredClone(current(element)). The previous shallow spread
  shared nested mutable references (start/end tuples, outline,
  points) with the source; immer's COW would have handled most
  mutations but ops that operate on nested arrays in place
  (sort/reverse/splice) would silently leak between source and
  duplicate. The deep clone keeps the kernel's invariants
  independent of how downstream op consumers write their recipes.

- slide.update gains a runtime guard that throws when patch
  contains elements / animations. The type-level SlideMetaPatch
  narrowing already forbids these keys, but the runtime guard
  closes the `as any` escape hatch for callers that might bypass
  the type system.

New tests cover both paths: meta-only slide.update succeeds, an
elements-containing patch throws, and a duplicated line element's
start/end/points tuples are independent from the source.

* feat(maic-editor): EditShell chrome and Pro mode toggle (#565)

* feat(maic-editor): EditShell chrome and Pro mode toggle

Adds the scene-type-agnostic editor chrome (EditShell + CommandBar +
FloatingToolbar + HintRail), an edit-mode sidebar, and the header Pro
toggle that flips into the 'edit' StageMode from #561.

No scene editor surfaces are registered yet — the next sub-PR wires up
the slide surface. In this PR every scene type falls through to the
i18n unsupportedScene placeholder, which is the verifiable visible
behavior.

- canEdit gating reuses the canonical isCurrentSceneEditable predicate
  shipped in #561 so the toggle and the auto-exit effect are in
  lock-step.
- handleToggleEditMode tears down live session / engine / TTS before
  entering edit mode.
- ChatArea slides out in edit mode for a full-width canvas.
- reorderScene extracted from EditModeSidebar with unit tests; the
  positional-order preservation is the part worth a guard test.
- i18n scoped to keys this PR's components actually reference;
  surface-specific keys deferred to the slide-surface PR.

* test(reorder-scenes): single-element + reference-inequality cases; zh-CN newSlide distinct from addSlide

CR follow-ups:
- reorderScene tests now cover a 1-element array (both directions
  return null) and explicitly assert the returned array is a new
  reference, not the input.
- zh-CN edit.sidebar.newSlide was duplicating the addSlide label
  ("新建幻灯片" both); using "未命名幻灯片" for the default new-slide
  title to match the English Add slide / New slide distinction.

* refactor(maic-editor): drop EditModeSidebar; clean Pro mode chrome (#568)

Course-correct on #565. EditModeSidebar was rejected by the design
owner as inappropriate for Pro mode (#560 wording is "minimal top
bar + slide thumbnail rail", not a file-list panel). #565 also left
the playback chrome wrapped around the editor — Header / sidebar /
Roundtable / ChatArea all stayed mounted with only the sidebar
swapped, and EditShell's CommandBar/FloatingToolbar/HintRail were
never visible since no surface registers yet.

Drop EditModeSidebar + reorder-scenes helper + tests + the edit.sidebar
i18n block (8 keys x 6 locales) + the CommandBar sidebar-toggle.

Stage keeps Header mounted in both modes — it owns the global Pro
toggle Switch, which is the entry AND exit affordance (closing the
Switch exits; no separate Done-editing button). In edit mode:
SceneSidebar / Roundtable / ChatArea are not mounted, and the canvas
slot renders <EditShell scene> instead of <CanvasArea>. EditShell
internally resolves the surface via sceneEditorRegistry; when none
is registered it falls through to edit.unsupportedScene. With no
surfaces registered, every scene type lands on that placeholder —
the visible v0 behavior.

New optional EditShell.leftRail slot reserves the spot for a
redesigned slide-navigation surface; v0 ships with the slot empty.

SceneRenderer is now playback-only — the mode === 'edit' branch and
its sidebarCollapsed / onToggleSidebar props moved up to EditShell /
Stage.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): enablement infrastructure (pre-slide-surface) (#571)

* feat(maic-editor): enablement infrastructure (pre-slide-surface)

Pre-requisite for the slide surface (#562). Ships the safety
infrastructure so each subsequent surface PR is small and
recoverable:

1. Feature flag NEXT_PUBLIC_MAIC_EDITOR_ENABLED, default OFF —
   gates the Pro toggle in Header. StageMode unchanged.

2. SlideContent.schemaVersion + pure idempotent migrateSlideContent
   / migrateScene; setScenes / addScene funnel legacy data through
   the migrate at the store boundary.

3. tests/edit/round-trip/ harness: apply ops -> buildPptxBlob ->
   JSZip parse -> assert content survived. No PPTX -> Slide reimport
   exists in the codebase, so the full reimport-diff shape isn't
   doable; per-op assertions extend the harness in #562.
   buildPptxBlob is now exported (hook is still the only runtime
   caller).

4. Per-scene slide-history persistence helpers (persist / load /
   has / clear, keyed maic-editor:slide-history:${sceneId}, swallow
   storage failures) + standalone SlideHistoryRestorePrompt dialog
   + 4 new i18n strings x 6 locales. Stage wiring deferred to #562.

5. Concurrency guards: isSceneEditLocked predicate (defensive; no
   current call path structurally hits it); localStorage-backed
   multi-tab edit lock with tryAcquire / refresh / release / heldByOther,
   stale-lock takeover after 3x heartbeat; standalone
   MultiTabEditConflictPrompt + 3 new i18n strings x 6 locales.
   Stage wiring deferred to #562.

The slide-surface PR owns the edit-entry effect machinery (where
the history-state lifecycle and per-tab tabId ref naturally live),
so shipping half-wired dialogs here would speculatively build Stage
state we know we'll restructure on contact with the surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): migrateSlideContent forward-compat — no silent downgrade

CR follow-up: previously, content with schemaVersion newer than
CURRENT (e.g. v2 written by a future client) was silently truncated
back to the current version. Now: if schemaVersion >= CURRENT, return
the content untouched. The slide may not render correctly on an older
client, but its on-disk shape stays intact for the next compatible
client to read.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): slide surface skeleton + #571 wiring + geometry (#562) (#579)

PR1 of the slide-surface work (infra-first slice). Registers the slide
SceneEditorSurface so EditShell lights up Pro mode for slide scenes.

- SceneEditorSurface impl + sceneEditorRegistry registration; the surface
  owns a SlideEditHistory via the #564 kernel.
- Reuse the unmodified slide renderer Canvas through a surface-owned
  scene context; geometry drag/resize/rotate commits funnel into
  element.update ops (scene-edit bridge), one gesture = one undo step.
- Geometry numeric x/y/w/h/rotate popover as the precise fallback; gated
  off for line elements (PPTLineElement omits height/rotate).
- Wire #571 infra: cross-tab edit lock + conflict prompt, slide-history
  persistence + restore prompt, regen-lock guard.
- Renderer-commit classification: a real geometry gesture commits
  synchronously inside a pointer interaction; the renderer's
  ResizeObserver text-normalization commits with none, so it is folded
  into the baseline (no undo step / no persist / no spurious restore
  prompt on entry) instead of being staged as a user edit.
- Per-op round-trip test for element.update geometry; bridge + session
  unit tests; edit.geometry i18n across all 6 locales.

Upstream-shared changes are kept minimal and additive: an optional
`controller` prop on SceneProvider (uncontrolled/playback path
unchanged) so staged edits don't write through to the live stage store,
and a FloatingToolbar trigger-nesting fix (it wrapped PopoverTrigger
around <Tooltip>, a provider, so no popoverContent action could open).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): lock data-URL image PPTX round-trip (PR2 R1 gate)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): insert palette — text box + image (data-URL/URL)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): address Task 1 review — spy cleanup, popover-only comment, ImagePicker error log

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): drop PR1 debug geometry toolbar; element-aware floating bar

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): drop redundant PPTTextElement cast (Task 2 review)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): additive ProseMirror command bridge for the property bar (C1)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): exhaustiveness guard + tidy C1 adapter (Task 3 review)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): refresh property-bar attrs on caret/keyboard selection (C2)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): satisfy no-explicit-any in PR2 test stubs (Task 1+4 review)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): compact text property bar in the reused floating slot

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): Task 5 review — uniform selection-guard, Lucide icons, JSX, memo

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n(maic-editor): edit.text.* + edit.insert.* across 6 locales

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): round-trip gate for formatted text + inserts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(maic-editor): clarify remote-URL image round-trip scope (Task 7 review)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): drop stale scaffolding comment + orphaned geometry i18n keys

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style(maic-editor): prettier --write PR2 files (pre-push check)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): make no-explicit-any suppression prettier-robust

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): CommandBar insert popover never opened (PopoverTrigger wrapped a Tooltip provider)

Insert→Image was unreachable: InsertButton wrapped <PopoverTrigger asChild>
around <Tooltip> (a context provider, no DOM node), so Radix's Slot bound
no element. Chain both triggers onto the real <button>, exactly mirroring
the PR1 fix already in FloatingToolbar's ActionButton. PR2's insert-image
is the first popoverContent InsertButton consumer to exercise this path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): text property bar no longer clips/overflows in the floating popover

The ~450px single-row bar was jammed into FloatingToolbar's fixed w-72
(288px) PopoverContent and clipped. Let the popover size to content
(w-auto, max-w-[92vw], Radix handles edge collision) and harden the bar
row (w-max + no child shrink, fixed-width font select) so it renders as
one clean line. Chrome/surface layout only — no renderer change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): property bar stays open across consecutive formatting steps

execCommand refocuses the editor after every command; the uncontrolled
Radix popover treated that focus-shift as focus-outside and dismissed,
forcing a re-open of the Text bar for each format action. Prevent
onOpenAutoFocus (don't steal the canvas selection on open) and
onFocusOutside (editor refocus must not dismiss); Escape and pointer-down
truly outside still close it. Chrome-only, no renderer change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): editor canvas now resolves gen_img_* media placeholders

The editor's interactive ImageElement rendered elementInfo.src raw,
so entering Pro mode on any slide whose image was a generation
placeholder showed a broken-image icon (while playback's read-only
BaseImageElement correctly resolved the placeholder to the generated
objectUrl). Extract the resolution into a shared useResolvedImageSrc
hook so both variants stay aligned. Strictly additive: for any
non-placeholder src (legacy / direct URL / data URL) resolvedSrc ===
elementInfo.src and the media store is not subscribed to. Pre-existing
upstream gap surfaced by PR2 as the first real-user editor consumer —
same shape as the CommandBar popover-trigger fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): unit-test gen_img placeholder resolution (9 cases)

Splits useResolvedImageSrc into a pure resolveImageSrc function (no
hooks) wrapped by the hook, so the resolution logic can be unit-tested
in vitest's plain node environment (no jsdom/RTL needed in this repo).
Covers: done→objectUrl; no task→raw; pending/generating/failed→raw;
done with no objectUrl→raw; cross-stage isolation; no-stageId path;
non-placeholder src passes through (the additive contract).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): auto-save edits to stage store, drop staging UX

Reverses PR1's "staged edits don't write through to the live lesson"
design. The slide-edit-session now writes through every history move
(applyOp / user commit / ResizeObserver normalization / undo / redo)
to useStageStore.updateScene as the canonical source of truth, which
Dexie already auto-persists. The renderer reads from the stage store
via the controller's getSnapshot.

Removes the entire staging surface that has no place in a modern
editor (Figma/Notion/Google Docs have no "unsaved changes" concept):

- DEL  lib/edit/slide-history-persistence.ts  (localStorage layer)
- DEL  tests/edit/slide-history-persistence.test.ts
- DEL  components/edit/SlideHistoryRestorePrompt.tsx  (restore dialog)
- DROP pendingRestore field + restore() action from slide-edit-session
- DROP restorePrompt branch + handlers from useSlideCanvasController
- DROP edit.history.restore.* keys across all 6 locales

Edits now flow: user input → renderer onUpdate → controller.updateSceneData
→ slide-edit-session.commitContent → writeThrough(useStageStore.updateScene)
→ Dexie. There is nothing "unsaved" to restore, by design.

The session retains its in-memory undo/redo history (per Pro session)
and the user-vs-ResizeObserver gesture classification (so reflow
doesn't push undo steps).

Test suite rewritten to assert write-through on every history move and
no write-through on seed (the stage already has that content).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): address PR review — element delete affordance + cross-platform fonts

Two issues from review on #586:

1. A selected image/text element couldn't be deleted — the renderer's
   delete lives only in a right-click menu, undiscoverable in Pro mode.
   Add a Delete button to the FloatingToolbar for any single selected
   element (text or image), dispatching the existing element.delete op.
   Button-only, consistent with #560's keyboard-shortcuts deferral.

2. Switching fonts had no effect on macOS Chrome — the property bar's
   font list was a hardcoded SimSun/SimHei set (Windows-only system
   fonts the renderer never loads). Use OpenMAIC's canonical FONTS
   registry (configs/font.ts) — the web fonts the renderer actually
   loads, so a pick renders identically on every platform.

Adds edit.delete × 6 locales + the parity-test key; floating-actions
unit tests for the delete action.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): selection-anchored text editing for the slide surface (#590)

* feat(maic-editor): add resolveEditingElementId text-editing policy

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): drop text-format floating action (moves to anchored bar)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): surface hooks to derive and sync editingElementId

Add useResolvedSlideContent / useEditingTextElementId / useSyncEditingElementId.
Realign the PR2 buildFloatingActions tests with the new behavior (text
formatting moved off the FloatingToolbar) and co-locate the editing-state test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(ui): export PopoverAnchor from the popover wrapper

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): add useTrackedRect for element screen-rect tracking

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): add AnchoredTextBar selection-anchored format bar

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): wire anchored text bar + editing flag into SlideCanvas

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): draw a clean solid frame for the text element being edited

Gated on the canvas store's editingElementId (default ""), so the dashed
select frame is unchanged for multi-select and for any consumer that never
sets the flag. Editor-path only; playback never renders Operate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): drop the editor focus ring so text editing shows one frame

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style(maic-editor): prettier-format the editing-state test import

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): anchor the bar to the text element node, not the wrapper

Code review caught that #editable-element-{id} is a zero-size absolute
wrapper — measuring it would pin the bar to the canvas origin. Measure the
.editable-element-text child, which carries the real geometry. Also correct
the dismiss-behavior comment: the bar is purely selection-driven.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): modernize the text format bar UI

Replace the native <select> font picker with the design-system Select,
rebuild the size control as one cohesive stepper pill, swap the color "A"
for a swatch chip, and unify every control to a single height and hover/
active language (violet accent, matching the editor's Pro-mode accent).
Behavior and the text commands are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): curate the font picker to fonts the app actually loads

configs/font.ts listed 29 fonts but the app only ever loads Inter (via
next/font); the other 28 had no @font-face or bundled file, so picking them
silently fell back with no visible effect — and nothing but the format bar
even imports the registry. Trim it to what genuinely renders; the file's
comment records how to restore the rest (wire up font loading first).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): load the picker fonts via @fontsource

The font registry listed 29 fonts the app never loaded. Wire up a curated
set that genuinely renders — 思源黑/宋, 霞鹜文楷, 站酷快乐体, and 9 Latin
families — via @fontsource packages (npm-managed, no font binaries in the
repo; CJK faces are unicode-range-subsetted so they download lazily per
glyph range). app/editor-fonts.ts registers the @font-face CSS from the
root layout; configs/font.ts is now the real, honest 14-entry list.

The ~14 commercial decorative Chinese fonts are intentionally left out —
they need self-hosting + subsetting + a licensing review, separate work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): quote font-family names so spaced/numeric ones work

Picking a font whose family name has spaces or a trailing digit (e.g.
"Source Sans 3") threw `Failed to execute 'check' on 'FontFaceSet'` —
`document.fonts.check(\`16px ${name}\`)` needs the family quoted — and the
fontname mark's toDOM emitted an invalid unquoted `font-family`, so the
font silently never applied. Quote the family in both spots; the mark's
parseDOM already strips quotes, so the attr still round-trips clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): make the editing frame pointer-events-none

The clean editing frame is a purely visual full-size overlay, but it was
pointer-events: auto — so it masked the text element's own move cursor,
text cursor, click-to-place-caret and drag-to-move; only a thin uncovered
sliver at the edges still triggered them. The dashed BorderLines it
replaced are thin edge lines, so they never had this problem. Mark the
frame pointer-events-none; the resize/rotate handles are separate and
keep their own pointer events.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): drop the "font is loading" toast

With @fontsource fonts and font-display: swap, a picked font swaps in
smoothly on its own — the "Font is loading, please wait..." toast was
noise (and fired on most CJK picks while a unicode-range chunk loaded).
Remove it along with the now-unused document.fonts.check and toast import.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): move the delete action onto the anchored text bar

A text element's contextual actions now sit together on the anchored bar —
format controls + delete, hugging the element — instead of delete sitting
alone in the top-center FloatingToolbar. buildFloatingActions returns
nothing for text (its FloatingToolbar then renders null); non-text
elements still get their delete there. Delete logic is shared via a new
deleteSlideElement helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): anchor the delete action for image elements

A selected image element now gets a selection-anchored bar hugging it —
just a delete button (image replace/crop/flip stay in a later sub-PR) —
the same way text elements do. The anchoring shell is extracted out of
AnchoredTextBar into a reusable AnchoredBar, and the delete button into a
shared DeleteButton; AnchoredTextBar and the new AnchoredImageBar are thin
wrappers. useTrackedRect now measures .editable-element-text or
.editable-element-image. buildFloatingActions returns nothing for image
elements too (other element types still get their delete there).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style(maic-editor): tighten the anchored bar padding (p-2 → p-1)

p-2 left a chunky white margin around the content — most visible on the
image bar, a lone delete button in an oversized box. p-1 (4px, the value
the FloatingToolbar used) makes both bars sit snug to their controls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): anchor the delete bar for every element type

The selection-anchored delete bar now covers all non-text element types
(shape, line, table, chart, …), not just image — so every element's
editing chrome is anchored uniformly. AnchoredImageBar becomes the
type-agnostic AnchoredDeleteBar; useTrackedRect matches any
.editable-element-{type} content root; buildFloatingActions is dropped —
the surface no longer contributes top-center FloatingToolbar actions,
everything is on an anchored bar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): show legacy font names in the picker trigger

When a text element's `fontname` was a value not in the curated FONTS
registry (e.g. `Microsoft YaHei`, `PingFang SC`, theme defaults), the
Select couldn't match it and `<SelectValue/>` rendered a blank trigger —
both reviewers (cosarah Important, xuyuanwei678 #1) caught this. Add a
placeholder fallback so the raw family name surfaces in the trigger.

Also clean up the dead `'默认字体'` label that `text-format-bar.tsx`
overrode unconditionally: introduce an optional `labelKey` field on
`FontEntry`, use it for the default entry, and let the picker prefer
the i18n key when present — no more by-value special case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): address cr minors

- `marks.ts` fontname `toDOM` rejects `"` or `\` instead of interpolating
  them: a hand-crafted mark with `fontname: 'X"; background:url(...);'`
  could otherwise close the quoted string and inject arbitrary CSS.
- `AnchoredBar` gains `onOpenChange` (clears the canvas selection on
  Radix-initiated dismiss): silences the controlled-without-handler dev
  warning, and brings back Esc / SR dismissal that our focus-outside
  hardening had cut off.
- `useSyncEditingElementId` folds two `useLayoutEffect`s into one with
  a cleanup; the previous unmount-only effect was structural noise.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: pin body padding-right so popovers don't reflow the page

Radix Select / Popover wrap with `react-remove-scroll`, which adds a
compensation `padding-right` to <body> when they open. Our <html>
already reserves the scrollbar gutter (`scrollbar-gutter: stable` +
`overflow-y: scroll`), so the compensation added a visible ~15px shift
on every dropdown open. Pin body's padding-right with `!important` so
the page stays still. (xuyuanwei678 review #2.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): surface legacy font names via SelectValue children

The earlier placeholder approach didn't work — Radix's `placeholder` only
fires for an empty `value`, not for an unmatched non-empty one. So an
element with a legacy fontname (e.g. `Microsoft YaHei`, `PingFang SC`,
theme defaults) outside the curated FONTS registry still rendered a blank
trigger. Render the trigger text via `SelectValue` children instead — the
new `currentFontLabel` helper covers all three cases: matched → entry's
i18n / fallback label, unmatched non-empty → the raw family name, empty
→ the default-font label. Unit tests cover each case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): preventDefault on pointer-down-outside so drag/resize work

The onOpenChange handler added to silence the Radix dev warning + restore
Esc dismissal also fired on pointer-down-outside — i.e. on every mousedown
on the selected element to drag it or grab a resize handle. That cleared
the selection before the drag could start, so nothing on the canvas could
be moved or resized. preventDefault on `onPointerDownOutside` (matching
the existing `onFocusOutside` hardening) keeps the bar selection-driven
while leaving Esc as the legitimate onOpenChange path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): arm-and-place insertion for text boxes

Replaces the "auto-insert at a hidden default position" UX. Click
`Text box` → arms text-insertion: the button takes the violet active
style, and the renderer's existing ElementCreateSelection overlay turns
the canvas cursor into a crosshair. On the canvas:

  - click  → 300×60 box at the click point
  - drag   → a box at the dragged rect

Either way the new box is auto-selected (addElement defaults that on),
and the surface's existing useEditingTextElementId picks it up so the
AnchoredTextBar opens on it. Esc disarms; clicking the armed button
again disarms (toggle).

Completes the text branch in the renderer's `useInsertFromCreateSelection`
(pptist scaffolding left it TODO) and bypasses the 200² square fallback
in `ElementCreateSelection` for the text type (a square wouldn't suit a
text box). `InsertPaletteItem` gains an `active?` field so `CommandBar`
can render the armed style.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): render list bullets in slide text

Tailwind's preflight resets `list-style` to none, so the format bar's
`bulletList` toggle wrapped selected text in `<ul><li>` but no marker
ever appeared — the button looked inert. Scope a list-style restoration
to `.editable-element-text ul/ol/li` so bullets / numbers render in the
slide text without leaking into the rest of the app.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): editable font-size input in the text format bar

The size was a read-only `<span>` between the −/+ steppers. Replace with
an `<input type=text>` that mirrors `attrs.fontsize` locally, commits on
Enter / blur (clamped to [8, 96]; non-numeric reverts), and reverts on
Escape. Adds the `edit.text.fontSize` aria-label key in all 6 locales.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): force list markers visible (defeat preflight specificity)

The earlier list CSS didn't survive Tailwind's preflight (which also
resets `padding: 0` on `<ul>`/`<ol>`, so with `list-style-position: outside`
the markers had no room to render). Add `!important` on `list-style` and
`padding-inline-start`, and broaden to also match `.prosemirror-editor ul`/
`ol`/`li` in case the markup ever nests differently than expected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): reset richTextAttrs when the editing element changes

`richTextAttrs` is a single shared store updated by whichever ProseMirror
was last focused. Switching from one text element to another visibly
carried the previous element's toggle states (bold / italic / alignment /
list) on the format bar for a moment — until the new element's
ProseMirror took focus and repopulated the attrs. `useSyncEditingElementId`
now resets the attrs to defaults whenever the editing id changes, so the
bar shows a neutral state during the transition instead of stale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): replace OS color dialog with a curated palette popover

Clicking the text-color swatch opened the browser's native `<input type=color>`
dialog — off-brand and inconsistent across platforms. Swap it for a
`ColorPicker` popover: a 12-swatch grid covering the common slide-text needs
(4 neutrals + warm + cool) plus a hex input for anything else. Closes on
pick. Selected swatch gets the violet outline; hex input commits on Enter /
blur (reverts if invalid).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): replace flat swatch popover with a real color picker

The previous popover was a chunky 12-swatch grid plus a hex input nobody
types into. Rebuild on `react-colorful` (3KB, well-tested):

- SV pad + hue slider for free-form picking, with scoped CSS overrides to
  keep the picker tight (128px pad height) and rounded — not stock.
- OS eyedropper via the EyeDropper API, feature-detected (Chrome / Edge;
  hidden on Safari / Firefox).
- Row of 10 small (18px) common colors at the foot for one-click reach.
- Current-color preview + read-only hex display.
- Hex input dropped entirely — picking is meant to be tactile.

Live preview while dragging; the popover closes on a swatch / eyedropper
commit (not on drag).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): keep the color popover open while dragging the picker

Each SV-pad / hue-slider drag tick fires onChange → dispatches the color
command → `editorView.focus()` pulls focus out of the popover into
ProseMirror. Radix's default onFocusOutside path was treating that as a
dismiss, so the popover closed the instant a drag started — clicking
anywhere on the picker shut it. preventDefault on `onFocusOutside`
(mirrors the AnchoredBar hardening) keeps it open; the popover still
closes on swatch / eyedropper commits and on outside-click / Esc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): scope body padding override + gate ColorPicker mid-drag sync

Two follow-ups from a self-CR on the branch:

- `body { padding-right: 0 !important }` was global, overriding Radix's
  `react-remove-scroll` compensation for every Dialog / Sheet / Select /
  Popover across the app. Scope it to a `body[data-maic-editor='true']`
  selector; `SlideCanvas` sets the attribute while mounted. Non-editor
  pages get Radix's default behavior back.
- `ColorPicker`'s `useEffect(() => setColor(value), [value])` mirror
  could race a stale `value` against the user's current pointer position
  mid-drag — a single late round-trip would snap the picker back. Gate
  the re-sync on `isDragging.current` (cleared on `pointerup`); external
  commits (swatch / eyedropper) still sync immediately because they fire
  while no drag is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): polish from self-CR

- Gate the `richTextAttrs` reset in `useSyncEditingElementId` to only
  fire on element-to-element transitions (track previous editing id via
  a ref). The unconditional reset on the first selection briefly flashed
  neutral defaults (color #000, fontsize 16px) before the focusing
  ProseMirror repopulated the real values.
- Doc-comment the text-insertion add-element asymmetry: text uses the
  renderer's `addElement` (because the rect math lives there and we get
  auto-select for free), image uses surface-side `applyOp` (its source
  is the ImagePicker, not a canvas gesture). Both commit through the
  same store, but the text lane doesn't show as a typed `element.add`
  op in the session history — acceptable, now explicit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): listen to every gesture-end channel in ColorPicker

CR round-2 residual nit: the single `pointerup` listener that clears the
drag-gate would silently keep the gate stuck on any browser / emulator
that only emits the older mouse/touch families. Listen on all four
(`mouseup`, `touchend`, `pointerup`, `pointercancel`) — belt-and-suspenders,
no behavior change on the common path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): preserve image aspect ratio on insert

`createDefaultImageElement` hardcoded the new image's box to 360×220, so
anything not ~1.6:1 (which is almost everything users upload — photos,
screenshots, logos) ended up squashed or stretched the moment it landed
on the slide. Wrap the factory in `insertImageElement` that measures the
source via `new Image()`, then dispatches `element.add` with dimensions
scaled to fit MAX 600×400 while preserving the natural ratio. Load
failure falls back to the factory default so insertion always succeeds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): drop the now-dead addElement helper

`addElement` was only ever used by the inline image-insert which became
`insertImageElement`; text uses `armText` (toggle). PPTElement-typed
parameter was already unused after the text refactor — removing the dead
helper resolves the lint warning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): drop now-unused PPTElement import in use-slide-surface

After `addElement` was dropped (55a9a71), the `PPTElement` type import has
no remaining consumers in this file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): nav rail, scene management, Pro mode chrome rework (#601)

* feat(maic-editor): slide nav rail + scene management (3/3)

PR3a Phase 1 ships the Pro mode left rail + slide-level management, the
last user-visible block of #562. Closes the gap where Pro mode locked
the user on the current scene with no way to navigate or manage the
deck.

SlideNavRail (Studio Editor aesthetic, mirrors playback `SceneSidebar`
visually — index badge + title above an aspect-video thumbnail card —
so the two sidebars read as the same component family across mode
toggle):

- Vertical thumbnail strip via `motion.dev` `Reorder.Group` with
  drag-to-reorder. `Reorder.Item layout="position"` keeps the layout
  animation on y-axis only; width changes from rail resize don't fight.
- Drag-to-resize handle on the right edge writes `style.width`
  directly on the DOM during the gesture and commits to settings store
  only on mouse-up; matches playback drag feel exactly and skips the
  per-frame `persist` serialization that would otherwise burn the
  frame budget at 60 Hz.
- Collapsed and expanded modes; width and collapsed flag persist in
  `useSettingsStore` (`editRailWidth`, `editRailCollapsed`).
- All scene types are first-class — slides render a live
  `ThumbnailSlide` (now with optional `size` prop → self-measures via
  `ResizeObserver` when omitted, so the rail width is the single source
  of truth), non-slide scenes render the same stylised mockups
  playback `SceneSidebar` uses (extracted to `SceneThumbnailContent`).

Slide management:

- `+ Add` in the rail header inserts a blank slide after the current
  scene; new store action `useStageStore.insertSceneAfter` validates
  stage id, migrates the scene, splices, rebalances `order`, and
  triggers `debouncedSave`.
- Three-dot menu per tile: Rename / Duplicate / Delete. Rename also
  reachable via double-click on the title; Enter commits, Escape
  cancels, blur commits, empty input reverts.
- Duplicate deep-clones slide content with fresh element IDs (avoid
  React key collisions) and a `(copy)` title suffix.
- Delete uses a toast with Undo action; deleted scene is held in a
  small `useDeletedSceneRecycle` zustand store and re-inserted at its
  original index on Undo. Deck-empty guard at the rail layer.
- Inter-thumb `InsertionZone` reveals a violet `+` badge on hover,
  right-anchored, with a popup motion (`cubic-bezier(0.34,1.56,0.64,1)`)
  + drop shadow + `z-20` so it lifts above the active tile's violet
  ring. Zero layout shift.

Chrome bar:

- `HeaderControls` (settings pill + Pro Switch) extracted from
  `Header` so Pro mode can mount it in the CommandBar's trailing slot
  — single top chrome bar in Pro mode instead of stacking Header +
  CommandBar.
- Back-to-home button in CommandBar mirrors the playback Header's
  leftmost button.

i18n: new `edit.nav.*` namespace across en-US / zh-CN / zh-TW / ja-JP
/ ar-SA / ru-RU.

Tests: vitest for `insertSceneAfter`, `useDeletedSceneRecycle`,
`createBlankSlideScene` / `duplicateSlideScene`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): split Stage chrome into mode-specific roots

Bug-driven architectural rework. Two symptoms motivated this:

1. Switching from a slide scene to a non-slide one (interactive / quiz
   / pbl) flickered the entire edit chrome — CommandBar and SlideNavRail
   remounted along with the canvas. Root cause: EditShell returned a
   different component type (EditShellWithSurface vs EditShellReadOnly)
   based on whether a SceneEditorSurface was registered for the scene
   type, so React reconciled the change as an unmount/remount of the
   whole subtree.

2. `components/stage.tsx` had grown to 1391 lines — playback engine
   state, chat / TTS / discussion wiring, presentation/fullscreen,
   keyboard handling, AND the edit-mode dispatcher all in one place.
   Any change to mode coordination meant touching this god component.

Changes:

- New `NOOP_SURFACE` (`lib/edit/noop-surface.tsx`) — a no-op
  SceneEditorSurface used as a fallback when a scene type has no
  registered editor surface. `SurfaceState.history` is now optional so
  read-only surfaces can omit undo/redo cleanly. EditShell falls back
  to NOOP for unregistered types.

- EditShell now mounts a single Frame across all scene types. Surface
  state is published from a child `SurfaceStateRunner` keyed by
  `scene.type` (so it remounts only when the runner's hook signature
  changes — rules-of-hooks compliant), with a custom shallow
  equality so the chrome doesn't re-render every render cycle for
  reference-fresh state objects. Result: slide ↔ interactive no longer
  remounts the CommandBar or the leftRail.

- `stage.tsx` → 113 lines. Mode dispatch + cross-tab edit-lock
  coordination + Pro-Switch toggle wiring + multi-tab conflict prompt
  only. Everything else moved into one of two new components:

  - `PlaybackChromeRoot` (`components/edit/PlaybackChromeRoot.tsx`):
    owns the entire playback / autonomous chrome — PlaybackEngine,
    chat, discussion TTS, presentation mode, keyboard shortcuts,
    SceneSidebar, Header, CanvasArea, Roundtable, ChatArea,
    AlertDialog. Exposes `teardown()` via forwardRef so the toggle can
    `await` SSE / engine / TTS shutdown before unmounting it.

  - `EditChromeRoot` (`components/edit/EditChromeRoot.tsx`): the Pro
    mode chrome wrapper — EditShell + SlideNavRail + HeaderControls
    trailing slot. Owns `body[data-maic-editor]` lifecycle (lifted from
    SlideCanvas so it covers read-only Pro-mode scene types too).

- New `StageGrid` (`components/edit/StageGrid.tsx`) — CSS-Grid named-
  slot layout shell with top / left / center / right / bottom areas
  for the Pro mode chrome. Future right panel (properties / AI) and
  bottom timeline plug in as props with no structural code change.
  EditShell's Frame now uses StageGrid internally.

- Deleted `components/edit/SlideTransitionBridge.tsx` (dead code from
  the original A3 transition plan that this rework supersedes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): cross-fade chrome roots on Pro mode toggle

Wrap the chrome-root dispatch in `AnimatePresence mode="wait"` with a
180 ms opacity fade-out / fade-in. The outgoing root fully exits before
the incoming one mounts, so:

- The single-canvasStore-writer guarantee from the chrome split is
  preserved (ScreenCanvas and Editor/Canvas never coexist).
- Mode toggle reads as a smooth fade instead of a hard cut.

Stage's outer wrapper now carries the stable `bg-gray-50 dark:bg-gray-900`
background so neither root reveals raw page colour while it passes
through opacity 0. `initial={false}` skips the entry animation on first
mount so the initial playback render is instant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): drawer-style mode swap transition

Pro toggle was a hard cut — playback chrome vanished, edit chrome
popped into place. Now wraps the swap in `AnimatePresence` with the
two chrome roots layered via `absolute inset-0` so they coexist for
~280ms:

- Edit chrome enters from above (`translateY: -32 → 0`) + fades in,
  giving a "drawer drops down" feel that matches the inner
  CommandBar/leftRail stagger choreography.
- Playback chrome cross-fades opacity-only; no transform so its
  active slide canvas stays put underneath while edit drops over it.

Both roots keep rendering during the overlap, so `canvasStore`'s
scale writer doesn't briefly read zero and snap the slide to a stale
size when one root exits ahead of the other. Duration 280ms /
`CHROME_EASE` matches the inner Frame timing source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): Pro Switch as a shared layout element across modes

The Pro Switch is the click anchor for the mode swap, but it lives in
two different positions: the 80px playback Header (top-right) vs the
56px edit CommandBar trailing slot (also top-right but at a different
y and with different padding). After the click the switch "jumped" —
it visibly moved + restyled — which felt unsmooth even though the
chrome itself was cross-fading.

Tag the Pro Switch label (and the settings pill) with `motion.layoutId`
so motion treats them as shared elements across the AnimatePresence
swap. During the ~280ms transition, motion measures both instances
and morphs position + size between them — the user's click target
slides into its new home instead of teleporting. Same easing source
as the chrome cross-fade so the two animations stay locked together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(maic-editor): unify chrome shell across modes

Pro Switch + settings pill + download icon are the user's mode-toggle
"anchors" — they need to sit at the same screen pixel across
playback ↔ edit. They didn't, because the two chromes had different
shapes:

  Playback: SceneSidebar (left, full height) | Header (h-20, 80px)
            on top of CanvasArea + Roundtable
  Edit:     CommandBar (h-14, 56px, FULL width) on top of a row of
            (SlideNavRail | content), so the rail sat *below* the bar

So when the user clicked Pro, the bar collapsed by 24px AND the rail
shifted down by 56px AND the right-side controls re-styled (compact
variant) — three simultaneous moves. layoutId masked some of it but
the underlying structure was wrong.

Unify the shells:

- `StageGrid` template flipped from `top top top / left center right /
  bottom bottom bottom` to `left top top / left center right / left
  bottom bottom`. The left column now spans all rows so the sidebar
  always reaches the absolute top edge, matching playback exactly.
- `CommandBar` grows h-14 → h-20 + px-5 → px-8, identical to playback
  Header.
- `EditChromeRoot` drops the `variant="compact"` flag on
  `HeaderControls` so the settings pill renders at the same h-9 pill
  it does in playback.
- `SlideNavRail` header replaces the "SCENES" label with the OpenMAIC
  logo (click → home), matching `SceneSidebar`'s shape so the sidebar
  top reads as the same component family in both modes.
- Download / Export dropdown moves out of `Header` and into
  `HeaderControls` so it's present in both playback and edit chrome at
  the same right-cluster position (was previously playback-only).

`Header.tsx` slimmed accordingly. Net effect: the right-edge cluster
(EN, theme, settings, download, Pro Switch) lives at the same screen
pixel across modes; the cross-fade transition only animates the
*contents* inside the bars + sidebar lists, not the bar/rail
positions themselves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): drop sidebar header + button, add insert-before-first zone

The header `+` was a duplicate affordance — every gap between thumbs
already has its own `InsertionZone`. Remove the header button; insert
flows entirely through the gap zones now (with hover-popup + and
right-anchored visual).

Add one extra `InsertionZone` rendered BEFORE the first thumb so the
top padding of the rail is also clickable / hoverable. Insert-before-
first is implemented inline via `setScenes([blank, ...scenes])`
because the `insertSceneAfter` store API only handles insertion after
an existing anchor.

`PlusCircle` import dropped (no longer used).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): move Download out of settings pill, place right of Pro Switch

Download isn't a settings function (it's an export/share action), so
it shouldn't sit inside the pill that hosts language/theme/settings.
Move it back to a standalone button on the right side of the Pro
Switch — both in playback and edit chrome. Right cluster now reads:

  [ EN · theme · settings ]  [ PRO switch ]  [ Download ]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): floating insert toolbar above canvas (collapsible)

Text box / Image / future shape buttons no longer share CommandBar
with global stage controls (back / undo / redo / title / settings /
Pro Switch / Download). Insert is a content-creation action, not a
stage-navigation one — mixing them blurred the chrome's role.

Lift insert items into a new `FloatingInsertToolbar` that floats
centered ~12px above the slide canvas card. Default expanded; collapse
arrow tucks it into a small chevron handle at the same anchor. State
persists in `settings.editInsertToolbarCollapsed`. Reuses the existing
`InsertButton` (extracted from CommandBar into a sibling module so
both surfaces — the now-removed CommandBar slot and the floating bar —
can share styling).

CommandBar drops its `insertItems` prop / middle slot entirely;
right-side controls collapse to a single `flex shrink-0` cluster
matching playback Header's shape.

i18n: `edit.insert.expandToolbar` / `collapseToolbar` across 6 locales.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): auto-focus text element after toolbar insert

Inserting a Text box via the FloatingInsertToolbar + click/drag on
the canvas left the user one click short — the new element was
selected and the AnchoredTextBar opened, but the ProseMirror editor
never received focus, so the first keystroke went nowhere and the user
had to click inside the element again before typing.

`useEditingTextElementId` already mirrors the surface's editing-target
choice into `canvasStore.editingElementId`. Have `ProsemirrorEditor`
watch that flag in an effect: whenever its own elementId becomes the
editing target (insert, programmatic selection, etc.) and it doesn't
already have focus, push focus into the view. `hasFocus()` guard keeps
this from re-focusing on every re-render of an already-active editor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(maic-editor): prettier + drop ThumbItem rename sync effect

- prettier --write on three files touched by the recent edits.
- ThumbItem: drop the `useEffect(() => { if (!renaming) setDraft(...) })`
  external-title sync that tripped `react-hooks/set-state-in-effect`.
  Idle display now reads from `scene.title` directly (derived rather
  than mirrored); `startRename` seeds `draft` at session start and
  `cancelRename` resets it so the next session starts clean. Rename
  e2e still passes the menu + double-click + Escape paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): CR-loop pass — pointer capture, stage-scoped recycle, equality docs

PR #601 reviewer feedback.

**Drag handle uses Pointer Events with `setPointerCapture`** so the rail
no longer gets stuck in "still dragging" state when the cursor leaves
the window, the OS reclaims focus, or a tab interrupt suppresses the
mouseup that the old document-bound mousemove/mouseup pair relied on.
The handle's onPointerMove/Up/Cancel are now bound directly on the
element; capture guarantees event delivery for the lifetime of the
gesture. Drag tracking e2e still PASS (1 px cursor lock).

**Toast Undo guards stage identity** before re-inserting the deleted
scene. If the user navigated to a different stage while the toast was
up, the recycle entry belongs to the previous stage and
`insertSceneAfter` would reject it on stage-id mismatch — silently
losing the deleted scene. New check drops the undo cleanly when stage
ids don't match. The `stageId` field was already captured on
RecycleEntry; just wasn't consulted.

**`surfaceStateEqual` extended** to compare per-item `id` / `disabled`
/ `label` on `floatingActions` (was length-only) and per-item
`id`/`severity`/`message` on `hints` (was length-only). Today's slide
surface returns `floatingActions: []` and `hints: []` so this is
dormant, but PR3b's z-order actions land in `floatingActions` — pinning
the equality semantics now keeps a future state field from silently
going stale in the chrome. SurfaceState gets a maintenance note
cross-linking to the equality function.

**Header.tsx mode guard comment** updated. The `mode !== 'edit'` guard
around the title block isn't dead — it covers the ~280ms
AnimatePresence exit window where playback chrome is still rendering
its exit animation while mode has flipped to 'edit'. Without the
guard, this title would briefly stack on top of the incoming
EditChromeRoot's CommandBar title during the cross-fade.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(maic-editor): CR-loop round 2 minors — sharpen JSDocs

Round-2 reviewer flagged two doc-only refinements:

- `surfaceStateEqual`: clarify that callback identity (`onInvoke`,
  `popoverContent`) is intentionally NOT compared, and that today's
  safety comes from slide-surface returning `floatingActions: []`
  rather than the per-item compare covering callbacks. A future
  surface that emits closure-capturing actions must fold its own
  change signal into the comparison or the stale callback fires at
  click time.
- `setPointerCapture` catch: spell out that this is paranoia, not a
  real fallback — if capture genuinely fails the gesture still
  tracks for in-window moves but out-of-window `pointerup` won't
  route here. Acceptable degradation; the catch exists only because
  the spec permits an `InvalidPointerId` throw that browsers we ship
  to don't actually emit on same-pointer `pointerdown`.

No functional changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(maic-editor): undo restore at index 0, reset mode on classroom load

Two issues from PR #601 manual-verification review:

**Undo of the first slide restored it as the second.** The toast undo
handler clamps `entry.index - 1` to 0 then calls
`insertSceneAfter(scenes[0], entry.scene)`, which lands the entry at
position 1 instead of position 0 — no scene exists before scenes[0] to
anchor on. Fall back to `setScenes([entry.scene, ...live])` when
`entry.index === 0` (or when the deck is empty). The store's existing
non-rebalancing `deleteScene` keeps the surviving scenes at orders
2..N, so the prepended entry's original order=1 lines up naturally;
StageGrid auto-selects the restored scene as current.

**`mode` survived SPA navigation between classrooms.** Refresh reset
mode to 'playback' via the initial store value, but switching
classrooms via Next.js navigation kept the zustand singleton intact;
entering Pro mode in A and then opening B left B in edit mode.
`loadFromStorage` and the server-side classroom-load path both now set
`mode: 'playback'` on every classroom load, normalising the SPA path
to match the refresh path. Mode stays transient UI state, not
persisted with the stage.

e2e: delete Slide 1 → Undo → restored to position 1 (was position 2
before fix).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* feat(maic-editor): gate slide scene creation until inserted scenes are playable (#612)

* feat(maic-editor): gate slide scene creation until inserted scenes are playable

Editor-created slide scenes (blank insert + duplicate) ship without
playback actions, so the playback engine gives them zero dwell and skips
straight past them — a freshly inserted slide is effectively unplayable.
Seeding default actions on new scenes is a separate change; until then,
hide the two scene-creation entry points so the editor stays coherent as
an in-place "fine-tune the generated deck" tool.

- add lib/edit/scene-creation-enabled.ts (SCENE_CREATION_ENABLED=false)
- hide inter-thumb "+" insertion zones (SlideNavRail)
- hide per-slide Duplicate menu item (ThumbItem)
- keep reorder / delete / rename, which are playback-safe

Re-enable by flipping the flag once new scenes get default actions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): e2e guard for slide scene-creation gate

Adds an e2e that generates a classroom (mocked), enters Pro mode, and
asserts the slide rail exposes no insertion "+" zones and the per-slide
overflow menu has only Rename + Delete (no Duplicate). Fails if
SCENE_CREATION_ENABLED is flipped back on without removing the gate.

Two stable test ids support locale-independent assertions:
- slide-nav-insert  (InsertionZone button)
- slide-nav-more    (ThumbItem overflow trigger)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): enable editor flag for the e2e webServer

The scene-creation gate e2e needs the Pro Switch, which only renders when
NEXT_PUBLIC_MAIC_EDITOR_ENABLED is on. It's a build-time NEXT_PUBLIC_* flag,
so set it in the Playwright webServer env (applies to `pnpm build` in CI and
`pnpm dev` locally). Fixes the e2e failure on CI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(maic-editor): attach gate screenshot to report instead of fixed path

CR: e2e-artifacts/ is not gitignored, so writing the screenshot to a fixed
path left an untracked file that could be committed by accident. Use
testInfo.attach so the image lands in the (ignored) Playwright report.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* i18n(maic-editor): add pt-BR translations for edit.* / stage.* keys

pt-BR locale (added on main post-stack) lacked the 51 editor keys, so
check:i18n-keys failed after rebase onto main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): defer editor-only side effects behind Pro mode mount

editor-fonts (~23 @fontsource CSS tables) and slide-surface registration
were top-level static imports in app/layout.tsx and components/stage.tsx,
so flag-off classroom/playback users paid the font-face CSS + slide-edit
module-init cost on every page load.

Move both to a dynamic import in EditChromeRoot (mounts only when
mode==='edit', which requires NEXT_PUBLIC_MAIC_EDITOR_ENABLED). Hold the
EditShell render until the slide surface registers to avoid a NOOP/
read-only flash on first Pro mode paint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): CR-loop correctness fixes (redo/groupId/edit-lock)

Three confirmed issues from the rebase code-review pass:

- slide-edit-session: a non-user (ResizeObserver auto-height) commit
  updated history.present while preserving a now-stale future, so a
  redo after undo silently resurrected pre-undo content. Clear future
  on the non-user path (present has diverged from the redo branch);
  past is left untouched so no spurious undo step is created.
- slide-defaults: duplicateSlideScene reassigned element ids inline,
  leaving grouped elements pointing at the source slide's groupId.
  Use the existing createElementIdMap so clones get a new shared
  groupId. (Path is gated off today via SCENE_CREATION_ENABLED; fixes
  a latent defect.)
- stage: wrap playback teardown on Pro-mode entry in try/catch and
  release the just-acquired cross-tab lock on failure, so a rejected
  teardown can't strand the lock with the UI stuck in playback.

Adds regression tests for the redo-stale and groupId cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): scope editor list-marker CSS to Pro mode

The .editable-element-text ul/ol/li rules used a bare selector, but
that class is the playback text wrapper rendered for every classroom
user — so the !important list-style overrides leaked into normal
playback. Scope them to body[data-maic-editor='true'] (set only while
Pro mode is mounted) so flag-off playback rendering stays unchanged;
markers still show while editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): smooth Pro mode transition and stabilize header controls

The Pro mode enter/exit animation janked and the right-side header
controls (settings pill + Pro Switch) drifted in width/position across
the swap. Three causes, all addressed:

- The flag-gating dynamic import gated EditShell behind surfaceReady, so
  the chrome animated in empty and content popped in once the slide-surface
  chunk loaded. Preload the editor chunk (fonts + surface registration) in
  the Pro Switch handler BEFORE flipping mode (lib/edit/preload-editor.ts),
  and drop the render gate — content is present when the animation starts.
- Mode-swap layers and EditShell chrome layers used translateY/translateX
  slides; with backdrop-blur on the rail and pills that forced a per-frame
  backdrop-filter recompute (dropped frames) and, as transform ancestors,
  distorted the layoutId measurement. Switched all chrome enter animations
  to pure opacity fades.
- HeaderControls rendered a fragment whose children were spaced by the
  host's flex gap (Header gap-4 vs CommandBar trailing gap-2), so the
  control cluster changed width/anchor between modes. Wrapped it in a
  self-contained gap-4 container and dropped the cross-bar layoutId morph
  so the cluster is pixel-stable across the swap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(maic-editor): address review — image insert race, scene migration, i18n staleness

Review feedback from @cosarah on the integration PR:

- Image insert resolved size via Image.onload then applied the op to
  whatever slide session was current at callback time, so switching
  slides before the image loaded inserted it into the wrong slide. Bind
  the insert to the scene active at click time and drop the op if the
  session changed before onload fires.
- Classroom scenes loaded from IndexedDB (loadFromStorage) and from the
  server API (classroom page) bypassed migrateScene, so legacy slide
  content was not normalized with schemaVersion. Both load paths now
  migrate on the way in, matching setScenes/addScene.
- surfaceStateEqual compared insert-item/command id/active/disabled but
  not label/tooltip, so the Pro-mode insert toolbar text stayed stale
  after a language switch. Compare label/tooltip too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:54:04 +08:00