mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-17 05:29:46 +08:00
@openmaic/renderer@0.1.1
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d1eeb95e1c |
feat(video-export): add deterministic Quiz question-list scrolling (#1102)
* feat(video-export): add deterministic Quiz question-list scrolling * fix(video-export): bound Quiz layout measurement |
||
|
|
ceb05adb41 |
feat(media): allocate generated assets through the registry (#1007 part 2, step b) (#1039)
* feat(media): establish shared asset ownership primitives Introduce global browser asset-pool ownership, asset-reference collection, stage reclamation planning, and lease-based URL access. Carry allocated media identity through storage and generation boundaries with regression coverage. * fix(media): enforce safe resolution across every consumer Route image, video, thumbnail, presentation, and video-export consumers through one resolution state machine. Prevent opaque allocated or generated references from reaching render and export sinks, with fallback and ownership tests. * fix(media): protect document-owned assets across mutations Allocate pool bytes before compatibility writes and document commits, then roll back uncommitted generations safely. Preserve document ownership across edits, retries, imports, speech generation, scene changes, and stage deletion. * fix(media): scope retries and tighten ownership guard Scope retries to the target scene and slide and refuse ambiguous shared-reference mutations. Expand retry rendering coverage and keep direct pool URL resolution behind the shared lease owner. * fix(storage): avoid nested lock during stage cleanup Execute prepared reclamation plans against an explicitly deleted document so the compatibility cascade cannot re-enter the per-document lock. Cover deletion of stage-owned media rows even when the document has no references. * refactor(media): confine reclamation to stage deletion Remove inline pool and compatibility-row cleanup from element, speech, scene, and audio replacement flows. Keep whole-stage reclamation behind explicit stage ownership, preserve stage-less legacy audio rows, and document deferred document-truth sweeping. * fix(media): close retry and resolution gaps Restore shared source tasks after successful forks, scope retries across both whiteboard locations, and wait for parallel TTS workers before rollback. Resolve background media through import, rendering, and PPTX export paths while keeping retry controls visible over last-good bytes. * test(media): execute the consumer safety matrix Replace source-substring checks with resolver seam execution across all six UI consumers, stage hydration, and both exporters. Pin each rollback layer independently and harden the ownership guard against aliased pool imports. * chore(packages): publish the additive DSL field Bump the DSL patch version for the optional speech-action field. Keep the transitional reclamation policy app-owned and document the legitimate transaction rollback removals there. * fix(media): make retry rollback task-safe Delay allocation task re-keying until final document reconciliation succeeds, restore shared source tasks on failed forks, and require exact stage-whiteboard targets before falling through from a missed scene. * fix(media): clear private assets and refresh leases Delete the asset-pool database during the confirmed local-data wipe. Notify the app lease layer after same-id replacement so mounted consumers re-resolve current bytes without reaching into storage internals. * test(media): pin closure safety guards Exercise the CSS allocation boundary, unfiltered legacy-row ownership, delete-and-undo byte survival, and real distinct consumer seams. Restore the video-only manifest overwrite condition and share the direct video resolution hook across both element variants. * test(media): narrow rollback element assertion Narrow the reconciled slide element to an image before checking its source so the rollback regression remains type-safe under the full root compiler configuration. * fix(media): close lease refresh races Gate the first batch publication by unique resolved refs, then publish every replacement snapshot without mutating the prior React state object. Serialize invalidation behind pending releases, evict rejected refreshes, and register replacement observation at the pool boundary. * fix(media): reopen pool after clear failures Always evict the singleton once its store has been closed, including blocked and failed database deletion paths. Report blocked deletion as deferred and prove a later write uses a fresh live store. * fix(media): isolate shared retry progress Track forked regeneration under the selected element until it receives a fresh asset identity, leaving the shared source task and bytes untouched. Surface targeted failures through renderer task lookup and skip the redundant fork reconciliation lock. * fix(media): close final asset retry gaps Keep blocked asset clears fail-loud until a successful retry, with actionable settings guidance. Clear failed shared-fork state across durable and live key spaces, and pin renderer lookups, lease publication identity, and committed-ref rollback protection. * fix: preserve actionable retry failures Localize the blocked cache-clear recovery hint across every supported locale and pin the deferred-error mapping. Retain durable fork failure rows while retries run, deleting them only after successful generation so unstructured failures survive reload. * fix(media): hydrate legacy stored video thumbnails Home-page recent-video thumbnails regressed for legacy Dexie mediaFiles rows keyed by gen_vid placeholders: the reworked hydration resolved the row's bytes through the sealed resolver but never surfaced the stored blob (and its poster) as object URLs for the preview card, so the CI recent-video-thumbnail e2e specs found no visible element. Hydration now materializes legacy stored video rows into blob URLs for both the element src and poster while keeping the resolver invariants: opaque refs still never reach a DOM src, and concrete addresses are never blanked. Unit pins cover the seam so the vitest suite catches this class without a browser. * fix(media): resolve sole restored legacy video Classroom playback restored tasks by exact document media references. Legacy gen_vid references can outlive the key used by the one persisted video row, leaving the player on a placeholder even though bytes were restored. Select the sole completed stage video only for legacy sequential refs after exact and reconciled matches. Keep exact failures authoritative, refuse ambiguous candidates, and cover success, ambiguity, and failure precedence in unit tests. * fix(media): scope legacy video recovery Decide restored legacy video recovery once from the complete document and record it through the shared task lookup consumed by playback, editing, and resolved slides. Keep ambiguous documents as placeholders, apply the same decision to thumbnail hydration, and preserve exact failure precedence. * fix(media): exclude claimed video recovery tasks Model restored legacy recovery as a two-pass match across document video elements and task rows. Remove tasks claimed by exact, targeted, or placeholder lookup before applying the sole-candidate fallback, and cover the ownership/cardinality matrix. * fix(media): unify video element resolution Centralize source, task, poster, and legacy recovery decisions for every video consumer. Ensure direct URLs win over opaque refs and play_video waits on element-targeted retry tasks. * refactor(media): route legacy recovery through resolver Let document-aware consumers request legacy video recovery through the unified element binding API, keeping thumbnail hydration on a single decision path. * fix(media): preserve import refs and prefer pool bytes Recognize unambiguous extensionless relative media addresses during classroom import. Resolve allocated export and thumbnail assets from the shared pool before falling back to lagging compatibility rows. * fix(export): preserve concrete video sources Route PPTX video elements through the shared media binding resolver so an unresolved opaque reference cannot replace a playable source. Complete browser and persisted-store cleanup when asset-pool deletion is deferred, while retaining distinct hard-failure behavior. * fix(media): fork retries without exclusive ownership Enumerate logical asset owners across every persisted document before allowing global pool replacement. Thread explicit targets through fresh-id rewrites and cover cross-document aliases plus unreadable ownership. * fix(media): guard global asset reclamation Share a fail-closed persisted-document liveness check between stage deletion and retry replacement. Preserve cross-document pool aliases while deleting stage-owned compatibility rows and cover enumeration failures. * fix(media): cover complete slide asset references Route slide media traversal through a shared mutable slot contract so backgrounds participate in export, thumbnail hydration, collection, and rewrite lifecycles. Snapshot complete surviving-document refs once per reclamation and preserve manifest-only owners while retaining fail-closed behavior. * fix(media): preserve exclusive retry asset ids Allow targeted retries to replace exclusively owned pool assets in place. Keep shared and unprovable ownership paths on fresh allocations, and pin production-shaped retries plus compatibility-row cleanup. * fix(media): revalidate asset bindings at completion Recheck repository-wide ownership before replacing generated media and fork scoped retries when exclusivity changed. Route video export selection through the unified resolver and keep concrete posters independent of task state. * fix(media): count unflushed owners and broadcast replacements The completion-time exclusivity proof read only the persisted document, but slide duplication updates the Zustand aggregate synchronously and schedules persistence behind a debounce. A retry finishing inside that window saw a single persisted owner and replaced the bytes behind a reference the duplicate also held. The proof now also counts owners in the live stage snapshot when that snapshot represents the stage being retried, so an unflushed duplicate forks instead. Same-id replacement notifications were realm-local, so a second tab showing the same classroom kept its lease pinned to the superseded blob URL. The notification now travels over a BroadcastChannel; each receiving realm runs its own observers against its own pool, so a spoofed message can at most force a re-resolve. A missing or failing channel never fails the replacement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): bind replacement listeners and spare shared audio rows A realm that only renders never sends a replacement, so binding the channel from the sender path left passive tabs deaf to peers. Binding now happens where the observer is registered, when the asset-pool module loads, and the receiving realm resolves its own pool lazily so a cleared or unavailable pool degrades to the next resolve instead of throwing. Observer notifications ran under Promise.all, so a rejection surfaced after BrowserAssetStore.replace had already committed and turned a durable success into a reported failure. They are settled individually now; the callback is wrapped because a synchronous throw would otherwise escape before allSettled sees the array. audioFiles rows are keyed globally by audioId, so deleting a stage removed the sole row for an id a surviving document still referenced — playback and both export paths read that table directly and cannot fall back to the preserved pool blob. Rows are now filtered against surviving references, while a failed enumeration still withholds only the irreversible pool removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): serve replaced bytes from the pool everywhere Unknown survivor liveness deleted every planned audioFiles row. Those rows are keyed globally by audioId, and playback plus both export paths read the table directly, so losing one is as irreversible for them as removing the pool entry. Unknown liveness now preserves the rows too, leaving bounded garbage for a later pass that can prove exclusivity. The earlier pool-first change covered PPTX, video collection and thumbnail hydration but missed classroom ZIP export, which still serialized the stale compatibility row after a lagged same-id replacement, shipping media the classroom no longer renders. Auditing every direct reader of the media and audio tables surfaced the same gap in playback: speech regeneration also replaces bytes under a stable id and does not roll the pool back when the compatibility write fails, so the player kept serving superseded narration. It now resolves the pool first and falls back to stored rows for legacy and imported audio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(audio): replace exclusively owned speech clips in place regenerateSpeechAudio accepted the action's audioId but always passed undefined as replaceAssetId, so regenerating an exclusively owned pool-backed clip allocated a new asset every time, rewrote the action and orphaned the previous pool entry and compatibility row until stage reclamation — contradicting the stable-id path generateAndStoreTTS already implements for media. Ownership is now established before synthesis, and the rule itself moved to the shared reference module so media retries, poster replacement and speech regeneration consume one implementation instead of restating it. An exclusively owned clip keeps its id and has its bytes replaced; a shared clip, a legacy id with no pool entry, or unprovable ownership still gets a fresh allocation so other holders keep their audio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(media): drop the import left behind by the ownership move Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): resolve audio pool-first and fence peer realms Stable-id TTS regeneration commits replaced narration to the pool before the audioFiles mirror write, so a failed mirror leaves the row stale. AudioPlayer already resolved pool-first, but audioObjectUrl, collectAudioFiles and the video timeline dependencies still read Dexie directly and would serve the superseded clip. All allocated-audio readers now share one resolver, with Dexie kept as the fallback for legacy and imported rows. The exclusivity proof modelled unflushed owners in the active realm only, so another tab duplicating the same asset during its save debounce could still be observed as a single owner and have its bytes replaced globally. A peer's pending state cannot be read across realms, so presence is probed instead: any realm holding the stage forces the fork path. A deferred asset-pool deletion no longer reloads the page. The database is still on disk and the guidance asks the user to close the other tab and retry, which the reload discarded. The decision moved into a shared helper so the rule is pinned rather than living inline in the component. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(media): fail closed when presence cannot be probed The presence helper documented that an unanswerable probe must count as a peer, but every unavailable path — no BroadcastChannel, a constructor that threw, a send that failed, and the window before the pool's asynchronous binding completes — returned false, so the ownership proof cleared a single local owner and replaced globally shared bytes in place. Probing now returns present, absent or unknown, and only a probe that was actually sent and went unanswered is absent; the ownership decision treats unknown exactly like present. The pool declares its binding intent synchronously so a probe issued during the load-time window waits for the bind instead of concluding that presence is unavailable, and releases that gate if the import fails. Coverage reaches the write boundary: with presence unknown, a production-shaped targeted retry forks to a fresh id instead of calling replace, and the original bytes stay intact for a peer's unflushed owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(dsl): bump to 0.6.3 after the release dedupe took 0.6.2 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(i18n): add the deferred-clear guidance to fr-FR The locale landed on main after this branch added the key, so the alignment check flagged it as the one missing translation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
6d29241bb0 |
feat(video-export): fidelity polish — spotlight geometry, video clips, formulas, subtitles (#952)
* fix(video-export): bridge play_video element id → media ref Generated-media records are keyed by the element's media ref (gen_vid_…), but a play_video action targets the slide element by its .id. The app-side compiler deps looked media/durations up by the raw element id, so every generated video missed → present:false → no <video> emitted, no bytes collected → the clip vanished from the export (only its static first frame, baked into the base PNG, remained). Build an elementId → mediaRef bridge across all slide scenes in createVideoTimelineDeps (mirroring the live engine's resolveMediaPlaceholderId) and resolve both media() and videoDurationMs() through it. Refs #867. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): re-measure KaTeX fit-scale after fonts settle KaTeX sizes large delimiters (\left\{, \begin{cases}) from its KaTeX_Size faces, which load asynchronously. KatexContent measured its shrink-to-fit scale once on mount, against the fallback font, and never recomputed — so a cold slide snapshot baked a stale scale and the big brace desynced from the piecewise body ("大括号后面的分段函数与大括号错位"). - Re-run the fit-scale measurement on document.fonts.ready and on each `loadingdone`, keeping the shrink-only (cap-at-1) behavior. - slideToPng waits two extra frames before capture so the font-triggered React re-measure commits into the DOM html2canvas reads. Refs #867. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): place spotlight/laser/video on the rendered content box The pure geometry helper resolves an element's authored outer box against a fixed 1000×562.5 base, but the live overlays — and the exported frame PNG — measure the element's `.element-content` box, which for horizontal text is auto-height plus 10px content padding. So a spotlight/laser sat offset from where the text actually renders ("spotlight 与对应元素的位置有偏差", #867 item 5). Add an optional GeometryProbe to the compiler's DI boundary: when supplied, the geometry pass prefers each element's measured content-box geometry (video keeps its rotation from the authored element), degrading to the pure authored-box calc on a miss so the compiler stays deterministic in unit contexts. App side: measureSlideElementGeometry (new, in @openmaic/renderer/snapshot) mounts the slide off-screen and reads the same `.element-content`-against- container box the live SpotlightOverlay uses; createVideoTimelineDeps pre-measures every spotlight/laser/play_video target per scene and serves the probe as a synchronous table lookup. Tests: geometry.test.ts quantifies the outer-box vs content-box delta and proves probe-wins / fallback / rotation-preservation; timeline-deps.test.ts covers the pre-measure wiring. Refs #867. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): decode a first-frame poster for posterless videos A play_video clip only shows during its play window; the rest of the time the static base-frame PNG backs that spot. But html2canvas can't draw a <video>, so the snapshot swaps it for its poster <img> — and generated videos usually carry no poster, leaving the video area blank whenever the clip isn't playing. When a video record has no stored poster bytes, decode the video blob's first frame (seek slightly off 0, draw to a canvas) and set element.poster before the base snapshot, so that spot shows the first frame — the "paused on frame one" look — instead of blank. Falls back to no poster on decode failure / timeout / CORS taint, never failing the export. Refs #867. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(video-export): split subtitles, make burn-in optional, centered modal Burned-in captions used one cue per whole narration paragraph, so a long cue became a 4–6 line block covering the lower half of the slide, and there was no way to turn it off. - Split cues (pure, compiler layer): lib/video-export/split-cue.ts splits each cue on sentence → clause → hard-wrap to ≤~40 CJK-equiv units, distributing the parent window by character weight (last piece pinned to the parent end, sub-1.2s slivers merged). Wired into the timeline pass, so the burned-in overlay and the SRT/VTT sidecar share one split track and can't drift. - Burn-in optional (default off, #867 item 2): emitHyperframes gains burnInSubtitles; off → clean video + sidecar SRT/VTT only. Caption layout hard- clamped to 2 lines so an outlier can't grow tall again. - Download subtitles without rendering: compileSubtitles() runs only the compiler (no assets/zip/service); new useDownloadSubtitles saves an .srt. - Centered modal: video-export-dialog.tsx replaces the cramped dropdown section, grouping Output / Subtitles / Actions / Progress with a stable lifecycle for multi-minute renders. header-controls opens it from an "Export Video" entry; old video-export-menu.tsx removed. i18n keys added across all 8 locales. Refs #867. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: prettier format video-export fidelity changes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drop unused playVideo helper in timeline-deps test The media-bridge tests assert via deps.assets.media(...) directly, so the playVideo helper (and its PlayVideoAction import) were dead. Flagged by the code-quality bot on #952. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): resolve cross-review findings + lower subtitle band - emit-hyperframes: drop duplicate `display:-webkit-box` on subtitle cues so they start truly `display:none` (a second declaration overrode the `none`, showing every cue stacked at t=0 when burn-in was enabled). - geometry: don't feed the measured content-box AABB to a rotated video clip — it already encloses the rotation, so re-applying `rotate` doubled it. Rotated elements fall back to the authored box (one, un-doubled source of truth). - build-export-zip: extract shared `compileStageIr` so buildExportZip and compileSubtitles can't drift on the timing/assets/geometry wiring. - split-cue: join merged CJK cue pieces without a spurious ASCII space. - subtitles: lower the caption band (bottomRatio 0.055 → 0.01) and hoist the band's magic numbers into a named SUBTITLE constants block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): stop the snapshot onclone override from reshaping KaTeX The export snapshot injects a neutral-kerning / geometricPrecision style over `.slide-renderer-prose *` to keep CJK prose+table glyphs from mis-advancing under html2canvas-pro. KaTeX formulas (output:'html') also live inside `.slide-renderer-prose`, so the override was reshaping their math glyph advances — a burned-in formula rendered subtly differently from the live canvas, which applies no such override. Empirical check (Playwright + system Chrome, cases+fraction formula, native paint vs html2canvas): the override raised the formula's mean per-pixel delta vs the live paint from 120.1 to 139.2 (~16%) and moved ~75% of inked pixels. Restoring native font-kerning/text-rendering for `.katex` subtrees brings the formula's delta back to 120.1 (bit-identical to the no-override capture) while prose and table text keep the override. The residual 120.1 is the deeper html2canvas rasterization gap (vlist/frac-line/delimiter), a separate track. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): render slides via native paint, bundle KaTeX font embed Root-fix the exported-vs-live formula distortion (and the filter/mask/video gaps) by rasterizing slides with native Chrome paint instead of html2canvas's reimplementation. - slideToPng now captures via html-to-image (foreignObject → the same Chrome engine that paints the live classroom rasterizes the same DOM). KaTeX formulas, CSS filter, soft-edge masks, and mixed CJK/Latin text come out as the classroom shows them. html2canvas-pro stays as a fallback (with the filter/mask bakes + KaTeX text-rendering reset) for when native paint can't run — e.g. a cross-origin image taints the canvas. - A foreignObject SVG can't reach the document font registry, so fonts must be inlined. KaTeX math faces are prepended from the bundled woff2 (KATEX_FONT_EMBED_CSS, generated by scripts/generate-katex-fonts.mjs), NOT left to runtime getFontEmbedCSS — which reads cssRules and silently drops a cross-origin KaTeX stylesheet, collapsing a large brace to a fallback glyph (the observed "formula render failure" on e.g. a \begin{cases} with \text{}). A per-face guard verifies every KaTeX face the formula references is embedded, falling back to html2canvas otherwise. Verified in a Playwright spike (native vs html2canvas vs html-to-image): formula inked-delta 143→53; filter/mask/video match native; and with getFontEmbedCSS forced empty, the bundled embed alone still renders the failing formula correctly. Renderer package tests: 226 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): prettier-format the generated KaTeX font-embed module The generator emitted an unformatted TS file (double quotes, long unwrapped CSS literal) that failed CI `prettier --check`. Run the generated output through the repo's Prettier config before writing, so every rebuild produces CI-clean, stable output. Regenerated the committed file to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lint): rename reserved `module` var in katex-fonts generator; ignore .scratch - generate-katex-fonts.mjs assigned to a variable named `module`, tripping @next/next/no-assign-module-variable (the one hard eslint error). Renamed to `fileContents`; generated output is byte-identical (deterministic). - Add `.scratch/**` to the eslint globalIgnores (alongside .claude/.worktrees) so local throwaway verification scaffolding doesn't pollute `pnpm lint`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): address PR #952 review — scene-scoped media bridge, safer cue splitting Resolves the two blocking correctness issues and three follow-ups from the cross-review on #952. - Scope the elementId→mediaRef bridge by scene (was deck-wide/last-writer-wins): slide-local ids like `video_001` now resolve within their own scene. `assets.media` uses its scene arg; `timing.videoDurationMs` resolves via action object identity. Adds a cross-scene collision test. - Stop treating every ASCII period as a sentence boundary: a lone `.` splits only when not between word chars (3.14, v1.2) and not an abbreviation dot (e.g.), collapsing repeated-punctuation runs. Adds decimal/version/abbrev/ellipsis tests. - Share the module-level in-flight guard across ZIP export and subtitle download (extracted to export-in-flight.ts); dialog `busy` folds in `downloading`. - Skip the off-screen geometry render for the subtitles-only path (skipGeometry); duration probes are kept so sidecar cue timing stays in sync with the video. - Align measure.ts font settling with slideToPng: force-load each used face before fonts.ready so measured content-boxes don't bake fallback advance widths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(video-export): prettier-format split-cue test Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(video-export): enable beginFrame capture via PRODUCER_BROWSER_GPU_MODE=hardware The producer defaults to `software` GPU mode, which silently force-enables the CPU-bound `Page.captureScreenshot` fallback: SwiftShader pins every core on rasterization and caps frame capture at ~10 fps. Selecting `hardware` keeps Chromium's `HeadlessExperimental.beginFrame` capture active instead. Measured on a 900-frame (30s) clip, no real GPU on the host: - capture ~10 -> ~19 fps, total 100s -> 62s (~38% faster) - CPU peak ~810% -> ~98%; peak mem 2.2 GiB -> 0.9 GiB - static frames pixel-identical; animation frames differ only in sub-pixel edge antialiasing (same-path re-render is deterministic/inf PSNR) No real GPU is required — the win is the faster capture API, not hardware rasterization; beginFrame falls back gracefully if the host lacks a GPU. Also eases the RENDER_JOB_DEADLINE_MS timeout risk on long videos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(video-export): sentence splitting via Intl.Segmenter + abbreviation merge Addresses the PR #952 follow-up: separate sentence-boundary detection from cue shaping and stop growing a custom punctuation heuristic. - splitSentences now delegates boundary detection to Intl.Segmenter (locale- agnostic ICU), the multilingual/CJK baseline. It already preserves 3.14, v1.2, e.g., U.S., ellipses, and CJK 。!?… without any hand-rolled period scan. - ICU still ends a sentence after a titlecase abbreviation before a capitalized word (Dr. Smith, Fig. 3) — the edge case the reviewer flagged. A small abbreviation-aware post-merge re-joins those, driven by an ABBREVIATIONS set. - Semicolons are no longer sentence ends (they join clauses); moved ;; into the clause-level SECONDARY splitter so over-budget sentences still break there. - Cue shaping (budget / clause / hard-wrap / weighted timing) is unchanged, so detection and shaping are now distinct stages. Not using `sbd` as suggested: lib/video-export/*.ts sits behind the machine- enforced eslint purity boundary (imports limited to @openmaic/dsl, zod, ../choreography, siblings), so a new npm dep is rejected. Intl.Segmenter is a pure Node >=20.9 builtin, matching the reviewer's "small abbreviation-aware post-merge layer" alternative. Tests: add Dr. Smith / Fig. 3 / U.S. coverage; the rhetorical ellipsis now stays one under-budget cue (was split); add a semicolon over-budget case. 22/22 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(video-export): prettier-format split-cue sentence refactor Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(es-MX): add 7 subtitle/video export keys to align with en-US main added the Spanish (Mexico) locale (#942) after this branch's subtitle keys landed, so es-MX was the only locale missing export.subtitles* and export.videoBurnSubtitles* — which failed the i18n key-alignment CI check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): restrict title post-merge so it never deletes real boundaries Addresses the blocking re-review on the Intl.Segmenter change: the abbreviation post-merge unconditionally rejoined the next ICU segment whenever the previous one ended in a listed abbreviation, which erased genuine sentence boundaries — `It was made by Acme Inc. Next point.` collapsed into one cue, and even an explicit newline (`Acme Inc.\nNext topic`) was rejoined. The merge now fires only for the case ICU actually mis-splits — a title (Mr/Mrs/Ms/Dr/Prof/Sr/Jr) before a capitalized proper noun — and only when: - the previous segment does not end at a newline (hard boundary), and - the next segment starts with a capital letter. Sentence-trailing abbreviations (Inc./Ltd./etc./vs./No.) are dropped from the set entirely: they legitimately end sentences, and ICU already keeps a title before a number (No. 5, Fig. 3) or a lowercase continuation (etc. and…) whole without any merge, so nothing there needs rejoining. Regression tests: Inc./etc. boundary preserved, newline-after-title preserved, lowercase continuation kept whole, plus the retained Dr. Smith / Fig. 3 / U.S. cases. 26 split-cue tests pass; full video-export suite 123 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <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> |
||
|
|
84b1907255 |
feat(video-export): service-backed MP4 render + in-app one-click export (#866) (#937)
* feat(video-export): service-backed MP4 render + in-app one-click export (#866)
Adds the last mile of classroom video export: turning the self-contained
Hyperframes project ZIP (#865) into an MP4 via an isolated render service,
one-click in-app.
- render-service/: standalone Node 22 + Chromium + FFmpeg container wrapping
@hyperframes/producer's library API. Async job model (POST /render -> 202
jobId, GET poll, GET download, DELETE cancel). Swappable JobStore /
ArtifactStore seams (in-memory + local-disk now; Redis/S3 + presigned-302
download later) so it scales horizontally without changing the HTTP contract.
Concurrency + per-user guards are config knobs.
- App integration: thin Next proxy routes under app/api/export-video/* (forward
only, no rendering) + capability probe. use-render-video.ts uploads the ZIP,
polls via runPolledTask, downloads the MP4; shared buildExportZip prefix with
the existing ZIP path. Export menu gains resolution/fps/quality selectors and
a progress bar; degrades to ZIP download when RENDER_SERVICE_URL is unset.
- docker-compose: render-service under an opt-in "video-export" profile.
- Entry is main.ts (not server.ts): the producer auto-starts its own server on
:9847 when the process entry path ends with /src/server.ts.
Verified end-to-end in the container: rendered a real 640s (10.7 min) classroom
ZIP to a valid H.264 720p + AAC MP4 (duration matches source) in ~9.6 min
(~0.9x realtime, 4-worker frame capture). Degrade path, queued-cancel + cleanup,
and per-user 429 guard all exercised. pnpm check / lint / tsc / i18n pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(video-export): global render progress store, percent+ETA UI, ring on export button (#866)
Addresses two UX issues found while driving the in-app MP4 export:
1. Progress display was raw and unfriendly (showed producer's English stage
strings like "Capturing frame 5130/19220") and had no time estimate. Now the
menu shows only "<percent>% · about <remaining> left". ETA is computed from a
recent-speed estimate (percent-per-ms over the last sample), EMA-smoothed —
which tracks the render's non-uniform pace (prep -> frame capture with a
4->1 worker drop -> encode) far better than a whole-run average, and never
shows a stale/rising ETA.
2. Switching scenes mid-render unmounted the export menu and lost the progress
(and reset the local "already rendering" ref, allowing a duplicate submit).
The whole render lifecycle now lives in a global store
(lib/store/video-render.ts), so progress survives menu close / scene switch
and duplicate submits are guarded by status. A persistent CircularProgress
ring on the export button shows live progress whether or not the menu is open.
Also fixes the progress scale: the producer reports progress as 0..100, but our
HTTP contract (and success path) is 0..1 — the service now normalizes it, so the
client no longer showed "2000%".
- lib/store/video-render.ts: new global store owning submit->poll->download,
recent-speed ETA, duplicate-submit guard.
- lib/video-export-app/use-render-video.ts: thin facade over the store.
- components/ui/circular-progress.tsx: lightweight SVG progress ring.
- components/stage/{header-controls,video-export-menu}.tsx: ring on the export
button; menu shows percent + ETA, subscribes to the store.
- render-service/src/render-manager.ts: normalize producer progress 0..100 -> 0..1.
- i18n: percent/ETA strings across all 8 locales (drops the stage-based string).
- render-service/package-lock.json: complete integrity hashes (reproducible npm ci).
Verified: ETA logic checked against the real segmented render curve (worker drop
raises ETA, encode speedup drives it to ~0); progress scale fix confirmed live
against the container (0.2 -> 20%). tsc / lint / prettier / i18n pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(video-export): persist render options in the store, not the menu component
Selecting 720p/24fps/draft, switching scenes, and reopening the export menu
showed the defaults again (1080p/30/standard). The selections lived in the
VideoExportMenu component's local state, which reset when the menu unmounted on
a scene switch — the running render still used the chosen options, but the UI
misrepresented them.
Move resolution/fps/quality into the global video-render store (with a
setOptions action). The menu now reads/writes the store, so selections survive
menu close / scene switch, and while a render runs the selectors reflect the
options that render is actually using. startRender() reads options from the
store instead of taking them as an argument.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(video-export): deployment correctness + resource/isolation controls (PR #937 review)
Addresses the blocking findings from wyuc's review. Output fidelity was fine;
these harden deployment and production resource/isolation boundaries.
#1 Compose advertised MP4 but couldn't render in prod:
- Capability now probes the service's /health (checkRenderServiceHealth), so a
configured-but-absent service reports disabled and the UI degrades to ZIP
instead of 502-ing.
- RENDER_SERVICE_URL is operator-supplied trusted config, so the proxy no longer
runs it through the SSRF guard — the one-command `docker compose --profile
video-export up` now works without globally weakening SSRF via
ALLOW_LOCAL_NETWORKS. resolveRenderServiceUrl() is now synchronous.
- Client degrades to ZIP on any failed submit (not only 501).
#2 Unbounded upload/queue (ZIP-bomb / DoS):
- unzip.ts bounds the archive via fflate's filter BEFORE decompression: entry
count, per-entry and total expanded size, and compression ratio.
- Proxy rejects oversized uploads (413) by Content-Length before forwarding.
- RenderManager enforces a global queue-depth cap (RENDER_MAX_QUEUE).
- All limits are env-tunable knobs in config.ts.
#3 Per-user guard was ineffective + admission ran after extraction:
- Identity is derived server-side (client IP) and forwarded as x-openmaic-client;
the service ignores any client-supplied userId, and the proxy strips it.
- Admission is split into reserve()/submit()/release(): the slot is reserved
BEFORE extraction, so a rejected caller never triggers a decompression.
Additional risks:
- Per-job wall-clock watchdog (RENDER_JOB_DEADLINE_MS) aborts + fails a hung
render so it can't hold a slot/scratch forever.
- Download proxy bounds only the time-to-headers, not the body stream, so large
MP4s over slow links no longer truncate.
- Client cancels the server job (DELETE) when a started render fails/times out.
- Compose puts render-service on an internal:true network (no host/internet
route), sandboxing the Chromium that runs the uploaded HTML; the export ZIP is
self-contained so no outbound is needed. README documents the standalone caveat.
Not closing #866: the smoke/golden-render CI acceptance criterion remains a
follow-up (see PR description).
Verified in-container: legal render 202; ZIP-bomb (entry-count + compression-
ratio) rejected 400 before any decompression; per-identity guard 429 with a
spoofed multipart userId ignored; reserve-before-extract leaves no scratch dir
on rejection; watchdog aborts an overrunning job and frees the slot. tsc / lint /
prettier / i18n pass; render-service tsc passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(video-export): real audio durations + burned-in subtitles (PR #937 review)
Two export-fidelity issues found in wyuc's deeper E2E:
A. Narration was scheduled from estimated durations, cutting audio off mid-
sentence and advancing the timeline early. The scheduler trusted
AudioFileRecord.duration (recorded only since #861), so the many existing
classrooms without it fell back to text-length estimates — measured 4.35s
average / 10.23s max underestimate across 47 clips. timeline-deps now probes
the real duration from each narration blob via an off-document <audio>
(symmetric to the existing video probe), preferring it over the stored
duration, then the estimate only when no audio asset exists. Everything
downstream (narration starts, scene/total duration, subtitle cues) re-derives
from the corrected value in the pure compiler — no compiler change needed.
B. The final MP4 had no subtitles (only H.264+AAC), and the ZIP's SRT/VTT used
the same estimated boundaries. The emitter now renders a burned-in subtitle
overlay: one caption box + a hidden div per cue, revealed/hidden by the paused
GSAP timeline at each cue's start/end (corrected timings from A), so Chromium's
frame capture bakes them in. The producer has no subtitle track of its own, so
burn-in is the v1 approach.
Verified: emitter unit tests + snapshot updated (subtitle overlay + toggle
statements, escaped text, hidden-by-default); 82 video-export tests pass incl.
the determinism red-line proxy. Rendered a synthetic subtitle project through the
container and confirmed by pixel analysis that captions appear only within their
cue window (2429 near-white px in the caption band at t=1.5s vs 0 at t=0.05s).
tsc / lint / prettier / i18n pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(video-export): subtitle layout + upload/admission hardening (PR #937 review)
Address the three P1 blockers plus actionable P2s from the
|
||
|
|
d8a0081c7d |
feat(video-export): L1 Hyperframes emitter + browser collection + export ZIP (#865) (#931)
* feat(video-export): L1 Hyperframes emitter + browser collection + export ZIP (#865) Consume the VideoTimeline IR (#864/#913) and produce a self-contained Hyperframes composition project that `npx hyperframes render` turns into an MP4 (render execution itself is #866). - lib/video-export/emit-hyperframes: pure IR → Hyperframes project emitter (one flat composition, one paused GSAP timeline on window.__timelines, spotlight/laser overlays from lib/choreography descriptors). Stays under the purity boundary; emits HTML/JS strings only. - lib/video-export/subtitles: SRT/VTT serialization of the IR subtitle track. - lib/video-export-app: impure app glue (DI adapters over Dexie, slide-snapshot collection lifted from #849, streaming ZIP packaging, useExportVideo hook). - Vendor GSAP locally (public/vendor/gsap.min.js) — determinism red-line. - Export menu entry with resolution select + i18n across all 8 locales. Verified end-to-end against the real hyperframes CLI: lint passes 0 errors on a real classroom export, and a rendered slice produces a valid H.264+AAC MP4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(video-export): gate Export Video menu behind NEXT_PUBLIC_ENABLE_VIDEO_EXPORT flag The video export UI is experimental until the render pipeline (#866) lands, so hide the "Export Video" affordance behind an off-by-default feature flag. - Add isVideoExportEnabled() to lib/config/feature-flags (NEXT_PUBLIC_, so it inlines at build time for the client-side export menu). - Gate the Export Video block in header-controls on the flag. - Cover the new flag in tests/config/feature-flags.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): ossKey fallback for evicted blobs + emitter dedup Address code-review findings on #931. - collect: resolveBytes() prefers the local Dexie blob and falls back to the record's CDN ossKey (audio) / ossKey+posterOssKey (media/poster) so a live-mode classroom whose local blobs were LRU-evicted still exports a self-contained ZIP. timeline-deps presence checks widened to `blob.size > 0 || !!ossKey` so the compiler keeps these entries instead of marking them absent; video-duration probe stays local-only. - emit-hyperframes: extract one shared escapeHtml + sec into format.ts, dropping the two divergent escapers (attr vs escapeHtml) and the two sec() definitions with different rounding, plus the middle-man escapeAttr. Output is byte-identical (snapshot unchanged). - Add tests/video-export/collect.test.ts (ossKey fallback, local-first, fetch 404/throw, video+poster). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(video-export): restore evicted generated media into base-frame snapshots Address review blocker (P1) on #931: the ossKey fallback covered standalone audio/video/image/poster asset entries but not the slide base-frame path. resolveGeneratedMedia only accepted a non-empty local blob, so a live-mode record with an evicted blob + valid ossKey had its image src cleared (and video/poster not restored) before slideToPng snapshotted the slide — the frame PNG lost the generated media even though the standalone asset was fetched. - resolveGeneratedMedia now resolves bytes via resolveBytes (local blob first, then ossKey / posterOssKey) before creating the snapshot object URLs. - Add frame-collection tests: evicted image restored via ossKey, evicted video+poster restored, and image with no ossKey still cleared. The renderer snapshot is mocked so the frame path runs in plain Node. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn> |
||
|
|
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. |
||
|
|
1c858960b4 |
refactor(app): consume @maic/dsl + @maic/renderer in the app (slide types + read-only thumbnails) (#707)
* refactor(app): consume @maic/dsl + @maic/renderer for slide types and read-only thumbnails
The app extracted @maic/{dsl,renderer,importer} (#668) but kept rendering its
own duplicated copies. Start dogfooding the packages back into the app:
1. lib/types/slides.ts is now a thin re-export of @maic/dsl. The hand-kept
copy was the seed for @maic/dsl, so consuming the package stops the three
former copies (app / renderer / importer) from drifting again. All
`from '@/lib/types/slides'` import sites keep working unchanged.
(ShapePathFormulasKeys / ElementTypes move from local `const enum` to the
package's regular `enum` — required across a package boundary; runtime
value usage is identical.)
2. Read-only slide thumbnails now render via @maic/renderer's SlideCanvas
through a small SlideThumbnail wrapper, replacing the in-app ThumbnailSlide
element renderers at the three read-only surfaces: playback scene sidebar,
scene thumbnail content (editor rail), and home recent-course cards. The
wrapper preserves the legacy thumbnail video treatment (muted play-badged
<video>, placeholder filtering) via the renderer's renderVideo slot. The
full-size editing canvas is untouched (renderer v1 is read-only).
Verified: tsc --noEmit clean, next build OK, e2e 19/19 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(thumbnail): resolve gen_* media placeholders so retries reflect in thumbnails
SlideThumbnail rendered slides through @maic/renderer with only a renderVideo
slot, so image (and video) `gen_img_*`/`gen_vid_*` placeholders were painted
as-is. @maic/renderer is a pure package and does not know about this app's
async media-generation store; the store also never mutates the slide's
`element.src` (it keeps the placeholder ref and tracks the generated objectUrl
in a task). Net effect inside a classroom: a thumbnail showed the broken
placeholder on first paint and — the reported bug — did NOT update when a
failed image's retry finally succeeded, because nothing re-resolved the src.
Add `useResolvedSlide`: resolve each placeholder element's `src` to its task
`objectUrl` (video `poster` too) against the media-generation store,
reactively. Unresolved (pending/failed) placeholders are blanked so the
renderer paints nothing instead of a broken-media icon. Off-classroom (no
stage context, e.g. home recent-course cards) renders raw, matching the legacy
thumbnail's behavior. Mirrors the app's existing `useResolvedImageSrc` resolver
that the full-size canvas already uses — restoring the reactive sync the
in-app ThumbnailElement had before the @maic/renderer migration.
The store subscription is a primitive signature of just this slide's
placeholder resolutions (src → done objectUrl), so unrelated task churn —
other slides' media generating or retrying — neither re-renders the thumbnail
nor invalidates the resolved-slide memo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(thumbnail): key video tasks by mediaRef; use a visible signature delimiter
Review follow-ups (#707):
- Videos' media-generation tasks are keyed by mediaRef (with gen_vid_* src as
the legacy fallback), not by element src — resolve them through
getVideoMediaRefForElement, matching BaseVideoElement's subscription, so a
mediaRef-keyed video thumbnail updates when generation or a retry completes.
A mediaRef-keyed video that already carries a real playable src keeps it
while the task is unresolved; only placeholder srcs are blanked.
- The resolution-signature delimiter was a literal NUL byte, which made git
treat the source file as binary (degrading diffs and review tooling). Use a
visible '|' delimiter instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
|
||
|
|
cde53843eb |
Fix overlay layout shift in home and classroom (#690)
* Fix classroom overlay layout shift * Fix homepage settings layout shift |
||
|
|
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 (
|
||
|
|
f86c0482dc |
feat: end-of-course completion page + persistent quiz state (#484)
* refactor(quiz): extract grading helpers into lib/quiz/grading * feat(classroom): add summarizeScenes aggregation helper * feat(i18n): add classroomComplete translations * feat(classroom): add ClassroomCompletePage component * feat(stage): render ClassroomCompletePage when course finishes * feat(stage): show 'Course complete' slot in sidebar * chore: apply prettier formatting Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(classroom): richer completion visuals — confetti, gold trophy, stats cards - Replace lucide Trophy with custom gradient SVG (3x larger, base + stem + star) - Add one-shot confetti burst (55 particles, 7-color palette, no new dep) - Add looping sparkles + pulsing halo around trophy - Redesign stats from thin icon row into metric cards with count-up numbers - Upgrade quiz block to gradient card with animated gold progress ring - Add gradient ribbon badge above title, bg-clip gradient title - Sidebar thumbnail: amber gradient + sparkles + larger filled trophy - Respect prefers-reduced-motion (skips confetti/sparkles/count-up) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(stage): show course-complete title in header; tone down sidebar thumbnail - Header top-left was showing loading fallback on pending slot; now renders t('stage.courseComplete') when isCourseComplete && isPendingScene - Sidebar thumbnail was too saturated next to neutral slide thumbnails; back to soft amber-50 base with a subtle radial glow, smaller muted sparkles, and colored amber-500 trophy (no more filled white on gradient) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(classroom): persist submitted quiz answers for completion page Previously handleSubmit cleared the quizDraft cache, leaving nothing for the completion page to read — score always showed 0/N. Now submit also writes to a separate quizAnswers:<sceneId> key that is not cleared by the normal in-progress draft lifecycle. - quiz-view: on submit, mirror answers into quizAnswers:<sceneId> - quiz-view: on retry, remove that persistent key too - classroom-complete: read quizAnswers first; fall back to quizDraft so an unsubmitted attempt still shows partial progress rather than 0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(quiz): persist submitted quiz state across unmounts Before: remounting a quiz scene after submit restarted it from 'not_started' because the draft cache was cleared at submit time. Reopening meant redoing everything. Now on mount, read quizAnswers:<sceneId> + quizResults:<sceneId> and restore: - Both present → jump to 'reviewing' with graded answers and feedback - Only answers (mid-grading unmount) → 'answering' with answers pre-filled - Neither → fall through to existing draft-restore path Results are persisted when the grading useEffect transitions to 'reviewing', and both keys are cleared together by handleRetry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(home): prevent horizontal shift when recent classrooms expand Expanding recent classrooms could grow the page past the viewport height, making a vertical scrollbar appear. Because the scrollbar takes layout width, the centered header and trigger row jumped horizontally. Reserve the scrollbar gutter on <html> so the gutter is always present and layout stays stable across expand/collapse. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(home): force always-visible scrollbar to eliminate expand/collapse shift scrollbar-gutter: stable alone wasn't enough (browser/config dependent). Adding overflow-y: scroll on html forces the vertical scrollbar to always occupy layout width, so expanding recent classrooms past the viewport no longer shifts the centered trigger row. Trade-off: home page now always shows a scrollbar track, even when content fits viewport. Acceptable cost for stable layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(quiz): extract persistence module + sweep localStorage on delete Addresses code review on #484: 1. New lib/quiz/persistence.ts centralizes the three per-scene localStorage keys (draft / answers / results) behind readSubmittedState, readAnswersForSummary, writeSubmittedAnswers, writeSubmittedResults, clearSubmitted, clearAllForScene. quiz-view and classroom-complete now both consume this module instead of hand-rolling localStorage calls, removing the schema-coupling between the two components. 2. deleteStageData now collects scene ids before deletion and sweeps the matching quiz persistence keys, fixing the storage leak when a classroom is deleted. 3. ClassroomCompletePage gets role=status / aria-live=polite / aria-label for screen readers to announce the completion moment. 4. Tests: 11 new unit tests covering the persistence module (read/write/clear roundtrips, draft fallback, corrupt-JSON tolerance, scene-isolation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(classroom): address review on PR #484 (a11y, motion, coupling) Addresses cosarah's review: 1. prefers-reduced-motion now fully honored — wrap ClassroomCompletePage in MotionConfig with reducedMotion='always' when the OS preference is on. This neutralizes the previously-uncovered animations (trophy float repeat:Infinity, halo pulse repeat:Infinity, page/cards/trophy entrance fade+scale+spring). 2. Move role='status' off the noisy outer wrapper; replace with a single sr-only span carrying the title text so screen readers get one clean announcement instead of every count-up tick. Outer is now a plain <section aria-label=...>. 3. Wrap ClassroomCompletePageConnected in a motion.div inside AnimatePresence so leaving the pending slot fades instead of snapping (matches the loading-overlay branch). 4. quiz-view's draft cache key now uses the draftKey() helper exported from lib/quiz/persistence so the prefix has one source of truth (no silent desync if the schema evolves). 5. isCourseComplete now requires scenes.length === outlines.length, so a partial generation with failed outlines doesn't falsely trigger. 6. aria-hidden on decorative motion wrappers (Confetti, Sparkles, trophy entrance) for cleaner SR experience. 7. Comment on summarizeScenes useMemo explaining the mount-fresh assumption. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: prettier-format review-fix changes CI's format check (pnpm check) failed because the previous commit's changes weren't run through prettier locally before push. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com> |
||
|
|
c02a607377 |
feat: interactive mode clean (#461)
* feat: add interactive mode * feat: add TeacherActions for interactive mode * fix: better prompt for ultra mode * feat: upgrade normal mode's interactive generation with 5 widget type and actions * feat: make interactive mode button clear * fix: remove unused TeacherOverlay * fix: resolve PR #458 review feedback - Revert unrelated infra changes (Dockerfile, docker-compose.yml) - Fix placeholder strings in generateWidgetContent - Rename ultraMode to interactiveMode consistently across codebase - Extract duplicated patchHtmlForIframe to shared utility - Fix widget store race condition by keying callbacks by sceneId - Fix teacher-action ID collisions by using descriptive IDs in prompt - Relax outline prompt constraints for flexibility - Optimize sidebar thumbnail with IntersectionObserver lazy loading - Add trailing newlines to modified files Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: update generateSceneActions call signatures The function signature changed to individual parameters instead of options object pattern after cherry-pick. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style: fix prettier formatting Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: removed extra language arg * style: fix prettier formatting v2 * fix: add missing i18n keys --------- Co-authored-by: Claude Opus 4.7 <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> |
||
|
|
843d33e5cc |
chore: enforce Prettier formatting and fix lint issues
- Add .prettierignore to exclude vendor packages, lock files, markdown, and YAML - Update .prettierrc: printWidth 100, singleQuote, trailingComma "all" - Run Prettier across all source files for consistent formatting - Fix unused imports (UserRequirements, setTTSProvider) - Fix eslint-disable comment placement after Prettier reformat Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
cc44f32ca8 |
Refactor: directory structure cleanup + Google Fonts localization
- Replace next/font/google with local fonts (geist + @fontsource-variable/inter) to eliminate build-time network dependency - Resolve lib/utils.ts vs lib/utils/ naming conflict (cn() → lib/utils/cn.ts) - Move React Context Providers from lib/store/ to lib/contexts/ - Consolidate hooks into lib/hooks/ (use-theme, use-i18n) - Organize loose components into subdirectories: agent-* → components/agent/ canvas-* → components/canvas/ generation-toolbar, media-popover → components/generation/ speech-button, tts-config-popover → components/audio/ proactive-card → components/chat/ 65 files changed, lint 0 errors, build 28 pages passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
0d20abfed9 |
Initial commit: OpenMAIC AI classroom platform
Next.js 16 App Router application with: - AI-powered interactive classroom generation from PDF/text requirements - Multi-agent discussion system (teacher, students, assistant roles) - Provider abstraction for LLM, TTS, ASR, image, video, web search - Local-first data architecture (IndexedDB/Dexie) - i18n support (zh-CN/en-US) - Structured logger, SSRF guard, lint/build passing (0 errors) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |