oficina / slides Public

feat: editor shell with text editing, live sync and undo

GET /d/{sqid}/edit renders the three panes server-side — the strip of
server thumbnails, the stage with the current slide and its notes, the
inspector (empty until the inspector task) — with every string the
bundle will show rendered from the catalog into data attributes. The
viewer offers Edit to the owner and members.

The bundle (fixtures/editor-entry.js, built by build-bundles.mjs into
internal/slides/static/editor.<hash>.js and resolved at init by
assets_embed.go) loads the framed log into a Y.Doc, binds a ProseMirror
view into every text block of the server's own slide HTML (stage.mjs),
saves through Docs' save queue, hears other writers over the stream
through Docs' live-updates, rebuilds through Docs' resync controller,
and undoes through one Y.UndoManager scoped to this client's origins.
New slide, duplicate and present have keys and menu items alike.

Tests: the page and the bundle in slidestest; the copied modules,
frames, stage, changes, text-keys and strip under node --test; and a
browser drive that types into the title, reloads, sees a remote write
land on the open stage, and makes and undoes a slide.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev bf4b5f3cffdde908c46c2045d53cdd21bb479886 parent f36ad1c
31 files changed, +3705 −15
  • fixtures/build-bundles.mjs +67 −0
  • fixtures/changes.mjs +44 −0
  • fixtures/changes.test.mjs +74 −0
  • fixtures/editor-entry.js +362 −0
  • fixtures/frames.mjs +37 −0
  • fixtures/frames.test.mjs +33 −0
  • fixtures/link-dialog.mjs +45 −0
  • fixtures/live-updates.mjs +282 −0
  • fixtures/live-updates.test.mjs +369 −0
  • fixtures/resync-controller.mjs +115 −0
  • fixtures/resync-controller.test.mjs +159 −0
  • fixtures/save-queue.mjs +122 −0
  • fixtures/save-queue.test.mjs +100 −0
  • fixtures/stage.mjs +207 −0
  • fixtures/stage.test.mjs +117 −0
  • fixtures/strip.mjs +145 −0
  • fixtures/strip.test.mjs +19 −0
  • fixtures/text-keys.mjs +102 −0
  • fixtures/text-keys.test.mjs +68 −0
  • internal/slides/app.go +5 −2
  • internal/slides/assets_embed.go +99 −0
  • internal/slides/editor.go +187 −3
  • internal/slides/locales/en.toml +28 −0
  • internal/slides/render.go +1 −1
  • internal/slides/static/editor.a12c76754a081686.js +15 −0
  • internal/slides/static/editor.css +301 −0
  • internal/slides/templates/editor.html +128 −0
  • internal/slides/templates/viewer.html +2 −1
  • internal/slides/viewer.go +10 −1
  • internal/slidestest/browser_test.go +273 −7
  • internal/slidestest/editor_test.go +189 −0

CI passed — run details

Intent

Human

<task-notification> <task-id>af40b990b8f63d1f4</task-id> <tool-use-id>toolu_01J7995M6rf59NAtHm2ZJ5ZJ</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/af40b990b8f63d1f4.output</output-file> <status>completed</status> <summary>Agent "S2 Task 2 fix: href shape" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Status: DONE. Working tree clean, nothing pushed. Commit: `f36ad1c` — fix: href round-trips through y-prosemirror; marks named after Y attributes Tests: `cargo test` 28/28 (4 new `schema::tests`), clippy clean (`-D warnings -D disallowed_methods`), `cargo fmt` no diff. Wasm rebuilt and verified reproducible (`cargo clean --release --target wasm32-unknown-unknown` → rebuild → `cmp` identical, 1162688 bytes). `node --test` in fixtures 20/20 (5 new in `fixtures/schema.test.mjs`, incl. the y-prosemirror round trip). `go test ./internal/slides/core/` green, both vector tests 5 subtests each. `make ci` green. Shape: writer emits `href: {"href": url}`; both readers take map-or-string via `href_of` (Rust) / `hrefOf` (JS); `b,i,c,h` still written `true`, read on any truthy value. Vectors regenerated — only `every-block.yjs` and `title-only.yjs` bytes moved; every `.view.json` byte-identical, so the view contract did not shift. Spec §1 ruling 12 and §2.6 amended (closes Task 2 concerns 1 and 2). Concerns: only the pre-existing one — `.amadan/ci.d/10-gate` still does not run `npm ci` / `node --test` / the vector regeneration check, so a stale vector is caught locally but not on the runner; this change makes that gap matter more.</result> <usage><subagent_tokens>95782</subagent_tokens><tool_uses>64</tool_uses><duration_ms>365194</duration_ms></usage> </task-notification>

Human

<task-notification> <task-id>adcde851c0352c13c</task-id> <tool-use-id>toolu_01GWyeQGT3PfjyJn6JrNcifM</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/adcde851c0352c13c.output</output-file> <status>completed</status> <summary>Agent "Review slice 2 Task 2" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Now I have everything needed for the review. --- ### Spec Compliance Matches §2.6 (as amended), §2.8, §7.2, and the plan's controller decisions closely. - **§7.2 op vocabulary**: all 15 ops in `core/src/ops.rs`'s `Op` enum (`set_meta`, `add_slide`, `remove_slide`, `move_slide`, `duplicate_slide`, `set_slide`, `set_notes`, `add_block`, `remove_block`, `move_block`, `set_block`, `set_text`, `set_phone`, `register_asset`, `remove_asset`) are implemented in `fixtures/ops-parse.mjs`/`ops-model.mjs`/`ops-apply.mjs` with matching field names, `Option&lt;Option&lt;T&gt;&gt;` (absent/null/value) semantics, and identical error messages (word for word) for the five spot-checked rules: unknown `after` (both sides route through the shared index lookup and throw the same "no slide/block …" message), self-move (`move_slide`/`move_block`, identical message text), `columns.children` naming a block not on the slide (identical message and logic — it doesn't distinguish "on another slide" from "doesn't exist", correctly, since neither implementation does), `duplicate_slide` id-count/distinctness checks, and template vocabulary (`TEMPLATES` array is byte-identical to the spec's §2.3 list). - **Layout table** (`fixtures/layout.mjs` vs `core/src/layout.rs`): every region rect, the aside-narrows-body rule, equal split of a shared region, `z` set to array index, and the `to_flow` position-based classification + stable y-then-x sort are reproduced exactly, including the `round4` half-away-from-zero rounding (JS emulates Rust's `f64::round` via a manual sign check, correctly diverging from `Math.round`'s half-up behaviour). - **`href` fix (ruling 12)**: `href_of`/`href_any` in `core/src/schema.rs` are the single point of read/write for the `href` attribute and are used everywhere text is read (`push_runs` → `read_text`, used by `view.rs`, `set_notes`, `set_text`); `duplicate_slide`/`move_slide`/`move_block` copy via `as_prelim`/`.clone()` which preserve the underlying Any value without reinterpretation, so no other read path exists. JS mirrors with `hrefOf`/`runAttributes`. A real y-prosemirror round-trip test (`schema.test.mjs`) exercises `prosemirrorToYXmlFragment`/`yXmlFragmentToProsemirrorJSON` and asserts both bold and the link survive — ran it, passes. - **Vectors**: the union of ops across `data/{title-only,every-block,canvas,columns,table}.json` covers all 15 op types (verified by grep). `generate.mjs` is deterministic: `guid` from the file name, `clientID = 1`, no RNG, sorted-key JSON output; refuses a data file whose `guid` doesn't match its file name. - **Markdown**: `fixtures/markdown.mjs` mirrors `core/src/markdown.rs` closely, including the `\*` escape, unclosed-delimiter-as-literal-text behaviour, nested marks, link-carries-marks, and a fixed `lines()` that (correctly) keeps a bare trailing `\r`, matching Rust's `str::lines()`. The `ops.test.mjs` markdown test uses effectively the same fixture string as the Rust `inline_marks` unit test. - **Contract tests**: `internal/slides/core/vectors_test.go` implements both `TestVectorsViewAsTheJSSideViewsThem` and `TestVectorsOpsAgreeWithTheJSOps` (the latter goes beyond the brief and is the real "one vocabulary" proof), with canonical JSON comparison and a helpful first-difference diff. - **Module size cap**: every module is under 300 lines (`ops-apply.mjs` 274, `ops-model.mjs` 222, `markdown.mjs` 190, `schema.mjs` 157, `ydoc.mjs` 110, `layout.mjs` 107, `ops-parse.mjs` 114, `view.mjs` 70, `generate.mjs` 58, `ops.mjs` 48); `ops.test.mjs` at 319 lines exceeds 300 but the cap applies to modules, not tests (consistent with Docs' precedent, cited in the report). - **Docs' pins**: `fixtures/package.json` matches Docs' `fixtures/package.json` pins exactly (minus `prosemirror-schema-basic`, correctly omitted since Slides uses its own constrained schema, not the basic one). ### Strengths - Extremely faithful line-by-line mirroring of the Rust core's validation model, apply pass, layout geometry, and text schema, with matching error-message text where the core's messages are supposed to be reused verbatim. - The `TestVectorsOpsAgreeWithTheJSOps` addition is a genuinely stronger contract check than the brief asked for. - The fix round correctly diagnosed a real interoperability bug (href dying silently on first browser edit) before it could reach Task 3/4, fixed it symmetrically on both sides, regenerated vectors, and amended the spec rather than leaving tribal knowledge in a report. - Good defensive engineering: `OpsError(message, index)` with a real "nothing applied on failure" test (`batch_is_atomic`), `truthy`/`hrefOf` tolerant readers matching the core's stated "reader is more tolerant than the writer" doctrine. - Honest, itemized self-review and RED/GREEN evidence in the report, including one caught fidelity bug (`lines()` stripping a bare `\r`) with the fix explained. ### Issues #### Critical None found. #### Important None found. #### Minor - The `.amadan/ci.d/10-gate` still doesn't run `npm ci`/`node --test`/vector-staleness checks (confirmed by reading the script — it only does Rust fmt/clippy/wasm build/test and `make ci`). This is correctly scoped to Task 3 per the plan's file map, and the report flags it honestly as a standing risk (a stale vector is currently only caught locally). Not a defect of this task, but worth the controller tracking so it isn't dropped. - `fixtures/ids.mjs` (`newID`) has no test of its own; low risk since it's a thin CSPRNG wrapper not exercised by the vector/contract tests, and the report is explicit that `generate.mjs` deliberately never calls it. ### Assessment **Task quality:** Approved **Reasoning:** I independently re-derived the spec and core requirements rather than trusting the report, read `ops.rs`, `layout.rs`, `schema.rs` (including the fix-round `href_any`/`href_of` addition and its four new unit tests), `markdown.rs`, and `view.rs` against their JS counterparts line by line, and ran the actual test suites rather than taking the report's claims on faith: `node --test schema.test.mjs ops.test.mjs` in `fixtures/` (20/20 pass, including the real y-prosemirror round-trip test), and `go test ./internal/slides/core/ -run TestVectors -v` (10/10 subtests pass). All six named-risk checks (op vocabulary, layout table, vector coverage, round-trip test authenticity, `href_of` reach, markdown fidelity, generator determinism) hold up under direct inspection. The fix round closed a real, well-diagnosed bug and left the spec, vectors, and wasm artifact in a consistent, verifiably-reproducible state (the committed wasm's byte count matches the report's claimed rebuild exactly). No critical or important issues found; the one open item (CI gate not yet wired for the JS toolchain) is explicitly out of this task's scope per the plan and is already flagged for the controller.</result> <usage><subagent_tokens>152313</subagent_tokens><tool_uses>60</tool_uses><duration_ms>248762</duration_ms></usage> </task-notification>

Attributed to this commit: 27.2 g CO₂e (3301713 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on slice-2-editor, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

fixtures/build-bundles.mjs

Loading diff…

fixtures/changes.mjs

Loading diff…

fixtures/changes.test.mjs

Loading diff…

fixtures/editor-entry.js

Loading diff…

fixtures/frames.mjs

Loading diff…

fixtures/frames.test.mjs

Loading diff…

fixtures/link-dialog.mjs

Loading diff…

fixtures/live-updates.mjs

Loading diff…

fixtures/live-updates.test.mjs

Loading diff…

fixtures/resync-controller.mjs

Loading diff…

fixtures/resync-controller.test.mjs

Loading diff…

fixtures/save-queue.mjs

Loading diff…

fixtures/save-queue.test.mjs

Loading diff…

fixtures/stage.mjs

Loading diff…

fixtures/stage.test.mjs

Loading diff…

fixtures/strip.mjs

Loading diff…

fixtures/strip.test.mjs

Loading diff…

fixtures/text-keys.mjs

Loading diff…

fixtures/text-keys.test.mjs

Loading diff…

internal/slides/app.go

Loading diff…

internal/slides/assets_embed.go

Loading diff…

internal/slides/editor.go

Loading diff…

internal/slides/locales/en.toml

Loading diff…

internal/slides/render.go

Loading diff…

internal/slides/static/editor.a12c76754a081686.js

Loading diff…

internal/slides/static/editor.css

Loading diff…

internal/slides/templates/editor.html

Loading diff…

internal/slides/templates/viewer.html

Loading diff…

internal/slides/viewer.go

Loading diff…

internal/slidestest/browser_test.go

Loading diff…

internal/slidestest/editor_test.go

Loading diff…