mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-02 09:24:43 +08:00
@openmaic/importer@0.3.0
28
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
44882254a4 |
fix(generation): stabilize model picker teardown (#1618)
* fix(generation): stabilize model picker teardown * test(generation): cover model picker teardown --------- Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
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> |
||
|
|
1fd4348f21 |
fix(classroom): generate classroom ids server-side and create files exclusively (#1489)
* fix(classroom): generate classroom ids server-side and create files exclusively
The legacy file-backed classroom store accepted a caller-chosen stage.id and
renamed a temp file over the target, so anyone who knew a public share URL
could POST the same id and replace that classroom's content. POST
/api/classroom now mints the id itself (nanoid, 10 characters, matching the
generation pipeline), ignores any client-supplied stage.id, and rebinds the
stage and every scene to the generated id. Both the route and
classroom-generation persist through a new exclusive create that hard-links a
temp file into place, so an existing classroom is never replaced; EEXIST is
retried with a fresh id a bounded number of times and then reported as 409.
Unchanged: the server-backed persistence path (app/api/persistence,
lib/persistence), the read/GET path, middleware, and the access-code gate;
writeJsonFileAtomic keeps its overwrite semantics for the other callers.
Adds regression tests pinning non-overwrite, the typed EEXIST error, collision
retry/exhaustion, and generation-path exclusivity.
* fix(classroom): fall back to exclusive open without hard links and reserve ids before media generation
writeJsonFileExclusive kept `fs.link` as its only route to the destination, so
classroom creation failed hard on mounts without hard links (gcsfuse, s3fs and
some FUSE gateways return ENOSYS/ENOTSUP/EOPNOTSUPP/EPERM/EXDEV). Keep `link`
as the fast path and, for exactly those codes, fall back to an exclusive `wx`
open: still never replaces an existing file and still maps EEXIST to
ClassroomAlreadyExistsError. Any other code keeps propagating, and the temp
file is always removed.
The generation pipeline wrote media and TTS into
<CLASSROOMS_DIR>/<id>/{media,audio} before the exclusive create, so a collision
would have landed the new classroom's files in an existing classroom's
directory and the retried document's URLs would still point at that other id.
Reserve the id first by exclusively creating the classroom file with a
placeholder document (`reserved: true`, empty scenes). The file is the only
token that atomically covers the whole collision namespace: a classroom created
through POST /api/classroom has a JSON file but no directory, so reserving a
directory would miss it. readClassroom treats a reserved document as absent, so
an in-flight or crashed reservation is never served as an empty classroom. The
retry on EEXIST now happens at reservation time (bounded, before any media),
and the final persist is an ordinary overwrite of the id the process owns, so
the post-media id retry is gone.
Document OPENMAIC_CLASSROOMS_DIR in .env.example, including that
CLASSROOM_JOBS_DIR does not move with it.
* fix(classroom): release an unused reservation when generation fails
|
||
|
|
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> |
||
|
|
6eb9492652 |
fix(classroom): adapt complete page to short viewports instead of clipping (#1461)
The complete page centers its content in an overflow-auto flex section. When the content is taller than the stage viewport (common on laptops), the centered overflow clips beyond the scroll origin at the top — the trophy is unreachable and the page opens mid-content. Restore an adaptive compact layout: a ResizeObserver with hysteresis (760/820px) toggles the page between full and compact forms so the content always fits, and a dedicated inner scroll layer keeps the decorative background pinned to the viewport when scrolling is still needed. Add an e2e spec covering both layouts and trophy reachability. Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
9104824b41 |
fix(slide): keep bottom table rows visible in classroom playback (#1366)
* fix(slide): keep table rows inside playback canvas * fix(slide): preserve full-cell table alignment * test(slide): focus table clipping regression * test(slide): verify wrapped table geometry * docs(slides): narrow table alignment contract |
||
|
|
132d01cd04 |
feat(canvas): add double-click text insertion (#1310)
* feat(canvas): add double-click text insertion * fix(ci): format canvas double-click text insertion * fix(canvas): prevent text insertion on double-click of locked elements - Add guard to check if double-click event target is inside an editable element - Prevents text box insertion when double-clicking locked elements (backgrounds/watermarks) - Fixes event-propagation bug where locked element selection handler returns before stopPropagation() - Add regression test suite for canvas double-click handler covering locked elements and blank canvas cases Addresses review feedback from CxuPercy on PR #1310 * fix: remove unused import in canvas-double-click-handler test * fix: improve test implementation to avoid DOM dependency - Rewrite tests to use mock objects instead of actual DOM elements - Properly type MockTarget interface to satisfy TypeScript checks - All tests now pass locally without DOM environment requirement * test(e2e): add end-to-end tests for canvas double-click text insertion - Add test for double-clicking blank canvas area to create new text element - Add test to verify double-clicking existing elements does NOT insert new text - Tests actual editor interactions, not just mock targets - Addresses CxuPercy's review feedback for proper regression test coverage * chore: format code with prettier * test(e2e): simplify double-click text insertion test - Remove flaky second test that checks element visibility state - Focus on core functionality: double-click on blank canvas creates new element - Use more lenient assertion (toBeGreaterThan) to account for dynamic elements - Increase wait time for slide editor to fully render (1000ms) * fix(storage): increase test timeout for replace/release operations The 'replace retires rather than revokes an issued snapshot; release revokes both' test performs multiple async operations (put, resolve, replace, release) that can exceed the default timeout. Increased timeout to 10 seconds to accommodate I/O operations. |
||
|
|
10f5222196 |
test(storage): cover indirect egress CORS in Chromium (#1138)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
f378c25a53 |
feat(storage): route settings/user-profile persist through KVStore (no legacy migration) (#1001)
* feat(storage): route zustand persist through KVStore The settings and user-profile stores persisted through zustand's default `localStorage` storage, while the rest of the app's small keyed values had already moved to `@openmaic/storage`'s `KVStore` — two unrelated mechanisms over the same browser API, which is the split-brain the storage RFC set out to remove. `kvPersistStorage` shipped with the package but had no importer. Add `lib/store/kv-persist.ts`, the app seam that adapts a `KVStore` into a zustand `PersistStorage`, and wire both stores to it under an explicit `account` scope. Store logic is unchanged: `version` / `migrate` / `merge` still belong to the stores, only the bytes' destination moves. Existing installs are adopted rather than orphaned. On first read the seam moves the raw `localStorage` entry into the KV scope — write first, then drop the raw key, so an interrupted migration leaves the value readable from one side or the other but never from neither. Once KV holds the entry it is authoritative, so a stale raw entry reappearing cannot roll back newer state, and re-running can neither duplicate nor resurrect anything. An entry that is not a readable persist envelope is left exactly where it is. Hydration is now asynchronous, because a `KVStore` may be remote. The browser backend resolves within a few microtasks of the store module being evaluated, so nothing user-visible changes, but tests that assert on persisted state now await `persist.rehydrate()` instead of relying on a synchronous read. The four pre-persist keys (`llmModel` / `providersConfig` / `ttsModel` / `selectedAgentIds`) are deliberately untouched: they are read-only inputs to a migration that never owned them, and `providersConfig` is still read independently by `lib/ai/providers.ts`. Part of #857. * fix(storage): close the async-storage failure modes in the KV persist seam Cross-review found that routing `persist` through an asynchronous store opens failure modes a synchronous `localStorage` never had. Each one below is now covered by a test that fails without its fix. Data loss on a failed or empty hydration. A KV read that threw was indistinguishable from an empty scope, so the store stayed on defaults while an always-on initializer's `set()` persisted those defaults; the next load then read them as a completed migration and deleted the raw original, leaving nothing. Reads now separate "nothing there" from "the backend could not answer": a failed read serves the raw entry read-only, adopts nothing and deletes nothing, and leaves the key unwritable for the session so no un-hydrated state can be persisted over an original. Raw entries are additionally only dropped against a recorded completion marker, so a KV entry of unknown provenance can never authorize deleting the only copy of the user's data. Lost adoption across tabs. A reader whose KV read answered before another tab's adoption write, and whose localStorage read landed after that tab's delete, saw nothing in either place and hydrated defaults. Against a remote backend that interleaving is ordinary rather than rare, so an apparent "nothing anywhere" is now re-checked before it is believed. Out-of-order writes. Two rapid `setItem`s could complete in either order and let the older value win. Reads and writes are serialized per key. Pre-persist migration resurrecting on every load. `migrateFromOldStorage` was guarded by the existence of the raw `settings-storage` key — the very key the migration removes — so once the blob moved into the KVStore it ran on every load and republished whatever the pre-persist keys still held, including API keys the user had since deleted, into first-frame state. It is now the persist layer's last-resort source instead of a synchronous read in the store initializer: its result is adopted like any other migration, so the adopted entry is the marker that stops it. Initial state is plain defaults, and nothing stored reaches observable state before the storage layer has vouched for it. The source keys are still only read, never deleted, since `lib/ai/providers.ts` remains a reader. Also: KV reads and writes are guarded so a corrupt entry surfaces instead of throwing through zustand's silent catch; clearing the cache now clears the migrated stores through their own storage, which a server-backed scope would need and a blanket `localStorage.clear()` would only appear to do; `removeItem` drops the KV copy before the raw one; and the e2e suite gains post-migration steady-state coverage, which every existing spec missed by seeding the pre-cutover key. Part of #857. * fix(storage): scope migration bookkeeping to the device, gate writes on call Second review round. The findings shared one root cause: two of the three sentinels recorded a fact about *this machine's* files but were stored beside the data, in the store's own scope. Under a syncing `account` backend that is a category error with teeth — one device's history would travel to another and authorize deleting files it says nothing about. Sentinels are now always `device`-scoped and carry a reserved `__` prefix, whatever scope the store uses. The worst combination this closes: device A migrates, its marker syncs, device B — which has never run this build — loads and deletes the raw entry that is still the only copy of its own settings. A test drives exactly that sequence against two devices sharing one account scope and nothing else. The pre-persist fallback gains a device sentinel of its own. Its source keys are device-local and deliberately left in place, so "KV and raw are both empty" was not a safe trigger: clearing the account entry, from the cache button or a server-side wipe, let any device re-read those keys and republish credentials the user had rotated. It is now consulted at most once per device, and `removeItem` deliberately does not clear that sentinel — clearing the entry is precisely the case it exists for. The fallback also no longer writes the adoption marker: that marker is a licence to delete a raw persist entry, and the fallback has adopted no such thing. Were it written, a raw entry later restored by an older bundle would be deleted unread. The write gate is now read when `setItem` is called rather than when its queued turn arrives. Evaluated inside the queue, a write issued during hydration waited behind `getItem`, found the gate opened by the very read it raced, and persisted its pre-hydration snapshot on top of the stored value. Refused writes are no longer a dead end: the first one asks the store to rehydrate, since the backend may have recovered, and if that does not work the user is told through a durable notice rather than losing every change on reload. Each refusal is logged, not just the first — a once-per-session warning hides how long a session has been silently unsaved. `removeItem` now propagates backend failures, so clearing the cache cannot report success for a clear that did not happen, and that flow reloads immediately instead of leaving a window in which a background `set()` re-persists the credentials just deleted. Also: the recovery hook is bound after each store is constructed, because naming the store inside its own definition made its type circular and silently widened every selector to `any`; the `serial` comment no longer claims an unreachable rejection handler; and the pre-persist version stamp documents that it skips `migrate`'s unversioned normalization too, which `merge` redoes anyway. Part of #857. * refactor(storage): make the KV persist seam a per-key state machine Three review rounds kept turning up the same defect class in new places: a backend failure read as "there is nothing there", and the result of an operation that nobody looked at. Patching instances of it was not working, because both are possible whenever a call site is free to decide for itself what a failed operation meant. This makes that decision unavailable to call sites. Every backend call now returns an `Outcome` whose payload is a private field and whose only reader demands a `KeyState`. Feeding the machine is not a convention to remember; it is the only way to get the value out. What comes back is the value or `UNAVAILABLE` — deliberately not `null`, so "the backend could not answer" is not spellable as "nothing is there". The machine then decides, in one place, what failure means: any read failure is unavailability, any write failure closes the key, and either raises the health signal and asks for recovery. The scattered closure flags (a settled set, a warned set, a recovery latch) become one explicit phase per key — unhydrated, settled, unavailable, clearing — with the transition table written out in the docstring and a test per row. What that consumes: - A write failing after the key settled used to be ignored; its outcome now closes the key, signals, and the value is remembered for replay. - `removeItem` enters `clearing` synchronously at call time, so a `set()` racing a clear cannot be admitted, queue behind the delete, and write the just-deleted value back. Nothing is replayed across a clear. - The pre-persist sentinel is recorded by *every* settling path, not only the one that consults the old keys. A device that had only ever loaded an already-populated account entry recorded nothing, and so stayed eligible to republish its own stale credentials the moment that entry was cleared — which the docstring already claimed it would not. - Two places treated a failed read as an answer: the sentinel read that decides whether the fallback runs, and the confirming re-read before adopting. Both now stop. - An adoption whose marker did not land no longer settles, and never deletes the raw entry: no proof, no licence. - Refused and failed writes are remembered and replayed once storage recovers, but only when the store was holding real data at the time — replaying a snapshot taken over defaults would overwrite the stored value, which is the failure this seam exists to prevent. When a snapshot cannot be replayed the user is told, because rehydration is about to discard those edits. - Health notices publish on a later task, so a recovery that lands moments later cancels a warning nobody should have seen, and a subscriber mounting after the failure is caught up once its toast host exists. `StorageHealthNotice` now sits after the `Toaster` for the same reason. Also: adoption re-reads before committing so a concurrent write is not rolled back; a shadowed raw entry is only deleted if it parses, matching the promise never to delete bytes we could not read; the unavailable copy points at the control that resolves it rather than suggesting a reload that cannot; and the version pin, the pre-persist retention rationale, and the cache-clear comment say what is actually true. Part of #857. * fix(storage): separate the contexts settle() was conflating Convergence round on the state machine. The findings share a root: settle() treated every read that concluded as licence to open the key, without asking what else was true at the time. - A read finishing while a clear is outstanding no longer settles. Under a remote backend a recovery rehydrate in flight during a clear is ordinary, and opening the gate there admits a write that queues behind the pending delete and restores the credentials just removed. Only finishClear opens a cleared key. - The raw entry served read-only after a failed read no longer counts as the authoritative value. A shadowed raw entry is by construction the older copy — long-term coexistence with a newer KV value is a designed state — so an edit taken against it must never be replayed, or one transient read failure on one device rolls the whole account back. - A write that lands supersedes an earlier failed one. Both were admitted before the key closed, so the queue ordered them; replaying the older snapshot on recovery would undo the newer value. - Unreachable browser storage is a failure, not an empty store. Privacy modes make localStorage throw; treating that as "nothing stored" hydrated defaults, refused every write and said nothing until the user reloaded and found the work gone. SSR stays a silent no-op. - The recovery latch re-arms when storage answers, so the three recovery transitions are not empty promises from the second failure onward. - A refusal carries the phase it happened in. Cold-start timing — an always-on initializer writing before the first read resolves, on a backend that never failed — is a log line, not a sticky red toast; only a refusal under `unavailable` reports lost changes. The health channel stops delivering stale catch-ups: its timers are cancellable and re-read the current state when they fire, so a recovery cannot be overtaken by a warning with nothing left to dismiss it. And `changes-lost` is now a fait accompli rather than a condition — a later recovery has nothing to retract and leaves it standing. Also: a throwing `localStorage` is unavailability rather than absence wherever the raw entry is read; clearing records the pre-persist sentinel like every other settling path, making that docstring true; the raw entry is dropped before the marker so a failed marker clear leaves something inert; and the pre-persist fallback and its four source reads can no longer throw into zustand's silent catch. Part of #857. * fix(storage): hold a replay until it lands, and prove a raw copy before deleting it Three failure-path corners. A replay was retired the moment its write was attempted. Readable but not writable is a real state — a quota-exhausted backend answers reads perfectly — so a recovering read could succeed, the replay write fail, and the snapshot be gone with nothing owed: the next rehydrate served the older stored value over the user's edit and said nothing. The snapshot is now held until the write is durable; a failed replay goes back to being a refused write, so the next recovery tries again and a permanent failure is eventually reported. The adoption marker was treated as proof that a raw entry is the migration's leftover. It proves only that a migration happened on this device once. A rolled-back deployment, or a tab still running the old bundle, writes the raw key again with settings the migration never saw, and the next load on the new bundle deleted them unread. A raw entry is now dropped only when its content equals the value shadowing it — anything else is data, and keeping a copy beats deleting an original here as everywhere else in this file. `changes-lost` was being cancelled by a later recovery. The two statuses answer different questions: "storage is down" describes the world now and stops being true when storage works, while "your edits were lost" describes something that already happened and no amount of recovery undoes. They are now tracked on independent lines per key and rendered as two toasts with distinct ids, so retracting the fault cannot take the acknowledgement with it; the loss notice goes away only when the user closes it. Part of #857. * fix(storage): bound recovery, keep a live store from walking backwards Three interactions the state machine did not have an answer for. Recovery had no budget. A backend that reads but cannot write — a quota-exhausted one does exactly that — turned it into a treadmill: the recovering read succeeds, the key settles, the replay write fails, and that failure asks for recovery again, forever. Attempts now follow a backoff schedule whose length is the cap, and the budget is re-armed only on real progress: a settle that left nothing owed. A settle reached *by* a recovery with a replay still queued is not progress, and counting it as such is precisely what made every lap look like a fresh start. When the budget runs out the key is left read-only and the loss is reported, rather than retrying where nobody can see it failing. A failed read could walk a live store backwards. Once a session has loaded the authoritative value, a transient read failure was still falling back to the shadowed raw copy — which is legitimately older, and legitimately long-lived now that a differing raw entry is kept rather than deleted — and handing it to zustand overwrote a live store with stale data. Worse, the store had already latched "holding real data", so edits made on top of that stale value counted as replayable. A failed read after an authoritative one now returns nothing, which is exactly "keep what you have", and the raw fallback stays where it belongs: the case where nothing authoritative has been seen at all. Dismissing a lost-changes toast only closed the UI. The key stayed in the lost set, so the same store losing changes a second time was swallowed as a duplicate and any later subscriber resurrected a notice the user had already dealt with. The close button now clears the latch, so a second loss is reported and an acknowledged one stays gone. Part of #857. * fix(storage): close the last two provisional and clear-ordering gaps An abandoned migration was handing back an authoritative value. When the confirming re-read fails, or the adoption write does, `adopt` returns the raw envelope unadopted — the same footing as the raw copy served after any other failed read — but it was returned bare, so the store latched "holding real data" and an edit taken on top of it counted as replayable. Another device publishing to the account scope in that window would have been rolled back. `adopt` now reports provisionality like every other read path, and the shape is a named `LoadedValue` so the next path cannot forget. Clearing swallowed a failed raw-entry deletion. The account entry was already gone by then and the marker was cleared afterwards, so the operation reported success while leaving an empty scope beside a live raw entry — precisely the shape the next hydration re-adopts, handing back the settings the user had just deleted. The raw entry now goes first and its failure propagates, which also fixes the ordering: failing before anything is removed leaves the KV value authoritative and the clear simply unperformed, which the caller can retry. Adoption keeps the tolerant variant, where a leftover raw entry is a duplicate rather than a loss. Part of #857. * fix(storage): make the shadowed-raw delete atomic against a concurrent tab cosarah reproduced a data loss: both raw-entry deletes verified the value, then awaited (reading or writing the adoption marker), then removed the key by name. A tab running an old bundle — which takes no Web Lock, so a lock could not have helped — overwrites the raw key with newer settings during that await, and the unconditional `removeItem` then deletes the newer value. The raw side is synchronous `localStorage`, so the fix needs no lock. Both deletes now finish every await first, then run a single synchronous compare-and-delete: re-read the raw bytes, and remove only if they still equal the exact string that was verified. A competing tab is a separate context whose writes surface here only at event-loop boundaries, so its write lands wholly before the re-read (seen — bytes differ — kept) or after the remove, never in between. A changed value is newer data, not a migration leftover, and is kept. Both sites cosarah named are covered: the shadowed-cleanup path (`dropShadowedLegacy`) and the adoption cleanup in `adopt`. `readLegacyEntry` now carries the exact bytes alongside the parse so the re-check is byte-exact rather than comparing a re-serialized value. The clear path's delete is unchanged: it is a deliberate full delete, not a conditional leftover cleanup. Part of #857. * fix(storage): stop deleting the raw entry — adopt by copy (Plan B) Maintainer decision after cross-vendor + red-team review: `localStorage` has no cross-process atomic compare-and-delete (the storage mutex was dropped from the spec), so a tab on an old bundle can insert a write between our re-read and our removeItem in a genuinely parallel process. The synchronous compare-and-delete narrowed that window but could not close it — as its own comment admitted. Since the raw-cleanup cannot be done safely on localStorage while an old bundle may be live, this stops doing it. Adoption is now a copy: the value is written to KV and the raw entry is left in place. The delete race is therefore impossible — there is no delete. KV stays authoritative (once it holds the value, reads take the KV path and never consume the raw one), so the shadow duplicate is inert, left for a wholesale raw-cleanup at migration exit. Removed with the two deletes: - `dropShadowedLegacy` (its only job was the gated delete) and its three call sites. - `deleteLegacyIfUnchanged` and the `{raw,value}` shape of the legacy read (`readLegacyEntry` → back to `readLegacy` returning the value). - The adoption marker (`__persist-adopted:`), whose only reader was that delete gate. It did not prevent re-adoption — the presence of the KV value does — so removing it breaks no invariant. `dropRaw` / `expectedRaw` on `adopt` and the marker-clear step in the clear path go with it. The clear path (user deletes all data) still deletes the raw entry strictly — that is a deliberate full delete, not a migration cleanup. Tests: the delete/marker cases become "the raw shadow always survives, KV is authoritative"; a two-device and an old-bundle-concurrent-write case guard it, and re-adding a delete on either adoption path turns them red. Part of #857. * fix(storage): add a monotonic migrated marker to stop shadow resurrection Cross-vendor + red-team review independently caught a P1 the previous "adopt-as-copy" revision introduced. Because the raw entry is now kept and writes only ever reach KV, the raw copy becomes a frozen pre-cutover value. If the account KV is later emptied by something other than a local clear — a server-side wipe, an account switch, KV eviction — the next load saw `stored === null` beside a live raw entry and re-adopted that stale copy, refilling the wiped account with ancient settings/credentials and syncing them to every device. Deterministic, not a race; it would fire once a server-backed account backend exists. Root cause: "keep the raw entry" inherently requires a record that it has already been migrated, so an empty-KV load can tell "first migration" from "external wipe". Removing the old delete-authorization marker in the copy revision dropped that record — Plan B was only half done. Adds a monotonic, device-scoped `__migrated:<name>` marker: - Read on every load. `stored === null` + raw present + marker set ⇒ respect the wipe, do not adopt (fall through to the confirm re-read, so a concurrent repopulation is still served). Only a genuine first migration (no marker, raw present) adopts. - Written best-effort after a successful KV write. A failed write degrades to the KV value's own presence check, so resurrection needs both a failed marker write and a later external wipe. - Read failure fails closed (do not adopt), never "assume not migrated". - Monotonic: only ever set true, cleared only by a deliberate full clear — no compare-and-delete race, which is why it is safe where deleting the raw entry was not. Live state with a real reader, not the delete-authorization marker removed earlier. The clear path clears the marker too (back to a genuine first-load state); best-effort, since with the raw entry already gone a leftover marker is inert. Tests: resurrection guard (external wipe does not re-adopt; concurrent post-wipe write is served; first migration records the marker; marker read fails closed) + the clear-path marker clearing. Mutation-checked: removing the gate resurrects the shadow, dropping the record leaves it unguarded. P3: the inert `__persist-adopted:` test rot is retargeted to the real `__migrated:` path. Part of #857. * refactor(storage): remove automatic legacy migration — pure KV wiring Maintainer decision (terminal): after repeated review rounds kept finding new P1s in the legacy-adoption path — its state space is too large to keep correct — the PR is narrowed from "migrate + wire" to "wire only". Settings and user-profile persist through the KVStore; pre-cutover data is not migrated; an upgrading user reconfigures once (the data is reconstructable and the cost is one-time). Removed the entire legacy-adoption subsystem from the adapter: - `adopt()`, `readLegacy()`, the `legacyStorage` dep, and the raw-fallback read on a failed KV read. - The `__migrated:` marker and its resurrection guard, the confirm-re-read, and the `provisional`/`LoadedValue` machinery. - The `prePersistFallback` dep, `recordPrePersistHandled`, the `__pre-persist-consulted:` sentinel, and `readSentinel`/`writeSentinel`. - In settings.ts, `readPrePersistSettings` (the llmModel/providersConfig reader wired as that fallback) — reading legacy keys is migration, which this PR no longer does. `lib/ai/providers.ts` still reads `providersConfig` independently, so those source keys are left in place. The adapter now reads and writes the KV scope and touches no legacy key: an empty scope hydrates the store to defaults (ordinary zustand). `load()` is a single KV read; a failed read stays unsettled so defaults are never persisted over an unread value; the clear path removes only the KV entry. Kept, because they are async-storage *steady state* a remote KV backend needs, not migration scaffolding: the KeyState machine (hydration gate, write gate, recovery, replay), the Outcome discipline, per-key serialization, and the persist-health channel. Added `purgeLegacyPersistKey`: a best-effort, fire-and-forget removal of the old raw persist blob, called once by each store. Never read, so a leftover is pure garbage — and the old settings blob held plaintext provider API keys, so clearing it is a small security win. Net: the adapter drops ~460 lines. Tests are rewritten to steady-state KV coverage plus an explicit "old raw key is ignored, not migrated" proof at the store level; the e2e seed fixtures move from the raw key to the KV scope key. Part of #857. * docs(storage): align README and comments with the no-migration behavior Follow-up requested on the approved PR: the migration/adoption logic was removed, but a few comments and one README roadmap line still referred to those deleted concepts. Documentation only — no behaviour change. - README roadmap: the settings/user-profile line said "adopting their pre-cutover raw keys"; it now states there is no automatic migration (legacy keys ignored and best-effort purged, users reconfigure once). - kv-persist.ts: the per-key serial chain comment no longer claims to keep "adoption" from interleaving; it serializes a key's read/write/clear. - settings.ts: `SETTINGS_PERSIST_VERSION` no longer documents a "pre-persist fallback" that no longer exists. - Test/fixture headers that described seeding "the pre-cutover raw key" for "one-time adoption" now describe seeding the KV `account` scope, with no legacy-key migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(storage): bump version to 0.1.1 for README doc alignment The README is a publishable input, and the no-migration doc alignment changed it, so the package version must increase. Patch bump: documentation only, no code or API change. 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> Co-authored-by: wyuc <zdq1204@gmail.com> |
||
|
|
d0d227424b |
feat(document): complete the document-persistence cutover — stage/scene/outline onto DocumentStore (#965)
* 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). * ci: whitelist the document-cutover integration trunk for PR checks * feat(document): widened-stage contracts + app document seam (#860 follow-up, PR1) (#961) Package: MaicDocument/DocumentStore/StageRow gain a TStage parameter (defaults keep existing users unchanged), BrowserDocumentStore accepts an injectable validateStage, and a version-guarded putStage closes the rename read-modify-write race — same current-version gate as putScene. App: lib/document/ seam — AppDocument types (currentSceneId is device state, not document data, and the app stage validator rejects it), a four-kind scene validator (slide/quiz delegate to the DSL; interactive/pbl validate envelope + discriminant binding only), and pure legacy canonicalizers (type rebound from content.type, singular whiteboard → whiteboards, outline envelope). No consumer switches off Dexie yet. Co-authored-by: Codex <codex@openai.com> * feat(document): stage-storage cutover + locked lazy migration (PR2) (#962) * feat(document): cut stage/scene persistence over to DocumentStore with locked lazy migration (PR2) stage-storage now persists through the lib/document seam: aggregate saveDocument with a mandatory outline envelope (omission means deletion in the contract; ordinary saves fall back to the stored envelope), lazy per-stage migration under a Web Lock (destination authoritative, one coherent legacy snapshot, save-verify-marker, legacy rows retained read-only; no-lock environments read legacy without migrating), currentSceneId in device KV, rename via version-guarded putStage, and a list that merges legacy-only stages until they migrate. stageOutlines' second write authority folds into the document service, making the final scene set and generationComplete one atomic commit. Co-authored-by: Codex <codex@openai.com> * fix(document-store): relocate the persistence seam out of lib/document lib/document/ is the server-side content-extraction domain (sharp, cloud SDKs); exporting the persistence seam from its barrel dragged node-only modules into the client bundle and broke the Turbopack build. The seam now lives in lib/document-store/ with its own barrel, and the extraction barrel is restored. Client-only guards become capability probes (indexedDB / localStorage presence) so injected node test environments keep working while a true server render still fails loud. * fix(document-store): no-lock write classification + lock-order inversion (PR2 fix round) Lock order is now per-stage document lock → global runtime epoch (a save never waits on a document lock while occupying the shared epoch, which deadlocked against fair-queued maintenance). Without Web Locks, mutation classifies instead of refusing wholesale: destination-backed and genuinely new documents accept the product's established lock-free/LWW risk, while a legacy-only document stays read-only so two authorities cannot fork — matching the chat-cutover-era acceptance suite, whose save assertions now verify through the public API instead of legacy Dexie rows. Co-authored-by: Codex <codex@openai.com> * style: prettier on document-store seam files --------- Co-authored-by: Codex <codex@openai.com> * feat(document): cut secondary consumers over to the document seam (PR3) (#963) ZIP classroom/video exports read stage metadata through the seam; classroom import builds one canonical aggregate under the per-stage lock and writes canonical 'whiteboards' (fixing the import-side singular-alias defect the manifest never had); backups become versioned document aggregates that finally include outline envelopes, while old {stages, scenes} backups are accepted through the legacy canonicalizers; clearDatabase now clears maic-documents and the seam's KV keys so clear-cache cannot strand documents; deleteStageWithRelatedData is deprecated in favor of deleteStageData. Four e2e seeders target maic-documents directly; classroom-interaction stays legacy-seeded to exercise real-UI lazy migration. Co-authored-by: Codex <codex@openai.com> * fix(document): final-review hardening — restore pre-images, honest markers, clear-race exclusion (3 P1 + 2 P2) (#964) - Backup rollback restores pre-existing destination documents (and imported current-scene KV values) from captured pre-images instead of deleting them - The skip-migration path only writes its marker after reconciling the legacy snapshot against the authoritative destination; a divergent snapshot logs and stays discoverable instead of being falsely certified - Migration's destination write enrolls in the global shared storage epoch (per-stage -> global order), so clearDatabase's exclusive epoch can no longer race a lazy migration into resurrecting a document - listStages drops legacy entries whose snapshot vanished mid-merge Co-authored-by: Codex <codex@openai.com> * 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> * fix(document): backup covers no-lock legacy courses; clears fence stale saves (review P1s) (#968) Backup assembly appends read-only canonicalized aggregates for legacy-only stages the lazy migration could not persist (no-Web-Locks environments) — courses visible in the app can no longer vanish from an export. A device-scoped storage generation now fences document mutations: clears bump it inside their exclusive section, and any save or migration commit that entered before the clear re-reads it inside the shared epoch and fails loud instead of repopulating a wiped store. Co-authored-by: Codex <codex@openai.com> * fix(document): review round 2 — legacy-only chat backup coverage + full-width generation fence (#972) Backups now enumerate runtime chat sessions for every exported document, not just store-backed ones — a legacy-only course's chat history (the chat and document cutovers are independent seams) no longer drops out of the export; the runtime read injects an empty legacy chat store since legacy rows are collected by direct table reads, which also keeps the export working without Web Locks. The storage-generation fence now guards every mutating store method, with the exclusive-epoch delete cascades passing storageSharedLockHeld so the guard cannot self-deadlock against their own hold. --------- Co-authored-by: Codex <codex@openai.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
6e9dcf45b6 | fix(roundtable): cap non-presentation input height (#917) | ||
|
|
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. |
||
|
|
fb4ce5341c |
fix(619): keep-alive e2e aligns with #777 edit-mode visibility (+ pi-ai server-external) (#781)
* fix(agent): mark pi-ai/pi-agent-core server-external to fix #619 e2e The editor-agent packages `@earendil-works/pi-ai` and `pi-agent-core` lazily load node builtins via a computed `import(specifier)` (deliberately, to avoid breaking browser/Vite builds). webpack cannot statically analyze that, so once these run on the server (the Pro-mode "Edit with AI" path) the bundled form throws `Cannot find module as expression is too dynamic` as an unhandled rejection. That broke the #619 interactive-iframe keep-alive e2e: entering Pro mode no longer settled, so the keep-alive iframe stayed visible (`interactive-iframe-keepalive-619.spec.ts:189` expected hidden). Add both packages to `serverExternalPackages` so Next loads them natively and their dynamic import resolves as a real Node call. Refs #619, #777. * test(619): interactive iframe stays visible in edit mode since #777 #777 intentionally dropped the `mode !== 'edit'` guard in InteractiveIframeHost so the interactive iframe stays visible during Pro-mode editing — the editor agent ("Edit with AI") fixes interactive HTML, so the teacher must see the live page while editing. The keep-alive e2e was still asserting the old "hidden in edit mode" behavior (toBeHidden at line 189) and so failed on every run since #777 landed. Update Trigger A to the new contract: after the Pro-mode toggle the iframe is visible and its in-iframe counter state is preserved (the real keep-alive proof — neither unmounted nor reloaded). Trigger B (scene switch to a slide) still hides it via ownership release, unchanged. Refs #619, #777. |
||
|
|
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. |
||
|
|
267965d6ed |
feat(generation): infer concise courseTitle from outlines for readable course names (#756)
* feat(generation): infer concise courseTitle from outlines for readable course names
Generated courses were named after the raw user prompt (truncated to 500 chars
client-side, or 50 chars / first outline title server-side), which is long and
unreadable. The outline-generation step already understands the full course
semantics, so have it emit a concise `courseTitle` alongside `languageDirective`
and `outlines`, and use it as the stage name.
- Prompts (requirements-to-outlines, interactive-outlines): top-level JSON gains
a required `courseTitle` (≤30 chars, in the teaching language, noun phrase).
- Streaming route: extract `courseTitle` head-bound like `languageDirective`,
emit a `courseTitle` SSE event, and include it in the `done` event.
- Non-streaming outline-generator: parse and return optional `courseTitle`
(defensive trim + 120-char cap).
- Naming: stage.name = courseTitle || outlines[0]?.title || requirement fallback
(client preview path backfills stage.name after outlines resolve; server
classroom-generation uses it directly).
- Session: persist `courseTitle` on GenerationSessionState for reload safety.
- Tests: add courseTitle parsing coverage; e2e mock done event carries it.
Fully optional in the pipeline — every consumer falls back to the previous
behavior when the field is absent, so legacy/LLM-skipped cases are unaffected.
* fix(generation): make courseTitle propagation robust across all outline paths
Address review feedback on the courseTitle threading.
Prompts — align every outline-prompt schema statement to the 3-key shape.
The user-prompt "final reminder" / top-level shape blocks for
requirements-to-outlines, interactive-outlines, and task-engine-outlines still
demanded the old two-key {languageDirective, outlines} object; as the last
instruction the model reads, that caused it to omit courseTitle and the stage
name to fall back to the raw requirement. Task Engine prompts now emit
courseTitle as well.
Streaming route — normalize the streamed courseTitle (trim, ignore
whitespace-only, cap length) to match the non-streaming parser, and add a
full-buffer fallback so a title emitted after the outlines array or beyond the
head-scan window is still recovered before the done event.
Client — reset latched languageDirective/title on outline retry so a
succeeding attempt that omits them falls back instead of inheriting a failed
attempt's stale value; also carry courseTitle in the stream-end resolve path
that fires when the stream closes without an explicit done event.
courseTitle remains optional on every consumer path.
---------
Co-authored-by: wyuc <zdq1204@gmail.com>
|
||
|
|
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> |
||
|
|
cde53843eb |
Fix overlay layout shift in home and classroom (#690)
* Fix classroom overlay layout shift * Fix homepage settings layout shift |
||
|
|
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> |
||
|
|
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 (
|
||
|
|
35d36905d1 |
Make server-configured providers admin-managed, not client-overridable (#620) (#624)
* refactor(providers): make server-configured providers admin-managed, not client-overridable A server-configured provider is now authoritative: the operator owns its API key and base URL, and the client cannot override either. This collapses the tri-state (server key / client base URL / client key) that was the root of a recurring "missing API key" bug class — when the settings UI echoed a base URL with an empty key, routes that treated "any base URL ⇒ fully client-configured" dropped the server key. #533 patched that per-route via canUseServerApiKeyForBaseUrl; this removes the guard entirely by fixing the design. Server: - resolve*ApiKey/BaseUrl ignore client values when the provider is server- configured; add isServerConfiguredProvider(section, id); drop canUseServerApiKeyForBaseUrl + normalizeProviderBaseUrl. - getServer*Providers() no longer return base URLs — only allowed models (LLM/image) and the managed flag (presence) — so an internal gateway/proxy endpoint is never exposed to the client. - routes (chat/image/video/tts/asr/pdf + verify-*) resolve managed providers purely server-side; SSRF validation now applies only to unmanaged (client) base URLs. Client: - drop serverBaseUrl from ProviderSettings, the settings store, request-building (TTS base URL no longer echoes the server URL) and voice resolution. - settings UI renders managed providers read-only: the key/base-URL override inputs and region toggles are hidden, leaving the "managed by server" notice. Tradeoff: overriding a server provider's key with your own for the same provider is removed; add a separate custom provider instead. Closes #620. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(providers): refresh managed-provider copy, drop dead audio-settings, add #620 e2e - serverConfiguredNotice now says the provider is managed and not editable here, pointing users to add a separate provider for their own credentials (7 locales). - delete components/settings/audio-settings.tsx — unreferenced dead code. - add e2e/tests/managed-provider-620.spec.ts: managed provider hides the key/base-URL inputs; unmanaged provider keeps them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(providers): lock the model catalog for managed providers with a pinned list When the operator pins an allowed model list (MODELS env/yaml ⇒ serverModels), the model catalog is admin-managed too: the LLM provider panel shows the pinned models read-only with a "Server" badge and no add / reset / edit / delete affordances. Without a pinned list the server manages only credentials and the user keeps curating models. This removes the prior inconsistency where the settings panel let you add models to a pinned managed provider that the generation toolbar then filtered out. e2e: the managed-openai case now also asserts the catalog is locked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(providers): ignore (not reject) client overrides for managed web-search; clear legacy serverBaseUrl Addresses review feedback on #624: 1. Managed web-search providers now ignore a client-sent key/baseUrl instead of validating and rejecting it. A server-configured provider is admin-owned, so the server config is authoritative and a stale client base URL is dropped rather than 400-ing the request. The allowlist validation still runs for unmanaged providers, where the client base URL is actually used. 2. Drop the removed `serverBaseUrl` field from persisted localStorage on rehydrate (settings version 2 -> 3, stripped in both migrate and merge), so old clients don't keep a stale server URL in client state. Tests: web-search route test split into unmanaged-rejects-allowlist and managed-ignores-client-baseUrl; settings rehydrate test asserts a legacy serverBaseUrl is stripped. Full suite 381 pass; e2e 2 pass. 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> |
||
|
|
d9aecf8052 |
fix(settings): enforce "usable provider ⇒ concrete model" invariant (#580) (#581)
* fix(settings): enforce "usable provider ⇒ concrete model" invariant (#580) Layer 1 — resolve provider AND model atomically at every source: - add resolveSelectedModel(): like validateModel but no empty-input short-circuit, so it returns '' only when the provider has no models - fetchServerProviders: symmetric LLM/image/video provider recovery + resolveSelectedModel; remove the asymmetric #577 isServerConfigured-gated LLM tail and the separate first-load auto-select block (now subsumed, and covers client-API-key providers too) - setProviderConfig: entering an API key for the active/unset LLM provider resolves a concrete model in the same set() - setVideoProvider: resolve a model on switch (was id-only); setImageProvider routed through the shared resolver Layer 2 — remove states unreachable under the invariant: - add shared isLLMProviderConfigured / hasUsableLLMProvider; gate the landing-page generate button on a usable provider (state A vs B) and drop the modelNotConfigured toast + forced SettingsDialog - generation-toolbar: use the shared predicate; drop the dead "Select Model" label/tooltip fallback - delete dead components/settings/model-selector.tsx (zero refs) and the now-orphan settings.selectModel i18n key (6 locales) Note: generation-toolbar's `providerEntries.length === 0` branch is KEPT — it is reachable via search (search-no-results), not the dead no-provider state the issue assumed. modelNotConfigured i18n key kept (still used by the chat path in use-chat-sessions). Tests: resolveSelectedModel + hasUsableLLMProvider unit tests; server-sync invariant tests (client-key resolution, atomic API-key entry, provider switch, serverModels-preference + first-load regression guards). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(settings): address code review — symmetric image/video config resolution Code review Important #1: fold atomic model resolution into setImageProviderConfig / setVideoProviderConfig (design doc Layer 1 step 2 scoped these). Deleting the selected custom image/video model on the active provider now resolves to a valid model in the same set() instead of leaving it stale until the next server sync — restores LLM/image/video symmetry. Minor #2/#3: document the deliberate store-level (credential path) vs UX-level (incl. ≥1 model) "usable" split on isProviderUsable; clarify that setProviderConfig also adopts providerId on the first-load API-key path. Tests: +2 invariant tests (delete selected custom image/video model → falls back to a valid model). 89/89 store tests pass, tsc clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(settings): keyless providers need explicit baseUrl to count as usable (#580) Code-review/e2e follow-up. ollama/lemonade ship requiresApiKey:false + a registry defaultBaseUrl, so the old isLLMProviderConfigured counted them as configured on a fresh install — but buildFallback/reconcile never auto-selects keyless providers, so hasUsableProvider could be true while modelId stayed '', re-enabling generation with no model (the exact #498/#580 class of bug, just via the keyless path). Narrow isLLMProviderConfigured to be consistent with isProviderUsable: keyless ⇒ usable only once the user sets an explicit baseUrl (registry defaultBaseUrl alone is not user intent); server-config and key-requiring paths unchanged. Now gate + toolbar + reconcile agree, and genuine State A (no usable provider) is reachable again. Add e2e/tests/model-invariant-580.spec.ts: State A (no provider → generate disabled + single "Set up model" affordance, no toast/forced dialog) and State B (server-configured → concrete model auto-resolved, generation enabled). +3 keyless unit tests. 92/92 store tests + 2 e2e pass, tsc clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(settings): re-resolve selection on bulk provider-config change (#580) Self-test found: deleting the selected provider in Settings left the model selector pointing at the deleted/invalid provider (or, via the component's hand-rolled fallback, at an unusable built-in like openai with no key). Root cause: setProvidersConfig (the bulk setter behind delete-provider / delete-model) did ZERO re-resolution — Layer 1 covered setProviderConfig (singular) + image/video setters + the reconcile, but not this path. The component's confirmDeleteProvider then picked Object.keys()[0] (always a built-in, ignoring usability), the exact per-call-site hand-rolled shape #580 condemns. Fix at the source: setProvidersConfig now re-resolves (providerId, modelId) via the canonical isLLMProviderConfigured + resolveSelectedModel — keep the current provider if still usable, else first usable, else State A. Removed the brittle confirmDeleteProvider override (kept only its local Settings-tab selection). Now every bulk config mutation (delete, import, reset) holds the invariant. Tests: +2 store tests (delete selected/only-usable provider → State A, not the deleted/invalid one; delete non-selected → keep usable selection). 94/94 store tests, full suite green (only pre-existing ssrf DNS), tsc/eslint clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(settings): one shared LLM-selection resolver for all config edits (#580) Clarified repro: the realistic case isn't deleting a custom provider — it's clearing the SELECTED provider's API key so it becomes invalid. setProvider Config only re-resolved when the edit made a provider *usable*; the "became unusable" transition fell through (return base) → the now-invalid provider stayed selected. The previous commit fixed only the bulk path. Root-cause fix: extract one shared `resolveLLMSelection(config, providerId, modelId)` — keep current provider if still usable, else first usable, else State A; then resolve the model. Use it in BOTH setProviderConfig (single edit: key add/clear, model-list edit) and setProvidersConfig (bulk: delete, import, reset). The two paths can no longer diverge — exactly the per-call- site asymmetry #580 set out to eliminate. Drops the now-unused isProviderUsable import. Tests: +2 store tests (clear selected provider key → drops stale selection; clear non-selected key → keeps usable selection). 96/96 store tests, full suite green except pre-existing ssrf DNS; tsc + eslint clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style: prettier-format #580 test specs CI 'Lint, Typecheck & Unit Tests' ran prettier . --check and failed on the two added test specs (only tsc/eslint/vitest were run locally). Pure whitespace reformat, no logic change — 96/96 store tests still green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
a5209d747e |
feat(outline-review): clickable streaming card morphs into editor (#558)
* feat: add outline review preview flow * style: refine outline review editor * fix(home): make draft cache survive round-trips through generation flows The `useDraftCache` hook initialised state via `useState`'s lazy initialiser. Under React 18+ SSR, the initialiser runs on the server (returns `undefined` because `window` is missing), the SSR'd state is sent to the client, and React reuses it on hydration without re-running the initialiser. As a result `cachedValue` was always `undefined` and nothing ever rehydrated from `localStorage`. The home page's `prevCachedRequirement` derived-state pattern then masked the symptom: it initialised `prev` to the same (always undefined) `cachedRequirement`, so the comparison never fired and the restore branch was unreachable. Switch to an effect-based read so consumers actually receive a state update after mount, and replace the home page's derived-state restore with a `useEffect` + `useRef` guard that only fills the input when it is empty. Side benefit: PBL chat draft and quiz answer caches start working too — they used the same broken pattern. * feat(outline-review): clickable streaming card morphs into editor + UX overhaul Major refresh of the outline review surface introduced in #198, applying the placement design from docs/superpowers/specs. Key shifts: Streaming card → editor morph - The streaming preview card in the generation-preview page is now an interactive surface (tilt + hover-straighten + always-visible "点击审阅" affordance) that morphs into the full editor via shared `layoutId`. - SSE keeps streaming uninterrupted into the expanded editor; the editor consumes either `session.sceneOutlines` or live `streamingOutlines` so scenes appear as they arrive and stay in sync after edits. - A header collapse button shrinks back to the preview card. Mid-stream collapse keeps SSE running; post-stream collapse re-arms the same 2.5s auto-continue timer the no-review path uses, so the card doesn't strand. Document-style editor - Replaces the bordered card + accordion + numbered rail with a Notion- feeling stack: drag handle and number share a flex left rail aligned to the title baseline; title/description are inline-editable borderless textareas with auto-resize. - ⋮ overflow menu and explicit move-up/move-down are removed; reordering is drag-only, with hover-revealed delete (popover-confirmed) next to the type pill. - Hover-revealed `+` insert dividers between scenes; faint `+` at the list edges replaces the "Add scene" bar so insertion is uniform. - Quiz config popover redesigned: stepper for question count, segmented control for difficulty, multi-select pills for question types so mixed types from the LLM round-trip correctly (the previous single-select silently truncated `questionTypes` to one element). - Streaming placeholder row pulses below the latest scene during SSE. - Scene types use a colour-coded pill that doubles as the type switcher. State + flow plumbing - Adds `previewPhase: 'review'` entry from the streaming visualizer via `outlineReviewIntentRef`; SSE-completion ORs in this intent when deciding whether to wait for confirmation. - `localStorage.removeItem('requirementDraft')` moves to after the review promise resolves so "Back to requirements" actually restores the draft. - Auto-start condition broadened so refreshes during early-review reattach to a fresh SSE rather than stalling on an empty editor. - Six new locales (en-US, zh-CN, zh-TW, ja-JP, ru-RU, ar-SA) for the expand hint, streaming progress, collapse, delete confirmation, insert hint, and quiz summary; locale coverage test asserts the new keys plus the `{{count}}` interpolation contracts. Removed: the redundant "Review outline" button + auto-continue caption under the preview card, since the card itself is now the entry point. Removed: the home toolbar "Outline" tab from #198 — placement design explicitly rejects adding another always-visible toolbar control. * fix(outline-review): address code review feedback (PR #558) Single follow-up commit batching the issues raised in code review: Critical - E2E page object's reviewOutlineButton was hunting for the removed "Review outline" button. Re-target it to the streaming card's outlineExpandHint aria-label across all six locales. Important - waitForOutlineReviewChoice now accepts an AbortSignal and rejects on abort, so goBackToHome / unmount settle the parked promise instead of leaking the awaiting startGeneration closure. The existing AbortError catch swallows the rejection cleanly. - Add CLDR plural variants for the count-bearing strings: en gets _one; ru gets _one and _few for quizConfigSummary; ar gets _one and _two for the three count-bearing strings. Bare key remains as _other fallback. (zh / zh-TW / ja have a single plural form.) - Document the mid-stream collapse intent: dropping outlineReviewIntentRef is deliberate so SSE-completion takes the no-review path. - Add keyboard reorder on the drag handle (Ctrl/⌘ + ArrowUp/ArrowDown) with aria-keyshortcuts; replace dragScene copy with dragSceneHint that surfaces the shortcut. Restores WCAG 2.1.1 reachability for reorder. - KeyPointInput no longer commits on blur — only Enter / comma. Avoids surprise chips when users click away mid-typing. Minor - Drop unused i18n keys: reviewOutlineAction, moveSceneUp/Down, dragScene, sceneDescriptionLabel, keyPointsLabel/Placeholder, quizConfigLabel, addScene. Locale coverage test pruned to match actual usage. - requestAnimationFrame-batch useAutoResize so a burst of edits doesn't thrash layout (forced reflow per keystroke). - Reset isConfirmingOutlines in the no-parked-promise fallback path of handleConfirmOutlines so state doesn't linger. * chore(outline-review): prettier format + drop redundant session check - Run prettier on outlines-editor.tsx (CI Lint job was red). - Remove `&& session` from the StepVisualizer onExpandOutline conditional; `session` is guaranteed non-null at that point by the early-return guard above (flagged by github-code-quality bot). * fix(home): silence react-hooks/set-state-in-effect on draft restore Match the existing eslint-disable convention already used in app/page.tsx for the other localStorage-hydration effects. The setState calls are deliberate: client-side state hydration from localStorage must happen in an effect to remain SSR-safe (the lazy useState initialiser runs on the server with `window` undefined). * revert(i18n): drop CLDR plural variants until key-alignment check supports them scripts/check-i18n-keys.mjs enforces strict key equality across all locales, which is incompatible with i18next's per-locale plural-suffix keys (a Russian locale needs `_one`/`_few`/`_many`/`_other`, an Arabic one adds `_two`/`_zero`, while zh / ja need none). The check script doesn't strip plural suffixes before comparing, so any partial pluralization breaks alignment. Roll back the partial fix from PR review #2 and revert to the bare keys. The original "ungrammatical for n=1" wording stays, to be addressed in a follow-up that also teaches check-i18n-keys.mjs to ignore CLDR plural suffixes when comparing key sets. * fix(outline-review): make streaming card expand-hint pill actually visible The "Tap to review" pill was rendering but invisible in practice because: - Both the bottom white gradient (z-auto) and the pill (z-10) sit inside the same parent stacking context, but motion.div applies a transform that creates per-element stacking contexts; the implicit ordering put the gradient above the pill in some browsers. - text-[9px] + bg-blue-500/95 was too thin/translucent to read against the white gradient even when on top. Make stacking explicit (gradient z-0, pill z-20), bump the pill to text-[10px] font-semibold with solid bg-blue-500, larger Maximize icon, and lift it slightly to bottom-2 so it sits above the gradient haze. |
||
|
|
22c637c0b6 |
Fix generated video thumbnails (#546)
* fix video thumbnails for generated media * add video affordance to thumbnails * address video thumbnail review findings |
||
|
|
2356d283e4 |
test(e2e): add end-to-end generation happy path test (#401) (#405)
Add a single E2E test that exercises the complete user flow without pre-seeding any intermediate state: Home page (fill + submit) → Generation preview (mocked SSE + content + actions) → Auto-redirect to classroom → Classroom (verify scenes load + navigation works) Also fix a latent bug in MockApi.mockSceneActions where a hardcoded stageId='test-stage' caused addScene() to silently drop the scene (stageId mismatch validation). The mock now extracts the actual stageId from the request body when none is explicitly provided. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
d789a389f2 |
fix(e2e): add provider apiKey to test settings fixture
#232 added validation that clears modelId when the selected provider has no apiKey and no server config. The E2E settings fixture was missing providersConfig, causing handleGenerate to bail out and the home-to-generation navigation to never happen. Fixes #264 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
37e04558b6 |
test: add Playwright e2e testing framework with core scenario coverage (#229)
* chore: add Playwright e2e testing infrastructure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add e2e mock fixture data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add MockApi route interception helper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add base fixture and page object models Also exclude e2e/ from ESLint to avoid react-hooks false positives on Playwright's fixture `use` callback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add home-to-generation e2e spec Adds the first Playwright spec covering the home page UI and navigation to generation-preview. Also updates playwright config to use port 3002 to avoid conflicts with other running services. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add generation-flow e2e spec Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add classroom-interaction e2e spec Seeds IndexedDB with a 3-scene stage by navigating to / first (so Dexie initializes the DB at v8), then writing data without re-triggering onupgradeneeded. Verifies the sidebar renders 3 scenes and that clicking a scene switches the active scene heading. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: add Playwright e2e test job Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(e2e): fix review issues — data-testid selectors, shared helpers, type annotations - Add data-testid="scene-list", "scene-item", "scene-title" to scene-sidebar.tsx - Update classroom.page.ts selectors to use data-testid instead of CSS class chains - Extract createSettingsStorage() helper into e2e/fixtures/test-data/settings.ts - Update all 3 spec files to use createSettingsStorage() and import defaultTheme from scene-content - Add SceneOutline[] type annotation to mockOutlines via relative import - Add SlideTheme type annotation to defaultTheme in scene-content.ts - Remove redundant mockServerProviders() call from setupGenerationMocks() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: exclude e2e/ from tsconfig, rename CI job - Add "e2e" to tsconfig.json exclude array to prevent Next.js compilation from pulling in Playwright/Node APIs - Rename CI job from "Lint & Typecheck" to "Lint, Typecheck & Unit Tests" to reflect that it now also runs unit tests (added in PR #144) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |