oficina / slides Public

fix: core review round 1 — validate columns children, split layout.rs

Spec §2.4: a columns block's props.children names blocks in the same
slide. Model::check now validates it on add_block/set_block (unknown
id, another slide's id, and self-reference all rejected), remove_block
drops a removed id from any columns block's children instead of
rejecting the removal, and duplicate_slide's model tracks the remap so
a later op in the same batch validates against the copy's new ids.

Split the §2.3 layout-conversion geometry (default_region, region_rect,
region_of, to_canvas, to_flow) out of the 1089-line ops.rs into
core/src/layout.rs, with unit tests for the box table, equal region
splitting, z-by-index, and canvas-to-flow y-then-x ordering; ops.rs
still calls it, nothing else restructured.

Minor: reworded the vendored fastrand patch comment and build-wasm.sh's
--lib comment to this repo (they still named Docs' paths), and noted
why a table's cols-only resize reflows cells row-major.
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 88674087e6bd25944af6fc04924d31596c208900 parent 0c9190f
7 files changed, +608 −120
  • core/src/layout.rs +327 −0
  • core/src/lib.rs +1 −0
  • core/src/ops.rs +140 −113
  • core/tests/ops.rs +133 −0
  • core/vendor/fastrand-2.5.0/Cargo.toml +2 −2
  • internal/slides/core/testdata/slides_core.wasm binary
  • scripts/build-wasm.sh +5 −5

Intent

Human

<task-notification> <task-id>abbcfd42cac482e25</task-id> <tool-use-id>toolu_01D6Wdt3QzwBCFqXYv3PQmwA</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/abbcfd42cac482e25.output</output-file> <status>completed</status> <summary>Agent "Re-review Task 1 fix round 1" 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>### Findings - **Important 1** (no-membership-oracle under rate limiting): **ADDRESSED** — `internal/slides/auth.go:475-524`. `gateBegin` now wraps the writer handed to `next` in `sameAnswer{ResponseWriter: w, logger: logger}` (auth.go:488), whose `WriteHeader` rewrites any `Location` starting `signinPath+"?err="` back to `sentLocation` (auth.go:515-523) before the status line is sent. Verified against `amadan.net/rastrillo/rastrillo@v0.26.0/auth/handlers.go`: `Begin` always redirects with `http.StatusSeeOther` for every outcome (`?err=rate`, `?err=address`, `?err=1`, `?sent=1`) via the same `a.redirect` helper, and `signin@v0.1.1`'s default `AddressBudget` is 5 — matching the test's assumptions. Ran the new test directly: `TestSigninAnswerIsIdenticalOverTheRateLimitBudget` — PASS (7 POSTs of an allowed address, byte-identical to a denied one; mailer capped at 5). Doc comment rewritten with an explicit "THE GUARANTEE" section correcting the prior false claim. Keymail branch is unreachable because `force=1` is set unconditionally (pre-existing, untouched). - **Important 2** (raw Config stored on app.cfg): **ADDRESSED** — `internal/slides/app.go:344-377`, `internal/slides/config.go:832-867`. New `resolve(cfg)` applies every fail-closed default and returns the fully resolved `Config`; `newApp` calls it and constructs `&amp;app{db: d, logger: logger, cfg: cfg, allowed: list}` (app.go:376) from the *resolved* `cfg`, and `New`'s `csrf.Protect` now uses `a.cfg.Origin` (app.go:303) rather than the caller's raw `cfg.Origin`. Ran `TestDevelopmentStoresTheResolvedConfig` and `TestSuppliedConfigSurvivesResolution` — both PASS. - **Important 3** (upsert+select on every request): **ADDRESSED** — `internal/slides/auth.go:599-630`. `ensureUser` now does `Take` first and only falls through to the `ON CONFLICT DO NOTHING` insert + read-back on `gorm.ErrRecordNotFound`. Ran `TestEnsureUserWritesOnceOnly` (counts actual `Create()` invocations via a GORM callback, asserts exactly 1 across 3 calls) — PASS. - **Ruling** (CARLOS_SMTP_* fallback per-variable): **ADDRESSED** — `cmd/slides/main.go:87-171`. `smtpSetting`/`smtpSecret` read `$SLIDES_SMTP_&lt;NAME&gt;` first, falling back to `$CARLOS_SMTP_&lt;NAME&gt;`, per variable (not per set), consistent with the ruling. Documented in `newMailer`'s doc comment and in `Config.Mailer`'s doc comment (config.go:795-798). - **Minor 4** (Sec-Fetch-Site making Origin dead): **ADDRESSED** — `internal/slidestest/harness_test.go` gained `postLegacy` (no `Sec-Fetch-Site`); `TestSigninAcceptsALegacyOriginOnlyPost` exercises the app's resolved origin from outside the package. Ran it — PASS. - **Minor 5** (explicit SigninPath): **ADDRESSED** — `internal/slides/auth.go:560` — `newAuthPlugin` now sets `SigninPath: signinPath` explicitly in `rauth.Config`. - **Minor 6** (index failure path via ErrorPage): **ADDRESSED** — `internal/slides/handlers.go:1078-1086` — now calls `ErrorPage(w, r, http.StatusInternalServerError, ref)` with `rastrillo.NewRef()`, matching `ErrorPage`'s real signature in `render.go:91`. - **Minor 7** (extractEnvFlag valueless trailing -env): **ADDRESSED** — `cmd/slides/main.go:59-84` — returns an error when `-env`/`--env` is the last argument; `main` logs and exits 1 (main.go:193-197). ### New Breakage in Fix Diff None found. One latent (not newly introduced) fragility worth naming: `sameAnswer.WriteHeader` only rewrites `Location` headers matching `signinPath+"?err="`; if `Begin`'s own `sameOrigin` check ever failed, it answers via `http.Error` (403, no `Location`) which `sameAnswer` would pass through unrewritten — a real divergence from the denied path's 303. Verified this is unreachable in practice: `csrf.Protect(origin)` (app-wide middleware, ahead of `gateBegin`) and `rauth`'s own `sameOrigin` both delegate to the identical `csrf.SameOrigin(r, origin)` with the same resolved `a.cfg.Origin`, so any request reaching `gateBegin` has already passed the identical check. Not something this diff introduced or needed to fix — just a coupling worth knowing about if that origin value is ever allowed to diverge between the two call sites. ### Out-of-Scope Observations - `make ci`'s `migration-check` target fails with `rastrillo: expected exactly one app package under ./internal, found [core slides]` — the report attributes this to Task 2's cherry-picked `internal/core` and claims it reproduces identically on `b745b2f` before this round's changes. `internal/core` is not present in this worktree checkout to independently re-verify that claim, but `go vet ./...` and `go test ./...` (the other two `make ci` targets) are unaffected and pass. Flagged by the implementer as needing controller reconciliation, not something in scope for this fix round. - Report's own "Concerns" section (copy review not done, `auth.go` duplication, `signinPath`/rauth-default coupling, locale middleware wired twice, `Config.Blobs` untested) are pre-existing/process notes from the original task report, not new items from this fix round. ### Verdict All findings addressed.</result> <usage><subagent_tokens>101718</subagent_tokens><tool_uses>17</tool_uses><duration_ms>152324</duration_ms></usage> </task-notification>

Human

<task-notification> <task-id>abd466b0d6ffa844b</task-id> <tool-use-id>toolu_01R4XHoqr3J6v5wH5jLUaVmt</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/abd466b0d6ffa844b.output</output-file> <status>completed</status> <summary>Agent "Review Task 2 (spec + quality)" 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>That's fine, I have what I need. Let me finalize the review. ### Spec Compliance - ✅ ABI surface (all `extern "C"` exports, handle-slab pattern, `ERR_*` codes, `out`/`err` read pairs) matches the brief exactly — `core/src/abi.rs:1-427`, `core/src/lib.rs:97-99` (`slides_core_abi_version`). - ✅ View JSON shape matches the brief's example verbatim, including omission of false/absent mark keys, `props.cells` folding, and defaulted keys — `core/src/view.rs:1-168`. - ✅ Full operation list implemented (`set_meta`, `add_slide`, `remove_slide`, `move_slide`, `duplicate_slide`, `set_slide` incl. layout conversion, `set_notes`, `add_block`, `remove_block`, `move_block`, `set_block`, `set_text`, `set_phone`, `register_asset`, `remove_asset`) — `core/src/ops.rs:127-262` (enum), `:497-737` (apply). - ✅ Markdown subset (bold/italic/code/highlight/link, list-item paragraphs, `\*` escape, unclosed-delimiter-as-literal) matches the brief and is unit-tested, including the unclosed `**`/`*` edge case — `core/src/markdown.rs:1-344`. - ✅ Layout conversion boxes and canvas↔flow rules match §2.3 exactly (region rects, aside narrowing body, z=index, canvas→flow ordering by y then x with heading/media/body classification) — `core/src/ops.rs:1830-1900` (`region_rect`, `to_canvas`, `to_flow`). - ✅ Table cells stored as a separate `cells` array on the block map and folded into `props.cells` in the view; `set_block` resize truncates/appends per the literal wording of the constraint — `core/src/ops.rs:2589-2611`, `core/src/view.rs:110-121`. - ✅ Go host API (`New`, `NewDoc`, `Apply`, `State`, `JSON`, `Ops`, `Close`) and Docs' trap-containment machinery (`rebuild`, `RecoveredError`/`Recovered`, `RejectedError`/`Rejected`) are present and behave as specified — `internal/core/core.go` (per diff, since the file has since been relocated to `internal/slides/core/core.go` by a concurrent agent; content is unchanged, confirmed by line count and diff). - ✅ Global constraints: zero host imports (test + `.cargo/config.toml`/vendored `fastrand` fix, verified as a faithful, correctly-renamed copy of Docs' own fix), no core-side entropy for ids (`clippy.toml` `disallowed-methods`, verified against real yrs 0.23.5 source line numbers), one transaction with origin `b"ops"` (`core/src/ops.rs:2732`: `doc.transact_mut_with("ops")`). - ❌ Issue found: `columns` block's `props.children` (a list of block ids per §2.4) is never validated against the slide's actual blocks by `add_block`/`set_block`/`duplicate_slide`'s `rewrite_ids` (only `rewrite_ids` remaps ids it recognizes, silently leaving unknown ones as-is). A `set_block` or `add_block` can set `children` to ids that don't exist, or that belong to another slide, and the core will accept it — see `core/src/ops.rs:2604-2626` (`SetBlock` props merge) and the check pass at `core/src/ops.rs:1972-1990` (no `children` check for `columns`). Not a crash risk (view.rs never dereferences `children`), but it's an unenforced schema invariant the review brief specifically asked to check. - ⚠️ Cannot verify from diff: the `internal/core/testdata/slides_core.wasm` byte-identity and `cargo test`/`go test` results (per instructions, already verified by the controller and not re-run here). ### Strengths - The Rust core is unusually well-documented and technically precise — e.g. `core/src/lib.rs:1-56` and `core/vendor/README.md` cite exact upstream `yrs`/`getrandom`/`fastrand` source lines and behavior, which I independently verified against `~/.cargo/registry/src/*/yrs-0.23.5/` (`Options::with_guid_and_client_id`, `ClientID = u64`, `Uuid = Arc&lt;str&gt;` all check out exactly as described). - `core/tests/ops.rs` goes well beyond the five tests the brief required (`remaining_ops_roundtrip` alone exercises nearly every op, plus a table of ten expected-refusal cases including `move_block` after-itself, `duplicate_slide` with an empty id list, and an out-of-vocabulary template) — genuinely verifies behavior, not just shape. - The two-pass "validate a model, then apply" design in `ops.rs` is carefully mirrored between `Model::check` and `apply_one` for every op (spot-checked `move_slide`/`move_block`'s remove-then-resolve-`after` ordering, which is subtle and correctly matched in both passes). - `internal/core/core.go`'s trap/timeout containment (bounded guest calls, `WithCloseOnContextDone`, log-replay rebuild, `RecoveredError` vs `RejectedError`) is thorough and clearly reasons about wasm trap semantics (no unwind under `panic = "abort"`, poisoned `RefCell` guards). - `abi.rs` correctly follows the "no borrow held across a yrs call" and bounds-checking rules the brief calls out, and is careful about the 0-handle wraparound sentinel. ### Issues #### Critical (Must Fix) None. #### Important (Should Fix) 1. **`columns.children` referential integrity is unenforced.** Per spec §2.4, `children` must be block ids "in this slide's blocks." Neither `Model::check` (`core/src/ops.rs:1780-1990`) nor `apply_one` validates this for `add_block` or `set_block`, so an op batch can write dangling/cross-slide references that the core will silently accept. `duplicate_slide`'s `rewrite_ids` (`core/src/ops.rs:2364-2402`) only remaps recognized ids and leaves anything else untouched, compounding the gap. This is exactly the edge case the review brief called out; it should either be validated (reject unknown ids) or explicitly deferred with a comment explaining why v1 doesn't check it. 2. **`ops.rs` at 1089 lines mixes several responsibilities**: op parsing/deserialization (`core/src/ops.rs:127-262`), the two-pass validation model (`:264-490`), layout-conversion geometry (`:822-905`, `to_canvas`/`to_flow`/`region_rect`), slide-duplication id-rewriting (`:945-990`), and the actual mutation pass (`:993-1089`... err through end). Internally it's well-organized with clear section comments, but the layout-conversion math in particular is a self-contained algorithm (spec §2.3) that would be easier to test and reason about in its own module (e.g. `layout.rs`) rather than folded into the general ops-application file. Not a correctness problem today, but a maintainability cost as more ops get added later. #### Minor (Nice to Have) 1. `core/vendor/fastrand-2.5.0/Cargo.toml`'s hand-written patch comment (lines ~79-81) still says "docs-wt-core/vendor/README.md" and "docs-core" instead of the slides equivalents — confirmed this is present verbatim in Docs' own original too, so it's an artifact of the copy rather than a rename miss, but worth a follow-up cleanup since every other copied file (`core/vendor/README.md`, `scripts/build-wasm.sh`) was correctly renamed. 2. `scripts/build-wasm.sh`'s comment about "native tooling under `src/bin` (e.g. `emit_update`, used from the host side of `wire-gate.sh`)" describes Docs' infrastructure (a `wire-gate.sh` and `src/bin/emit_update`) that doesn't exist in this repo — copied verbatim from Docs without adjustment. Harmless (it's just an explanatory comment and the underlying `--lib`-only reasoning still applies), but slightly confusing for a future reader of this repo. 3. `set_block`'s table-resize logic (`core/src/ops.rs:2597-2611`) truncates/appends at the tail of the flat row-major `cells` array. This literally matches the constraint's wording ("truncates or appends"), but a `cols`-only change (as opposed to `rows`-only) will scramble which stored cell content ends up under which new row/column, since the resize doesn't reflow per-row. Worth a code comment noting this is a known, spec-sanctioned limitation, so a future reader doesn't mistake it for an oversight. ### Assessment **Task quality:** Approved **Reasoning:** The implementation is thorough, exceptionally well-documented, and independently verified against the real `yrs` source and Docs' reference files; every required ABI export, op, view field, and Go containment behavior is present and tested well beyond the brief's minimum. The one genuine gap (unenforced `columns.children` referential integrity) is a data-correctness soft spot rather than a crash or spec-shape violation, and the `ops.rs` file-size/separation-of-concerns point is a maintainability suggestion, not a defect — neither blocks merging this task's slice.</result> <usage><subagent_tokens>179320</subagent_tokens><tool_uses>52</tool_uses><duration_ms>418710</duration_ms></usage> </task-notification>

Attributed to this commit: 13.6 g CO₂e (1688834 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on worktree-slice-1, 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.

diff --git a/core/src/layout.rs b/core/src/layout.rs
new file mode 100644
index 0000000..1104550
--- /dev/null
+++ b/core/src/layout.rs
@@ -0,0 +1,327 @@
+//! Layout conversion between a slide's two modes (spec §2.3).
+//!
+//! A `flow` slide places blocks by `region` (`heading`, `body`, `aside`,
+//! `media`); a `canvas` slide places them by an absolute `box` and `z`.
+//! Switching a slide's `layout` (`ops::apply_one`'s `SetSlide` arm) calls
+//! [`to_canvas`] or [`to_flow`] here to carry blocks across, lossily in
+//! both directions as the spec says. Everything else in `ops.rs` — op
+//! parsing, the validation model, id rewriting, mutation — stays there;
+//! this module is just the geometry.
+
+use crate::ops::blocks_of;
+use crate::schema;
+use std::collections::HashMap;
+use yrs::types::AsPrelim;
+use yrs::{Any, Array, Map, MapRef, ReadTxn, TransactionMut};
+
+/// The region a block lands in when nobody said: headings in `heading`,
+/// images in `media`, the rest in `body`.
+pub(crate) fn default_region(kind: &str) -> &'static str {
+ match kind {
+ "heading" => "heading",
+ "image" => "media",
+ _ => "body",
+ }
+}
+
+/// The frame fraction each region occupies: `(x, y, w, h)`. `body`
+/// narrows to make room when the slide has an `aside`.
+pub(crate) fn region_rect(region: &str, has_aside: bool) -> (f64, f64, f64, f64) {
+ match region {
+ "heading" => (0.06, 0.08, 0.88, 0.18),
+ "aside" => (0.54, 0.30, 0.40, 0.62),
+ "media" => (0.50, 0.08, 0.44, 0.84),
+ _ if has_aside => (0.06, 0.30, 0.44, 0.62),
+ _ => (0.06, 0.30, 0.88, 0.62),
+ }
+}
+
+fn region_of<T: ReadTxn>(txn: &T, block: &MapRef) -> String {
+ match schema::get_string(txn, block, "region") {
+ Some(r) if schema::REGIONS.contains(&r.as_str()) => r,
+ _ => {
+ default_region(&schema::get_string(txn, block, "type").unwrap_or_default()).to_string()
+ }
+ }
+}
+
+/// Flow to canvas: every block gets the box its region gave it, blocks
+/// sharing a region split its height equally in order, `z` is the
+/// array index, and `region` goes.
+pub(crate) fn to_canvas(txn: &mut TransactionMut, slide: &MapRef) {
+ let blocks = blocks_of(txn, slide);
+ let maps = schema::maps_of(txn, &blocks);
+ let regions: Vec<String> = maps.iter().map(|(_, m)| region_of(txn, m)).collect();
+ let has_aside = regions.iter().any(|r| r == "aside");
+ let mut counts: HashMap<&str, usize> = HashMap::new();
+ for r in &regions {
+ *counts.entry(r.as_str()).or_default() += 1;
+ }
+ let mut seen: HashMap<&str, usize> = HashMap::new();
+ for ((i, m), region) in maps.iter().zip(&regions) {
+ let n = counts[region.as_str()] as f64;
+ let k = seen.entry(region.as_str()).or_default();
+ let (x, y, w, h) = region_rect(region, has_aside);
+ let slot = h / n;
+ let bx = schema::box_json(x, y + slot * (*k as f64), w, slot);
+ *k += 1;
+ m.insert(txn, "box", schema::json_to_any(&bx));
+ m.insert(txn, "z", Any::Number(*i as f64));
+ m.remove(txn, "region");
+ }
+}
+
+/// Canvas to flow: boxes go, regions come from position (a heading in
+/// the top quarter is `heading`, an image is `media`, the rest `body`),
+/// and the array is reordered top to bottom, left to right.
+pub(crate) fn to_flow(txn: &mut TransactionMut, slide: &MapRef) {
+ let blocks = blocks_of(txn, slide);
+ let maps = schema::maps_of(txn, &blocks);
+ let mut order: Vec<(f64, f64, usize)> = Vec::with_capacity(maps.len());
+ for (pos, (_, m)) in maps.iter().enumerate() {
+ let kind = schema::get_string(txn, m, "type").unwrap_or_default();
+ let bx = schema::get_object(txn, m, "box");
+ let coord = |k: &str, default: f64| {
+ bx.as_ref()
+ .and_then(|b| b.get(k))
+ .and_then(serde_json::Value::as_f64)
+ .unwrap_or(default)
+ };
+ let (x, y) = (coord("x", 0.06), coord("y", 0.30));
+ let region = match kind.as_str() {
+ "heading" if y < 0.25 => "heading",
+ "image" => "media",
+ _ => "body",
+ };
+ m.insert(txn, "region", Any::from(region));
+ m.remove(txn, "box");
+ m.remove(txn, "z");
+ order.push((y, x, pos));
+ }
+ order.sort_by(|a, b| {
+ a.0.partial_cmp(&b.0)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ .then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
+ });
+ if order.iter().enumerate().all(|(i, (_, _, pos))| i == *pos) {
+ return;
+ }
+ let prelims: Vec<yrs::MapPrelim> = order
+ .iter()
+ .map(|(_, _, pos)| maps[*pos].1.as_prelim(txn))
+ .collect();
+ let len = blocks.len(txn);
+ blocks.remove_range(txn, 0, len);
+ for p in prelims {
+ blocks.push_back(txn, p);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use yrs::{Doc, Options, Transact};
+
+ fn doc() -> Doc {
+ Doc::with_options(Options::with_guid_and_client_id("layout-test".into(), 1))
+ }
+
+ /// A slide with `blocks` built through `schema::block_prelim`, so
+ /// these tests exercise the same shapes `ops.rs` writes.
+ #[allow(clippy::type_complexity)]
+ fn slide_with_blocks(
+ txn: &mut TransactionMut,
+ root: &yrs::ArrayRef,
+ blocks: Vec<(
+ &str,
+ &str,
+ Option<&str>,
+ Option<(f64, f64, f64, f64)>,
+ Option<f64>,
+ )>,
+ ) -> MapRef {
+ let slide = root.push_back(txn, yrs::MapPrelim::default());
+ let arr = slide.insert(txn, "blocks", yrs::ArrayPrelim::default());
+ let empty_props = serde_json::Map::new();
+ for (id, kind, region, bx, z) in blocks {
+ let box_json = bx.map(|(x, y, w, h)| schema::box_json(x, y, w, h));
+ let prelim = schema::block_prelim(
+ id,
+ kind,
+ &empty_props,
+ region,
+ box_json.as_ref(),
+ z,
+ None,
+ None,
+ );
+ arr.push_back(txn, prelim);
+ }
+ slide
+ }
+
+ // --- the box table (§2.3) ---
+
+ #[test]
+ fn heading_region_rect() {
+ assert_eq!(region_rect("heading", false), (0.06, 0.08, 0.88, 0.18));
+ assert_eq!(region_rect("heading", true), (0.06, 0.08, 0.88, 0.18));
+ }
+
+ #[test]
+ fn aside_region_rect() {
+ assert_eq!(region_rect("aside", false), (0.54, 0.30, 0.40, 0.62));
+ assert_eq!(region_rect("aside", true), (0.54, 0.30, 0.40, 0.62));
+ }
+
+ #[test]
+ fn media_region_rect() {
+ assert_eq!(region_rect("media", false), (0.50, 0.08, 0.44, 0.84));
+ assert_eq!(region_rect("media", true), (0.50, 0.08, 0.44, 0.84));
+ }
+
+ #[test]
+ fn body_region_rect_narrows_for_an_aside() {
+ assert_eq!(region_rect("body", false), (0.06, 0.30, 0.88, 0.62));
+ assert_eq!(region_rect("body", true), (0.06, 0.30, 0.44, 0.62));
+ }
+
+ #[test]
+ fn default_region_by_kind() {
+ assert_eq!(default_region("heading"), "heading");
+ assert_eq!(default_region("image"), "media");
+ assert_eq!(default_region("text"), "body");
+ assert_eq!(default_region("columns"), "body");
+ }
+
+ // --- flow to canvas ---
+
+ #[test]
+ fn to_canvas_splits_a_shared_region_equally_and_sets_z_by_index() {
+ let d = doc();
+ let root = d.get_or_insert_array("slides");
+ let mut txn = d.transact_mut();
+ let slide = slide_with_blocks(
+ &mut txn,
+ &root,
+ vec![
+ ("b1", "heading", Some("heading"), None, None),
+ ("b2", "text", Some("body"), None, None),
+ ("b3", "text", Some("body"), None, None),
+ ],
+ );
+ to_canvas(&mut txn, &slide);
+
+ let blocks = schema::get_array(&txn, &slide, "blocks").unwrap();
+ let maps = schema::maps_of(&txn, &blocks);
+
+ let heading_box = schema::get_object(&txn, &maps[0].1, "box").unwrap();
+ assert_eq!(heading_box, schema::box_json(0.06, 0.08, 0.88, 0.18));
+ assert_eq!(schema::get_number(&txn, &maps[0].1, "z"), Some(0.0));
+ assert!(schema::get_string(&txn, &maps[0].1, "region").is_none());
+
+ // Two blocks share `body` (h=0.62): each gets half, stacked.
+ let b2_box = schema::get_object(&txn, &maps[1].1, "box").unwrap();
+ assert_eq!(b2_box, schema::box_json(0.06, 0.30, 0.88, 0.31));
+ assert_eq!(schema::get_number(&txn, &maps[1].1, "z"), Some(1.0));
+
+ let b3_box = schema::get_object(&txn, &maps[2].1, "box").unwrap();
+ assert_eq!(b3_box, schema::box_json(0.06, 0.61, 0.88, 0.31));
+ assert_eq!(schema::get_number(&txn, &maps[2].1, "z"), Some(2.0));
+ }
+
+ #[test]
+ fn to_canvas_narrows_body_when_an_aside_is_present() {
+ let d = doc();
+ let root = d.get_or_insert_array("slides");
+ let mut txn = d.transact_mut();
+ let slide = slide_with_blocks(
+ &mut txn,
+ &root,
+ vec![
+ ("b1", "text", Some("body"), None, None),
+ ("b2", "text", Some("aside"), None, None),
+ ],
+ );
+ to_canvas(&mut txn, &slide);
+ let blocks = schema::get_array(&txn, &slide, "blocks").unwrap();
+ let maps = schema::maps_of(&txn, &blocks);
+ let body_box = schema::get_object(&txn, &maps[0].1, "box").unwrap();
+ assert_eq!(body_box, schema::box_json(0.06, 0.30, 0.44, 0.62));
+ }
+
+ // --- canvas to flow ---
+
+ #[test]
+ fn to_flow_classifies_by_position_and_kind() {
+ let d = doc();
+ let root = d.get_or_insert_array("slides");
+ let mut txn = d.transact_mut();
+ let slide = slide_with_blocks(
+ &mut txn,
+ &root,
+ vec![
+ ("b1", "heading", None, Some((0.06, 0.08, 0.88, 0.18)), Some(0.0)),
+ ("b2", "image", None, Some((0.5, 0.5, 0.2, 0.2)), Some(1.0)),
+ ("b3", "text", None, Some((0.06, 0.6, 0.5, 0.3)), Some(2.0)),
+ ],
+ );
+ to_flow(&mut txn, &slide);
+ let blocks = schema::get_array(&txn, &slide, "blocks").unwrap();
+ let maps = schema::maps_of(&txn, &blocks);
+ assert_eq!(schema::get_string(&txn, &maps[0].1, "region").as_deref(), Some("heading"));
+ assert_eq!(schema::get_string(&txn, &maps[1].1, "region").as_deref(), Some("media"));
+ assert_eq!(schema::get_string(&txn, &maps[2].1, "region").as_deref(), Some("body"));
+ for (_, m) in &maps {
+ assert!(schema::get_object(&txn, m, "box").is_none());
+ assert!(schema::get_number(&txn, m, "z").is_none());
+ }
+ }
+
+ #[test]
+ fn to_flow_orders_y_then_x() {
+ let d = doc();
+ let root = d.get_or_insert_array("slides");
+ let mut txn = d.transact_mut();
+ // Written in an order the y-then-x sort must undo.
+ let slide = slide_with_blocks(
+ &mut txn,
+ &root,
+ vec![
+ ("bottom-right", "text", None, Some((0.6, 0.6, 0.2, 0.2)), Some(0.0)),
+ ("top-right", "text", None, Some((0.6, 0.1, 0.2, 0.2)), Some(1.0)),
+ ("top-left", "text", None, Some((0.1, 0.1, 0.2, 0.2)), Some(2.0)),
+ ("bottom-left", "text", None, Some((0.1, 0.6, 0.2, 0.2)), Some(3.0)),
+ ],
+ );
+ to_flow(&mut txn, &slide);
+ let blocks = schema::get_array(&txn, &slide, "blocks").unwrap();
+ let ids: Vec<String> = schema::maps_of(&txn, &blocks)
+ .iter()
+ .map(|(_, m)| schema::get_string(&txn, m, "id").unwrap())
+ .collect();
+ assert_eq!(ids, vec!["top-left", "top-right", "bottom-left", "bottom-right"]);
+ }
+
+ #[test]
+ fn to_flow_is_a_no_op_when_already_in_order() {
+ let d = doc();
+ let root = d.get_or_insert_array("slides");
+ let mut txn = d.transact_mut();
+ let slide = slide_with_blocks(
+ &mut txn,
+ &root,
+ vec![
+ ("a", "text", None, Some((0.1, 0.1, 0.2, 0.2)), Some(0.0)),
+ ("b", "text", None, Some((0.1, 0.6, 0.2, 0.2)), Some(1.0)),
+ ],
+ );
+ to_flow(&mut txn, &slide);
+ let blocks = schema::get_array(&txn, &slide, "blocks").unwrap();
+ let ids: Vec<String> = schema::maps_of(&txn, &blocks)
+ .iter()
+ .map(|(_, m)| schema::get_string(&txn, m, "id").unwrap())
+ .collect();
+ assert_eq!(ids, vec!["a", "b"]);
+ }
+}
diff --git a/core/src/lib.rs b/core/src/lib.rs
index 204cd63..cccdfd8 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -62,6 +62,7 @@
//! supplying both explicitly (see above) rather than relying on `fastrand`
//! to fail loudly when it can't — it won't.
pub mod abi;
+mod layout;
pub mod markdown;
mod ops;
mod schema;
diff --git a/core/src/ops.rs b/core/src/ops.rs
index 52de59d..23b16cb 100644
--- a/core/src/ops.rs
+++ b/core/src/ops.rs
@@ -34,6 +34,7 @@
//! so no observer, no captured buffer, and nothing that could see a
//! different transaction's changes.
+use crate::layout;
use crate::markdown;
use crate::schema::{self, Paragraph, Roots};
use crate::CoreError;
@@ -242,6 +243,12 @@ fn table_side(props: &Props, key: &str) -> Result<Option<i64>, CoreError> {
struct BlockModel {
id: String,
kind: String,
+ /// `Some(children)` for a `columns` block: the block ids its
+ /// `props.children` currently names, kept in step through the batch
+ /// so `remove_block` can drop a removed id from it and a later
+ /// `set_block` on the same columns block sees the drop (spec §2.4).
+ /// `None` for every other kind.
+ children: Option<Vec<String>>,
}
struct SlideModel {
@@ -263,9 +270,25 @@ impl Model {
.map(|a| {
schema::maps_of(txn, &a)
.into_iter()
- .map(|(_, b)| BlockModel {
- id: schema::get_string(txn, &b, "id").unwrap_or_default(),
- kind: schema::get_string(txn, &b, "type").unwrap_or_default(),
+ .map(|(_, b)| {
+ let kind =
+ schema::get_string(txn, &b, "type").unwrap_or_default();
+ let children = (kind == "columns").then(|| {
+ schema::get_object(txn, &b, "props")
+ .and_then(|p| p.get("children").cloned())
+ .and_then(|v| v.as_array().cloned())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|c| c.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default()
+ });
+ BlockModel {
+ id: schema::get_string(txn, &b, "id").unwrap_or_default(),
+ kind,
+ children,
+ }
})
.collect()
})
@@ -366,6 +389,16 @@ impl Model {
if distinct.len() != n || new_block_ids.iter().any(String::is_empty) {
return fail("duplicate_slide: new_block_ids must be distinct and non-empty");
}
+ // Old block id -> new, so a duplicated `columns` block's
+ // children (already validated to name blocks on this
+ // slide) follow the copy exactly like `rewrite_ids`
+ // remaps them at apply time.
+ let id_map: HashMap<&str, &str> = self.slides[i]
+ .blocks
+ .iter()
+ .map(|b| b.id.as_str())
+ .zip(new_block_ids.iter().map(String::as_str))
+ .collect();
let blocks = self.slides[i]
.blocks
.iter()
@@ -373,6 +406,16 @@ impl Model {
.map(|(b, nid)| BlockModel {
id: nid.clone(),
kind: b.kind.clone(),
+ children: b.children.as_ref().map(|cs| {
+ cs.iter()
+ .map(|c| {
+ id_map
+ .get(c.as_str())
+ .map(|n| n.to_string())
+ .unwrap_or_else(|| c.clone())
+ })
+ .collect()
+ }),
})
.collect();
self.slides.insert(
@@ -433,6 +476,14 @@ impl Model {
if s.blocks.iter().any(|b| &b.id == id) {
return fail(format!("block {id:?} already exists in slide {slide:?}"));
}
+ let children = if kind == "columns" {
+ Some(match props.get("children") {
+ Some(v) => validate_children(s, id, v)?,
+ None => Vec::new(),
+ })
+ } else {
+ None
+ };
let at = match after {
None => s.blocks.len(),
Some(None) => 0,
@@ -443,6 +494,7 @@ impl Model {
BlockModel {
id: id.clone(),
kind: kind.clone(),
+ children,
},
);
Ok(())
@@ -451,6 +503,14 @@ impl Model {
let s = self.slide_mut(slide)?;
let i = block_index(s, id)?;
s.blocks.remove(i);
+ // Spec §2.4: a `columns` block's children may not name a
+ // block that no longer exists — drop it rather than
+ // reject the removal.
+ for b in s.blocks.iter_mut() {
+ if let Some(children) = &mut b.children {
+ children.retain(|c| c != id);
+ }
+ }
Ok(())
}
Op::MoveBlock { slide, id, after } => {
@@ -488,6 +548,12 @@ impl Model {
table_side(p, "cols")?;
}
}
+ if s.blocks[i].kind == "columns" {
+ if let Some(children_value) = props.as_ref().and_then(|p| p.get("children")) {
+ let children = validate_children(s, id, children_value)?;
+ s.blocks[i].children = Some(children);
+ }
+ }
Ok(())
}
Op::SetText { slide, id, .. } => {
@@ -534,6 +600,37 @@ fn block_index(s: &SlideModel, id: &str) -> Result<usize, CoreError> {
.ok_or_else(|| CoreError::Ops(format!("no block {id:?} in slide {:?}", s.id)))
}
+/// A `columns` block's `props.children` (spec §2.4): an array of block
+/// ids, each naming a block on `s` other than `self_id`. `null` clears
+/// (an absent-props-key removal), so it validates to no children.
+fn validate_children(s: &SlideModel, self_id: &str, value: &Value) -> Result<Vec<String>, CoreError> {
+ if value.is_null() {
+ return Ok(Vec::new());
+ }
+ let items = value
+ .as_array()
+ .ok_or_else(|| CoreError::Ops("columns children must be an array of block ids".into()))?;
+ let mut out = Vec::with_capacity(items.len());
+ for item in items {
+ let cid = item.as_str().ok_or_else(|| {
+ CoreError::Ops("columns children must be an array of block ids".into())
+ })?;
+ if cid == self_id {
+ return fail(format!(
+ "columns {self_id:?} cannot name itself in children"
+ ));
+ }
+ if !s.blocks.iter().any(|b| b.id == cid) {
+ return fail(format!(
+ "columns children names {cid:?}, not a block on slide {:?}",
+ s.id
+ ));
+ }
+ out.push(cid.to_string());
+ }
+ Ok(out)
+}
+
// --- the applying pass ---
fn slide_map(txn: &TransactionMut, roots: &Roots, id: &str) -> Result<(u32, MapRef), CoreError> {
@@ -542,7 +639,7 @@ fn slide_map(txn: &TransactionMut, roots: &Roots, id: &str) -> Result<(u32, MapR
}
/// The slide's block array, created if a peer wrote a slide without one.
-fn blocks_of(txn: &mut TransactionMut, slide: &MapRef) -> ArrayRef {
+pub(crate) fn blocks_of(txn: &mut TransactionMut, slide: &MapRef) -> ArrayRef {
match schema::get_array(txn, slide, "blocks") {
Some(a) => a,
None => slide.insert(txn, "blocks", yrs::ArrayPrelim::default()),
@@ -557,6 +654,32 @@ fn block_map(
schema::find_by_id(txn, blocks, id).ok_or_else(|| CoreError::Ops(format!("no block {id:?}")))
}
+/// Drops `removed_id` from every `columns` block's `props.children` in
+/// `blocks` (spec §2.4: children may not name a block that no longer
+/// exists — `remove_block` prunes rather than rejects).
+fn remove_from_columns_children(txn: &mut TransactionMut, blocks: &ArrayRef, removed_id: &str) {
+ for (_, m) in schema::maps_of(txn, blocks) {
+ if schema::get_string(txn, &m, "type").as_deref() != Some("columns") {
+ continue;
+ }
+ let Some(pm) = schema::get_map(txn, &m, "props") else {
+ continue;
+ };
+ let Some(Value::Array(children)) = schema::map_to_json(txn, &pm).get("children").cloned()
+ else {
+ continue;
+ };
+ if !children.iter().any(|c| c.as_str() == Some(removed_id)) {
+ continue;
+ }
+ let filtered: Vec<Value> = children
+ .into_iter()
+ .filter(|c| c.as_str() != Some(removed_id))
+ .collect();
+ pm.insert(txn, "children", schema::json_to_any(&Value::Array(filtered)));
+ }
+}
+
fn fragment_of(txn: &mut TransactionMut, map: &MapRef, key: &str) -> yrs::XmlFragmentRef {
match schema::get_fragment(txn, map, key) {
Some(f) => f,
@@ -600,111 +723,6 @@ fn text_for(kind: &str, markdown: &str) -> Vec<Paragraph> {
}
}
-// --- layout conversion (§2.3) ---
-
-/// The region a block lands in when nobody said: headings in `heading`,
-/// images in `media`, the rest in `body`.
-fn default_region(kind: &str) -> &'static str {
- match kind {
- "heading" => "heading",
- "image" => "media",
- _ => "body",
- }
-}
-
-/// The frame fraction each region occupies: `(x, y, w, h)`. `body`
-/// narrows to make room when the slide has an `aside`.
-fn region_rect(region: &str, has_aside: bool) -> (f64, f64, f64, f64) {
- match region {
- "heading" => (0.06, 0.08, 0.88, 0.18),
- "aside" => (0.54, 0.30, 0.40, 0.62),
- "media" => (0.50, 0.08, 0.44, 0.84),
- _ if has_aside => (0.06, 0.30, 0.44, 0.62),
- _ => (0.06, 0.30, 0.88, 0.62),
- }
-}
-
-fn region_of<T: ReadTxn>(txn: &T, block: &MapRef) -> String {
- match schema::get_string(txn, block, "region") {
- Some(r) if schema::REGIONS.contains(&r.as_str()) => r,
- _ => {
- default_region(&schema::get_string(txn, block, "type").unwrap_or_default()).to_string()
- }
- }
-}
-
-/// Flow to canvas: every block gets the box its region gave it, blocks
-/// sharing a region split its height equally in order, `z` is the
-/// array index, and `region` goes.
-fn to_canvas(txn: &mut TransactionMut, slide: &MapRef) {
- let blocks = blocks_of(txn, slide);
- let maps = schema::maps_of(txn, &blocks);
- let regions: Vec<String> = maps.iter().map(|(_, m)| region_of(txn, m)).collect();
- let has_aside = regions.iter().any(|r| r == "aside");
- let mut counts: HashMap<&str, usize> = HashMap::new();
- for r in &regions {
- *counts.entry(r.as_str()).or_default() += 1;
- }
- let mut seen: HashMap<&str, usize> = HashMap::new();
- for ((i, m), region) in maps.iter().zip(&regions) {
- let n = counts[region.as_str()] as f64;
- let k = seen.entry(region.as_str()).or_default();
- let (x, y, w, h) = region_rect(region, has_aside);
- let slot = h / n;
- let bx = schema::box_json(x, y + slot * (*k as f64), w, slot);
- *k += 1;
- m.insert(txn, "box", schema::json_to_any(&bx));
- m.insert(txn, "z", Any::Number(*i as f64));
- m.remove(txn, "region");
- }
-}
-
-/// Canvas to flow: boxes go, regions come from position (a heading in
-/// the top quarter is `heading`, an image is `media`, the rest `body`),
-/// and the array is reordered top to bottom, left to right.
-fn to_flow(txn: &mut TransactionMut, slide: &MapRef) {
- let blocks = blocks_of(txn, slide);
- let maps = schema::maps_of(txn, &blocks);
- let mut order: Vec<(f64, f64, usize)> = Vec::with_capacity(maps.len());
- for (pos, (_, m)) in maps.iter().enumerate() {
- let kind = schema::get_string(txn, m, "type").unwrap_or_default();
- let bx = schema::get_object(txn, m, "box");
- let coord = |k: &str, default: f64| {
- bx.as_ref()
- .and_then(|b| b.get(k))
- .and_then(Value::as_f64)
- .unwrap_or(default)
- };
- let (x, y) = (coord("x", 0.06), coord("y", 0.30));
- let region = match kind.as_str() {
- "heading" if y < 0.25 => "heading",
- "image" => "media",
- _ => "body",
- };
- m.insert(txn, "region", Any::from(region));
- m.remove(txn, "box");
- m.remove(txn, "z");
- order.push((y, x, pos));
- }
- order.sort_by(|a, b| {
- a.0.partial_cmp(&b.0)
- .unwrap_or(std::cmp::Ordering::Equal)
- .then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
- });
- if order.iter().enumerate().all(|(i, (_, _, pos))| i == *pos) {
- return;
- }
- let prelims: Vec<yrs::MapPrelim> = order
- .iter()
- .map(|(_, _, pos)| maps[*pos].1.as_prelim(txn))
- .collect();
- let len = blocks.len(txn);
- blocks.remove_range(txn, 0, len);
- for p in prelims {
- blocks.push_back(txn, p);
- }
-}
-
// --- duplicate ---
/// Gives a copied slide its new ids: the slide's own, each block's, and
@@ -829,8 +847,8 @@ fn apply_one(txn: &mut TransactionMut, roots: &Roots, op: &Op) -> Result<(), Cor
schema::get_string(txn, &m, "layout").unwrap_or_else(|| "flow".into());
if *l != current {
match l.as_str() {
- "canvas" => to_canvas(txn, &m),
- _ => to_flow(txn, &m),
+ "canvas" => layout::to_canvas(txn, &m),
+ _ => layout::to_flow(txn, &m),
}
m.insert(txn, "layout", Any::from(l.as_str()));
}
@@ -892,10 +910,10 @@ fn apply_one(txn: &mut TransactionMut, roots: &Roots, op: &Op) -> Result<(), Cor
} else {
None
};
- let region = region.as_deref().unwrap_or_else(|| default_region(kind));
+ let region = region.as_deref().unwrap_or_else(|| layout::default_region(kind));
let (region_key, box_value, z_value) = if canvas {
let bx = bx.map(|b| b.json()).unwrap_or_else(|| {
- let (x, y, w, h) = region_rect(region, false);
+ let (x, y, w, h) = layout::region_rect(region, false);
schema::box_json(x, y, w, h)
});
(None, Some(bx), Some(z.unwrap_or(at as f64)))
@@ -919,6 +937,7 @@ fn apply_one(txn: &mut TransactionMut, roots: &Roots, op: &Op) -> Result<(), Cor
let blocks = blocks_of(txn, &s);
let (i, _) = block_map(txn, &blocks, id)?;
blocks.remove(txn, i);
+ remove_from_columns_children(txn, &blocks, id);
}
Op::MoveBlock { slide, id, after } => {
let (_, s) = slide_map(txn, roots, slide)?;
@@ -950,6 +969,14 @@ fn apply_one(txn: &mut TransactionMut, roots: &Roots, op: &Op) -> Result<(), Cor
}
}
let is_table = schema::get_string(txn, &m, "type").as_deref() == Some("table");
+ // `cells` is row-major and flat, so growing/shrinking
+ // only appends to or truncates the end of it -- a
+ // cols-only change (rows unchanged) reflows every row's
+ // cells rather than inserting/removing per row, which
+ // shuffles existing cell text across the grid. Spec
+ // §2.4 sanctions this as a v1 limitation; a person
+ // resizing columns should expect to recheck cell
+ // contents.
if is_table && (p.contains_key("rows") || p.contains_key("cols")) {
let rows = schema::get_number(txn, &pm, "rows")
.unwrap_or(schema::DEFAULT_TABLE_ROWS as f64)
diff --git a/core/tests/ops.rs b/core/tests/ops.rs
index 953c029..2d2e03e 100644
--- a/core/tests/ops.rs
+++ b/core/tests/ops.rs
@@ -385,3 +385,136 @@ fn remaining_ops_roundtrip() {
}
assert_eq!(view_json(&a), view_json(&b));
}
+
+/// Spec §2.4: a `columns` block's `children` names blocks in the same
+/// slide. `add_block` and `set_block` must reject a child that isn't.
+#[test]
+fn columns_children_must_name_blocks_on_the_slide() {
+ let d = doc(9);
+ let setup = r#"[
+ {"op":"add_slide","id":"s1","layout":"flow"},
+ {"op":"add_slide","id":"s2","layout":"flow"},
+ {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"},
+ {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"},
+ {"op":"add_block","slide":"s2","id":"c1","type":"text","markdown":"C"}
+ ]"#;
+ apply_ops(&d, setup).expect("setup applies");
+ let before = view_json(&d);
+
+ // An id that names no block anywhere.
+ let bad = r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns",
+ "props":{"count":2,"children":["b1","nope"]}}]"#;
+ let err = apply_ops(&d, bad).expect_err("unknown child id is rejected");
+ assert!(err.to_string().contains("nope"), "{err}");
+ assert_eq!(view_json(&d), before, "nothing applied");
+
+ // An id that names a block, but on a different slide.
+ let bad = r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns",
+ "props":{"count":2,"children":["b1","c1"]}}]"#;
+ let err = apply_ops(&d, bad).expect_err("a block on another slide is rejected");
+ assert!(err.to_string().contains("c1"), "{err}");
+ assert_eq!(view_json(&d), before, "nothing applied");
+
+ // Valid children apply; then set_block naming the columns block
+ // itself is rejected the same way.
+ apply_ops(
+ &d,
+ r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns",
+ "props":{"count":2,"children":["b1","b2"]}}]"#,
+ )
+ .expect("valid children apply");
+ let before = view_json(&d);
+ let bad = r#"[{"op":"set_block","slide":"s1","id":"b3","props":{"children":["b1","b3"]}}]"#;
+ let err = apply_ops(&d, bad).expect_err("a columns block cannot name itself");
+ assert!(err.to_string().contains("b3"), "{err}");
+ assert_eq!(view_json(&d), before, "nothing applied");
+}
+
+/// Spec §2.4: removing a block that a `columns` block's `children`
+/// names drops it from that list rather than rejecting the removal.
+#[test]
+fn remove_block_drops_it_from_columns_children() {
+ let d = doc(10);
+ apply_ops(
+ &d,
+ r#"[
+ {"op":"add_slide","id":"s1","layout":"flow"},
+ {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"},
+ {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"},
+ {"op":"add_block","slide":"s1","id":"b3","type":"columns",
+ "props":{"count":2,"children":["b1","b2"]}}
+ ]"#,
+ )
+ .expect("setup applies");
+
+ apply_ops(&d, r#"[{"op":"remove_block","slide":"s1","id":"b1"}]"#)
+ .expect("remove applies");
+ let v = view(&d);
+ let columns = v["slides"][0]["blocks"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|b| b["id"] == "b3")
+ .expect("columns block still there");
+ assert_eq!(
+ columns["props"]["children"],
+ serde_json::json!(["b2"]),
+ "the removed block is dropped from children, not the whole op rejected"
+ );
+
+ // The model saw the drop too: a later op naming the now-removed
+ // block is still rejected as unknown.
+ let before = view_json(&d);
+ let bad = r#"[{"op":"set_block","slide":"s1","id":"b3","props":{"children":["b1"]}}]"#;
+ apply_ops(&d, bad).expect_err("b1 no longer exists");
+ assert_eq!(view_json(&d), before);
+}
+
+/// Spec §2.4 + duplicate_slide: a copied `columns` block's children
+/// follow the copy's new block ids, and the model tracks the remap so
+/// later ops against the copy validate against its ids, not the
+/// original's.
+#[test]
+fn duplicate_slide_remaps_columns_children() {
+ let d = doc(11);
+ apply_ops(
+ &d,
+ r#"[
+ {"op":"add_slide","id":"s1","layout":"flow"},
+ {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"},
+ {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"},
+ {"op":"add_block","slide":"s1","id":"b3","type":"columns",
+ "props":{"count":2,"children":["b1","b2"]}}
+ ]"#,
+ )
+ .expect("setup applies");
+
+ apply_ops(
+ &d,
+ r#"[{"op":"duplicate_slide","id":"s1","new_id":"s1copy","new_block_ids":["d1","d2","d3"]}]"#,
+ )
+ .expect("duplicate applies");
+ let v = view(&d);
+ let copy = &v["slides"][1];
+ assert_eq!(copy["id"], "s1copy");
+ assert_eq!(
+ copy["blocks"][2]["props"]["children"],
+ serde_json::json!(["d1", "d2"]),
+ "columns children follow the new ids"
+ );
+
+ // The model tracked the remap: a later op naming the copy's new
+ // children validates...
+ apply_ops(
+ &d,
+ r#"[{"op":"set_block","slide":"s1copy","id":"d3","props":{"children":["d2","d1"]}}]"#,
+ )
+ .expect("remapped children still validate");
+
+ // ...and one naming the original slide's old ids against the copy
+ // is rejected: they aren't blocks on s1copy.
+ let before = view_json(&d);
+ let bad = r#"[{"op":"set_block","slide":"s1copy","id":"d3","props":{"children":["b1"]}}]"#;
+ apply_ops(&d, bad).expect_err("the original's id does not exist on the copy");
+ assert_eq!(view_json(&d), before);
+}
diff --git a/core/vendor/fastrand-2.5.0/Cargo.toml b/core/vendor/fastrand-2.5.0/Cargo.toml
index e21cde2..8136e27 100644
--- a/core/vendor/fastrand-2.5.0/Cargo.toml
+++ b/core/vendor/fastrand-2.5.0/Cargo.toml
@@ -76,9 +76,9 @@ version = "0.10"
[dev-dependencies.wyhash]
version = "0.6"
-# Vendored patch (see docs-wt-core/vendor/README.md): dropped the forced
+# Vendored patch (see core/vendor/README.md): dropped the forced
# "wasm_js" feature so that a `--cfg getrandom_backend="..."` override (used
-# by docs-core to avoid a wasm-bindgen/JS import dependency) doesn't hit
+# by slides-core to avoid a wasm-bindgen/JS import dependency) doesn't hit
# getrandom's `Error::WEB_CRYPTO` cfg mismatch (that item only exists when
# the "wasm_js" backend module itself is compiled, which a cfg override to
# a different backend skips, but `error.rs`'s Display impl references it
diff --git a/internal/slides/core/testdata/slides_core.wasm b/internal/slides/core/testdata/slides_core.wasm
index 450ee08..75432c3 100755
Binary files a/internal/slides/core/testdata/slides_core.wasm and b/internal/slides/core/testdata/slides_core.wasm differ
diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh
index 2e6976d..c56cdfe 100755
--- a/scripts/build-wasm.sh
+++ b/scripts/build-wasm.sh
@@ -9,11 +9,11 @@ cd "$(dirname "$0")/.."
# for. A doc comment alone has been wrong, or gotten around, more than
# once; this is the actual enforcement.
cargo clippy --manifest-path core/Cargo.toml --all-targets -- -D warnings -D clippy::disallowed_methods
-# --lib only: the wasm module shipped to wazero is the library crate. Native
-# tooling under src/bin (e.g. emit_update, used from the host side of
-# wire-gate.sh) is never part of that artifact and does not define the
-# __getrandom_v03_custom symbol itself, so building it for wasm32 fails to
-# link -- it was never meant to run there.
+# --lib only: the wasm module shipped to wazero is the library crate. Any
+# native tooling this crate ever grows under src/bin would not be part of
+# that artifact and would not define the __getrandom_v03_custom symbol
+# itself, so building it for wasm32 would fail to link -- it isn't meant to
+# run there.
#
# --remap-path-prefix (x2): rustc embeds the absolute source path of every
# panic site (Location::caller()) into the binary. Left alone, that means