CANVAS core: Excalidraw canvas as a Note, live co-editing, one event path, chrome, element links, export (§60) #976

Open
opened 2026-10-03 06:38:50 +00:00 by kayg · 56 comments
Owner

Owner decisions (2026-10-03, #509 grill)

Contract: docs/DESIGN.md §60 (read it all). Summary:

  • A Canvas is a Note. It opens from Notes and Files, is listed in the Notes sidebar, and is embeddable with ![[Board.excalidraw]] (live preview). There is no Tab.
  • Open .excalidraw and .excalidraw.md without loss. Save new canvases as .excalidraw.md with uncompressed JSON. Byte-preserve unknown fields and sections (opening never writes, #661).
  • New canvas in the Notes sidebar New menu, the Files New menu and the palette. It creates Untitled canvas.excalidraw.md in the current folder (Notes root when there is none), opens it and selects the title.
  • Live co-editing in v1: a Yjs map entry per element, keyed by element ID, with a fractional order index. The server is the single writer and saves on a debounce.
  • One event path shared by the web editor, API, CLI, MCP and WebMCP. No second write route.
  • Chrome: Excalidraw's drawing surface and tool palette, themed with calternal tokens (light and dark). Replace its main menu, export dialog and help with calternal chrome: one bar, ⋯ menu, warm tooltips with shortcuts, palette commands (§34). No stock look. Upstream PRs are fine; no long-lived fork.
  • Element links: Copy link on every element → /n/<calternal-id>?el=<element-id>. It opens zoomed to the element, with the element selected. Add the grammar to §33.
  • Search indexes canvas text. Backlinks: links in the canvas count as backlinks.
  • Export: PNG and SVG with the scene embedded (reopens editable). Parity: API, CLI, MCP and WebMCP can create a canvas, add, change or remove elements, convert Mermaid into a diagram (@excalidraw/mermaid-to-excalidraw runs in a browser; decide where it runs and document why), and export.
  • Performance: Excalidraw loads only when a canvas opens (its own chunk; measure the bundle). Add a bench profile: open a 5k-element canvas cold and warm, pan/zoom INP, and a live update storm.

Not in this issue (separate issues)

  • Live cards for calternal items: the next issue, which builds on this one.
  • iPad/Pencil polish: a separate issue (pen mode must not be broken here).
  • History, Restore and per-author undo: the history issue. Write updates through the collaboration path so history can tag authors later; do not build your own versioning.
  • Agents drawing: later (AI plan pending).

Reuse first

  • Collaboration: crates/calternal-collab and the Notes live-edit path. Extend them.
  • Notes file handling, Notes sidebar, Files New menu, palette, Copy link helper (copyLinkWithToast), tooltip, ⋯ menu: reuse the existing units.
  • Port from /home/kayg/Developer/calternal.js/packages/web/src/lib/components where a component exists.

Security

  • All file access goes through calternal-fs. Parse untrusted JSON with size and depth limits: a hostile .excalidraw.md must not crash or hang the server (add cases to tests/adversarial/). Embedded image files (data URLs) count against quota and size limits.
  • Classify every new route in the cross-user isolation matrix (#472/#331). A shared canvas exposes only what Share/Collaborate (§54) allows.

Verification (per-branch policy)

Crate gates + focused tests + screenshots of a production build (390/820/1440, light and dark): an empty canvas, a canvas with shapes and text, the ⋯ menu, a tooltip, and a canvas embedded in a note. E2E as a User: create from Notes, draw a rectangle and text, reload, see it; open the same canvas in two browser contexts and see live edits; Copy link on an element, open it, element selected; open an existing .excalidraw and .excalidraw.md fixture and save without loss of unknown fields.

Gates

cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test (touched crates), bun run check, bun run test. Quote the output verbatim.

## Owner decisions (2026-10-03, #509 grill) Contract: `docs/DESIGN.md` §60 (read it all). Summary: - A Canvas is a **Note**. It opens from Notes and Files, is listed in the Notes sidebar, and is embeddable with `![[Board.excalidraw]]` (live preview). There is no Tab. - Open `.excalidraw` and `.excalidraw.md` without loss. Save new canvases as `.excalidraw.md` with **uncompressed** JSON. Byte-preserve unknown fields and sections (opening never writes, #661). - **New canvas** in the Notes sidebar New menu, the Files New menu and the palette. It creates `Untitled canvas.excalidraw.md` in the current folder (Notes root when there is none), opens it and selects the title. - Live co-editing in v1: a Yjs map entry per element, keyed by element ID, with a fractional order index. The server is the single writer and saves on a debounce. - **One event path** shared by the web editor, API, CLI, MCP and WebMCP. No second write route. - Chrome: Excalidraw's drawing surface and tool palette, themed with calternal tokens (light and dark). Replace its main menu, export dialog and help with calternal chrome: one bar, ⋯ menu, warm tooltips with shortcuts, palette commands (§34). No stock look. Upstream PRs are fine; no long-lived fork. - Element links: Copy link on every element → `/n/<calternal-id>?el=<element-id>`. It opens zoomed to the element, with the element selected. Add the grammar to §33. - Search indexes canvas text. Backlinks: links in the canvas count as backlinks. - Export: PNG and SVG with the scene embedded (reopens editable). Parity: API, CLI, MCP and WebMCP can create a canvas, add, change or remove elements, convert Mermaid into a diagram (`@excalidraw/mermaid-to-excalidraw` runs in a browser; decide where it runs and document why), and export. - Performance: Excalidraw loads only when a canvas opens (its own chunk; measure the bundle). Add a bench profile: open a 5k-element canvas cold and warm, pan/zoom INP, and a live update storm. ## Not in this issue (separate issues) - Live cards for calternal items: the next issue, which builds on this one. - iPad/Pencil polish: a separate issue (pen mode must not be broken here). - History, Restore and per-author undo: the history issue. Write updates through the collaboration path so history can tag authors later; do not build your own versioning. - Agents drawing: later (AI plan pending). ## Reuse first - Collaboration: `crates/calternal-collab` and the Notes live-edit path. Extend them. - Notes file handling, Notes sidebar, Files New menu, palette, Copy link helper (`copyLinkWithToast`), tooltip, ⋯ menu: reuse the existing units. - Port from `/home/kayg/Developer/calternal.js/packages/web/src/lib/components` where a component exists. ## Security - All file access goes through `calternal-fs`. Parse untrusted JSON with size and depth limits: a hostile `.excalidraw.md` must not crash or hang the server (add cases to `tests/adversarial/`). Embedded image `files` (data URLs) count against quota and size limits. - Classify every new route in the cross-user isolation matrix (#472/#331). A shared canvas exposes only what Share/Collaborate (§54) allows. ## Verification (per-branch policy) Crate gates + focused tests + screenshots of a production build (390/820/1440, light and dark): an empty canvas, a canvas with shapes and text, the ⋯ menu, a tooltip, and a canvas embedded in a note. E2E as a User: create from Notes, draw a rectangle and text, reload, see it; open the same canvas in two browser contexts and see live edits; Copy link on an element, open it, element selected; open an existing `.excalidraw` and `.excalidraw.md` fixture and save without loss of unknown fields. ## Gates `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (touched crates), `bun run check`, `bun run test`. Quote the output verbatim.
Author
Owner

Format research (2026-10-03): read before building

Must-follow findings: (1) element IDs we create are exactly 8 chars [0-9A-Za-z], or the Obsidian plugin renames them and breaks ?el= links and Yjs keys; (2) keep header, unknown sections, unknown JSON keys and deleted elements byte-for-byte; never rewrite an unchanged scene; (3) an existing compressed-json file is written uncompressed on its first real change (same as the plugin's Decompress command); (4) images: see C6 (pending owner answer; recommendation = separate Home files listed in Embedded Files + calternal id in customData). Do not copy plugin code (AGPL-3.0 per LICENSE): write our own parser from this description.

Excalidraw file formats for calternal Canvas (DESIGN §60)

Source read: zsviczian/obsidian-excalidraw-plugin, shallow clone of HEAD
f591b75 (2026-09-30), plugin manifest version 2.28.1. Files read:
src/shared/excalidrawMarkdownParsing.ts, src/shared/ExcalidrawData.ts
(loadData, generateMDBase, syncFiles, syncElements),
src/view/ExcalidrawView.ts (save assembly), src/constants/constants.ts,
src/utils/sceneDataUtils.ts, src/core/managers/FileManager.ts.
This document describes behaviour only. No plugin code is copied.

0. Licence

  • The repo LICENSE file at HEAD is GNU AGPL-3.0. package.json still
    says "license": "MIT" (stale field). Treat the plugin as AGPL-3.0.
  • Excalidraw itself (@excalidraw/excalidraw) is MIT. The plugin uses a fork
    @zsviczian/excalidraw.
  • calternal is AGPL-3.0-only, so reuse would be licence-compatible only if
    the plugin is "or later"/plain AGPL-3.0 (the header does not say "only";
    verify before any reuse). Recommendation: do not copy code. Implement a
    clean-room parser from this behavioural spec and from our own fixtures.

1. .excalidraw.md file structure

Order of parts in a file the plugin writes (save = header + generated + tail):

---                                  <- frontmatter (part of "header", user-owned)
excalidraw-plugin: parsed
tags: [excalidraw]
---
==⚠  Switch to EXCALIDRAW VIEW ... ⚠== ...   <- default banner, user-owned

<any user Markdown: "back of the card" notes>   <- header, user-owned
# Markdown Images                    <- optional, new in 2.2x (see 1.6)
<!-- excalidraw-markdown-image:<fileId> -->
...markdown body...
<!-- /excalidraw-markdown-image:<fileId> -->

%%                                   <- comment opener (Obsidian hides it)
# Excalidraw Data

## Text Elements
<raw text of element> ^<8-char id>

## Element Links
<8-char id>: <link>

## Embedded Files
<40-hex fileId>: [[path/to/image.png]]
<fileId>: https://example.com/a.png
<fileId>: $$\LaTeX$$
<fileId>: markdown-image

%%
## Drawing
```json
{ ...scene JSON, tab-indented... }

%%
<optional "tail" text, kept only in Zotero-compatibility mode>


### 1.1 Frontmatter

- `excalidraw-plugin: parsed` (or `raw`) is the marker that makes Obsidian
  open the note in the Excalidraw view. `parsed`/`raw` is the text mode
  (whether text elements show transcluded/parsed text or raw Markdown).
  The plugin rewrites only this key (and, when the export dialog changed,
  `excalidraw-export-embed-scene` and `excalidraw-export-internal-links`).
- Default new file also has `tags: [excalidraw]`.
- Other recognised keys (all optional, all prefixed `excalidraw-`):
  `export-transparent`, `mask`, `export-dark`, `export-padding`,
  `export-svgpadding` (deprecated), `export-pngscale`, `export-embed-scene`,
  `export-internal-links`, `link-prefix`, `url-prefix`, `link-brackets`,
  `onload-script`, `linkbutton-opacity`, `default-mode`, `font`,
  `font-color`, `border-color`, `css`, `autoexport`, `iframe-theme`,
  `embeddable-theme`, `open-md`, `embed-md`.
- calternal: detect the file by the key `excalidraw-plugin` (any value);
  keep every other frontmatter key and its formatting unchanged.

### 1.2 `%%` comment block

- `%%` is Obsidian comment syntax. The plugin puts `%%` before
  `# Excalidraw Data` and again before `## Drawing`, and one `%%` after the
  code fence. Thus in Markdown view the data is hidden.
- Variant: if the file had `%%\n# Excalidraw Data` it is "commented out"
  (state `textElementCommentedOut`); the writer then emits `%%` at the top of
  the data and does **not** emit the second `%%` before `## Drawing`. Older
  files have `%%` only before `## Drawing` (text elements visible in
  Markdown view). Preserve whichever variant the file had.
- Card variant: a lone `#` line before `%%` ("back of card" separator) is
  preserved by the header extractor; there is also a repair for `text#\n%%`.

### 1.3 Text Elements

- One entry per Excalidraw `text` element: `<raw text> ^<id>` followed by a
  blank line. `raw` is the Markdown source (may contain `[[links]]`,
  `![[transclusions]]`, URLs). The scene JSON holds the rendered text in
  `text`/`originalText` and the source in the plugin field `rawText`.
- The reader matches `\s\^(.{8})\n+`: **ids are exactly 8 characters**. The
  plugin renames any text element or linked element whose id is longer than
  8 chars (Excalidraw's default ids are ~20 chars) to an 8-char nanoid from
  `[0-9a-zA-Z]`, and updates bindings/containers/groups with it. Reason:
  Obsidian block references `^id` accept only safe characters, and short ids
  read better.
- Optional dummy entry `^_dummy!_` (setting `addDummyTextElement`).
- Legacy (< 2.0.26): non-text elements with a link also appeared here as
  `<link> ^<id>`; legacy inline form `%%***>>>text element-link:[[x]]<<<***%%`.
- Text Elements are **authoritative for text** on load: the raw text from
  Markdown overwrites `rawText` and, after parsing, the displayed text. This
  is what lets a user edit drawing text in Markdown view.
- Heading may be `# Text Elements` (old) or `## Text Elements`.

### 1.4 Element Links (since 2.0.26)

- Lines `<8-char id>: <link>` (regex `^(.{8}):\s*(.*)$`), blank line between.
- Links of non-text elements, and links of text elements when the link is not
  derived from the text. On load they overwrite `element.link`.
- Heading `## Element Links` (old: `# Element Links`). Omitted if empty.

### 1.5 Embedded Files

- Lines keyed by Excalidraw `fileId` (plugin ids are 40 hex chars; ids from
  excalidraw.com are other strings, matched by `[\w\d]*`):
  - vault file: `<fileId>: [[path|alias#page=3]]` optionally followed by a
    JSON colour map `{"#000":"#fff"}` (SVG recolouring). `![[...]]` also
    accepted on read. The path is written via Obsidian's shortest-link text
    for the file relative to the drawing (respects user link settings).
  - URL image: `<fileId>: https://...` (also `file://`, `ftp://`).
  - LaTeX: `<fileId>: $$...$$`.
  - local Markdown image: `<fileId>: markdown-image` (body lives in the
    header block, 1.6).
  - Mermaid: not listed; source is in the image element's `customData`.
- Heading `## Embedded Files` (old: `# Embedded files`). Omitted if empty.

### 1.6 Markdown Images (new, 2.2x)

- `# Markdown Images` heading in the user header, then blocks delimited by
  `<!-- excalidraw-markdown-image:<fileId> -->` ... `<!-- /... -->`.
  The plugin keeps blocks where the user placed them and only appends missing
  ones. Treat as opaque header content; do not render specially in v1.

### 1.7 Drawing block

- `## Drawing` (old: `# Drawing`) then either
  - ```` ```json ```` + scene JSON (`JSON.stringify(scene, null, "\t")`) +
    ```` ``` ````, or
  - ```` ```compressed-json ```` + `LZString.compressToBase64(json)` split
    into 256-char lines separated by **blank lines** (`\n\n`); the reader
    strips every `\r`/`\n` and decompresses.
- Compression is a user setting (`compress`, default **on** in recent
  versions). Users can run "Decompress current Excalidraw file". The plugin
  reads both. calternal must read both and writes `json` (§60).
- Very old files: JSON without code fence on one line after `# Drawing`
  (fallback regex). The reader also cuts the JSON at the last `}` to survive
  sync merges.
- Scene written: `{type, version, source, elements (incl. deleted), appState,
  files}`. In `.md` mode `files` is `{}` after the first save (see 2).

### 1.8 Minimal valid example (uncompressed, what calternal should write)

````markdown
---

excalidraw-plugin: parsed
tags: [excalidraw]

---

%%
# Excalidraw Data

## Text Elements
Hello [[Note]] ^Ab3dE9xQ

## Embedded Files
5f1e0c9a2b7d4e6f8a1b3c5d7e9f0a2b4c6d8e0f: [[photo.png]]

%%
## Drawing
```json
{
	"type": "excalidraw",
	"version": 2,
	"source": "https://calternal.cloud",
	"elements": [
		{"id":"Ab3dE9xQ","type":"text","x":0,"y":0,"width":120,"height":25,
		 "angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent",
		 "fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,
		 "opacity":100,"groupIds":[],"frameId":null,"index":"a0","roundness":null,
		 "seed":1,"version":1,"versionNonce":1,"isDeleted":false,
		 "boundElements":null,"updated":1,"link":null,"locked":false,
		 "text":"Hello Note","rawText":"Hello [[Note]]","originalText":"Hello Note",
		 "fontSize":20,"fontFamily":5,"textAlign":"left","verticalAlign":"top",
		 "containerId":null,"autoResize":true,"lineHeight":1.25},
		{"id":"Im9kQ2pL","type":"image","fileId":"5f1e0c9a2b7d4e6f8a1b3c5d7e9f0a2b4c6d8e0f",
		 "status":"saved","scale":[1,1],"crop":null, "...common fields": "..."}
	],
	"appState": {"gridSize": null, "viewBackgroundColor": "#ffffff"},
	"files": {}
}

%%


(The example image element is abbreviated; a real file has all common
element fields.) The banner line is optional; we can omit it or write our own.

## 2. Images: inline dataURL vs. vault files

- **`.excalidraw.md` (normal mode):** on every save `syncFiles` writes each
  new `files[id].dataURL` that has no known source to the vault as a separate
  file (pasted images use Obsidian's attachment naming/folder rules, recent
  PR #2964 uses `app.saveAttachment`), registers `<fileId>: [[name.png]]` in
  Embedded Files, then sets `scene.files = {}`. So `.md` files **never** keep
  inline images; the JSON keeps only `image` elements with `fileId`.
- **`.excalidraw` (compatibility mode, `.excalidraw` extension):** saved as
  plain `JSON.stringify(scene, null, "\t")` with inline `files` dataURLs
  (same as excalidraw.com).
- **What Obsidian users expect:** separate attachment files in the vault
  (visible in file explorer, reusable, de-duplicated, small `.md`). Inline
  base64 in a `.md` would look wrong to them and bloat sync.
- **Rename of a vault image:** the Embedded Files line is an ordinary wiki
  link in the note body. Obsidian indexes links in the file (including the
  `%%` block, per Obsidian behaviour; verify on a test vault), so with
  "Automatically update internal links" Obsidian rewrites `[[old.png]]` to
  the new name. The plugin itself only handles rename of the drawing (moves
  its `.bak` and auto-exported PNG/SVG siblings), not of images. Links are
  resolved shortest-path (`getFirstLinkpathDest`), so a move to another
  folder with the same basename still resolves. If a rename happens outside
  Obsidian (WebDAV client, shell) nothing updates the link and the image
  shows as missing; the `fileId` in the JSON stays valid.

## 3. Derived vs. authoritative; round-tripping

On save the plugin regenerates everything from `%%`/`# Excalidraw Data` to
the end, and rewrites only selected frontmatter keys in the header:

| Part | On save | On load, authoritative for |
|---|---|---|
| Frontmatter | kept; only `excalidraw-plugin` (and 2 export keys) rewritten in place | text mode, export options |
| User Markdown header, banner, `# Markdown Images` blocks | kept byte-for-byte (blocks only added/removed/bodies updated) | Markdown-image bodies |
| `## Text Elements` | regenerated from in-memory map | **raw text** of text elements (wins over JSON) |
| `## Element Links` | regenerated | **element.link** (wins over JSON) |
| `## Embedded Files` | regenerated (paths re-derived from resolved TFile) | **image source** per fileId (JSON has no source) |
| `## Drawing` JSON | regenerated, compressed per setting | everything else (geometry, style, ids, appState) |
| tail after final `%%` | dropped unless Zotero mode | - |

Rules for calternal (lossless):

1. Split the file into: `header` (bytes before the data marker, found with the
   same fallback order: `#\n%%\n# Excalidraw Data`, `%%\n# Excalidraw Data`,
   `# Excalidraw Data`, then `# Text Elements` forms, then `## Drawing`),
   `data` (sections), `drawing` (fence + JSON), `tail`.
2. Keep `header` and `tail` as opaque bytes. Change frontmatter only through a
   minimal in-place line edit, never by re-serialising YAML.
3. Keep unknown `## <heading>` sections inside the data block as opaque byte
   ranges in their original position. (The plugin itself would drop them on
   its next save; we keep them so calternal never is the one to lose data.)
4. Keep unknown JSON keys at every level (top level, `appState`, each
   element, `customData`, `files` entries). Parse into a model with a
   catch-all map, or patch the JSON. Keep `isDeleted: true` elements (the
   plugin writes deleted elements as tombstones for sync merges).
5. Keep the comment variant (1.2), heading levels (`#` vs `##`), and the
   compression choice of an existing file? §60 says new canvases use `json`;
   for existing compressed files, decide: either keep `compressed-json` on
   save (most lossless, other Obsidian users expect it) or decompress (better
   for Search and diffs). Recommend: decompress on first calternal save, keep
   everything else; this is exactly the plugin's own "Decompress" command, so
   the plugin reads it fine.
6. Ids: new text elements and new linked elements must get 8-char
   `[0-9A-Za-z]` ids, or the plugin renames them on open (harmless for the
   plugin but breaks our `?el=<element-id>` deep links and Yjs keys). Use the
   same id policy for every new element. Never rename existing ids.
7. Write Text Elements, Element Links and Embedded Files from the scene so the
   plugin sees consistent data; on read, apply them over the JSON as the
   plugin does (so edits made in Obsidian's Markdown view win).
8. Byte-for-byte no-op: if the scene did not change, do not rewrite the file.
   Golden tests: plugin-written fixtures (compressed, uncompressed, legacy
   `# Drawing`, commented-out text, card `#`, Markdown Images, colour map,
   LaTeX, URL images) must round-trip identically through load + save when no
   edit was made.

## 4. Plain `.excalidraw` JSON essentials

```
{ "type": "excalidraw", "version": 2, "source": "<url>",
  "elements": [...], "appState": {...}, "files": { "<fileId>": {...} } }
```

- `type` must be `"excalidraw"`; `version` 2; `source` is free text.
- Element common fields (preserve all, plus unknown ones): `id`, `type`,
  `x`, `y`, `width`, `height`, `angle`, `strokeColor`, `backgroundColor`,
  `fillStyle`, `strokeWidth`, `strokeStyle`, `roughness`, `opacity`,
  `groupIds`, `frameId`, `index` (fractional order key, Excalidraw >= 0.17;
  maps to our Yjs fractional order), `roundness`, `seed`, `version`,
  `versionNonce`, `isDeleted`, `boundElements`, `updated`, `link`, `locked`,
  `customData`.
- Type fields: text (`text`, `originalText`, `fontSize`, `fontFamily`,
  `textAlign`, `verticalAlign`, `containerId`, `autoResize`, `lineHeight`;
  plugin adds `rawText`, `hasTextLink`), linear/arrow (`points`,
  `startBinding`, `endBinding`, `startArrowhead`, `endArrowhead`, `elbowed`,
  `fixedSegments`...), freedraw (`points`, `pressures`, `simulatePressure`),
  image (`fileId`, `status`, `scale`, `crop`), embeddable/iframe (`link`,
  `customData`), frame (`name`).
- `files[id]`: `{ id, mimeType, dataURL, created, lastRetrieved }` (+ unknown).
- `appState`: Excalidraw saves a small subset (`gridSize`, `gridStep`,
  `gridModeEnabled`, `viewBackgroundColor`, `theme`, plus others). Keep all
  keys as received; do not inject view state such as scroll and zoom per
  user into the shared file.
- Plugin extras to keep: `rawText`, `customData.mermaidText`, Markdown-image
  `customData`, colour maps, plugin `source` URL.

## 5. Recommendation for calternal

**Store dropped images as separate Home files, not inline base64.** Write the
Embedded Files line as a vault-compatible wiki link, and keep the calternal
stable identity alongside it.

- Location: the user's attachment folder rule (same default as Notes
  attachments; reuse the existing Notes attachment logic, do not add a new
  one), name `Pasted image <timestamp>.png` style or the dropped file's name.
- Reference: `<fileId>: [[Pasted image 20261003.png]]` in Embedded Files
  (what Obsidian reads), and in the image element
  `customData.calternal = { "item": "<calternal-id>" }` (unknown to the
  plugin, kept by it because it preserves element JSON). On load resolve by
  calternal-id first, then by the wiki link. On a rename inside calternal,
  the server rewrites the wiki link text (the backlink index already knows
  the canvas references the item), so both Obsidian and calternal stay
  correct. A rename outside calternal (WebDAV, Obsidian) still resolves by
  calternal-id, and the next save repairs the link text.
- `.excalidraw` files opened from elsewhere keep their inline `files` on save
  (that format has no other place). Optional "Move images to Home" action.

Trade-offs:

| | Separate Home files + `[[link]]` (+ calternal-id) | Inline base64 in JSON |
|---|---|---|
| File size | `.md` stays small (KB); image once on disk | +33% base64 per image; MB-size `.md` |
| Sync (WebDAV, Yjs) | each canvas edit re-sends only small text; images sync once, de-duplicated | every save rewrites and re-uploads all image bytes; Yjs doc holds blobs |
| Search | canvas text indexes cleanly; images get the normal photo/OCR/file pipeline and show up as Files | base64 noise in the index unless stripped; images invisible to Files/Photos |
| History (§61) | per-save snapshots are text-small; image history is its own file history | every snapshot copies all images: storage grows fast |
| Obsidian compat | exactly what the plugin writes and expects | plugin converts to vault files on its first save anyway |
| Portability | needs the image files next to the canvas (WebDAV vault has them) | single self-contained file |
| Rename | wiki link breaks for external renames; mitigated by calternal-id fallback and repair | no rename problem |
| Security | images go through `calternal-fs` like any upload; per-item ACL applies to live cards and images alike (#472) | no separate ACL; whoever reads the canvas sees all images |

Self-contained export stays available: PNG/SVG with scene embedded (§60) and
"Export as .excalidraw" with inline `files`.

## Open points to verify

- Confirm on a real Obsidian vault that links inside `%%` blocks are indexed
  and updated on rename (expected yes).
- Confirm the plugin keeps unknown `customData` keys and unknown element keys
  after a round trip (expected yes, it stores the scene as received).
- Confirm plugin licence header (AGPL-3.0 "only" vs "or later") if reuse is
  ever considered.
## Format research (2026-10-03): read before building **Must-follow findings:** (1) element IDs we create are exactly 8 chars [0-9A-Za-z], or the Obsidian plugin renames them and breaks `?el=` links and Yjs keys; (2) keep header, unknown sections, unknown JSON keys and deleted elements byte-for-byte; never rewrite an unchanged scene; (3) an existing compressed-json file is written uncompressed on its first real change (same as the plugin's Decompress command); (4) images: see C6 (pending owner answer; recommendation = separate Home files listed in Embedded Files + calternal id in customData). Do not copy plugin code (AGPL-3.0 per LICENSE): write our own parser from this description. # Excalidraw file formats for calternal Canvas (DESIGN §60) Source read: zsviczian/obsidian-excalidraw-plugin, shallow clone of HEAD `f591b75` (2026-09-30), plugin manifest version 2.28.1. Files read: `src/shared/excalidrawMarkdownParsing.ts`, `src/shared/ExcalidrawData.ts` (`loadData`, `generateMDBase`, `syncFiles`, `syncElements`), `src/view/ExcalidrawView.ts` (save assembly), `src/constants/constants.ts`, `src/utils/sceneDataUtils.ts`, `src/core/managers/FileManager.ts`. This document describes behaviour only. No plugin code is copied. ## 0. Licence - The repo `LICENSE` file at HEAD is **GNU AGPL-3.0**. `package.json` still says `"license": "MIT"` (stale field). Treat the plugin as AGPL-3.0. - Excalidraw itself (`@excalidraw/excalidraw`) is MIT. The plugin uses a fork `@zsviczian/excalidraw`. - calternal is AGPL-3.0-only, so reuse would be licence-compatible only if the plugin is "or later"/plain AGPL-3.0 (the header does not say "only"; verify before any reuse). Recommendation: do not copy code. Implement a clean-room parser from this behavioural spec and from our own fixtures. ## 1. `.excalidraw.md` file structure Order of parts in a file the plugin writes (save = `header + generated + tail`): ``` --- <- frontmatter (part of "header", user-owned) excalidraw-plugin: parsed tags: [excalidraw] --- ==⚠ Switch to EXCALIDRAW VIEW ... ⚠== ... <- default banner, user-owned <any user Markdown: "back of the card" notes> <- header, user-owned # Markdown Images <- optional, new in 2.2x (see 1.6) <!-- excalidraw-markdown-image:<fileId> --> ...markdown body... <!-- /excalidraw-markdown-image:<fileId> --> %% <- comment opener (Obsidian hides it) # Excalidraw Data ## Text Elements <raw text of element> ^<8-char id> ## Element Links <8-char id>: <link> ## Embedded Files <40-hex fileId>: [[path/to/image.png]] <fileId>: https://example.com/a.png <fileId>: $$\LaTeX$$ <fileId>: markdown-image %% ## Drawing ```json { ...scene JSON, tab-indented... } ``` %% <optional "tail" text, kept only in Zotero-compatibility mode> ``` ### 1.1 Frontmatter - `excalidraw-plugin: parsed` (or `raw`) is the marker that makes Obsidian open the note in the Excalidraw view. `parsed`/`raw` is the text mode (whether text elements show transcluded/parsed text or raw Markdown). The plugin rewrites only this key (and, when the export dialog changed, `excalidraw-export-embed-scene` and `excalidraw-export-internal-links`). - Default new file also has `tags: [excalidraw]`. - Other recognised keys (all optional, all prefixed `excalidraw-`): `export-transparent`, `mask`, `export-dark`, `export-padding`, `export-svgpadding` (deprecated), `export-pngscale`, `export-embed-scene`, `export-internal-links`, `link-prefix`, `url-prefix`, `link-brackets`, `onload-script`, `linkbutton-opacity`, `default-mode`, `font`, `font-color`, `border-color`, `css`, `autoexport`, `iframe-theme`, `embeddable-theme`, `open-md`, `embed-md`. - calternal: detect the file by the key `excalidraw-plugin` (any value); keep every other frontmatter key and its formatting unchanged. ### 1.2 `%%` comment block - `%%` is Obsidian comment syntax. The plugin puts `%%` before `# Excalidraw Data` and again before `## Drawing`, and one `%%` after the code fence. Thus in Markdown view the data is hidden. - Variant: if the file had `%%\n# Excalidraw Data` it is "commented out" (state `textElementCommentedOut`); the writer then emits `%%` at the top of the data and does **not** emit the second `%%` before `## Drawing`. Older files have `%%` only before `## Drawing` (text elements visible in Markdown view). Preserve whichever variant the file had. - Card variant: a lone `#` line before `%%` ("back of card" separator) is preserved by the header extractor; there is also a repair for `text#\n%%`. ### 1.3 Text Elements - One entry per Excalidraw `text` element: `<raw text> ^<id>` followed by a blank line. `raw` is the Markdown source (may contain `[[links]]`, `![[transclusions]]`, URLs). The scene JSON holds the rendered text in `text`/`originalText` and the source in the plugin field `rawText`. - The reader matches `\s\^(.{8})\n+`: **ids are exactly 8 characters**. The plugin renames any text element or linked element whose id is longer than 8 chars (Excalidraw's default ids are ~20 chars) to an 8-char nanoid from `[0-9a-zA-Z]`, and updates bindings/containers/groups with it. Reason: Obsidian block references `^id` accept only safe characters, and short ids read better. - Optional dummy entry `^_dummy!_` (setting `addDummyTextElement`). - Legacy (< 2.0.26): non-text elements with a link also appeared here as `<link> ^<id>`; legacy inline form `%%***>>>text element-link:[[x]]<<<***%%`. - Text Elements are **authoritative for text** on load: the raw text from Markdown overwrites `rawText` and, after parsing, the displayed text. This is what lets a user edit drawing text in Markdown view. - Heading may be `# Text Elements` (old) or `## Text Elements`. ### 1.4 Element Links (since 2.0.26) - Lines `<8-char id>: <link>` (regex `^(.{8}):\s*(.*)$`), blank line between. - Links of non-text elements, and links of text elements when the link is not derived from the text. On load they overwrite `element.link`. - Heading `## Element Links` (old: `# Element Links`). Omitted if empty. ### 1.5 Embedded Files - Lines keyed by Excalidraw `fileId` (plugin ids are 40 hex chars; ids from excalidraw.com are other strings, matched by `[\w\d]*`): - vault file: `<fileId>: [[path|alias#page=3]]` optionally followed by a JSON colour map `{"#000":"#fff"}` (SVG recolouring). `![[...]]` also accepted on read. The path is written via Obsidian's shortest-link text for the file relative to the drawing (respects user link settings). - URL image: `<fileId>: https://...` (also `file://`, `ftp://`). - LaTeX: `<fileId>: $$...$$`. - local Markdown image: `<fileId>: markdown-image` (body lives in the header block, 1.6). - Mermaid: not listed; source is in the image element's `customData`. - Heading `## Embedded Files` (old: `# Embedded files`). Omitted if empty. ### 1.6 Markdown Images (new, 2.2x) - `# Markdown Images` heading in the user header, then blocks delimited by `<!-- excalidraw-markdown-image:<fileId> -->` ... `<!-- /... -->`. The plugin keeps blocks where the user placed them and only appends missing ones. Treat as opaque header content; do not render specially in v1. ### 1.7 Drawing block - `## Drawing` (old: `# Drawing`) then either - ```` ```json ```` + scene JSON (`JSON.stringify(scene, null, "\t")`) + ```` ``` ````, or - ```` ```compressed-json ```` + `LZString.compressToBase64(json)` split into 256-char lines separated by **blank lines** (`\n\n`); the reader strips every `\r`/`\n` and decompresses. - Compression is a user setting (`compress`, default **on** in recent versions). Users can run "Decompress current Excalidraw file". The plugin reads both. calternal must read both and writes `json` (§60). - Very old files: JSON without code fence on one line after `# Drawing` (fallback regex). The reader also cuts the JSON at the last `}` to survive sync merges. - Scene written: `{type, version, source, elements (incl. deleted), appState, files}`. In `.md` mode `files` is `{}` after the first save (see 2). ### 1.8 Minimal valid example (uncompressed, what calternal should write) ````markdown --- excalidraw-plugin: parsed tags: [excalidraw] --- %% # Excalidraw Data ## Text Elements Hello [[Note]] ^Ab3dE9xQ ## Embedded Files 5f1e0c9a2b7d4e6f8a1b3c5d7e9f0a2b4c6d8e0f: [[photo.png]] %% ## Drawing ```json { "type": "excalidraw", "version": 2, "source": "https://calternal.cloud", "elements": [ {"id":"Ab3dE9xQ","type":"text","x":0,"y":0,"width":120,"height":25, "angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent", "fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1, "opacity":100,"groupIds":[],"frameId":null,"index":"a0","roundness":null, "seed":1,"version":1,"versionNonce":1,"isDeleted":false, "boundElements":null,"updated":1,"link":null,"locked":false, "text":"Hello Note","rawText":"Hello [[Note]]","originalText":"Hello Note", "fontSize":20,"fontFamily":5,"textAlign":"left","verticalAlign":"top", "containerId":null,"autoResize":true,"lineHeight":1.25}, {"id":"Im9kQ2pL","type":"image","fileId":"5f1e0c9a2b7d4e6f8a1b3c5d7e9f0a2b4c6d8e0f", "status":"saved","scale":[1,1],"crop":null, "...common fields": "..."} ], "appState": {"gridSize": null, "viewBackgroundColor": "#ffffff"}, "files": {} } ``` %% ```` (The example image element is abbreviated; a real file has all common element fields.) The banner line is optional; we can omit it or write our own. ## 2. Images: inline dataURL vs. vault files - **`.excalidraw.md` (normal mode):** on every save `syncFiles` writes each new `files[id].dataURL` that has no known source to the vault as a separate file (pasted images use Obsidian's attachment naming/folder rules, recent PR #2964 uses `app.saveAttachment`), registers `<fileId>: [[name.png]]` in Embedded Files, then sets `scene.files = {}`. So `.md` files **never** keep inline images; the JSON keeps only `image` elements with `fileId`. - **`.excalidraw` (compatibility mode, `.excalidraw` extension):** saved as plain `JSON.stringify(scene, null, "\t")` with inline `files` dataURLs (same as excalidraw.com). - **What Obsidian users expect:** separate attachment files in the vault (visible in file explorer, reusable, de-duplicated, small `.md`). Inline base64 in a `.md` would look wrong to them and bloat sync. - **Rename of a vault image:** the Embedded Files line is an ordinary wiki link in the note body. Obsidian indexes links in the file (including the `%%` block, per Obsidian behaviour; verify on a test vault), so with "Automatically update internal links" Obsidian rewrites `[[old.png]]` to the new name. The plugin itself only handles rename of the drawing (moves its `.bak` and auto-exported PNG/SVG siblings), not of images. Links are resolved shortest-path (`getFirstLinkpathDest`), so a move to another folder with the same basename still resolves. If a rename happens outside Obsidian (WebDAV client, shell) nothing updates the link and the image shows as missing; the `fileId` in the JSON stays valid. ## 3. Derived vs. authoritative; round-tripping On save the plugin regenerates everything from `%%`/`# Excalidraw Data` to the end, and rewrites only selected frontmatter keys in the header: | Part | On save | On load, authoritative for | |---|---|---| | Frontmatter | kept; only `excalidraw-plugin` (and 2 export keys) rewritten in place | text mode, export options | | User Markdown header, banner, `# Markdown Images` blocks | kept byte-for-byte (blocks only added/removed/bodies updated) | Markdown-image bodies | | `## Text Elements` | regenerated from in-memory map | **raw text** of text elements (wins over JSON) | | `## Element Links` | regenerated | **element.link** (wins over JSON) | | `## Embedded Files` | regenerated (paths re-derived from resolved TFile) | **image source** per fileId (JSON has no source) | | `## Drawing` JSON | regenerated, compressed per setting | everything else (geometry, style, ids, appState) | | tail after final `%%` | dropped unless Zotero mode | - | Rules for calternal (lossless): 1. Split the file into: `header` (bytes before the data marker, found with the same fallback order: `#\n%%\n# Excalidraw Data`, `%%\n# Excalidraw Data`, `# Excalidraw Data`, then `# Text Elements` forms, then `## Drawing`), `data` (sections), `drawing` (fence + JSON), `tail`. 2. Keep `header` and `tail` as opaque bytes. Change frontmatter only through a minimal in-place line edit, never by re-serialising YAML. 3. Keep unknown `## <heading>` sections inside the data block as opaque byte ranges in their original position. (The plugin itself would drop them on its next save; we keep them so calternal never is the one to lose data.) 4. Keep unknown JSON keys at every level (top level, `appState`, each element, `customData`, `files` entries). Parse into a model with a catch-all map, or patch the JSON. Keep `isDeleted: true` elements (the plugin writes deleted elements as tombstones for sync merges). 5. Keep the comment variant (1.2), heading levels (`#` vs `##`), and the compression choice of an existing file? §60 says new canvases use `json`; for existing compressed files, decide: either keep `compressed-json` on save (most lossless, other Obsidian users expect it) or decompress (better for Search and diffs). Recommend: decompress on first calternal save, keep everything else; this is exactly the plugin's own "Decompress" command, so the plugin reads it fine. 6. Ids: new text elements and new linked elements must get 8-char `[0-9A-Za-z]` ids, or the plugin renames them on open (harmless for the plugin but breaks our `?el=<element-id>` deep links and Yjs keys). Use the same id policy for every new element. Never rename existing ids. 7. Write Text Elements, Element Links and Embedded Files from the scene so the plugin sees consistent data; on read, apply them over the JSON as the plugin does (so edits made in Obsidian's Markdown view win). 8. Byte-for-byte no-op: if the scene did not change, do not rewrite the file. Golden tests: plugin-written fixtures (compressed, uncompressed, legacy `# Drawing`, commented-out text, card `#`, Markdown Images, colour map, LaTeX, URL images) must round-trip identically through load + save when no edit was made. ## 4. Plain `.excalidraw` JSON essentials ``` { "type": "excalidraw", "version": 2, "source": "<url>", "elements": [...], "appState": {...}, "files": { "<fileId>": {...} } } ``` - `type` must be `"excalidraw"`; `version` 2; `source` is free text. - Element common fields (preserve all, plus unknown ones): `id`, `type`, `x`, `y`, `width`, `height`, `angle`, `strokeColor`, `backgroundColor`, `fillStyle`, `strokeWidth`, `strokeStyle`, `roughness`, `opacity`, `groupIds`, `frameId`, `index` (fractional order key, Excalidraw >= 0.17; maps to our Yjs fractional order), `roundness`, `seed`, `version`, `versionNonce`, `isDeleted`, `boundElements`, `updated`, `link`, `locked`, `customData`. - Type fields: text (`text`, `originalText`, `fontSize`, `fontFamily`, `textAlign`, `verticalAlign`, `containerId`, `autoResize`, `lineHeight`; plugin adds `rawText`, `hasTextLink`), linear/arrow (`points`, `startBinding`, `endBinding`, `startArrowhead`, `endArrowhead`, `elbowed`, `fixedSegments`...), freedraw (`points`, `pressures`, `simulatePressure`), image (`fileId`, `status`, `scale`, `crop`), embeddable/iframe (`link`, `customData`), frame (`name`). - `files[id]`: `{ id, mimeType, dataURL, created, lastRetrieved }` (+ unknown). - `appState`: Excalidraw saves a small subset (`gridSize`, `gridStep`, `gridModeEnabled`, `viewBackgroundColor`, `theme`, plus others). Keep all keys as received; do not inject view state such as scroll and zoom per user into the shared file. - Plugin extras to keep: `rawText`, `customData.mermaidText`, Markdown-image `customData`, colour maps, plugin `source` URL. ## 5. Recommendation for calternal **Store dropped images as separate Home files, not inline base64.** Write the Embedded Files line as a vault-compatible wiki link, and keep the calternal stable identity alongside it. - Location: the user's attachment folder rule (same default as Notes attachments; reuse the existing Notes attachment logic, do not add a new one), name `Pasted image <timestamp>.png` style or the dropped file's name. - Reference: `<fileId>: [[Pasted image 20261003.png]]` in Embedded Files (what Obsidian reads), and in the image element `customData.calternal = { "item": "<calternal-id>" }` (unknown to the plugin, kept by it because it preserves element JSON). On load resolve by calternal-id first, then by the wiki link. On a rename inside calternal, the server rewrites the wiki link text (the backlink index already knows the canvas references the item), so both Obsidian and calternal stay correct. A rename outside calternal (WebDAV, Obsidian) still resolves by calternal-id, and the next save repairs the link text. - `.excalidraw` files opened from elsewhere keep their inline `files` on save (that format has no other place). Optional "Move images to Home" action. Trade-offs: | | Separate Home files + `[[link]]` (+ calternal-id) | Inline base64 in JSON | |---|---|---| | File size | `.md` stays small (KB); image once on disk | +33% base64 per image; MB-size `.md` | | Sync (WebDAV, Yjs) | each canvas edit re-sends only small text; images sync once, de-duplicated | every save rewrites and re-uploads all image bytes; Yjs doc holds blobs | | Search | canvas text indexes cleanly; images get the normal photo/OCR/file pipeline and show up as Files | base64 noise in the index unless stripped; images invisible to Files/Photos | | History (§61) | per-save snapshots are text-small; image history is its own file history | every snapshot copies all images: storage grows fast | | Obsidian compat | exactly what the plugin writes and expects | plugin converts to vault files on its first save anyway | | Portability | needs the image files next to the canvas (WebDAV vault has them) | single self-contained file | | Rename | wiki link breaks for external renames; mitigated by calternal-id fallback and repair | no rename problem | | Security | images go through `calternal-fs` like any upload; per-item ACL applies to live cards and images alike (#472) | no separate ACL; whoever reads the canvas sees all images | Self-contained export stays available: PNG/SVG with scene embedded (§60) and "Export as .excalidraw" with inline `files`. ## Open points to verify - Confirm on a real Obsidian vault that links inside `%%` blocks are indexed and updated on rename (expected yes). - Confirm the plugin keeps unknown `customData` keys and unknown element keys after a round trip (expected yes, it stores the scene as received). - Confirm plugin licence header (AGPL-3.0 "only" vs "or later") if reuse is ever considered.
Author
Owner

Format research (2026-10-03): read before building

Must-follow findings: (1) element IDs we create are exactly 8 chars [0-9A-Za-z], or the Obsidian plugin renames them and breaks ?el= links and Yjs keys; (2) keep header, unknown sections, unknown JSON keys and deleted elements byte-for-byte; never rewrite an unchanged scene; (3) an existing compressed-json file is written uncompressed on its first real change (same as the plugin's Decompress command); (4) images: see C6 (pending owner answer; recommendation = separate Home files listed in Embedded Files + calternal id in customData). Do not copy plugin code (AGPL-3.0 per LICENSE): write our own parser from this description.

Excalidraw file formats for calternal Canvas (DESIGN §60)

Source read: zsviczian/obsidian-excalidraw-plugin, shallow clone of HEAD
f591b75 (2026-09-30), plugin manifest version 2.28.1. Files read:
src/shared/excalidrawMarkdownParsing.ts, src/shared/ExcalidrawData.ts
(loadData, generateMDBase, syncFiles, syncElements),
src/view/ExcalidrawView.ts (save assembly), src/constants/constants.ts,
src/utils/sceneDataUtils.ts, src/core/managers/FileManager.ts.
This document describes behaviour only. No plugin code is copied.

0. Licence

  • The repo LICENSE file at HEAD is GNU AGPL-3.0. package.json still
    says "license": "MIT" (stale field). Treat the plugin as AGPL-3.0.
  • Excalidraw itself (@excalidraw/excalidraw) is MIT. The plugin uses a fork
    @zsviczian/excalidraw.
  • calternal is AGPL-3.0-only, so reuse would be licence-compatible only if
    the plugin is "or later"/plain AGPL-3.0 (the header does not say "only";
    verify before any reuse). Recommendation: do not copy code. Implement a
    clean-room parser from this behavioural spec and from our own fixtures.

1. .excalidraw.md file structure

Order of parts in a file the plugin writes (save = header + generated + tail):

---                                  <- frontmatter (part of "header", user-owned)
excalidraw-plugin: parsed
tags: [excalidraw]
---
==⚠  Switch to EXCALIDRAW VIEW ... ⚠== ...   <- default banner, user-owned

<any user Markdown: "back of the card" notes>   <- header, user-owned
# Markdown Images                    <- optional, new in 2.2x (see 1.6)
<!-- excalidraw-markdown-image:<fileId> -->
...markdown body...
<!-- /excalidraw-markdown-image:<fileId> -->

%%                                   <- comment opener (Obsidian hides it)
# Excalidraw Data

## Text Elements
<raw text of element> ^<8-char id>

## Element Links
<8-char id>: <link>

## Embedded Files
<40-hex fileId>: [[path/to/image.png]]
<fileId>: https://example.com/a.png
<fileId>: $$\LaTeX$$
<fileId>: markdown-image

%%
## Drawing
```json
{ ...scene JSON, tab-indented... }

%%
<optional "tail" text, kept only in Zotero-compatibility mode>


### 1.1 Frontmatter

- `excalidraw-plugin: parsed` (or `raw`) is the marker that makes Obsidian
  open the note in the Excalidraw view. `parsed`/`raw` is the text mode
  (whether text elements show transcluded/parsed text or raw Markdown).
  The plugin rewrites only this key (and, when the export dialog changed,
  `excalidraw-export-embed-scene` and `excalidraw-export-internal-links`).
- Default new file also has `tags: [excalidraw]`.
- Other recognised keys (all optional, all prefixed `excalidraw-`):
  `export-transparent`, `mask`, `export-dark`, `export-padding`,
  `export-svgpadding` (deprecated), `export-pngscale`, `export-embed-scene`,
  `export-internal-links`, `link-prefix`, `url-prefix`, `link-brackets`,
  `onload-script`, `linkbutton-opacity`, `default-mode`, `font`,
  `font-color`, `border-color`, `css`, `autoexport`, `iframe-theme`,
  `embeddable-theme`, `open-md`, `embed-md`.
- calternal: detect the file by the key `excalidraw-plugin` (any value);
  keep every other frontmatter key and its formatting unchanged.

### 1.2 `%%` comment block

- `%%` is Obsidian comment syntax. The plugin puts `%%` before
  `# Excalidraw Data` and again before `## Drawing`, and one `%%` after the
  code fence. Thus in Markdown view the data is hidden.
- Variant: if the file had `%%\n# Excalidraw Data` it is "commented out"
  (state `textElementCommentedOut`); the writer then emits `%%` at the top of
  the data and does **not** emit the second `%%` before `## Drawing`. Older
  files have `%%` only before `## Drawing` (text elements visible in
  Markdown view). Preserve whichever variant the file had.
- Card variant: a lone `#` line before `%%` ("back of card" separator) is
  preserved by the header extractor; there is also a repair for `text#\n%%`.

### 1.3 Text Elements

- One entry per Excalidraw `text` element: `<raw text> ^<id>` followed by a
  blank line. `raw` is the Markdown source (may contain `[[links]]`,
  `![[transclusions]]`, URLs). The scene JSON holds the rendered text in
  `text`/`originalText` and the source in the plugin field `rawText`.
- The reader matches `\s\^(.{8})\n+`: **ids are exactly 8 characters**. The
  plugin renames any text element or linked element whose id is longer than
  8 chars (Excalidraw's default ids are ~20 chars) to an 8-char nanoid from
  `[0-9a-zA-Z]`, and updates bindings/containers/groups with it. Reason:
  Obsidian block references `^id` accept only safe characters, and short ids
  read better.
- Optional dummy entry `^_dummy!_` (setting `addDummyTextElement`).
- Legacy (< 2.0.26): non-text elements with a link also appeared here as
  `<link> ^<id>`; legacy inline form `%%***>>>text element-link:[[x]]<<<***%%`.
- Text Elements are **authoritative for text** on load: the raw text from
  Markdown overwrites `rawText` and, after parsing, the displayed text. This
  is what lets a user edit drawing text in Markdown view.
- Heading may be `# Text Elements` (old) or `## Text Elements`.

### 1.4 Element Links (since 2.0.26)

- Lines `<8-char id>: <link>` (regex `^(.{8}):\s*(.*)$`), blank line between.
- Links of non-text elements, and links of text elements when the link is not
  derived from the text. On load they overwrite `element.link`.
- Heading `## Element Links` (old: `# Element Links`). Omitted if empty.

### 1.5 Embedded Files

- Lines keyed by Excalidraw `fileId` (plugin ids are 40 hex chars; ids from
  excalidraw.com are other strings, matched by `[\w\d]*`):
  - vault file: `<fileId>: [[path|alias#page=3]]` optionally followed by a
    JSON colour map `{"#000":"#fff"}` (SVG recolouring). `![[...]]` also
    accepted on read. The path is written via Obsidian's shortest-link text
    for the file relative to the drawing (respects user link settings).
  - URL image: `<fileId>: https://...` (also `file://`, `ftp://`).
  - LaTeX: `<fileId>: $$...$$`.
  - local Markdown image: `<fileId>: markdown-image` (body lives in the
    header block, 1.6).
  - Mermaid: not listed; source is in the image element's `customData`.
- Heading `## Embedded Files` (old: `# Embedded files`). Omitted if empty.

### 1.6 Markdown Images (new, 2.2x)

- `# Markdown Images` heading in the user header, then blocks delimited by
  `<!-- excalidraw-markdown-image:<fileId> -->` ... `<!-- /... -->`.
  The plugin keeps blocks where the user placed them and only appends missing
  ones. Treat as opaque header content; do not render specially in v1.

### 1.7 Drawing block

- `## Drawing` (old: `# Drawing`) then either
  - ```` ```json ```` + scene JSON (`JSON.stringify(scene, null, "\t")`) +
    ```` ``` ````, or
  - ```` ```compressed-json ```` + `LZString.compressToBase64(json)` split
    into 256-char lines separated by **blank lines** (`\n\n`); the reader
    strips every `\r`/`\n` and decompresses.
- Compression is a user setting (`compress`, default **on** in recent
  versions). Users can run "Decompress current Excalidraw file". The plugin
  reads both. calternal must read both and writes `json` (§60).
- Very old files: JSON without code fence on one line after `# Drawing`
  (fallback regex). The reader also cuts the JSON at the last `}` to survive
  sync merges.
- Scene written: `{type, version, source, elements (incl. deleted), appState,
  files}`. In `.md` mode `files` is `{}` after the first save (see 2).

### 1.8 Minimal valid example (uncompressed, what calternal should write)

````markdown
---

excalidraw-plugin: parsed
tags: [excalidraw]

---

%%
# Excalidraw Data

## Text Elements
Hello [[Note]] ^Ab3dE9xQ

## Embedded Files
5f1e0c9a2b7d4e6f8a1b3c5d7e9f0a2b4c6d8e0f: [[photo.png]]

%%
## Drawing
```json
{
	"type": "excalidraw",
	"version": 2,
	"source": "https://calternal.cloud",
	"elements": [
		{"id":"Ab3dE9xQ","type":"text","x":0,"y":0,"width":120,"height":25,
		 "angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent",
		 "fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,
		 "opacity":100,"groupIds":[],"frameId":null,"index":"a0","roundness":null,
		 "seed":1,"version":1,"versionNonce":1,"isDeleted":false,
		 "boundElements":null,"updated":1,"link":null,"locked":false,
		 "text":"Hello Note","rawText":"Hello [[Note]]","originalText":"Hello Note",
		 "fontSize":20,"fontFamily":5,"textAlign":"left","verticalAlign":"top",
		 "containerId":null,"autoResize":true,"lineHeight":1.25},
		{"id":"Im9kQ2pL","type":"image","fileId":"5f1e0c9a2b7d4e6f8a1b3c5d7e9f0a2b4c6d8e0f",
		 "status":"saved","scale":[1,1],"crop":null, "...common fields": "..."}
	],
	"appState": {"gridSize": null, "viewBackgroundColor": "#ffffff"},
	"files": {}
}

%%


(The example image element is abbreviated; a real file has all common
element fields.) The banner line is optional; we can omit it or write our own.

## 2. Images: inline dataURL vs. vault files

- **`.excalidraw.md` (normal mode):** on every save `syncFiles` writes each
  new `files[id].dataURL` that has no known source to the vault as a separate
  file (pasted images use Obsidian's attachment naming/folder rules, recent
  PR #2964 uses `app.saveAttachment`), registers `<fileId>: [[name.png]]` in
  Embedded Files, then sets `scene.files = {}`. So `.md` files **never** keep
  inline images; the JSON keeps only `image` elements with `fileId`.
- **`.excalidraw` (compatibility mode, `.excalidraw` extension):** saved as
  plain `JSON.stringify(scene, null, "\t")` with inline `files` dataURLs
  (same as excalidraw.com).
- **What Obsidian users expect:** separate attachment files in the vault
  (visible in file explorer, reusable, de-duplicated, small `.md`). Inline
  base64 in a `.md` would look wrong to them and bloat sync.
- **Rename of a vault image:** the Embedded Files line is an ordinary wiki
  link in the note body. Obsidian indexes links in the file (including the
  `%%` block, per Obsidian behaviour; verify on a test vault), so with
  "Automatically update internal links" Obsidian rewrites `[[old.png]]` to
  the new name. The plugin itself only handles rename of the drawing (moves
  its `.bak` and auto-exported PNG/SVG siblings), not of images. Links are
  resolved shortest-path (`getFirstLinkpathDest`), so a move to another
  folder with the same basename still resolves. If a rename happens outside
  Obsidian (WebDAV client, shell) nothing updates the link and the image
  shows as missing; the `fileId` in the JSON stays valid.

## 3. Derived vs. authoritative; round-tripping

On save the plugin regenerates everything from `%%`/`# Excalidraw Data` to
the end, and rewrites only selected frontmatter keys in the header:

| Part | On save | On load, authoritative for |
|---|---|---|
| Frontmatter | kept; only `excalidraw-plugin` (and 2 export keys) rewritten in place | text mode, export options |
| User Markdown header, banner, `# Markdown Images` blocks | kept byte-for-byte (blocks only added/removed/bodies updated) | Markdown-image bodies |
| `## Text Elements` | regenerated from in-memory map | **raw text** of text elements (wins over JSON) |
| `## Element Links` | regenerated | **element.link** (wins over JSON) |
| `## Embedded Files` | regenerated (paths re-derived from resolved TFile) | **image source** per fileId (JSON has no source) |
| `## Drawing` JSON | regenerated, compressed per setting | everything else (geometry, style, ids, appState) |
| tail after final `%%` | dropped unless Zotero mode | - |

Rules for calternal (lossless):

1. Split the file into: `header` (bytes before the data marker, found with the
   same fallback order: `#\n%%\n# Excalidraw Data`, `%%\n# Excalidraw Data`,
   `# Excalidraw Data`, then `# Text Elements` forms, then `## Drawing`),
   `data` (sections), `drawing` (fence + JSON), `tail`.
2. Keep `header` and `tail` as opaque bytes. Change frontmatter only through a
   minimal in-place line edit, never by re-serialising YAML.
3. Keep unknown `## <heading>` sections inside the data block as opaque byte
   ranges in their original position. (The plugin itself would drop them on
   its next save; we keep them so calternal never is the one to lose data.)
4. Keep unknown JSON keys at every level (top level, `appState`, each
   element, `customData`, `files` entries). Parse into a model with a
   catch-all map, or patch the JSON. Keep `isDeleted: true` elements (the
   plugin writes deleted elements as tombstones for sync merges).
5. Keep the comment variant (1.2), heading levels (`#` vs `##`), and the
   compression choice of an existing file? §60 says new canvases use `json`;
   for existing compressed files, decide: either keep `compressed-json` on
   save (most lossless, other Obsidian users expect it) or decompress (better
   for Search and diffs). Recommend: decompress on first calternal save, keep
   everything else; this is exactly the plugin's own "Decompress" command, so
   the plugin reads it fine.
6. Ids: new text elements and new linked elements must get 8-char
   `[0-9A-Za-z]` ids, or the plugin renames them on open (harmless for the
   plugin but breaks our `?el=<element-id>` deep links and Yjs keys). Use the
   same id policy for every new element. Never rename existing ids.
7. Write Text Elements, Element Links and Embedded Files from the scene so the
   plugin sees consistent data; on read, apply them over the JSON as the
   plugin does (so edits made in Obsidian's Markdown view win).
8. Byte-for-byte no-op: if the scene did not change, do not rewrite the file.
   Golden tests: plugin-written fixtures (compressed, uncompressed, legacy
   `# Drawing`, commented-out text, card `#`, Markdown Images, colour map,
   LaTeX, URL images) must round-trip identically through load + save when no
   edit was made.

## 4. Plain `.excalidraw` JSON essentials

```
{ "type": "excalidraw", "version": 2, "source": "<url>",
  "elements": [...], "appState": {...}, "files": { "<fileId>": {...} } }
```

- `type` must be `"excalidraw"`; `version` 2; `source` is free text.
- Element common fields (preserve all, plus unknown ones): `id`, `type`,
  `x`, `y`, `width`, `height`, `angle`, `strokeColor`, `backgroundColor`,
  `fillStyle`, `strokeWidth`, `strokeStyle`, `roughness`, `opacity`,
  `groupIds`, `frameId`, `index` (fractional order key, Excalidraw >= 0.17;
  maps to our Yjs fractional order), `roundness`, `seed`, `version`,
  `versionNonce`, `isDeleted`, `boundElements`, `updated`, `link`, `locked`,
  `customData`.
- Type fields: text (`text`, `originalText`, `fontSize`, `fontFamily`,
  `textAlign`, `verticalAlign`, `containerId`, `autoResize`, `lineHeight`;
  plugin adds `rawText`, `hasTextLink`), linear/arrow (`points`,
  `startBinding`, `endBinding`, `startArrowhead`, `endArrowhead`, `elbowed`,
  `fixedSegments`...), freedraw (`points`, `pressures`, `simulatePressure`),
  image (`fileId`, `status`, `scale`, `crop`), embeddable/iframe (`link`,
  `customData`), frame (`name`).
- `files[id]`: `{ id, mimeType, dataURL, created, lastRetrieved }` (+ unknown).
- `appState`: Excalidraw saves a small subset (`gridSize`, `gridStep`,
  `gridModeEnabled`, `viewBackgroundColor`, `theme`, plus others). Keep all
  keys as received; do not inject view state such as scroll and zoom per
  user into the shared file.
- Plugin extras to keep: `rawText`, `customData.mermaidText`, Markdown-image
  `customData`, colour maps, plugin `source` URL.

## 5. Recommendation for calternal

**Store dropped images as separate Home files, not inline base64.** Write the
Embedded Files line as a vault-compatible wiki link, and keep the calternal
stable identity alongside it.

- Location: the user's attachment folder rule (same default as Notes
  attachments; reuse the existing Notes attachment logic, do not add a new
  one), name `Pasted image <timestamp>.png` style or the dropped file's name.
- Reference: `<fileId>: [[Pasted image 20261003.png]]` in Embedded Files
  (what Obsidian reads), and in the image element
  `customData.calternal = { "item": "<calternal-id>" }` (unknown to the
  plugin, kept by it because it preserves element JSON). On load resolve by
  calternal-id first, then by the wiki link. On a rename inside calternal,
  the server rewrites the wiki link text (the backlink index already knows
  the canvas references the item), so both Obsidian and calternal stay
  correct. A rename outside calternal (WebDAV, Obsidian) still resolves by
  calternal-id, and the next save repairs the link text.
- `.excalidraw` files opened from elsewhere keep their inline `files` on save
  (that format has no other place). Optional "Move images to Home" action.

Trade-offs:

| | Separate Home files + `[[link]]` (+ calternal-id) | Inline base64 in JSON |
|---|---|---|
| File size | `.md` stays small (KB); image once on disk | +33% base64 per image; MB-size `.md` |
| Sync (WebDAV, Yjs) | each canvas edit re-sends only small text; images sync once, de-duplicated | every save rewrites and re-uploads all image bytes; Yjs doc holds blobs |
| Search | canvas text indexes cleanly; images get the normal photo/OCR/file pipeline and show up as Files | base64 noise in the index unless stripped; images invisible to Files/Photos |
| History (§61) | per-save snapshots are text-small; image history is its own file history | every snapshot copies all images: storage grows fast |
| Obsidian compat | exactly what the plugin writes and expects | plugin converts to vault files on its first save anyway |
| Portability | needs the image files next to the canvas (WebDAV vault has them) | single self-contained file |
| Rename | wiki link breaks for external renames; mitigated by calternal-id fallback and repair | no rename problem |
| Security | images go through `calternal-fs` like any upload; per-item ACL applies to live cards and images alike (#472) | no separate ACL; whoever reads the canvas sees all images |

Self-contained export stays available: PNG/SVG with scene embedded (§60) and
"Export as .excalidraw" with inline `files`.

## Open points to verify

- Confirm on a real Obsidian vault that links inside `%%` blocks are indexed
  and updated on rename (expected yes).
- Confirm the plugin keeps unknown `customData` keys and unknown element keys
  after a round trip (expected yes, it stores the scene as received).
- Confirm plugin licence header (AGPL-3.0 "only" vs "or later") if reuse is
  ever considered.
## Format research (2026-10-03): read before building **Must-follow findings:** (1) element IDs we create are exactly 8 chars [0-9A-Za-z], or the Obsidian plugin renames them and breaks `?el=` links and Yjs keys; (2) keep header, unknown sections, unknown JSON keys and deleted elements byte-for-byte; never rewrite an unchanged scene; (3) an existing compressed-json file is written uncompressed on its first real change (same as the plugin's Decompress command); (4) images: see C6 (pending owner answer; recommendation = separate Home files listed in Embedded Files + calternal id in customData). Do not copy plugin code (AGPL-3.0 per LICENSE): write our own parser from this description. # Excalidraw file formats for calternal Canvas (DESIGN §60) Source read: zsviczian/obsidian-excalidraw-plugin, shallow clone of HEAD `f591b75` (2026-09-30), plugin manifest version 2.28.1. Files read: `src/shared/excalidrawMarkdownParsing.ts`, `src/shared/ExcalidrawData.ts` (`loadData`, `generateMDBase`, `syncFiles`, `syncElements`), `src/view/ExcalidrawView.ts` (save assembly), `src/constants/constants.ts`, `src/utils/sceneDataUtils.ts`, `src/core/managers/FileManager.ts`. This document describes behaviour only. No plugin code is copied. ## 0. Licence - The repo `LICENSE` file at HEAD is **GNU AGPL-3.0**. `package.json` still says `"license": "MIT"` (stale field). Treat the plugin as AGPL-3.0. - Excalidraw itself (`@excalidraw/excalidraw`) is MIT. The plugin uses a fork `@zsviczian/excalidraw`. - calternal is AGPL-3.0-only, so reuse would be licence-compatible only if the plugin is "or later"/plain AGPL-3.0 (the header does not say "only"; verify before any reuse). Recommendation: do not copy code. Implement a clean-room parser from this behavioural spec and from our own fixtures. ## 1. `.excalidraw.md` file structure Order of parts in a file the plugin writes (save = `header + generated + tail`): ``` --- <- frontmatter (part of "header", user-owned) excalidraw-plugin: parsed tags: [excalidraw] --- ==⚠ Switch to EXCALIDRAW VIEW ... ⚠== ... <- default banner, user-owned <any user Markdown: "back of the card" notes> <- header, user-owned # Markdown Images <- optional, new in 2.2x (see 1.6) <!-- excalidraw-markdown-image:<fileId> --> ...markdown body... <!-- /excalidraw-markdown-image:<fileId> --> %% <- comment opener (Obsidian hides it) # Excalidraw Data ## Text Elements <raw text of element> ^<8-char id> ## Element Links <8-char id>: <link> ## Embedded Files <40-hex fileId>: [[path/to/image.png]] <fileId>: https://example.com/a.png <fileId>: $$\LaTeX$$ <fileId>: markdown-image %% ## Drawing ```json { ...scene JSON, tab-indented... } ``` %% <optional "tail" text, kept only in Zotero-compatibility mode> ``` ### 1.1 Frontmatter - `excalidraw-plugin: parsed` (or `raw`) is the marker that makes Obsidian open the note in the Excalidraw view. `parsed`/`raw` is the text mode (whether text elements show transcluded/parsed text or raw Markdown). The plugin rewrites only this key (and, when the export dialog changed, `excalidraw-export-embed-scene` and `excalidraw-export-internal-links`). - Default new file also has `tags: [excalidraw]`. - Other recognised keys (all optional, all prefixed `excalidraw-`): `export-transparent`, `mask`, `export-dark`, `export-padding`, `export-svgpadding` (deprecated), `export-pngscale`, `export-embed-scene`, `export-internal-links`, `link-prefix`, `url-prefix`, `link-brackets`, `onload-script`, `linkbutton-opacity`, `default-mode`, `font`, `font-color`, `border-color`, `css`, `autoexport`, `iframe-theme`, `embeddable-theme`, `open-md`, `embed-md`. - calternal: detect the file by the key `excalidraw-plugin` (any value); keep every other frontmatter key and its formatting unchanged. ### 1.2 `%%` comment block - `%%` is Obsidian comment syntax. The plugin puts `%%` before `# Excalidraw Data` and again before `## Drawing`, and one `%%` after the code fence. Thus in Markdown view the data is hidden. - Variant: if the file had `%%\n# Excalidraw Data` it is "commented out" (state `textElementCommentedOut`); the writer then emits `%%` at the top of the data and does **not** emit the second `%%` before `## Drawing`. Older files have `%%` only before `## Drawing` (text elements visible in Markdown view). Preserve whichever variant the file had. - Card variant: a lone `#` line before `%%` ("back of card" separator) is preserved by the header extractor; there is also a repair for `text#\n%%`. ### 1.3 Text Elements - One entry per Excalidraw `text` element: `<raw text> ^<id>` followed by a blank line. `raw` is the Markdown source (may contain `[[links]]`, `![[transclusions]]`, URLs). The scene JSON holds the rendered text in `text`/`originalText` and the source in the plugin field `rawText`. - The reader matches `\s\^(.{8})\n+`: **ids are exactly 8 characters**. The plugin renames any text element or linked element whose id is longer than 8 chars (Excalidraw's default ids are ~20 chars) to an 8-char nanoid from `[0-9a-zA-Z]`, and updates bindings/containers/groups with it. Reason: Obsidian block references `^id` accept only safe characters, and short ids read better. - Optional dummy entry `^_dummy!_` (setting `addDummyTextElement`). - Legacy (< 2.0.26): non-text elements with a link also appeared here as `<link> ^<id>`; legacy inline form `%%***>>>text element-link:[[x]]<<<***%%`. - Text Elements are **authoritative for text** on load: the raw text from Markdown overwrites `rawText` and, after parsing, the displayed text. This is what lets a user edit drawing text in Markdown view. - Heading may be `# Text Elements` (old) or `## Text Elements`. ### 1.4 Element Links (since 2.0.26) - Lines `<8-char id>: <link>` (regex `^(.{8}):\s*(.*)$`), blank line between. - Links of non-text elements, and links of text elements when the link is not derived from the text. On load they overwrite `element.link`. - Heading `## Element Links` (old: `# Element Links`). Omitted if empty. ### 1.5 Embedded Files - Lines keyed by Excalidraw `fileId` (plugin ids are 40 hex chars; ids from excalidraw.com are other strings, matched by `[\w\d]*`): - vault file: `<fileId>: [[path|alias#page=3]]` optionally followed by a JSON colour map `{"#000":"#fff"}` (SVG recolouring). `![[...]]` also accepted on read. The path is written via Obsidian's shortest-link text for the file relative to the drawing (respects user link settings). - URL image: `<fileId>: https://...` (also `file://`, `ftp://`). - LaTeX: `<fileId>: $$...$$`. - local Markdown image: `<fileId>: markdown-image` (body lives in the header block, 1.6). - Mermaid: not listed; source is in the image element's `customData`. - Heading `## Embedded Files` (old: `# Embedded files`). Omitted if empty. ### 1.6 Markdown Images (new, 2.2x) - `# Markdown Images` heading in the user header, then blocks delimited by `<!-- excalidraw-markdown-image:<fileId> -->` ... `<!-- /... -->`. The plugin keeps blocks where the user placed them and only appends missing ones. Treat as opaque header content; do not render specially in v1. ### 1.7 Drawing block - `## Drawing` (old: `# Drawing`) then either - ```` ```json ```` + scene JSON (`JSON.stringify(scene, null, "\t")`) + ```` ``` ````, or - ```` ```compressed-json ```` + `LZString.compressToBase64(json)` split into 256-char lines separated by **blank lines** (`\n\n`); the reader strips every `\r`/`\n` and decompresses. - Compression is a user setting (`compress`, default **on** in recent versions). Users can run "Decompress current Excalidraw file". The plugin reads both. calternal must read both and writes `json` (§60). - Very old files: JSON without code fence on one line after `# Drawing` (fallback regex). The reader also cuts the JSON at the last `}` to survive sync merges. - Scene written: `{type, version, source, elements (incl. deleted), appState, files}`. In `.md` mode `files` is `{}` after the first save (see 2). ### 1.8 Minimal valid example (uncompressed, what calternal should write) ````markdown --- excalidraw-plugin: parsed tags: [excalidraw] --- %% # Excalidraw Data ## Text Elements Hello [[Note]] ^Ab3dE9xQ ## Embedded Files 5f1e0c9a2b7d4e6f8a1b3c5d7e9f0a2b4c6d8e0f: [[photo.png]] %% ## Drawing ```json { "type": "excalidraw", "version": 2, "source": "https://calternal.cloud", "elements": [ {"id":"Ab3dE9xQ","type":"text","x":0,"y":0,"width":120,"height":25, "angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent", "fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1, "opacity":100,"groupIds":[],"frameId":null,"index":"a0","roundness":null, "seed":1,"version":1,"versionNonce":1,"isDeleted":false, "boundElements":null,"updated":1,"link":null,"locked":false, "text":"Hello Note","rawText":"Hello [[Note]]","originalText":"Hello Note", "fontSize":20,"fontFamily":5,"textAlign":"left","verticalAlign":"top", "containerId":null,"autoResize":true,"lineHeight":1.25}, {"id":"Im9kQ2pL","type":"image","fileId":"5f1e0c9a2b7d4e6f8a1b3c5d7e9f0a2b4c6d8e0f", "status":"saved","scale":[1,1],"crop":null, "...common fields": "..."} ], "appState": {"gridSize": null, "viewBackgroundColor": "#ffffff"}, "files": {} } ``` %% ```` (The example image element is abbreviated; a real file has all common element fields.) The banner line is optional; we can omit it or write our own. ## 2. Images: inline dataURL vs. vault files - **`.excalidraw.md` (normal mode):** on every save `syncFiles` writes each new `files[id].dataURL` that has no known source to the vault as a separate file (pasted images use Obsidian's attachment naming/folder rules, recent PR #2964 uses `app.saveAttachment`), registers `<fileId>: [[name.png]]` in Embedded Files, then sets `scene.files = {}`. So `.md` files **never** keep inline images; the JSON keeps only `image` elements with `fileId`. - **`.excalidraw` (compatibility mode, `.excalidraw` extension):** saved as plain `JSON.stringify(scene, null, "\t")` with inline `files` dataURLs (same as excalidraw.com). - **What Obsidian users expect:** separate attachment files in the vault (visible in file explorer, reusable, de-duplicated, small `.md`). Inline base64 in a `.md` would look wrong to them and bloat sync. - **Rename of a vault image:** the Embedded Files line is an ordinary wiki link in the note body. Obsidian indexes links in the file (including the `%%` block, per Obsidian behaviour; verify on a test vault), so with "Automatically update internal links" Obsidian rewrites `[[old.png]]` to the new name. The plugin itself only handles rename of the drawing (moves its `.bak` and auto-exported PNG/SVG siblings), not of images. Links are resolved shortest-path (`getFirstLinkpathDest`), so a move to another folder with the same basename still resolves. If a rename happens outside Obsidian (WebDAV client, shell) nothing updates the link and the image shows as missing; the `fileId` in the JSON stays valid. ## 3. Derived vs. authoritative; round-tripping On save the plugin regenerates everything from `%%`/`# Excalidraw Data` to the end, and rewrites only selected frontmatter keys in the header: | Part | On save | On load, authoritative for | |---|---|---| | Frontmatter | kept; only `excalidraw-plugin` (and 2 export keys) rewritten in place | text mode, export options | | User Markdown header, banner, `# Markdown Images` blocks | kept byte-for-byte (blocks only added/removed/bodies updated) | Markdown-image bodies | | `## Text Elements` | regenerated from in-memory map | **raw text** of text elements (wins over JSON) | | `## Element Links` | regenerated | **element.link** (wins over JSON) | | `## Embedded Files` | regenerated (paths re-derived from resolved TFile) | **image source** per fileId (JSON has no source) | | `## Drawing` JSON | regenerated, compressed per setting | everything else (geometry, style, ids, appState) | | tail after final `%%` | dropped unless Zotero mode | - | Rules for calternal (lossless): 1. Split the file into: `header` (bytes before the data marker, found with the same fallback order: `#\n%%\n# Excalidraw Data`, `%%\n# Excalidraw Data`, `# Excalidraw Data`, then `# Text Elements` forms, then `## Drawing`), `data` (sections), `drawing` (fence + JSON), `tail`. 2. Keep `header` and `tail` as opaque bytes. Change frontmatter only through a minimal in-place line edit, never by re-serialising YAML. 3. Keep unknown `## <heading>` sections inside the data block as opaque byte ranges in their original position. (The plugin itself would drop them on its next save; we keep them so calternal never is the one to lose data.) 4. Keep unknown JSON keys at every level (top level, `appState`, each element, `customData`, `files` entries). Parse into a model with a catch-all map, or patch the JSON. Keep `isDeleted: true` elements (the plugin writes deleted elements as tombstones for sync merges). 5. Keep the comment variant (1.2), heading levels (`#` vs `##`), and the compression choice of an existing file? §60 says new canvases use `json`; for existing compressed files, decide: either keep `compressed-json` on save (most lossless, other Obsidian users expect it) or decompress (better for Search and diffs). Recommend: decompress on first calternal save, keep everything else; this is exactly the plugin's own "Decompress" command, so the plugin reads it fine. 6. Ids: new text elements and new linked elements must get 8-char `[0-9A-Za-z]` ids, or the plugin renames them on open (harmless for the plugin but breaks our `?el=<element-id>` deep links and Yjs keys). Use the same id policy for every new element. Never rename existing ids. 7. Write Text Elements, Element Links and Embedded Files from the scene so the plugin sees consistent data; on read, apply them over the JSON as the plugin does (so edits made in Obsidian's Markdown view win). 8. Byte-for-byte no-op: if the scene did not change, do not rewrite the file. Golden tests: plugin-written fixtures (compressed, uncompressed, legacy `# Drawing`, commented-out text, card `#`, Markdown Images, colour map, LaTeX, URL images) must round-trip identically through load + save when no edit was made. ## 4. Plain `.excalidraw` JSON essentials ``` { "type": "excalidraw", "version": 2, "source": "<url>", "elements": [...], "appState": {...}, "files": { "<fileId>": {...} } } ``` - `type` must be `"excalidraw"`; `version` 2; `source` is free text. - Element common fields (preserve all, plus unknown ones): `id`, `type`, `x`, `y`, `width`, `height`, `angle`, `strokeColor`, `backgroundColor`, `fillStyle`, `strokeWidth`, `strokeStyle`, `roughness`, `opacity`, `groupIds`, `frameId`, `index` (fractional order key, Excalidraw >= 0.17; maps to our Yjs fractional order), `roundness`, `seed`, `version`, `versionNonce`, `isDeleted`, `boundElements`, `updated`, `link`, `locked`, `customData`. - Type fields: text (`text`, `originalText`, `fontSize`, `fontFamily`, `textAlign`, `verticalAlign`, `containerId`, `autoResize`, `lineHeight`; plugin adds `rawText`, `hasTextLink`), linear/arrow (`points`, `startBinding`, `endBinding`, `startArrowhead`, `endArrowhead`, `elbowed`, `fixedSegments`...), freedraw (`points`, `pressures`, `simulatePressure`), image (`fileId`, `status`, `scale`, `crop`), embeddable/iframe (`link`, `customData`), frame (`name`). - `files[id]`: `{ id, mimeType, dataURL, created, lastRetrieved }` (+ unknown). - `appState`: Excalidraw saves a small subset (`gridSize`, `gridStep`, `gridModeEnabled`, `viewBackgroundColor`, `theme`, plus others). Keep all keys as received; do not inject view state such as scroll and zoom per user into the shared file. - Plugin extras to keep: `rawText`, `customData.mermaidText`, Markdown-image `customData`, colour maps, plugin `source` URL. ## 5. Recommendation for calternal **Store dropped images as separate Home files, not inline base64.** Write the Embedded Files line as a vault-compatible wiki link, and keep the calternal stable identity alongside it. - Location: the user's attachment folder rule (same default as Notes attachments; reuse the existing Notes attachment logic, do not add a new one), name `Pasted image <timestamp>.png` style or the dropped file's name. - Reference: `<fileId>: [[Pasted image 20261003.png]]` in Embedded Files (what Obsidian reads), and in the image element `customData.calternal = { "item": "<calternal-id>" }` (unknown to the plugin, kept by it because it preserves element JSON). On load resolve by calternal-id first, then by the wiki link. On a rename inside calternal, the server rewrites the wiki link text (the backlink index already knows the canvas references the item), so both Obsidian and calternal stay correct. A rename outside calternal (WebDAV, Obsidian) still resolves by calternal-id, and the next save repairs the link text. - `.excalidraw` files opened from elsewhere keep their inline `files` on save (that format has no other place). Optional "Move images to Home" action. Trade-offs: | | Separate Home files + `[[link]]` (+ calternal-id) | Inline base64 in JSON | |---|---|---| | File size | `.md` stays small (KB); image once on disk | +33% base64 per image; MB-size `.md` | | Sync (WebDAV, Yjs) | each canvas edit re-sends only small text; images sync once, de-duplicated | every save rewrites and re-uploads all image bytes; Yjs doc holds blobs | | Search | canvas text indexes cleanly; images get the normal photo/OCR/file pipeline and show up as Files | base64 noise in the index unless stripped; images invisible to Files/Photos | | History (§61) | per-save snapshots are text-small; image history is its own file history | every snapshot copies all images: storage grows fast | | Obsidian compat | exactly what the plugin writes and expects | plugin converts to vault files on its first save anyway | | Portability | needs the image files next to the canvas (WebDAV vault has them) | single self-contained file | | Rename | wiki link breaks for external renames; mitigated by calternal-id fallback and repair | no rename problem | | Security | images go through `calternal-fs` like any upload; per-item ACL applies to live cards and images alike (#472) | no separate ACL; whoever reads the canvas sees all images | Self-contained export stays available: PNG/SVG with scene embedded (§60) and "Export as .excalidraw" with inline `files`. ## Open points to verify - Confirm on a real Obsidian vault that links inside `%%` blocks are indexed and updated on rename (expected yes). - Confirm the plugin keeps unknown `customData` keys and unknown element keys after a round trip (expected yes, it stores the scene as received). - Confirm plugin licence header (AGPL-3.0 "only" vs "or later") if reuse is ever considered.
Author
Owner

Embedding research (2026-10-03): read before building

Key calls: lazy React host like analytics/BklitChart.svelte (React 19 already shipped); self-host Excalidraw fonts/assets (EXCALIDRAW_ASSET_PATH; CSP); Y.Map elementId → whole element JSON ordered by index, server enforces version/versionNonce rule in yrs; images out of Yjs; renderEmbeddable renders our ItemCard (no iframes); Mermaid runs client-side in a lazy chunk/worker (document this in §60); upstream PRs for context-menu hook, tooltip hook and UIOptions flags as separate small issues.

Research: Excalidraw inside calternal (Svelte 5), for #976 / DESIGN §60–61

Date 2026-10-03. Time-boxed read-only research. Items marked (verify) are from memory of the
source and were not confirmed against current code in this round.

0. Facts from the repo

  • apps/web/package.json already has react 19.2.0 and react-dom 19.2.0 (for the vendored
    Bklit charts). tsconfig.json has "jsx": "react-jsx".
  • A React-in-Svelte pattern exists: apps/web/src/lib/components/analytics/BklitChart.svelte
    does const { mountBklitChart } = await import('./BklitAnalytics'), and
    BklitAnalytics.tsx calls createRoot(target) and returns an unmount. Reuse gate: the Canvas
    host must follow (or generalise) this pattern, not invent a new one.

1. Embedding in Svelte 5 and bundle size

npm metadata (2026-10-03): @excalidraw/excalidraw latest 0.18.1 (MIT, published
2026-04-20; next = 0.18.0-4ce38fb snapshots). peerDependencies
react/react-dom ^17.0.2 || ^18.2.0 || ^19.0.0, so React 19.2 already in the app is valid.

Recommended shape:

  • CanvasEditor.svelte holds a <div bind:this={host}>. In onMount, await import('./excalidraw-host')
    (a .tsx module) that imports @excalidraw/excalidraw and its index.css, calls
    createRoot(host) and renders <Excalidraw …/>. Return root.unmount() from the cleanup.
    The dynamic import makes Vite emit a separate chunk that loads only when a canvas opens.
  • Pass props from Svelte by re-rendering (root.render(...) with new props) from a $effect,
    or better, keep the React tree static and drive it through the imperative
    excalidrawAPI (updateScene, scrollToContent, setActiveTool). That avoids React
    re-renders on every Svelte state change.
  • Disable SSR for the route part (ssr = false or client-only mount). Excalidraw touches window.
  • Self-host fonts: set window.EXCALIDRAW_ASSET_PATH to a calternal static path and copy
    dist/prod/fonts there (verify path). By default fonts load from a public CDN, which breaks
    CSP and privacy.
  • Size estimate (no measurement): bundlephobia for 0.18.1 reports the main bundle
    1,124,502 B min / 352,695 B gzip (~345 KB gzip). Excalidraw code-splits further lazy
    chunks: one of 1.82 MB min / 735 KB gzip (mermaid + cytoscape + katex + d3, loaded only for
    the Mermaid dialog) and one of 662 KB / 161 KB gzip. react-dom 19 client is about 60 KB gzip
    (+ react ~3 KB), and the app already ships it on the analytics route, so Vite can share the
    chunk. Realistic first canvas open: ~410 KB gzip JS + CSS + fonts on demand. #976 asks to
    measure it; this is the expectation to compare against.
  • Lighter, React-free packages for previews and thumbnails:
    • @excalidraw/utils 0.1.5 (MIT, 2026-09-10): exportToSvg, exportToBlob,
      exportToCanvas, serializeAsJSON without React (deps: roughjs, perfect-freehand, pako…).
      Good for the ![[Board.excalidraw]] live preview in a note without loading the editor.
    • @excalidraw/element, @excalidraw/common, @excalidraw/math (MIT, 0.18.0-snapshot
      builds, 2026-10-01): the split-out element model (bounds, fractional index helpers,
      restore). Useful for scene math and validation without React; API not yet stable
      (snapshot versions only).
    • Recommendation: the note embed renders an SVG from @excalidraw/utils in a lazy chunk, and
      caches it. Optionally the editor writes a small preview SVG next to the save so the server
      can serve a thumbnail without any JS (decision for the job).

2. Live collaboration

How upstream reconciles (Excalidraw P2P blog; packages/excalidraw/data/reconcile.ts):

  • Every element has id, version (incremented on each change), versionNonce (new random
    int on each increment), isDeleted (tombstone, never removed from the array), and since
    0.17 a fractional index string (rocicorp fractional-indexing 3.2.0 format, base62).
  • shouldDiscardRemoteElement(localAppState, local, remote) keeps the local element when:
    the local element is being edited (editing text, resizing, new element in appState), or
    local.version > remote.version, or versions are equal and local.versionNonce <= remote.versionNonce.
  • reconcileElements(local, remote, appState) takes the union by id with that rule, then
    orderByFractionalIndex(), then syncInvalidIndices() to de-duplicate or repair indices
    (validation throws only in dev/test).
  • Remote scenes are applied with excalidrawAPI.updateScene({ elements, captureUpdate: CaptureUpdateAction.NEVER }) (0.18 API) so remote changes do not enter the local undo stack.
  • excalidraw.com transports this over socket.io with end-to-end encryption plus Firebase
    storage; none of that is needed here.

Existing binding y-excalidraw (RahulBadenkal, MIT, 2.0.12, last release 2024-12-10,
~38 stars, peer @excalidraw/excalidraw ^0.17.6): Y.Array<{el, pos}> with its own
fractional pos, Y.Map for files, awareness for cursors, optional Y.UndoManager. Syncs at
whole-element level, no tests or benchmarks. Not maintained for 0.18 and uses an Array plus a
duplicate position field, so it does not match §60. Use as a reference only.

Recommended binding (fits §60 "one Yjs map entry per element keyed by element ID with a
fractional order index", server single writer in yrs):

  • Doc layout: elements: Y.Map<id, JSON> where the value is the whole element as a plain
    JSON object
    (not a nested Y.Map). Order comes from the element's own index field, so
    there is no second position field. files: Y.Map<fileId, {mimeType, size, blobRef, created}>
    with binary data kept outside the Yjs doc as calternal blobs (quota and size limits apply,
    per #976). meta: Y.Map for appState that is shared (background colour, grid).
  • Why whole-element values: Excalidraw invariants span fields (points vs width/height,
    boundElements vs containerId, text vs dimensions). A per-field Y.Map would merge two
    half edits into an invalid element. Whole-element last-writer-wins matches upstream
    semantics, keeps per-author undo (§61) at element granularity, and keeps updates small
    (one map set per changed element).
  • Client to server: in onChange, skip when getSceneVersion(elements) is unchanged; else
    compare each element's version with the last version sent for that id and send only the
    changed ones in one Yjs transaction (origin = local). Throttle to animation frames during
    drags.
  • Server to client: on a remote Yjs event, collect the changed values, run upstream
    reconcileElements(localElements, changed, appState) (keeps the "being edited" guard), and
    updateScene({ elements, captureUpdate: CaptureUpdateAction.NEVER }).
  • Server (yrs, single writer) validates each update before it applies and persists: key equals
    value.id; JSON size and depth limits; known type; version strictly greater than the
    stored one (or equal with the nonce rule); index is a valid base62 key. Reject or drop
    otherwise. Because the server is the single writer, Yjs map LWW by client ID never decides a
    conflict on its own: the server applies the Excalidraw version rule.
  • Ordering: sort by (index, id); base62 order equals ASCII byte order, so Rust can compare
    strings directly. Concurrent inserts can produce equal indices; clients repair only their own
    elements with syncInvalidIndices to avoid fix storms. API/CLI/MCP adds need
    generateKeyBetween in Rust: port the ~150-line rocicorp algorithm (CC0) or use a crate that
    emits the same format (verify).
  • Deletes: keep isDeleted: true entries (Excalidraw undo restores by flipping the flag). The
    server prunes old tombstones when it writes a snapshot (§61 retention rule).
  • Presence: y-protocols awareness (already a dependency) for pointer, selection and
    username; feed Excalidraw's collaborators map via updateScene({ collaborators }) and set
    isCollaborating.
  • Save: server serialises the Y.Map to .excalidraw.md JSON (sorted by index) on debounce,
    preserving unknown fields byte-for-byte as #976 requires.

3. Theming and hiding chrome

Available without a fork:

  • theme: "light" | "dark" prop; drive it from calternal's theme.
  • CSS variables on .excalidraw and .excalidraw.theme--dark, with a higher-specificity
    prefix: --color-primary, --color-primary-darker, --color-primary-darkest,
    --color-primary-light, --color-primary-contrast-offset, plus the theme.scss set
    (--island-bg-color, --popup-bg-color, --button-gray-1/2/3, --default-border-color,
    --border-radius-md/lg, --ui-font, --shadow-island, … verify names against 0.18
    theme.scss). Map these to calternal tokens in one stylesheet.
  • UIOptions.canvasActions: changeViewBackgroundColor, clearCanvas, export (false),
    loadScene, saveToActiveFile, toggleTheme, saveAsImage — set all to false.
    UIOptions.tools.image, UIOptions.welcomeScreen, UIOptions.dockedSidebarBreakpoint.
    Disabled actions also stop their keyboard shortcuts (verify for each).
  • Children API: render your own <MainMenu> (replaces the default items),
    <WelcomeScreen> (omit it and there is no welcome screen), <Footer>, <Sidebar>,
    <LiveCollaborationTrigger>. renderTopRightUI(isMobile, appState) for custom top-right UI.
  • viewModeEnabled, zenModeEnabled, gridModeEnabled, handleKeyboardGlobally (keep false
    so calternal owns global shortcuts), name, langCode, onLinkOpen,
    generateLinkForSelection(id, type) (use it to emit /n/<calternal-id>?el=<element-id>),
    onPaste (intercept calternal links for live cards).
  • Export from calternal chrome with the library functions exportToSvg / exportToBlob
    with exportEmbedScene: true (scene embedded, reopens editable — §60).

Needs CSS hiding (brittle; pin the version and add a screenshot test) or a fork/upstream PR:

  • The main-menu hamburger trigger itself, the help "?" button and help dialog (also the ?
    shortcut: block it with a capture-phase keydown), the library button/sidebar trigger, the
    Excalidraw command palette and its shortcut, the stats dialog.
  • Toolbar and properties-panel layout, icons and their native tooltips (the warm tooltip
    cannot attach to Excalidraw's buttons without DOM patching), the context menu (no API to add
    "Copy link" items: needs an upstream PR or our own context menu over the canvas), the colour
    picker preset palette, mobile breakpoints.
  • Dark mode on the canvas is a CSS filter: invert(93%) hue-rotate(180deg) applied to the
    canvas and exports (verify for 0.18), so canvas colours are not token driven in dark mode.
  • Drawing fonts are a fixed enum (Excalifont, Nunito, Lilita One, Comic Shanns, Liberation
    Sans, Cascadia, Virgil legacy). The calternal UI font can be set via --ui-font, but element
    text cannot use calternal fonts without a fork, and doing so would break interop with other
    Excalidraw tools anyway.
  • Upstream PR candidates: UIOptions flags for help, library trigger, main-menu trigger and
    command palette; a context-menu items hook; a tooltip render hook.

4. Pen mode and Apple Pencil

  • Excalidraw detects pointerType === "pen" on first pen input and turns on penMode
    (appState penMode, penDetected; toolbar toggle; onPenModeToggle). In pen mode touch
    pointers do not draw: a finger pans and pinches, the pen draws. That is the palm rejection;
    it is app-level, not OS-level.
  • Freedraw elements store pressures: number[] and simulatePressure: boolean. With a real
    pen (PointerEvent.pressure is not the 0.5 mouse default) simulatePressure is false and
    perfect-freehand 1.2.0 uses the real pressures. Safari on iPadOS reports Pencil pressure.
  • Status: works upstream; known rough edges are iPadOS Scribble and long-press/selection
    callouts and double-tap gestures (see obsidian-excalidraw-plugin issue #2773). Do not add
    touch-action or gesture handlers on the host that steal pointer events. Full polish is the
    separate Pencil issue; #976 must only not break it.

5. Embeddable elements

  • validateEmbeddable: boolean | string[] | RegExp | RegExp[] | ((link) => boolean | undefined).
    The function form returns undefined to fall back to the built-in allow-list (YouTube,
    Figma, …). Results are cached per element in embedsValidationStatus.
  • renderEmbeddable(element, appState) => JSX.Element | null. App.tsx renders
    renderEmbeddable?.(el, this.state) ?? <iframe …/>, so returning a React node replaces the
    iframe entirely. The node is placed in Excalidraw's DOM overlay layer, transformed with
    pan/zoom, and is interactive only when the element is "activated" (click to activate).
  • So yes: calternal can render its own card. Use validateEmbeddable = (link) => isCalternalDeepLink(link) ? true : undefined (or false for all external links if only cards
    are wanted) and a renderEmbeddable that returns a small React component which mounts the
    Svelte ItemCard (mount() from svelte into a ref div, unmount on cleanup). Store the §33 deep
    link in element.link. Only render cards in view (Excalidraw already skips off-screen
    embeddables, verify) and batch live updates, per §60.
  • Exports render embeddables as a placeholder box with the link, not the card; plan a
    static SVG fallback for cards in export (job decision).

6. Mermaid conversion

@excalidraw/mermaid-to-excalidraw 2.2.2 (MIT; deps mermaid ^11.12.1, @mermaid-js/parser).
parseMermaidToExcalidraw(definition, config?) returns { elements, files } as element
skeletons; the caller must run convertToExcalidrawElements() (from the excalidraw package,
which measures text) to get real elements. parseMermaid.ts calls
mermaid.render(...), document.createElement, document.body.appendChild,
querySelector("svg") and getBoundingClientRect(), and mermaid's layout itself measures
text with getBBox. Unsupported diagram types fall back to an SVG image (data:image/svg+xml).

  • jsdom has no layout: getBBox/getBoundingClientRect return zeros, so a server-side
    jsdom run produces collapsed geometry. It is not a safe server path.
  • Server-side options are therefore a headless browser (Chromium worker, heavy, new
    attack surface) or the client.
  • Recommendation: run Mermaid conversion in the browser in a lazy chunk (Excalidraw already
    lazy-loads mermaid for its own dialog). For API/CLI/MCP parity, the server accepts Mermaid
    text and either (a) delegates to an open client session/WebMCP, or (b) runs a sandboxed
    headless-browser worker if parity without a browser is required. Document the choice in §60
    (#976 asks for it). Option (a) first; (b) only if the owner requires agent-only creation.

7. Licences (all AGPL-3.0-only compatible)

Package Licence
@excalidraw/excalidraw, /utils, /element, /common, /math, /mermaid-to-excalidraw MIT
react, react-dom, scheduler MIT
mermaid 11 MIT (deps: d3 ISC, cytoscape MIT, katex MIT, dompurify MPL-2.0 OR Apache-2.0)
yjs, y-protocols, y-excalidraw (reference only) MIT
yrs (Rust) MIT
fractional-indexing CC0-1.0
roughjs, perfect-freehand, jotai, @radix-ui/*, pica, nanoid, clsx, tunnel-rat MIT
pako MIT AND Zlib
Fonts: Excalifont, Virgil, Nunito, Lilita One, Liberation Sans, Cascadia Code, Xiaolai SIL OFL-1.1 (Comic Shanns: MIT)

No GPL-2.0-only, proprietary or non-commercial terms found. OFL fonts may be bundled with
AGPL software; keep their licence files in the static asset directory.

Sources

## Embedding research (2026-10-03): read before building Key calls: lazy React host like `analytics/BklitChart.svelte` (React 19 already shipped); self-host Excalidraw fonts/assets (EXCALIDRAW_ASSET_PATH; CSP); Y.Map elementId → whole element JSON ordered by `index`, server enforces version/versionNonce rule in yrs; images out of Yjs; `renderEmbeddable` renders our ItemCard (no iframes); Mermaid runs client-side in a lazy chunk/worker (document this in §60); upstream PRs for context-menu hook, tooltip hook and UIOptions flags as separate small issues. # Research: Excalidraw inside calternal (Svelte 5), for #976 / DESIGN §60–61 Date 2026-10-03. Time-boxed read-only research. Items marked (verify) are from memory of the source and were not confirmed against current code in this round. ## 0. Facts from the repo - `apps/web/package.json` already has `react` 19.2.0 and `react-dom` 19.2.0 (for the vendored Bklit charts). `tsconfig.json` has `"jsx": "react-jsx"`. - A React-in-Svelte pattern exists: `apps/web/src/lib/components/analytics/BklitChart.svelte` does `const { mountBklitChart } = await import('./BklitAnalytics')`, and `BklitAnalytics.tsx` calls `createRoot(target)` and returns an unmount. Reuse gate: the Canvas host must follow (or generalise) this pattern, not invent a new one. ## 1. Embedding in Svelte 5 and bundle size npm metadata (2026-10-03): `@excalidraw/excalidraw` latest **0.18.1** (MIT, published 2026-04-20; `next` = 0.18.0-4ce38fb snapshots). peerDependencies `react`/`react-dom` `^17.0.2 || ^18.2.0 || ^19.0.0`, so React 19.2 already in the app is valid. Recommended shape: - `CanvasEditor.svelte` holds a `<div bind:this={host}>`. In `onMount`, `await import('./excalidraw-host')` (a `.tsx` module) that imports `@excalidraw/excalidraw` and its `index.css`, calls `createRoot(host)` and renders `<Excalidraw …/>`. Return `root.unmount()` from the cleanup. The dynamic import makes Vite emit a separate chunk that loads only when a canvas opens. - Pass props from Svelte by re-rendering (`root.render(...)` with new props) from a `$effect`, or better, keep the React tree static and drive it through the imperative `excalidrawAPI` (`updateScene`, `scrollToContent`, `setActiveTool`). That avoids React re-renders on every Svelte state change. - Disable SSR for the route part (`ssr = false` or client-only mount). Excalidraw touches `window`. - Self-host fonts: set `window.EXCALIDRAW_ASSET_PATH` to a calternal static path and copy `dist/prod/fonts` there (verify path). By default fonts load from a public CDN, which breaks CSP and privacy. - Size estimate (no measurement): bundlephobia for 0.18.1 reports the main bundle **1,124,502 B min / 352,695 B gzip** (~345 KB gzip). Excalidraw code-splits further lazy chunks: one of 1.82 MB min / **735 KB gzip** (mermaid + cytoscape + katex + d3, loaded only for the Mermaid dialog) and one of 662 KB / 161 KB gzip. react-dom 19 client is about 60 KB gzip (+ react ~3 KB), and the app already ships it on the analytics route, so Vite can share the chunk. Realistic first canvas open: **~410 KB gzip JS + CSS + fonts on demand**. #976 asks to measure it; this is the expectation to compare against. - Lighter, React-free packages for previews and thumbnails: - `@excalidraw/utils` 0.1.5 (MIT, 2026-09-10): `exportToSvg`, `exportToBlob`, `exportToCanvas`, `serializeAsJSON` without React (deps: roughjs, perfect-freehand, pako…). Good for the `![[Board.excalidraw]]` live preview in a note without loading the editor. - `@excalidraw/element`, `@excalidraw/common`, `@excalidraw/math` (MIT, 0.18.0-snapshot builds, 2026-10-01): the split-out element model (bounds, fractional index helpers, restore). Useful for scene math and validation without React; API not yet stable (snapshot versions only). - Recommendation: the note embed renders an SVG from `@excalidraw/utils` in a lazy chunk, and caches it. Optionally the editor writes a small preview SVG next to the save so the server can serve a thumbnail without any JS (decision for the job). ## 2. Live collaboration How upstream reconciles (Excalidraw P2P blog; `packages/excalidraw/data/reconcile.ts`): - Every element has `id`, `version` (incremented on each change), `versionNonce` (new random int on each increment), `isDeleted` (tombstone, never removed from the array), and since 0.17 a fractional `index` string (rocicorp `fractional-indexing` 3.2.0 format, base62). - `shouldDiscardRemoteElement(localAppState, local, remote)` keeps the local element when: the local element is being edited (editing text, resizing, new element in appState), or `local.version > remote.version`, or versions are equal and `local.versionNonce <= remote.versionNonce`. - `reconcileElements(local, remote, appState)` takes the union by id with that rule, then `orderByFractionalIndex()`, then `syncInvalidIndices()` to de-duplicate or repair indices (validation throws only in dev/test). - Remote scenes are applied with `excalidrawAPI.updateScene({ elements, captureUpdate: CaptureUpdateAction.NEVER })` (0.18 API) so remote changes do not enter the local undo stack. - excalidraw.com transports this over socket.io with end-to-end encryption plus Firebase storage; none of that is needed here. Existing binding `y-excalidraw` (RahulBadenkal, MIT, 2.0.12, last release 2024-12-10, ~38 stars, peer `@excalidraw/excalidraw ^0.17.6`): `Y.Array<{el, pos}>` with its own fractional `pos`, `Y.Map` for files, awareness for cursors, optional `Y.UndoManager`. Syncs at whole-element level, no tests or benchmarks. Not maintained for 0.18 and uses an Array plus a duplicate position field, so it does not match §60. Use as a reference only. Recommended binding (fits §60 "one Yjs map entry per element keyed by element ID with a fractional order index", server single writer in `yrs`): - Doc layout: `elements: Y.Map<id, JSON>` where the value is the **whole element as a plain JSON object** (not a nested Y.Map). Order comes from the element's own `index` field, so there is no second position field. `files: Y.Map<fileId, {mimeType, size, blobRef, created}>` with binary data kept outside the Yjs doc as calternal blobs (quota and size limits apply, per #976). `meta: Y.Map` for appState that is shared (background colour, grid). - Why whole-element values: Excalidraw invariants span fields (points vs width/height, `boundElements` vs `containerId`, text vs dimensions). A per-field Y.Map would merge two half edits into an invalid element. Whole-element last-writer-wins matches upstream semantics, keeps per-author undo (§61) at element granularity, and keeps updates small (one map set per changed element). - Client to server: in `onChange`, skip when `getSceneVersion(elements)` is unchanged; else compare each element's `version` with the last version sent for that id and send only the changed ones in one Yjs transaction (origin = local). Throttle to animation frames during drags. - Server to client: on a remote Yjs event, collect the changed values, run upstream `reconcileElements(localElements, changed, appState)` (keeps the "being edited" guard), and `updateScene({ elements, captureUpdate: CaptureUpdateAction.NEVER })`. - Server (`yrs`, single writer) validates each update before it applies and persists: key equals `value.id`; JSON size and depth limits; known `type`; `version` strictly greater than the stored one (or equal with the nonce rule); `index` is a valid base62 key. Reject or drop otherwise. Because the server is the single writer, Yjs map LWW by client ID never decides a conflict on its own: the server applies the Excalidraw version rule. - Ordering: sort by `(index, id)`; base62 order equals ASCII byte order, so Rust can compare strings directly. Concurrent inserts can produce equal indices; clients repair only their own elements with `syncInvalidIndices` to avoid fix storms. API/CLI/MCP adds need `generateKeyBetween` in Rust: port the ~150-line rocicorp algorithm (CC0) or use a crate that emits the same format (verify). - Deletes: keep `isDeleted: true` entries (Excalidraw undo restores by flipping the flag). The server prunes old tombstones when it writes a snapshot (§61 retention rule). - Presence: `y-protocols` awareness (already a dependency) for pointer, selection and username; feed Excalidraw's `collaborators` map via `updateScene({ collaborators })` and set `isCollaborating`. - Save: server serialises the Y.Map to `.excalidraw.md` JSON (sorted by index) on debounce, preserving unknown fields byte-for-byte as #976 requires. ## 3. Theming and hiding chrome Available without a fork: - `theme: "light" | "dark"` prop; drive it from calternal's theme. - CSS variables on `.excalidraw` and `.excalidraw.theme--dark`, with a higher-specificity prefix: `--color-primary`, `--color-primary-darker`, `--color-primary-darkest`, `--color-primary-light`, `--color-primary-contrast-offset`, plus the `theme.scss` set (`--island-bg-color`, `--popup-bg-color`, `--button-gray-1/2/3`, `--default-border-color`, `--border-radius-md/lg`, `--ui-font`, `--shadow-island`, … verify names against 0.18 `theme.scss`). Map these to calternal tokens in one stylesheet. - `UIOptions.canvasActions`: `changeViewBackgroundColor`, `clearCanvas`, `export` (false), `loadScene`, `saveToActiveFile`, `toggleTheme`, `saveAsImage` — set all to false. `UIOptions.tools.image`, `UIOptions.welcomeScreen`, `UIOptions.dockedSidebarBreakpoint`. Disabled actions also stop their keyboard shortcuts (verify for each). - Children API: render your own `<MainMenu>` (replaces the default items), `<WelcomeScreen>` (omit it and there is no welcome screen), `<Footer>`, `<Sidebar>`, `<LiveCollaborationTrigger>`. `renderTopRightUI(isMobile, appState)` for custom top-right UI. - `viewModeEnabled`, `zenModeEnabled`, `gridModeEnabled`, `handleKeyboardGlobally` (keep false so calternal owns global shortcuts), `name`, `langCode`, `onLinkOpen`, `generateLinkForSelection(id, type)` (use it to emit `/n/<calternal-id>?el=<element-id>`), `onPaste` (intercept calternal links for live cards). - Export from calternal chrome with the library functions `exportToSvg` / `exportToBlob` with `exportEmbedScene: true` (scene embedded, reopens editable — §60). Needs CSS hiding (brittle; pin the version and add a screenshot test) or a fork/upstream PR: - The main-menu hamburger trigger itself, the help "?" button and help dialog (also the `?` shortcut: block it with a capture-phase keydown), the library button/sidebar trigger, the Excalidraw command palette and its shortcut, the stats dialog. - Toolbar and properties-panel layout, icons and their native tooltips (the warm tooltip cannot attach to Excalidraw's buttons without DOM patching), the context menu (no API to add "Copy link" items: needs an upstream PR or our own context menu over the canvas), the colour picker preset palette, mobile breakpoints. - Dark mode on the canvas is a CSS `filter: invert(93%) hue-rotate(180deg)` applied to the canvas and exports (verify for 0.18), so canvas colours are not token driven in dark mode. - Drawing fonts are a fixed enum (Excalifont, Nunito, Lilita One, Comic Shanns, Liberation Sans, Cascadia, Virgil legacy). The calternal UI font can be set via `--ui-font`, but element text cannot use calternal fonts without a fork, and doing so would break interop with other Excalidraw tools anyway. - Upstream PR candidates: `UIOptions` flags for help, library trigger, main-menu trigger and command palette; a context-menu items hook; a tooltip render hook. ## 4. Pen mode and Apple Pencil - Excalidraw detects `pointerType === "pen"` on first pen input and turns on `penMode` (appState `penMode`, `penDetected`; toolbar toggle; `onPenModeToggle`). In pen mode touch pointers do not draw: a finger pans and pinches, the pen draws. That is the palm rejection; it is app-level, not OS-level. - Freedraw elements store `pressures: number[]` and `simulatePressure: boolean`. With a real pen (PointerEvent.pressure is not the 0.5 mouse default) `simulatePressure` is false and perfect-freehand 1.2.0 uses the real pressures. Safari on iPadOS reports Pencil pressure. - Status: works upstream; known rough edges are iPadOS Scribble and long-press/selection callouts and double-tap gestures (see obsidian-excalidraw-plugin issue #2773). Do not add `touch-action` or gesture handlers on the host that steal pointer events. Full polish is the separate Pencil issue; #976 must only not break it. ## 5. Embeddable elements - `validateEmbeddable: boolean | string[] | RegExp | RegExp[] | ((link) => boolean | undefined)`. The function form returns `undefined` to fall back to the built-in allow-list (YouTube, Figma, …). Results are cached per element in `embedsValidationStatus`. - `renderEmbeddable(element, appState) => JSX.Element | null`. App.tsx renders `renderEmbeddable?.(el, this.state) ?? <iframe …/>`, so returning a React node replaces the iframe entirely. The node is placed in Excalidraw's DOM overlay layer, transformed with pan/zoom, and is interactive only when the element is "activated" (click to activate). - So yes: calternal can render its own card. Use `validateEmbeddable = (link) => isCalternalDeepLink(link) ? true : undefined` (or false for all external links if only cards are wanted) and a `renderEmbeddable` that returns a small React component which mounts the Svelte ItemCard (`mount()` from svelte into a ref div, unmount on cleanup). Store the §33 deep link in `element.link`. Only render cards in view (Excalidraw already skips off-screen embeddables, verify) and batch live updates, per §60. - Exports render embeddables as a placeholder box with the link, not the card; plan a static SVG fallback for cards in export (job decision). ## 6. Mermaid conversion `@excalidraw/mermaid-to-excalidraw` 2.2.2 (MIT; deps `mermaid ^11.12.1`, `@mermaid-js/parser`). `parseMermaidToExcalidraw(definition, config?)` returns `{ elements, files }` as element *skeletons*; the caller must run `convertToExcalidrawElements()` (from the excalidraw package, which measures text) to get real elements. `parseMermaid.ts` calls `mermaid.render(...)`, `document.createElement`, `document.body.appendChild`, `querySelector("svg")` and `getBoundingClientRect()`, and mermaid's layout itself measures text with `getBBox`. Unsupported diagram types fall back to an SVG image (`data:image/svg+xml`). - jsdom has no layout: `getBBox`/`getBoundingClientRect` return zeros, so a server-side jsdom run produces collapsed geometry. It is not a safe server path. - Server-side options are therefore a headless browser (Chromium worker, heavy, new attack surface) or the client. - Recommendation: run Mermaid conversion in the browser in a lazy chunk (Excalidraw already lazy-loads mermaid for its own dialog). For API/CLI/MCP parity, the server accepts Mermaid text and either (a) delegates to an open client session/WebMCP, or (b) runs a sandboxed headless-browser worker if parity without a browser is required. Document the choice in §60 (#976 asks for it). Option (a) first; (b) only if the owner requires agent-only creation. ## 7. Licences (all AGPL-3.0-only compatible) | Package | Licence | |---|---| | @excalidraw/excalidraw, /utils, /element, /common, /math, /mermaid-to-excalidraw | MIT | | react, react-dom, scheduler | MIT | | mermaid 11 | MIT (deps: d3 ISC, cytoscape MIT, katex MIT, dompurify MPL-2.0 OR Apache-2.0) | | yjs, y-protocols, y-excalidraw (reference only) | MIT | | yrs (Rust) | MIT | | fractional-indexing | CC0-1.0 | | roughjs, perfect-freehand, jotai, @radix-ui/*, pica, nanoid, clsx, tunnel-rat | MIT | | pako | MIT AND Zlib | | Fonts: Excalifont, Virgil, Nunito, Lilita One, Liberation Sans, Cascadia Code, Xiaolai | SIL OFL-1.1 (Comic Shanns: MIT) | No GPL-2.0-only, proprietary or non-commercial terms found. OFL fonts may be bundled with AGPL software; keep their licence files in the static asset directory. ## Sources - npm registry metadata (`npm view`) for every package above, 2026-10-03. - Props: https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props - UIOptions: https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ui-options - Render props: https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/render-props - Styles: https://docs.excalidraw.com/docs/@excalidraw/excalidraw/customizing-styles - Reconcile: https://raw.githubusercontent.com/excalidraw/excalidraw/master/packages/excalidraw/data/reconcile.ts - App.tsx (pen mode, embeddables): https://raw.githubusercontent.com/excalidraw/excalidraw/master/packages/excalidraw/components/App.tsx - P2P collab blog: https://plus.excalidraw.com/blog/building-excalidraw-p2p-collaboration-feature - y-excalidraw: https://github.com/RahulBadenkal/y-excalidraw - mermaid-to-excalidraw: https://github.com/excalidraw/mermaid-to-excalidraw (src/index.ts, src/parseMermaid.ts) - Bundle size: https://bundlephobia.com/api/size?package=@excalidraw/excalidraw@0.18.1 - Pencil issue example: https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/2773
Author
Owner

Security requirements (design review 2026-10-03): must be met

Canvas (§60/§61, #976/#977): defensive design review

Read-only review, 2026-10-03. Inputs: CLAUDE.md, DESIGN §33, §37, §54, §60, §61,
issues #976 and #977, crates/calternal-server/src/security.rs,
crates/plugins/files/src/user_bytes.rs, crates/calternal-collab/src/{session,stored}.rs.

0. Current state that matters

  • Shell CSP (security.rs:121): frame-src 'none', connect-src 'self',
    font-src 'self' data:, img-src 'self' data: blob: https:,
    frame-ancestors 'none', plus X-Frame-Options: DENY. The module doc says
    "the app has no <iframe> of its own". Stock Excalidraw embeddables
    (iframes) cannot render under this CSP
    , and they must not: keep
    frame-src 'none' for v1.
  • img-src https: lets any https: image load directly from the viewer's
    browser (IP/UA leak, tracking pixel). A canvas makes this attacker-controlled
    by collaborators. Card images must go through a server proxy (see §2).
  • User bytes are served with SANDBOX_POLICY (sandbox; default-src 'none').
    SVG exports must use this path.
  • Collab today (Notes): MAX_MESSAGE_BYTES = 12 MiB, MAX_STORED_STATE = 16 MiB,
    share recheck every 500 ms, public-edit frame cap 3 MiB and 300 frames/min,
    "update must still convert to Markdown, else roll back and disconnect",
    awareness clocks near u32::MAX refused. Reuse all of it; the Canvas needs
    a canvas-specific validator in place of the Markdown check.
  • Inconsistency to flag: session.rs has a public Edit grant
    (PublicEditAccess), but §54 says "Public links are view only". A canvas
    must not inherit public edit. Decide/record before #976 merges.

1. Untrusted file input (server parse + client restore)

The same hostile bytes reach three parsers: the server (search, backlinks,
room load), Excalidraw restore() in every viewer's browser, and other tools
over WebDAV. Validate on the server at load and on every Yjs update, so one
hostile collaborator cannot freeze every other viewer.

Requirements (numbers are starting points; record the final ones in §60):

Limit Value Why
File bytes (.excalidraw, .excalidraw.md) 32 MiB read cap, streamed memory
JSON nesting depth 64 (serde_json default 128 is too deep for customData) stack
Elements per scene 20 000 (bench needs 5k) render / Yjs map size
Points per linear/freedraw element 20 000; total points per scene 2 M rough.js / path cost
Coordinates, width, height finite, abs <= 1e7; -0, NaN, Inf, 1e308 rejected rough.js hachure line count = size/gap: huge shapes hang every viewer
strokeWidth, fontSize, roughness, opacity, angle finite, clamped ranges same
Text per element / per scene 64 KiB / 4 MiB layout cost
Element ID, fileId [A-Za-z0-9_-]{1,64} Y.Map keys, selectors, deep links
Fractional index (index) <= 64 chars, valid base-62 unbounded growth by repeated insert-between
Unknown fields per element (byte-preserved) <= 16 KiB opaque preservation without unbounded blobs
files (dataURL images) mime allowlist png/jpeg/webp/gif/svg+xml; base64 must decode; magic bytes match mime; <= 10 MiB each, counts against quota bombs, disguised types
compressed-json (LZ-String base64) decode with an output cap (equal to the file cap) and a work budget LZW output can grow quadratically with input
PNG/SVG embedded scene on import zlib/pako inflate with output cap inflate bomb
.excalidraw.md sections one linear pass; first json/compressed-json block only; fence tricks ( ````, %%, nested fences) cannot hide a second scene parser differential

Other rules:

  • Opening never writes (#661): a file that fails validation opens read-only
    with a real error state. The server never "repairs" and saves it.
  • Parser differential: the server and Excalidraw must agree on what is visible.
    Index only text that Excalidraw renders (skip isDeleted: true, skip text
    bound to a deleted container). Duplicate JSON keys: reject (serde
    Value keeps the last, JSON.parse keeps the last, but other tools differ).
    Lone surrogates, BOM, overlong numbers: open read-only, never 5xx.
  • Prototype pollution: reject keys __proto__, constructor, prototype in
    element IDs, files keys, appState, customData and Y.Map keys. Client
    code that turns Y.Map into objects must use Object.create(null)/Map.
  • Text tricks: render bidi controls (U+202A–U+202E, U+2066–U+2069) and
    zero-width chars (U+200B–U+200D, U+2060, U+FEFF) visibly in the canvas
    title, card labels and link tooltips; reuse the existing name sanitiser
    (strip_unsafe_name_chars) for the file name / title. External link hover
    shows the punycode host for mixed-script hosts.
  • Element link: allow only https:, http:, mailto: and same-origin
    calternal paths that start with exactly one / (not //, not /\).
    Reject javascript:, data:, vbscript:, file:, blob:. Open external
    links with noopener noreferrer. Check on the server (update validator)
    and in the client.
  • Images: keep image bytes out of Yjs. Store them as content-addressed
    attachments through Files/calternal-fs and put only the fileId in the
    element. Otherwise every image lands in the §61 history log forever.
    Plain .excalidraw files with inline dataURLs keep them byte-preserved on
    disk, but the room holds a reference.
  • Never decode images on the server outside the media sandbox. Client decode
    of a 100k x 100k PNG is a viewer-side DoS: check declared dimensions from
    the header before upload is accepted.
  • Excalidraw network: self-host fonts and assets (EXCALIDRAW_ASSET_PATH),
    remove library browsing (libraries.excalidraw.com), share/collab links
    (json.excalidraw.com) and any Excalidraw+ promotion. The CSP already blocks
    them; remove them so nothing fails visibly.
  • Mermaid conversion: run in a dedicated Web Worker with securityLevel: 'strict', input cap (64 KiB), and terminate on a time budget. If API/CLI/MCP
    need it server-side, run it in the existing sandbox, never in the server
    process.

2. Embeddables and external URLs (privacy by default)

Risks of stock Excalidraw embeddables: third-party tracking on open (cookies,
IP, referrer), a collaborator-placed page that looks like calternal inside
calternal chrome (credential phishing in a trusted origin), clickjacking of
the embedded page, autoplay, allow-same-origin allow-scripts allow-popups
in Excalidraw's default sandbox, and a CSP hole if frame-src is opened.

Recommendation ("normie friendly, privacy invisible"):

  1. No third-party iframes in v1. Keep frame-src 'none'. Set
    validateEmbeddable={false} for foreign URLs and draw every embeddable
    through renderEmbeddable (React, same document) with the ItemCard family.
  2. External URL = server-fetched preview card: title, site name, favicon,
    og:image. Fetch with the #431 SSRF guard (DNS pinning, no private,
    loopback, link-local, CGNAT or metadata ranges, re-check every redirect,
    max 3 redirects), no cookies, fixed UA, 5 s timeout, 1 MiB HTML cap,
    text/html only, image cap 2 MiB and decoded in the media sandbox. Cache
    per owner. Images are served from the calternal origin (proxy), so the
    viewer's browser never contacts the third party.
  3. Only editors trigger a fetch (when adding or refreshing a card). A viewer,
    a share recipient or a public link never causes an outgoing request
    (no SSRF amplifier, no "who opened this" signal to the site).
  4. Video embeds (YouTube/Vimeo) are a later, separate decision: if built, use
    a click-to-play facade (cached thumbnail), then youtube-nocookie.com /
    player.vimeo.com?dnt=1 only, sandbox="allow-scripts allow-same-origin allow-presentation", referrerpolicy="no-referrer", and add only those
    two hosts to frame-src on the shell. Not part of #976/#977.
  5. Card click on an external URL opens a new tab (noopener noreferrer) and
    shows the real host before navigation; never navigate the app frame.
  6. Consider removing https: from shell img-src once card images are
    proxied (separate issue; check Mail remote images and other users first).

3. Live cards and isolation (#977)

  • The file stores only the deep link (§33 stable identity) and the card
    mode. Never write a resolved title, amount, snippet or thumbnail into the
    element, customData, the text section or the links section. The file is
    read by WebDAV clients, exports, search and every recipient.
  • Resolution is server-side per viewer, through one batch endpoint
    (POST with up to 200 links, rate limited per User). Each link resolves
    under the viewer's own authority: own item, item shared to the viewer
    (§54), or placeholder.
  • Placeholder is uniform: same response shape, same status, same size class
    and no timing difference for "deleted", "never existed", "not yours" and
    "malformed". No title, no count, no kind-specific icon beyond what the
    link text already shows. (§54: non-recipients get 404 with no timing or
    size difference.)
  • Deep links must be opaque IDs. Check the §33 grammar: /search?q=… puts
    query text into the canvas file; /mail/m/<message-id> must be calternal's
    ID, not an RFC Message-ID that contains an address. A card for a search
    query stores a saved-search ID or warns that the query text is visible to
    collaborators.
  • The live change feed for cards subscribes per viewer and only to IDs the
    viewer can read; a revoked share turns the card into a placeholder within
    the existing recheck interval (500 ms).
  • Enumeration: the batch resolver is an oracle only for what the viewer
    can already read. Keep it so: no existence bit, cap batch size, rate limit,
    log bursts. Element-link ?el= is opaque: never put it into a CSS selector
    or HTML; look it up in the element map.
  • Search: index the canvas's own text only, never resolved card content
    (else a recipient's search for "salary" matches the canvas through the
    owner's Money card).
  • Backlinks "On ": show a canvas in an item's backlinks only
    when the viewer can read that canvas. A hostile User can put cards that
    point at another User's item IDs; that must not create backlinks, counts or
    notices in the item owner's view (spam and canvas-name leak).
  • Shared canvas (Share/Collaborate): recipients see placeholders for the
    owner's private items. Collaborators can add cards for their own items;
    the owner then sees placeholders for those unless shared to the owner.
  • Public link (/s/<slug>): resolve as an anonymous viewer: every
    calternal item is a placeholder; external cards show the cached preview
    from the proxy. No live feed, no edit (see the public-edit note in §0).
  • Export renders cards as the exporting User sees them; the embedded
    scene in the export carries only links. Agents/MCP get the same per-viewer
    resolution as the web (no "raw" read that bypasses it).

4. Collaboration path (one event path)

  • Per-frame authz: reuse the 500 ms recheck. Viewers (Share) may receive
    sync and send SyncStep1 and awareness only; drop and disconnect on
    Update/SyncStep2 from a Viewer
    . API/CLI/MCP/WebMCP writes go through
    the same room and the same check.
  • Author binding (§61): the author of an update comes from the
    authenticated connection, never from the update contents. Each connection
    gets its own Yjs clientIDs; reject an update with structs under a clientID
    that belongs to another connection or author. Without this, a collaborator
    can spoof history attribution and make per-author undo revert someone
    else's work, or corrupt convergence by clientID reuse.
  • Update validator (replaces "still converts to Markdown"): after applying
    in a transaction, every changed element passes the §1 schema; else roll
    back and disconnect.
  • Pending structs: an update with missing dependencies is held by yrs in a
    pending store. Cap pending bytes per room (for example 1 MiB) and
    disconnect when exceeded, or a client can grow server memory without
    bound.
  • Clock abuse: reject clocks/lengths near u32::MAX/2^53, GC/skip ranges
    longer than the document, and delete sets that name clients the room has
    never seen. Fuzz yrs decode with these (it must error, never panic or
    allocate by declared length).
  • Sizes: per-frame cap for canvas lower than Notes (2 MiB, since images are
    out of Yjs); room state cap reuse 16 MiB; awareness state <= 8 KiB per
    client, server stamps name/colour from the session (no spoofed cursor
    labels), awareness rate cap.
  • Rates: per connection and per author frames/min (reuse the 300/min
    bucket), per User open rooms and connections (reuse client_stream_permit).
  • Mass delete: legitimate for an editor, so authz + history is the defence.
    Restore must handle "all elements deleted" in one update; add a test.
  • History growth DoS (§61): coalesce updates per author over 1–2 s before
    append; history bytes count against the owner's quota; per
    collaborator daily write budget so a recipient cannot fill the owner's
    disk; snapshot + fold by the retention rule; open history without full
    replay (already a §61 rule). A move-storm (one element dragged 300
    frames/min for an hour) is a bench and adversarial case.
  • Flush: debounced writes go through calternal-fs atomic replace; a
    concurrent WebDAV write of the same file triggers the existing reload
    path, never a silent overwrite (sync collision = merge blocker).

5. Export (SVG/PNG with embedded scene)

  • Excalidraw's SVG export wraps linked elements in <a href> and can emit
    <foreignObject> for embeddables and @font-face data URLs in <style>.
    Post-process every SVG export on a strict allowlist: no <script>, no
    on* attributes, no <foreignObject>, no <iframe>/<embed>/<object>,
    href only #…, data:image/(png|jpeg|webp|gif), https:, http:,
    mailto:; no external <use>, <image> or CSS url() to remote hosts;
    fonts only as embedded data URLs.
  • Serve exports and any .svg from Home with SANDBOX_POLICY and
    nosniff; show them in <img>, never inline in the app DOM.
  • The embedded scene payload (SVG metadata, PNG tEXt/iTXt) is untrusted
    on re-import: same §1 limits, inflate cap, chunk size cap (16 MiB).
  • The embedded scene carries links only, never resolved card data (§3).
  • PNG export of a huge scene: cap the export canvas size (browser max
    canvas area) and the scale; fail with a message, not a tab crash.

6. Adversarial cases for tests/adversarial/ (new canvas_probe)

File input (write via WebDAV and the API, then open, search, backlinks):

  1. 200 MiB .excalidraw → refused/streamed, no OOM, no 5xx.
  2. JSON nested 100 000 deep in customData → read-only error, server alive.
  3. 1 M elements; 1 element with 5 M freedraw points.
  4. Rectangle with width 1e308, x NaN (as 1e999), negative-zero, -1e308;
    hachure fill with roughness 0 and tiny gap → rejected server-side; second
    browser context stays responsive (Playwright INP check).
  5. LZ-String bomb: 1 MiB compressed-json that expands past the cap.
  6. PNG with a pako-compressed scene bomb in tEXt; SVG with a 50 MiB metadata payload.
  7. files with mime image/png but HTML/SVG-with-script bytes; base64 garbage; 11 MiB image; 1 000 images.
  8. Keys __proto__, constructor, prototype as element ID, fileId, appState key; check Object.prototype is clean in the page afterwards.
  9. Duplicate keys, lone surrogate \ud800, BOM, trailing garbage, two json blocks, fence-in-fence in .excalidraw.md.
  10. Element link = javascript:alert(1), JaVaScRiPt:, \x01javascript:, //evil.example, /\evil.example, data:text/html,….
  11. Text with U+202E, zero-width joiners, homoglyph host xn-- link; file name gpj.exe‮oard.excalidraw.md.
  12. Element IDs 10 KiB long, with ", ], </script>; fractional index 1 MiB long.
  13. Opening every hostile file leaves its bytes unchanged on disk (#661).

Collaboration (WebSocket, two Users + one Viewer):
14. Viewer sends Update and SyncStep2 → dropped, disconnected, document unchanged.
15. Share revoked mid-session → next frame refused within 500 ms.
16. Update using another connection's clientID → refused; history author unchanged.
17. Update with missing dependencies repeated until the pending cap → disconnect, RSS flat.
18. Clock 0xFFFFFFFF, struct length 2^53, delete set over 2^32 range, unknown clients.
19. Truncated/garbage varints, 2 MiB + 1 frame, 10 000 tiny frames/min (rate limit).
20. Valid update that sets an element to an invalid schema value (NaN, 1e308) → rolled back, sender disconnected.
21. Awareness 1 MiB, spoofed user name, clock near max.
22. Move-storm for 10 minutes: history bytes and RSS bounded; quota charged to the owner; collaborator daily budget enforced.
23. Mass delete of 5 000 elements, then Restore.
24. Concurrent WebDAV PUT of the file during a live session → no lost edits, no 5xx.
25. Public link to a canvas: WebSocket with an Edit token → refused (unless §54 is changed).

Cards and embeds:
26. Card pointing at http://127.0.0.1, 169.254.169.254, [::1], DNS-rebind host (reuse dns_rebind_preload.c), redirect to private IP → no fetch.
27. Preview HTML 50 MiB, slowloris server, og:image 100k x 100k PNG.
28. Viewer opens a canvas with external cards → zero outgoing requests from the server and from the browser to third-party hosts (network log).
29. ?el= with "] <img onerror> and 10 KiB value → no injection, canvas opens.
30. SVG export of an element with javascript: link and an embeddable → output passes the allowlist; served with sandbox CSP.
31. Mermaid input of 10 MiB and a pathological diagram → Worker killed by budget, UI usable.

7. Isolation matrix cases (#472/#331)

New routes to classify: canvas room WebSocket, canvas create/update/export
API, card batch resolver, card live feed, URL preview fetch/proxy, image
attachment upload/read, element-link resolution, history list/restore/undo.

Cases (User A owner, User B recipient or stranger, anonymous public):

  • M1 B (no share) opens A's canvas by calternal-id, by ?el=, via room WS, via export, via history → 404 identical to a missing ID.
  • M2 B with Share sees A's canvas; cards for A's Mail, Money, Contacts, Tasks, private Notes are placeholders; batch resolver returns uniform placeholders; timing/size match a missing ID.
  • M3 B with Collaborate adds a card for B's own Money item; A sees a placeholder.
  • M4 B places cards with A's item IDs on B's canvas → A's backlinks/Inspector show nothing; A gets no notice.
  • M5 Search as B (Share) for a word only in A's Money card title → no hit on the canvas.
  • M6 Public link /s/<slug>: every calternal card is a placeholder, no live feed, no WS write, no preview fetch triggered.
  • M7 Revoke Share during a live session → WS closed, card feed closed, placeholders.
  • M8 B's history view/per-author undo cannot reveal or revert another canvas, and undo of B's turn cannot remove A's edits made later.
  • M9 Preview proxy: B cannot read A's cached preview by URL hash or ID unless B can read a canvas that contains it.
  • M10 Image attachment fileId of A's canvas fetched by B directly → 404.
  • M11 Export by B (Share) embeds links only; resolved data is B's view only.
  • M12 MCP/CLI/WebMCP as B: same results as M1–M11 (one event path).
## Security requirements (design review 2026-10-03): must be met # Canvas (§60/§61, #976/#977): defensive design review Read-only review, 2026-10-03. Inputs: CLAUDE.md, DESIGN §33, §37, §54, §60, §61, issues #976 and #977, `crates/calternal-server/src/security.rs`, `crates/plugins/files/src/user_bytes.rs`, `crates/calternal-collab/src/{session,stored}.rs`. ## 0. Current state that matters - Shell CSP (security.rs:121): `frame-src 'none'`, `connect-src 'self'`, `font-src 'self' data:`, `img-src 'self' data: blob: https:`, `frame-ancestors 'none'`, plus `X-Frame-Options: DENY`. The module doc says "the app has no `<iframe>` of its own". **Stock Excalidraw embeddables (iframes) cannot render under this CSP**, and they must not: keep `frame-src 'none'` for v1. - `img-src https:` lets any `https:` image load directly from the viewer's browser (IP/UA leak, tracking pixel). A canvas makes this attacker-controlled by collaborators. Card images must go through a server proxy (see §2). - User bytes are served with `SANDBOX_POLICY` (`sandbox; default-src 'none'`). SVG exports must use this path. - Collab today (Notes): `MAX_MESSAGE_BYTES` = 12 MiB, `MAX_STORED_STATE` = 16 MiB, share recheck every 500 ms, public-edit frame cap 3 MiB and 300 frames/min, "update must still convert to Markdown, else roll back and disconnect", awareness clocks near `u32::MAX` refused. Reuse all of it; the Canvas needs a canvas-specific validator in place of the Markdown check. - Inconsistency to flag: `session.rs` has a **public Edit** grant (`PublicEditAccess`), but §54 says "Public links are view only". A canvas must not inherit public edit. Decide/record before #976 merges. ## 1. Untrusted file input (server parse + client restore) The same hostile bytes reach three parsers: the server (search, backlinks, room load), Excalidraw `restore()` in every viewer's browser, and other tools over WebDAV. Validate on the server at load **and on every Yjs update**, so one hostile collaborator cannot freeze every other viewer. Requirements (numbers are starting points; record the final ones in §60): | Limit | Value | Why | |---|---|---| | File bytes (`.excalidraw`, `.excalidraw.md`) | 32 MiB read cap, streamed | memory | | JSON nesting depth | 64 (serde_json default 128 is too deep for `customData`) | stack | | Elements per scene | 20 000 (bench needs 5k) | render / Yjs map size | | Points per linear/freedraw element | 20 000; total points per scene 2 M | rough.js / path cost | | Coordinates, width, height | finite, abs <= 1e7; `-0`, NaN, Inf, 1e308 rejected | rough.js hachure line count = size/gap: huge shapes hang every viewer | | strokeWidth, fontSize, roughness, opacity, angle | finite, clamped ranges | same | | Text per element / per scene | 64 KiB / 4 MiB | layout cost | | Element ID, fileId | `[A-Za-z0-9_-]{1,64}` | Y.Map keys, selectors, deep links | | Fractional index (`index`) | <= 64 chars, valid base-62 | unbounded growth by repeated insert-between | | Unknown fields per element (byte-preserved) | <= 16 KiB opaque | preservation without unbounded blobs | | `files` (dataURL images) | mime allowlist png/jpeg/webp/gif/svg+xml; base64 must decode; magic bytes match mime; <= 10 MiB each, counts against quota | bombs, disguised types | | compressed-json (LZ-String base64) | decode with an **output cap** (equal to the file cap) and a work budget | LZW output can grow quadratically with input | | PNG/SVG embedded scene on import | zlib/pako inflate with output cap | inflate bomb | | `.excalidraw.md` sections | one linear pass; first `json`/`compressed-json` block only; fence tricks (```` ```` ````, `%%`, nested fences) cannot hide a second scene | parser differential | Other rules: - **Opening never writes** (#661): a file that fails validation opens read-only with a real error state. The server never "repairs" and saves it. - Parser differential: the server and Excalidraw must agree on what is visible. Index only text that Excalidraw renders (skip `isDeleted: true`, skip text bound to a deleted container). Duplicate JSON keys: reject (serde `Value` keeps the last, JSON.parse keeps the last, but other tools differ). Lone surrogates, BOM, overlong numbers: open read-only, never 5xx. - Prototype pollution: reject keys `__proto__`, `constructor`, `prototype` in element IDs, `files` keys, `appState`, `customData` and Y.Map keys. Client code that turns Y.Map into objects must use `Object.create(null)`/`Map`. - Text tricks: render bidi controls (U+202A–U+202E, U+2066–U+2069) and zero-width chars (U+200B–U+200D, U+2060, U+FEFF) visibly in the canvas title, card labels and link tooltips; reuse the existing name sanitiser (`strip_unsafe_name_chars`) for the file name / title. External link hover shows the punycode host for mixed-script hosts. - Element `link`: allow only `https:`, `http:`, `mailto:` and same-origin calternal paths that start with exactly one `/` (not `//`, not `/\`). Reject `javascript:`, `data:`, `vbscript:`, `file:`, `blob:`. Open external links with `noopener noreferrer`. Check on the server (update validator) and in the client. - Images: keep image bytes **out of Yjs**. Store them as content-addressed attachments through Files/`calternal-fs` and put only the fileId in the element. Otherwise every image lands in the §61 history log forever. Plain `.excalidraw` files with inline dataURLs keep them byte-preserved on disk, but the room holds a reference. - Never decode images on the server outside the media sandbox. Client decode of a 100k x 100k PNG is a viewer-side DoS: check declared dimensions from the header before upload is accepted. - Excalidraw network: self-host fonts and assets (`EXCALIDRAW_ASSET_PATH`), remove library browsing (libraries.excalidraw.com), share/collab links (json.excalidraw.com) and any Excalidraw+ promotion. The CSP already blocks them; remove them so nothing fails visibly. - Mermaid conversion: run in a dedicated Web Worker with `securityLevel: 'strict'`, input cap (64 KiB), and terminate on a time budget. If API/CLI/MCP need it server-side, run it in the existing sandbox, never in the server process. ## 2. Embeddables and external URLs (privacy by default) Risks of stock Excalidraw embeddables: third-party tracking on open (cookies, IP, referrer), a collaborator-placed page that looks like calternal inside calternal chrome (credential phishing in a trusted origin), clickjacking of the embedded page, autoplay, `allow-same-origin allow-scripts allow-popups` in Excalidraw's default sandbox, and a CSP hole if `frame-src` is opened. Recommendation ("normie friendly, privacy invisible"): 1. **No third-party iframes in v1.** Keep `frame-src 'none'`. Set `validateEmbeddable={false}` for foreign URLs and draw every embeddable through `renderEmbeddable` (React, same document) with the ItemCard family. 2. **External URL = server-fetched preview card**: title, site name, favicon, og:image. Fetch with the #431 SSRF guard (DNS pinning, no private, loopback, link-local, CGNAT or metadata ranges, re-check every redirect, max 3 redirects), no cookies, fixed UA, 5 s timeout, 1 MiB HTML cap, `text/html` only, image cap 2 MiB and decoded in the media sandbox. Cache per owner. Images are served from the calternal origin (proxy), so the viewer's browser never contacts the third party. 3. Only editors trigger a fetch (when adding or refreshing a card). A viewer, a share recipient or a public link never causes an outgoing request (no SSRF amplifier, no "who opened this" signal to the site). 4. Video embeds (YouTube/Vimeo) are a later, separate decision: if built, use a click-to-play facade (cached thumbnail), then `youtube-nocookie.com` / `player.vimeo.com?dnt=1` only, `sandbox="allow-scripts allow-same-origin allow-presentation"`, `referrerpolicy="no-referrer"`, and add only those two hosts to `frame-src` on the shell. Not part of #976/#977. 5. Card click on an external URL opens a new tab (`noopener noreferrer`) and shows the real host before navigation; never navigate the app frame. 6. Consider removing `https:` from shell `img-src` once card images are proxied (separate issue; check Mail remote images and other users first). ## 3. Live cards and isolation (#977) - **The file stores only the deep link** (§33 stable identity) and the card mode. Never write a resolved title, amount, snippet or thumbnail into the element, `customData`, the text section or the links section. The file is read by WebDAV clients, exports, search and every recipient. - Resolution is server-side **per viewer**, through one batch endpoint (`POST` with up to 200 links, rate limited per User). Each link resolves under the viewer's own authority: own item, item shared to the viewer (§54), or placeholder. - Placeholder is uniform: same response shape, same status, same size class and no timing difference for "deleted", "never existed", "not yours" and "malformed". No title, no count, no kind-specific icon beyond what the link text already shows. (§54: non-recipients get 404 with no timing or size difference.) - Deep links must be opaque IDs. Check the §33 grammar: `/search?q=…` puts query text into the canvas file; `/mail/m/<message-id>` must be calternal's ID, not an RFC Message-ID that contains an address. A card for a search query stores a saved-search ID or warns that the query text is visible to collaborators. - The live change feed for cards subscribes per viewer and only to IDs the viewer can read; a revoked share turns the card into a placeholder within the existing recheck interval (500 ms). - **Enumeration:** the batch resolver is an oracle only for what the viewer can already read. Keep it so: no existence bit, cap batch size, rate limit, log bursts. Element-link `?el=` is opaque: never put it into a CSS selector or HTML; look it up in the element map. - **Search:** index the canvas's own text only, never resolved card content (else a recipient's search for "salary" matches the canvas through the owner's Money card). - **Backlinks "On <canvas name>":** show a canvas in an item's backlinks only when the viewer can read that canvas. A hostile User can put cards that point at another User's item IDs; that must not create backlinks, counts or notices in the item owner's view (spam and canvas-name leak). - **Shared canvas (Share/Collaborate):** recipients see placeholders for the owner's private items. Collaborators can add cards for their own items; the owner then sees placeholders for those unless shared to the owner. - **Public link (`/s/<slug>`):** resolve as an anonymous viewer: every calternal item is a placeholder; external cards show the cached preview from the proxy. No live feed, no edit (see the public-edit note in §0). - **Export** renders cards as the exporting User sees them; the embedded scene in the export carries only links. Agents/MCP get the same per-viewer resolution as the web (no "raw" read that bypasses it). ## 4. Collaboration path (one event path) - Per-frame authz: reuse the 500 ms recheck. Viewers (Share) may receive sync and send SyncStep1 and awareness only; **drop and disconnect on `Update`/`SyncStep2` from a Viewer**. API/CLI/MCP/WebMCP writes go through the same room and the same check. - **Author binding (§61):** the author of an update comes from the authenticated connection, never from the update contents. Each connection gets its own Yjs clientIDs; reject an update with structs under a clientID that belongs to another connection or author. Without this, a collaborator can spoof history attribution and make per-author undo revert someone else's work, or corrupt convergence by clientID reuse. - Update validator (replaces "still converts to Markdown"): after applying in a transaction, every changed element passes the §1 schema; else roll back and disconnect. - Pending structs: an update with missing dependencies is held by yrs in a pending store. Cap pending bytes per room (for example 1 MiB) and disconnect when exceeded, or a client can grow server memory without bound. - Clock abuse: reject clocks/lengths near `u32::MAX`/`2^53`, GC/skip ranges longer than the document, and delete sets that name clients the room has never seen. Fuzz yrs decode with these (it must error, never panic or allocate by declared length). - Sizes: per-frame cap for canvas lower than Notes (2 MiB, since images are out of Yjs); room state cap reuse 16 MiB; awareness state <= 8 KiB per client, server stamps name/colour from the session (no spoofed cursor labels), awareness rate cap. - Rates: per connection and per author frames/min (reuse the 300/min bucket), per User open rooms and connections (reuse `client_stream_permit`). - Mass delete: legitimate for an editor, so authz + history is the defence. Restore must handle "all elements deleted" in one update; add a test. - **History growth DoS (§61):** coalesce updates per author over 1–2 s before append; history bytes count against the **owner's** quota; per collaborator daily write budget so a recipient cannot fill the owner's disk; snapshot + fold by the retention rule; open history without full replay (already a §61 rule). A move-storm (one element dragged 300 frames/min for an hour) is a bench and adversarial case. - Flush: debounced writes go through `calternal-fs` atomic replace; a concurrent WebDAV write of the same file triggers the existing reload path, never a silent overwrite (sync collision = merge blocker). ## 5. Export (SVG/PNG with embedded scene) - Excalidraw's SVG export wraps linked elements in `<a href>` and can emit `<foreignObject>` for embeddables and `@font-face` data URLs in `<style>`. Post-process every SVG export on a strict allowlist: no `<script>`, no `on*` attributes, no `<foreignObject>`, no `<iframe>`/`<embed>`/`<object>`, `href` only `#…`, `data:image/(png|jpeg|webp|gif)`, `https:`, `http:`, `mailto:`; no external `<use>`, `<image>` or CSS `url()` to remote hosts; fonts only as embedded data URLs. - Serve exports and any `.svg` from Home with `SANDBOX_POLICY` and `nosniff`; show them in `<img>`, never inline in the app DOM. - The embedded scene payload (SVG metadata, PNG `tEXt`/`iTXt`) is untrusted on re-import: same §1 limits, inflate cap, chunk size cap (16 MiB). - The embedded scene carries links only, never resolved card data (§3). - PNG export of a huge scene: cap the export canvas size (browser max canvas area) and the scale; fail with a message, not a tab crash. ## 6. Adversarial cases for `tests/adversarial/` (new `canvas_probe`) File input (write via WebDAV and the API, then open, search, backlinks): 1. 200 MiB `.excalidraw` → refused/streamed, no OOM, no 5xx. 2. JSON nested 100 000 deep in `customData` → read-only error, server alive. 3. 1 M elements; 1 element with 5 M freedraw points. 4. Rectangle with width 1e308, x NaN (as `1e999`), negative-zero, `-1e308`; hachure fill with roughness 0 and tiny gap → rejected server-side; second browser context stays responsive (Playwright INP check). 5. LZ-String bomb: 1 MiB compressed-json that expands past the cap. 6. PNG with a pako-compressed scene bomb in `tEXt`; SVG with a 50 MiB metadata payload. 7. `files` with mime `image/png` but HTML/SVG-with-script bytes; base64 garbage; 11 MiB image; 1 000 images. 8. Keys `__proto__`, `constructor`, `prototype` as element ID, fileId, appState key; check `Object.prototype` is clean in the page afterwards. 9. Duplicate keys, lone surrogate `\ud800`, BOM, trailing garbage, two json blocks, fence-in-fence in `.excalidraw.md`. 10. Element `link` = `javascript:alert(1)`, `JaVaScRiPt:`, `\x01javascript:`, `//evil.example`, `/\evil.example`, `data:text/html,…`. 11. Text with U+202E, zero-width joiners, homoglyph host `xn--` link; file name `gpj.exe‮oard.excalidraw.md`. 12. Element IDs 10 KiB long, with `"`, `]`, `</script>`; fractional index 1 MiB long. 13. Opening every hostile file leaves its bytes unchanged on disk (#661). Collaboration (WebSocket, two Users + one Viewer): 14. Viewer sends `Update` and `SyncStep2` → dropped, disconnected, document unchanged. 15. Share revoked mid-session → next frame refused within 500 ms. 16. Update using another connection's clientID → refused; history author unchanged. 17. Update with missing dependencies repeated until the pending cap → disconnect, RSS flat. 18. Clock `0xFFFFFFFF`, struct length `2^53`, delete set over 2^32 range, unknown clients. 19. Truncated/garbage varints, 2 MiB + 1 frame, 10 000 tiny frames/min (rate limit). 20. Valid update that sets an element to an invalid schema value (NaN, 1e308) → rolled back, sender disconnected. 21. Awareness 1 MiB, spoofed user name, clock near max. 22. Move-storm for 10 minutes: history bytes and RSS bounded; quota charged to the owner; collaborator daily budget enforced. 23. Mass delete of 5 000 elements, then Restore. 24. Concurrent WebDAV PUT of the file during a live session → no lost edits, no 5xx. 25. Public link to a canvas: WebSocket with an Edit token → refused (unless §54 is changed). Cards and embeds: 26. Card pointing at `http://127.0.0.1`, `169.254.169.254`, `[::1]`, DNS-rebind host (reuse `dns_rebind_preload.c`), redirect to private IP → no fetch. 27. Preview HTML 50 MiB, slowloris server, `og:image` 100k x 100k PNG. 28. Viewer opens a canvas with external cards → zero outgoing requests from the server and from the browser to third-party hosts (network log). 29. `?el=` with `"]` `<img onerror>` and 10 KiB value → no injection, canvas opens. 30. SVG export of an element with `javascript:` link and an embeddable → output passes the allowlist; served with `sandbox` CSP. 31. Mermaid input of 10 MiB and a pathological diagram → Worker killed by budget, UI usable. ## 7. Isolation matrix cases (#472/#331) New routes to classify: canvas room WebSocket, canvas create/update/export API, card batch resolver, card live feed, URL preview fetch/proxy, image attachment upload/read, element-link resolution, history list/restore/undo. Cases (User A owner, User B recipient or stranger, anonymous public): - M1 B (no share) opens A's canvas by calternal-id, by `?el=`, via room WS, via export, via history → 404 identical to a missing ID. - M2 B with Share sees A's canvas; cards for A's Mail, Money, Contacts, Tasks, private Notes are placeholders; batch resolver returns uniform placeholders; timing/size match a missing ID. - M3 B with Collaborate adds a card for B's own Money item; A sees a placeholder. - M4 B places cards with A's item IDs on B's canvas → A's backlinks/Inspector show nothing; A gets no notice. - M5 Search as B (Share) for a word only in A's Money card title → no hit on the canvas. - M6 Public link `/s/<slug>`: every calternal card is a placeholder, no live feed, no WS write, no preview fetch triggered. - M7 Revoke Share during a live session → WS closed, card feed closed, placeholders. - M8 B's history view/per-author undo cannot reveal or revert another canvas, and undo of B's turn cannot remove A's edits made later. - M9 Preview proxy: B cannot read A's cached preview by URL hash or ID unless B can read a canvas that contains it. - M10 Image attachment fileId of A's canvas fetched by B directly → 404. - M11 Export by B (Share) embeds links only; resolved data is B's view only. - M12 MCP/CLI/WebMCP as B: same results as M1–M11 (one event path).
Author
Owner

Started #976 on job/canvas-core-976, base 48c94c9776660cee105be86c5a6ace90dd425367.

Read CLAUDE.md, CONTEXT.md, DESIGN §33/34/54/60/61 and all four comments (the format research is posted twice). The first slice is pure, bounded Canvas parsing and scene validation in calternal-notes-core. The existing room's representability and persistence checks are Markdown-specific; Canvas must replace those checks before it can use that room safely. No new image upload or external embeds will be built.

Started #976 on `job/canvas-core-976`, base `48c94c9776660cee105be86c5a6ace90dd425367`. Read CLAUDE.md, CONTEXT.md, DESIGN §33/34/54/60/61 and all four comments (the format research is posted twice). The first slice is pure, bounded Canvas parsing and scene validation in calternal-notes-core. The existing room's representability and persistence checks are Markdown-specific; Canvas must replace those checks before it can use that room safely. No new image upload or external embeds will be built.
Author
Owner

Owner answers 2026-10-03 (DESIGN §60 updated in 4a871b383): hand-drawn font is the default (self-hosted), per-canvas switch to the app font. Images: nothing embedded ever; image drop/linking is #989 (builds on this issue), so here keep reading old inline images and do not add new inline ones. External links → #977 preview cards. Share/Collaborate polish → #991 (headline); keep the core collaboration path ready for it.

Owner answers 2026-10-03 (DESIGN §60 updated in 4a871b383): hand-drawn font is the default (self-hosted), per-canvas switch to the app font. Images: nothing embedded ever; image drop/linking is #989 (builds on this issue), so here keep reading old inline images and do not add new inline ones. External links → #977 preview cards. Share/Collaborate polish → #991 (headline); keep the core collaboration path ready for it.
Author
Owner

Resumed #976 on job/canvas-core-976, head/base 48c94c9776660cee105be86c5a6ace90dd425367. No implementation commit survived; canvas.rs and its module export survive uncommitted. The first test run found zero-length Rust rlibs left by the OOM/reboot (memory map must have a non-zero length). Cleaning only this job target and rebuilding the focused parser tests. Latest owner follow-up moves new image handling to #989 and sharing polish to #991; existing inline/embedded content must still survive.

Resumed #976 on `job/canvas-core-976`, head/base `48c94c9776660cee105be86c5a6ace90dd425367`. No implementation commit survived; `canvas.rs` and its module export survive uncommitted. The first test run found zero-length Rust rlibs left by the OOM/reboot (`memory map must have a non-zero length`). Cleaning only this job target and rebuilding the focused parser tests. Latest owner follow-up moves new image handling to #989 and sharing polish to #991; existing inline/embedded content must still survive.
Author
Owner

Committed parser layer 5ee0b39c1: .excalidraw, Markdown Drawing fences (JSON, compressed-json, legacy), authoritative Text Elements / Element Links, source-span JSON patching, bounded element/point/text/depth validation and nine focused regression tests. Opening and semantic no-op saves preserve bytes; actual edits preserve unchanged JSON subtrees and unknown Markdown sections. Crate tests pass.

Evidence: three new tests first failed on outer JSON whitespace loss, absent generated text/link sections, and unchecked style/reference fields; all pass after the fixes. Image source bytes are retained as opaque data at this layer; image MIME/header validation before rendering remains part of the host integration.

Decision under evaluation for the next slice: clients submit validated whole-element events to the existing room; the server alone creates Yrs map transactions with the authenticated author. This avoids accepting client-authored Yrs structs, so pending structs and forged client IDs cannot control Canvas state. No second persistence path.

Committed parser layer `5ee0b39c1`: `.excalidraw`, Markdown Drawing fences (JSON, compressed-json, legacy), authoritative Text Elements / Element Links, source-span JSON patching, bounded element/point/text/depth validation and nine focused regression tests. Opening and semantic no-op saves preserve bytes; actual edits preserve unchanged JSON subtrees and unknown Markdown sections. Crate tests pass. Evidence: three new tests first failed on outer JSON whitespace loss, absent generated text/link sections, and unchecked style/reference fields; all pass after the fixes. Image source bytes are retained as opaque data at this layer; image MIME/header validation before rendering remains part of the host integration. Decision under evaluation for the next slice: clients submit validated whole-element events to the existing room; the server alone creates Yrs map transactions with the authenticated author. This avoids accepting client-authored Yrs structs, so pending structs and forged client IDs cannot control Canvas state. No second persistence path.
Author
Owner

Owner decision C10 (2026-10-03, DESIGN §60 'Open mode'): a canvas with content opens in edit mode on desktop/iPad and read mode on phones; an empty canvas opens in read mode everywhere except right after New canvas or Sketch (edit). One Edit tap switches; mode is per open, never stored in the file. e2e must cover all four cases (phone/desktop × empty/content) plus New canvas.

Owner decision C10 (2026-10-03, DESIGN §60 'Open mode'): a canvas with content opens in edit mode on desktop/iPad and read mode on phones; an empty canvas opens in read mode everywhere except right after New canvas or Sketch (edit). One Edit tap switches; mode is per open, never stored in the file. e2e must cover all four cases (phone/desktop × empty/content) plus New canvas.
Author
Owner

Committed client foundations in separate layers: 4292e82d4 pins Excalidraw 0.18.1 (npm registry verified MIT and React 19 compatibility); ad2f4424c gives new elements eight base62 characters and rewrites container/frame/arrow/bound-element references together, preserving imported identities; 70190debc copies all nine pinned font families to same-origin production assets and includes font licence notices extracted from their metadata and the upstream OFL/MIT notices.

Verification: bun run check reports svelte-check found 0 errors and 0 warnings; focused ID tests report Tests 2 passed (2); the production build completed (✔ done). The editor itself is not wired yet, so no screenshot claims.

Build finding: the first calternal-collab test build is linking after rebuilding the OOM-damaged target. Process inspection traced the delay through rustc → cc → collect2 → ld wrappers → rust-lld; rust-lld is in D (I/O wait), rather than executing tests. No changes made to the shared linker or target directory.

Committed client foundations in separate layers: `4292e82d4` pins Excalidraw 0.18.1 (npm registry verified MIT and React 19 compatibility); `ad2f4424c` gives new elements eight base62 characters and rewrites container/frame/arrow/bound-element references together, preserving imported identities; `70190debc` copies all nine pinned font families to same-origin production assets and includes font licence notices extracted from their metadata and the upstream OFL/MIT notices. Verification: `bun run check` reports `svelte-check found 0 errors and 0 warnings`; focused ID tests report `Tests 2 passed (2)`; the production build completed (`✔ done`). The editor itself is not wired yet, so no screenshot claims. Build finding: the first calternal-collab test build is linking after rebuilding the OOM-damaged target. Process inspection traced the delay through rustc → cc → collect2 → ld wrappers → rust-lld; rust-lld is in D (I/O wait), rather than executing tests. No changes made to the shared linker or target directory.
Author
Owner

#976 progress at d5d01aa2b: server integration is in progress, not yet claimed working.

Canvas uses the existing Note room and If-Match writer. Its custom collaboration event is whole-element JSON (type 102), and the server supplies the authenticated author. Client Yrs Update/SyncStep2 frames are refused before document application. Public Edit is refused for Canvas, per DESIGN §54. This avoids importing untrusted Yrs client IDs and pending structs while retaining the shared sync/broadcast/save path.

New regression coverage includes legacy array ordering, opaque source fields omitted by upstream restore, and MIME/magic/dimension checks for old inline images. Inline SVG uses an inert allowlist and no external resources. Current dimension decision: maximum 8192 per side and 40 million raster pixels; source/decoded-file cap remains 32 MiB and per-image decoded cap 10 MiB.

Host evidence: the pending calternal-collab test process is waiting on rust-lld, which repeatedly enters uninterruptible disk I/O wait. It is not running tests yet. I am keeping one Cargo process and continuing frontend integration. No gate result is claimed for this uncommitted layer.

#976 progress at d5d01aa2b: server integration is in progress, not yet claimed working. Canvas uses the existing Note room and If-Match writer. Its custom collaboration event is whole-element JSON (type 102), and the server supplies the authenticated author. Client Yrs Update/SyncStep2 frames are refused before document application. Public Edit is refused for Canvas, per DESIGN §54. This avoids importing untrusted Yrs client IDs and pending structs while retaining the shared sync/broadcast/save path. New regression coverage includes legacy array ordering, opaque source fields omitted by upstream restore, and MIME/magic/dimension checks for old inline images. Inline SVG uses an inert allowlist and no external resources. Current dimension decision: maximum 8192 per side and 40 million raster pixels; source/decoded-file cap remains 32 MiB and per-image decoded cap 10 MiB. Host evidence: the pending calternal-collab test process is waiting on rust-lld, which repeatedly enters uninterruptible disk I/O wait. It is not running tests yet. I am keeping one Cargo process and continuing frontend integration. No gate result is claimed for this uncommitted layer.
Author
Owner

Progress on job/canvas-core-976, head e6d5793e7. Ten atomic commits preserve source formats, pin and self-host Excalidraw/fonts, add stable eight-character IDs, mount the lazy editor, provide shared chrome/export/element links, create Canvas Notes from Notes/Files/palette, and validate old inline images. The production web build passed. Notes core full tests and clippy passed before the latest grid-limit regression addition. Current server collaboration checks are compiling dependencies; no server success claim yet.

Findings fixed with regression coverage: restored elements could trigger an initial write; Canvas presence cleanup must send only the connection's own client ID; the awareness envelope has a length prefix before its client count; legacy array order needs stable indices; Markdown authoritative rawText must follow actual text edits; plain .excalidraw index refresh needed the same file classification as adoption. The one HTTP element-events adapter calls the same author-bound server transaction as the WebSocket, and cannot accept writable client Yjs structs.

Decisions: existing inline images permit at most 8192 pixels per side and 40 million pixels; no image drop/upload. DOM-dependent Mermaid conversion is not exposed until it has bounded safe browser execution; §60 now records the browser-only requirement. Remaining feature gaps will be listed explicitly, including the per-Canvas application font option, Markdown embedding, and tool export/conversion parity if they cannot be completed within this job's time limit.

Progress on job/canvas-core-976, head e6d5793e7. Ten atomic commits preserve source formats, pin and self-host Excalidraw/fonts, add stable eight-character IDs, mount the lazy editor, provide shared chrome/export/element links, create Canvas Notes from Notes/Files/palette, and validate old inline images. The production web build passed. Notes core full tests and clippy passed before the latest grid-limit regression addition. Current server collaboration checks are compiling dependencies; no server success claim yet. Findings fixed with regression coverage: restored elements could trigger an initial write; Canvas presence cleanup must send only the connection's own client ID; the awareness envelope has a length prefix before its client count; legacy array order needs stable indices; Markdown authoritative rawText must follow actual text edits; plain .excalidraw index refresh needed the same file classification as adoption. The one HTTP element-events adapter calls the same author-bound server transaction as the WebSocket, and cannot accept writable client Yjs structs. Decisions: existing inline images permit at most 8192 pixels per side and 40 million pixels; no image drop/upload. DOM-dependent Mermaid conversion is not exposed until it has bounded safe browser execution; §60 now records the browser-only requirement. Remaining feature gaps will be listed explicitly, including the per-Canvas application font option, Markdown embedding, and tool export/conversion parity if they cannot be completed within this job's time limit.
Author
Owner

Progress, head cc2de85d1; origin/dev was fetched and merged once. Latest calternal-collab Clippy passed. Latest calternal-notes-core Clippy and tests passed, with output:

test result: ok. 531 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.47s
test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.21s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Additional fixes committed: invalid imported Canvas Markdown cannot mount a writable Markdown editor; rename offers Undo that refuses to overwrite a later title, with shared-index title updates; SVG exports cannot retain external CSS paint references. Focused Canvas export tests pass; svelte-check found 0 errors and 0 warnings. The offline authorization-classification suite reports Ran 8 tests in 0.183s and OK for the owner-bound Note identity.

The full calternal-collab test command is compiling. Its Cargo process spent approximately nine minutes in target-file I/O (folio_wait_bit_common), before dependency compilation resumed. No test result or browser screenshots are claimed yet. There is one Cargo process, with CARGO_BUILD_JOBS=3. No pushes or deploys.

The final report will distinguish completed checks from pending Notes/server gates and production browser evidence. Mermaid, application-font switching, Markdown Canvas embedding, embedded Home image resolution, and export/conversion tool parity remain explicit gaps.

Progress, head cc2de85d1; origin/dev was fetched and merged once. Latest calternal-collab Clippy passed. Latest calternal-notes-core Clippy and tests passed, with output: ``` test result: ok. 531 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.47s test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.21s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s ``` Additional fixes committed: invalid imported Canvas Markdown cannot mount a writable Markdown editor; rename offers Undo that refuses to overwrite a later title, with shared-index title updates; SVG exports cannot retain external CSS paint references. Focused Canvas export tests pass; `svelte-check found 0 errors and 0 warnings`. The offline authorization-classification suite reports `Ran 8 tests in 0.183s` and `OK` for the owner-bound Note identity. The full calternal-collab test command is compiling. Its Cargo process spent approximately nine minutes in target-file I/O (`folio_wait_bit_common`), before dependency compilation resumed. No test result or browser screenshots are claimed yet. There is one Cargo process, with CARGO_BUILD_JOBS=3. No pushes or deploys. The final report will distinguish completed checks from pending Notes/server gates and production browser evidence. Mermaid, application-font switching, Markdown Canvas embedding, embedded Home image resolution, and export/conversion tool parity remain explicit gaps.
Author
Owner

#976: incomplete at the four-hour timebox

Branch: job/canvas-core-976. Head: 8351951d34695d26da2f2826e7bf59a34a3ebee7. There are 20 commits since origin/dev, including the required origin/dev merge. No push or deploy was run. Do not merge this job yet.

Built and committed

  • Pure, bounded Canvas parsing with byte-preserving no-op saves, plain JSON and Markdown, LZ-string read support, unknown-field retention, managed Canvas retitles, and validation of old inline image bytes.
  • Pinned Excalidraw 0.18.1, lazy React host, self-hosted fonts and licence notices, eight-character new element IDs with atomic binding rewrites, stable element links, shared chrome, PNG/SVG downloads and safe SVG export attributes.
  • Notes/Files/palette creation entry points, Canvas transport in the existing Note provider, invalid-import guard, guarded rename Undo and shared-index title updates.
  • A hot-path bench profile and a real production browser probe for creation, drawing, saved edits, a second browser context, clipboard links, and macOS screenshots at 390/820/1440 in light and dark. The browser probe has syntax checks only; it has NOT run.

Files

Committed: crates/calternal-notes-core/src/{canvas.rs,lib.rs,rename.rs}, its Cargo.toml and Cargo.lock; apps/web/src/lib/canvas/; Notes provider/API/view/explorer, FilesBrowser and search providers; Excalidraw asset script and nine font licence notices; apps/web/package.json and bun.lock; apps/web/e2e/canvas-976.mjs; bench/canvas-976.mjs; DESIGN §60; the offline cross-User classification probe and tests.

Pending, UNCOMMITTED server work remains in exactly five files:

  • crates/calternal-collab/src/canvas.rs (new)
  • crates/calternal-collab/src/lib.rs
  • crates/calternal-collab/src/session.rs
  • crates/plugins/notes/src/lib.rs
  • crates/plugins/notes/src/store.rs

This prepares whole-element Yrs state, the version/nonce rule, author-bound events and presence, one HTTP/WebSocket event transaction, checked Note persistence, external-file reconciliation, creation/read/index support and bounded file opening. It is NOT runtime verified and is not in the head commit. Its latest patch is saved at artifacts/canvas-server-pending.patch in the worktree. Keep that work for continuation; do not apply it twice.

Gate output (verbatim)

cargo fmt --check: no output, exit 0. A later change updates doc comments only.

Latest core Clippy:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 25s

Latest cargo test -p calternal-notes-core -- --test-threads=4:

test result: ok. 531 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.47s
test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.21s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Earlier collaboration Clippy passed BEFORE the final room pin/format guards:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 9m 17s

Latest web check and focused Vitest:

svelte-check found 0 errors and 0 warnings
 Test Files  3 passed (3)
      Tests  19 passed (19)

Production build:

  Wrote site to "build"

Offline authorization classification:

Ran 8 tests in 0.183s

OK

The full collaboration test invocation was stopped after about 25 minutes of compilation/linking without reaching the new regressions. Its library-only replacement also stalled: rustc used seven seconds of CPU in 16 minutes, with workers waiting on futex/poll. A command-local retry without RUSTC_WRAPPER waited on the shared package cache and did not finish. No collaboration test pass is claimed. Notes and server final gates are outstanding.

Cleanup completed:

     Removed 9529 files, 4.0GiB total

Removed web/build, .svelte-kit/output and generated font copies. Source and artifacts remain. A rejected rm -rf cleanup was replaced with removal restricted to known generated directories inside this worktree.

UX gaps closed

Invalid Canvas Markdown cannot enter the Markdown writer. Opening restored elements does not submit an edit. Imported identities survive geometry edits and binding rewrites. Presence cleanup sends only the connection's identity. Rename offers guarded Undo and updates shared titles. Export removes external paint references. Loading, error and reconnecting states use the real provider.

Pending server regressions cover byte-preserving open/save and retry of an index callback without another file write. The latest source review also found and patched idle-room retention for wrong-format event requests; that regression has NOT run.

Known gaps / UX gaps left

  • All server integration remains uncommitted until its focused regressions and per-crate gates pass. No real-server e2e or screenshot set exists; no visual-quality or icon-alignment claim is made.
  • Mermaid conversion, per-Canvas application-font switching, live Markdown embeds, existing Embedded Files Home-image resolution, tool export/conversion parity and rendered collaboration cursors remain unfinished. Old inline images are validated and provided to the editor; Home-image references are preserved but not resolved.
  • Plain JSON rename is disabled. Full clipboard, keyboard, touch/Pencil, export round-trip and concurrent-client browser behavior need the committed production probe plus additional fixtures.
  • Generated OpenAPI/actions/API-client contracts have not been regenerated for the new event route and creation/read fields.
  • Bench measurements were not run: the current verification policy allows them only for performance issues on the perf VM. There is no Canvas baseline metric yet.
  • New image drop/upload, external cards and history remain #989, #977 and #975 scope. The owner file-only migration decision is retained in DESIGN; this core slice preserves old inline bytes as instructed.

Decisions

Legacy images and exports use an 8192-pixel side limit and a 40-million-pixel work limit. Older indexless scenes receive fixed-width base62 indices in their original array order; opening still does not write. The native Mermaid conversion dependency requires DOM measurement, so bounded browser execution is documented in §60 and the action is withheld. Public Edit sessions refuse Canvas pending its Share/Collaborate surface; existing ordinary Note behavior stays intact.

Continuation and merge round

Continue from the five pending files, not from scratch. No more origin/dev merge is needed for this round. Run with CARGO_PROFILE_DEV_DEBUG=line-tables-only, CARGO_INCREMENTAL=0, CARGO_BUILD_JOBS=3 and TMPDIR=/target/tmp:

cargo fmt --check
cargo clippy -p calternal-collab --all-targets -- -D warnings
cargo test -p calternal-collab -- --test-threads=4
cargo clippy -p calternal-plugin-notes --all-targets -- -D warnings
cargo test -p calternal-plugin-notes -- --test-threads=4
cargo clippy -p calternal-server --all-targets -- -D warnings
cargo test -p calternal-server -- --test-threads=4

Build the job's server, regenerate contracts with its openapi subcommand and the established action/API-client generators, then:

cd apps/web
bun run build
CALTERNAL_SERVER_BIN=$CARGO_TARGET_DIR/debug/calternal-server bun e2e/canvas-976.mjs

This must prove real create/draw/save/reload, a live second-client update, element-link selection/clipboard and all macOS screenshot variants. Attach screenshots for Claude review. Expand it for inline/Embedded Files images, lossless imported fixtures, export re-open, Undo and input modes.

For the merge round (not run in this job): full bun run test; the full e2e suite; tests/adversarial/run.sh with the authz, cross-User and robustness matrices. These must prove owner/recipient separation, malformed input rejection, bounded source/updates, concurrency consistency and no crashes or 5xx. Release/staging/Mac interop stay with the merge round. The new bench profile can be run by the performance job under flock /root/perf.lock using the shared release build, with load average recorded inside the lock.

# #976: incomplete at the four-hour timebox Branch: `job/canvas-core-976`. Head: `8351951d34695d26da2f2826e7bf59a34a3ebee7`. There are 20 commits since origin/dev, including the required origin/dev merge. No push or deploy was run. Do not merge this job yet. ## Built and committed - Pure, bounded Canvas parsing with byte-preserving no-op saves, plain JSON and Markdown, LZ-string read support, unknown-field retention, managed Canvas retitles, and validation of old inline image bytes. - Pinned Excalidraw 0.18.1, lazy React host, self-hosted fonts and licence notices, eight-character new element IDs with atomic binding rewrites, stable element links, shared chrome, PNG/SVG downloads and safe SVG export attributes. - Notes/Files/palette creation entry points, Canvas transport in the existing Note provider, invalid-import guard, guarded rename Undo and shared-index title updates. - A hot-path bench profile and a real production browser probe for creation, drawing, saved edits, a second browser context, clipboard links, and macOS screenshots at 390/820/1440 in light and dark. The browser probe has syntax checks only; it has NOT run. ## Files Committed: `crates/calternal-notes-core/src/{canvas.rs,lib.rs,rename.rs}`, its Cargo.toml and Cargo.lock; `apps/web/src/lib/canvas/`; Notes provider/API/view/explorer, FilesBrowser and search providers; Excalidraw asset script and nine font licence notices; apps/web/package.json and bun.lock; `apps/web/e2e/canvas-976.mjs`; `bench/canvas-976.mjs`; DESIGN §60; the offline cross-User classification probe and tests. Pending, UNCOMMITTED server work remains in exactly five files: - `crates/calternal-collab/src/canvas.rs` (new) - `crates/calternal-collab/src/lib.rs` - `crates/calternal-collab/src/session.rs` - `crates/plugins/notes/src/lib.rs` - `crates/plugins/notes/src/store.rs` This prepares whole-element Yrs state, the version/nonce rule, author-bound events and presence, one HTTP/WebSocket event transaction, checked Note persistence, external-file reconciliation, creation/read/index support and bounded file opening. It is NOT runtime verified and is not in the head commit. Its latest patch is saved at `artifacts/canvas-server-pending.patch` in the worktree. Keep that work for continuation; do not apply it twice. ## Gate output (verbatim) `cargo fmt --check`: no output, exit 0. A later change updates doc comments only. Latest core Clippy: ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 25s ``` Latest `cargo test -p calternal-notes-core -- --test-threads=4`: ``` test result: ok. 531 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.47s test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.21s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s ``` Earlier collaboration Clippy passed BEFORE the final room pin/format guards: ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 9m 17s ``` Latest web check and focused Vitest: ``` svelte-check found 0 errors and 0 warnings Test Files 3 passed (3) Tests 19 passed (19) ``` Production build: ``` Wrote site to "build" ``` Offline authorization classification: ``` Ran 8 tests in 0.183s OK ``` The full collaboration test invocation was stopped after about 25 minutes of compilation/linking without reaching the new regressions. Its library-only replacement also stalled: rustc used seven seconds of CPU in 16 minutes, with workers waiting on futex/poll. A command-local retry without RUSTC_WRAPPER waited on the shared package cache and did not finish. No collaboration test pass is claimed. Notes and server final gates are outstanding. Cleanup completed: ``` Removed 9529 files, 4.0GiB total ``` Removed web/build, .svelte-kit/output and generated font copies. Source and artifacts remain. A rejected `rm -rf` cleanup was replaced with removal restricted to known generated directories inside this worktree. ## UX gaps closed Invalid Canvas Markdown cannot enter the Markdown writer. Opening restored elements does not submit an edit. Imported identities survive geometry edits and binding rewrites. Presence cleanup sends only the connection's identity. Rename offers guarded Undo and updates shared titles. Export removes external paint references. Loading, error and reconnecting states use the real provider. Pending server regressions cover byte-preserving open/save and retry of an index callback without another file write. The latest source review also found and patched idle-room retention for wrong-format event requests; that regression has NOT run. ## Known gaps / UX gaps left - All server integration remains uncommitted until its focused regressions and per-crate gates pass. No real-server e2e or screenshot set exists; no visual-quality or icon-alignment claim is made. - Mermaid conversion, per-Canvas application-font switching, live Markdown embeds, existing Embedded Files Home-image resolution, tool export/conversion parity and rendered collaboration cursors remain unfinished. Old inline images are validated and provided to the editor; Home-image references are preserved but not resolved. - Plain JSON rename is disabled. Full clipboard, keyboard, touch/Pencil, export round-trip and concurrent-client browser behavior need the committed production probe plus additional fixtures. - Generated OpenAPI/actions/API-client contracts have not been regenerated for the new event route and creation/read fields. - Bench measurements were not run: the current verification policy allows them only for performance issues on the perf VM. There is no Canvas baseline metric yet. - New image drop/upload, external cards and history remain #989, #977 and #975 scope. The owner file-only migration decision is retained in DESIGN; this core slice preserves old inline bytes as instructed. ## Decisions Legacy images and exports use an 8192-pixel side limit and a 40-million-pixel work limit. Older indexless scenes receive fixed-width base62 indices in their original array order; opening still does not write. The native Mermaid conversion dependency requires DOM measurement, so bounded browser execution is documented in §60 and the action is withheld. Public Edit sessions refuse Canvas pending its Share/Collaborate surface; existing ordinary Note behavior stays intact. ## Continuation and merge round Continue from the five pending files, not from scratch. No more origin/dev merge is needed for this round. Run with CARGO_PROFILE_DEV_DEBUG=line-tables-only, CARGO_INCREMENTAL=0, CARGO_BUILD_JOBS=3 and TMPDIR=<worktree>/target/tmp: ``` cargo fmt --check cargo clippy -p calternal-collab --all-targets -- -D warnings cargo test -p calternal-collab -- --test-threads=4 cargo clippy -p calternal-plugin-notes --all-targets -- -D warnings cargo test -p calternal-plugin-notes -- --test-threads=4 cargo clippy -p calternal-server --all-targets -- -D warnings cargo test -p calternal-server -- --test-threads=4 ``` Build the job's server, regenerate contracts with its `openapi` subcommand and the established action/API-client generators, then: ``` cd apps/web bun run build CALTERNAL_SERVER_BIN=$CARGO_TARGET_DIR/debug/calternal-server bun e2e/canvas-976.mjs ``` This must prove real create/draw/save/reload, a live second-client update, element-link selection/clipboard and all macOS screenshot variants. Attach screenshots for Claude review. Expand it for inline/Embedded Files images, lossless imported fixtures, export re-open, Undo and input modes. For the merge round (not run in this job): full `bun run test`; the full e2e suite; `tests/adversarial/run.sh` with the authz, cross-User and robustness matrices. These must prove owner/recipient separation, malformed input rejection, bounded source/updates, concurrency consistency and no crashes or 5xx. Release/staging/Mac interop stay with the merge round. The new bench profile can be run by the performance job under `flock /root/perf.lock` using the shared release build, with load average recorded inside the lock.
Author
Owner

Round 2 started on job/canvas-core-976, head 8351951d34695d26da2f2826e7bf59a34a3ebee7, base f06679b11cde29cc0b7120fdab5f721389caf695. The five server files already match the saved pending patch (git apply --reverse --check passes); I will not apply it twice. First check: focused collaboration Canvas regressions, then the C10 open-mode rule and per-Canvas font option. No push or deploy.

Round 2 started on `job/canvas-core-976`, head `8351951d34695d26da2f2826e7bf59a34a3ebee7`, base `f06679b11cde29cc0b7120fdab5f721389caf695`. The five server files already match the saved pending patch (`git apply --reverse --check` passes); I will not apply it twice. First check: focused collaboration Canvas regressions, then the C10 open-mode rule and per-Canvas font option. No push or deploy.
Author
Owner

Round 2 findings, head 6bd653a46:

  • The pending patch already exists in the worktree; the reverse apply check passes. The focused collaboration invocation is compiling, not executing tests: its log progressed from proc-macro2 through hyper/libsqlite3-sys over about 17 minutes. Cargo is sleeping while three sccache workers compile dependencies. At 12:12 UTC the host load average was 36.75 39.70 38.89; the per-job target had only 702 MiB after the previous cleanup. Existing build issue #1007 describes the cold per-job dependency rebuild and slow links. Current evidence supports build contention; no collaboration-test deadlock or pass is claimed.
  • Filesystem adoption still called Task identity repair and Task indexing on Canvas Text Elements. Added a Canvas guard and a regression that checks source bytes, no Task rows, and searchable drawing text.
  • C10 is committed with focused tests: empty opens read, content opens edit off phones, and explicit New canvas starts edit. New canvas intent is consumed, so a reload cannot accidentally stay edit.
  • The pinned Excalidraw public package has no custom-font registration hook. The app-font view uses renderer-only family slots and the existing self-hosted app fonts; stored events and exports keep standard font IDs. The per-Canvas preference will use the same validated event transaction. Exact app-font exports remain a gap: portable exports currently use a standard sans-serif fallback.

Latest web checks: svelte-check found 0 errors and 0 warnings; Test Files 3 passed (3) and Tests 21 passed (21). These are focused checks, not browser evidence.

Round 2 findings, head `6bd653a46`: - The pending patch already exists in the worktree; the reverse apply check passes. The focused collaboration invocation is compiling, not executing tests: its log progressed from proc-macro2 through hyper/libsqlite3-sys over about 17 minutes. Cargo is sleeping while three sccache workers compile dependencies. At 12:12 UTC the host load average was `36.75 39.70 38.89`; the per-job target had only 702 MiB after the previous cleanup. Existing build issue #1007 describes the cold per-job dependency rebuild and slow links. Current evidence supports build contention; no collaboration-test deadlock or pass is claimed. - Filesystem adoption still called Task identity repair and Task indexing on Canvas Text Elements. Added a Canvas guard and a regression that checks source bytes, no Task rows, and searchable drawing text. - C10 is committed with focused tests: empty opens read, content opens edit off phones, and explicit New canvas starts edit. New canvas intent is consumed, so a reload cannot accidentally stay edit. - The pinned Excalidraw public package has no custom-font registration hook. The app-font view uses renderer-only family slots and the existing self-hosted app fonts; stored events and exports keep standard font IDs. The per-Canvas preference will use the same validated event transaction. Exact app-font exports remain a gap: portable exports currently use a standard sans-serif fallback. Latest web checks: `svelte-check found 0 errors and 0 warnings`; `Test Files 3 passed (3)` and `Tests 21 passed (21)`. These are focused checks, not browser evidence.
Author
Owner

Source review found that the ordinary Note PUT /api/v1/notes/{id}/body route could replace Canvas data after the new Note path classification accepted Canvas files. Added a format guard and a real Router regression: create two Canvas Notes in the requested folder, confirm unique portable filenames and the hand-drawn default, then prove a Markdown body PUT is refused and leaves source bytes unchanged. Plain .excalidraw JSON also refuses Note properties/retitle that would prepend YAML. These guards are pending Rust verification with the server slice; no runtime pass is claimed.

The Task guard now covers both targeted filesystem adoption and startup reconciliation, with byte and projection assertions.

Source review found that the ordinary Note `PUT /api/v1/notes/{id}/body` route could replace Canvas data after the new Note path classification accepted Canvas files. Added a format guard and a real Router regression: create two Canvas Notes in the requested folder, confirm unique portable filenames and the hand-drawn default, then prove a Markdown body PUT is refused and leaves source bytes unchanged. Plain `.excalidraw` JSON also refuses Note properties/retitle that would prepend YAML. These guards are pending Rust verification with the server slice; no runtime pass is claimed. The Task guard now covers both targeted filesystem adoption and startup reconciliation, with byte and projection assertions.
Author
Owner

The round-2 focused collaboration tests reached execution and passed:

    Finished `test` profile [unoptimized + debuginfo] target(s) in 48m 19s
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 25 filtered out; finished in 2.77s

The Notes compiler wait ended normally; no cache-wrapper workaround was applied. The preceding delay was a cold dependency build on the shared host, not a test deadlock. Existing build issue #1007 records that class of delay. Current checks are Clippy for the pending collaboration slice and the focused Notes Canvas regressions.

The round-2 focused collaboration tests reached execution and passed: ``` Finished `test` profile [unoptimized + debuginfo] target(s) in 48m 19s test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 25 filtered out; finished in 2.77s ``` The Notes compiler wait ended normally; no cache-wrapper workaround was applied. The preceding delay was a cold dependency build on the shared host, not a test deadlock. Existing build issue #1007 records that class of delay. Current checks are Clippy for the pending collaboration slice and the focused Notes Canvas regressions.
Author
Owner

The Task exclusion needed one small public addition in calternal-tags: prepare_note_projection prepares the existing Tag Index payload with an explicit Note ID, item kind and real source hash. Existing Markdown Tag behavior is unchanged. Canvas uses its visible drawing text and frontmatter Tags, so JSON colour strings do not become Tags and Task-like canvas text cannot classify it as a Task. The Notes regression now checks the stable Note identity, source hash and Tag list as well as no Task rows. Targeted adoption refreshes plain-JSON Tags after the Note ID exists. This avoids duplicating the shared Tag persistence code. Tags joins the per-crate final gates.

The Task exclusion needed one small public addition in calternal-tags: `prepare_note_projection` prepares the existing Tag Index payload with an explicit Note ID, item kind and real source hash. Existing Markdown Tag behavior is unchanged. Canvas uses its visible drawing text and frontmatter Tags, so JSON colour strings do not become Tags and Task-like canvas text cannot classify it as a Task. The Notes regression now checks the stable Note identity, source hash and Tag list as well as no Task rows. Targeted adoption refreshes plain-JSON Tags after the Note ID exists. This avoids duplicating the shared Tag persistence code. Tags joins the per-crate final gates.
Author
Owner

Committed the preserved server patch at 5dea4262c: whole-element rooms, one author-bound HTTP/WebSocket validator, byte-preserving clean-room closes, checked Note persistence, and external-file reconciliation. The pending patch was already present, so I verified it rather than applying it twice.

Focused collaboration evidence:

test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 25 filtered out; finished in 2.77s

The additional body-write/Task guards remain a separate slice while their focused Notes tests compile. The per-Canvas font event is now implemented and queued for its regression tests. The production web build completed:

  Wrote site to "build"

I stopped the preliminary cold Clippy invocation to prioritize executable regressions; it has no pass result. Final Clippy will run once after the required origin/dev merge. No push or deploy was run.

Committed the preserved server patch at `5dea4262c`: whole-element rooms, one author-bound HTTP/WebSocket validator, byte-preserving clean-room closes, checked Note persistence, and external-file reconciliation. The pending patch was already present, so I verified it rather than applying it twice. Focused collaboration evidence: ``` test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 25 filtered out; finished in 2.77s ``` The additional body-write/Task guards remain a separate slice while their focused Notes tests compile. The per-Canvas font event is now implemented and queued for its regression tests. The production web build completed: ``` Wrote site to "build" ``` I stopped the preliminary cold Clippy invocation to prioritize executable regressions; it has no pass result. Final Clippy will run once after the required origin/dev merge. No push or deploy was run.
Author
Owner

Round 2 production findings at a5c992214:

  • The real HTTPS production probe creates the Canvas and GET returns a validated empty scene. The /n/<id> route then redirects without page.state, so New Canvas loses its explicit C10 edit/title flag. The pending fix opens the real Note view directly and keeps per-open state when resolving stable links; a focused creation regression covers the navigation argument.
  • Full collaboration gates pass. Notes Clippy passes. The full Notes run returned 168 passed; 1 failed: daily_and_composer_preserve_unrelated_bytes received 404 instead of 200. The same test alone passes (1 passed; 0 failed). Its legacy-ID repair uses try_lock_owned on the process-wide User lock, while test Homes share the same User ID. The new Canvas startup fixture also uses that ID. The pending test-only change gives the two new Canvas fixtures separate User identities; existing fixtures and expectations remain unchanged. A serial Notes run checks the complete suite without unrelated Home lock contention.
  • Whole-Canvas Note embeds now use the shared live renderer and provider, with read-only controls, stable Open/Copy link actions, offscreen release, and unchanged Markdown source. Focused web checks passed before the navigation fix: svelte-check found 0 errors and 0 warnings; Test Files 4 passed (4); Tests 29 passed (29).
  • The event schema, generated API client and action registry now include Canvas font changes through the same validated event writer. python3 scripts/action_registry.py --check returned Action registry: 334 operations, 316 generated tools.

The required single git fetch origin and git merge origin/dev completed at 90710eaaa. No push, deploy or issue closure.

Round 2 production findings at a5c992214: - The real HTTPS production probe creates the Canvas and GET returns a validated empty scene. The `/n/<id>` route then redirects without `page.state`, so New Canvas loses its explicit C10 edit/title flag. The pending fix opens the real Note view directly and keeps per-open state when resolving stable links; a focused creation regression covers the navigation argument. - Full collaboration gates pass. Notes Clippy passes. The full Notes run returned `168 passed; 1 failed`: `daily_and_composer_preserve_unrelated_bytes` received 404 instead of 200. The same test alone passes (`1 passed; 0 failed`). Its legacy-ID repair uses `try_lock_owned` on the process-wide User lock, while test Homes share the same User ID. The new Canvas startup fixture also uses that ID. The pending test-only change gives the two new Canvas fixtures separate User identities; existing fixtures and expectations remain unchanged. A serial Notes run checks the complete suite without unrelated Home lock contention. - Whole-Canvas Note embeds now use the shared live renderer and provider, with read-only controls, stable Open/Copy link actions, offscreen release, and unchanged Markdown source. Focused web checks passed before the navigation fix: `svelte-check found 0 errors and 0 warnings`; `Test Files 4 passed (4)`; `Tests 29 passed (29)`. - The event schema, generated API client and action registry now include Canvas font changes through the same validated event writer. `python3 scripts/action_registry.py --check` returned `Action registry: 334 operations, 316 generated tools`. The required single `git fetch origin` and `git merge origin/dev` completed at 90710eaaa. No push, deploy or issue closure.
Author
Owner

Round 2 gates at 2aad2e12b, with the small library CSS fix pending its own commit:

All four touched Rust crates pass Clippy and tests. The complete Notes suite passes serially after isolating the two new Canvas fixtures. Output excerpts are verbatim:

calternal-collab:
test result: ok. 39 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.72s
calternal-plugin-notes:
test result: ok. 169 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 241.28s
calternal-tags:
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.92s
calternal-server:
test result: ok. 107 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 18.39s

The focused production probe found a real creation bug: normalizing an element ID while Excalidraw still holds its pointer/text-edit identity detaches subsequent updates. A rectangle could remain zero height and text empty. The pending fix waits for creation/text editing to finish, then changes the scene bindings and selection together before the event writer sees the new IDs. The actual browser regression checks positive rectangle geometry, exact text, persistence and reload. The current web check passes:

svelte-check found 0 errors and 0 warnings

The probe also used async predicates in waitForFunction, which treats their Promise as truthy. It now reuses the existing Notes poll helper; its focused helper regression passes. The unchanged theme harness fixtures fail seven tests with window is not defined; reproduced with the starting-head fixture and filed separately as #1013. Existing expectations and fixtures stay unchanged.

Next: finish the production browser regression and complete/attach the macOS screenshot matrix. PNG/SVG editable metadata round trips are added to the same focused flow.

Round 2 gates at 2aad2e12b, with the small library CSS fix pending its own commit: All four touched Rust crates pass Clippy and tests. The complete Notes suite passes serially after isolating the two new Canvas fixtures. Output excerpts are verbatim: ``` calternal-collab: test result: ok. 39 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.72s calternal-plugin-notes: test result: ok. 169 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 241.28s calternal-tags: test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.92s calternal-server: test result: ok. 107 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 18.39s ``` The focused production probe found a real creation bug: normalizing an element ID while Excalidraw still holds its pointer/text-edit identity detaches subsequent updates. A rectangle could remain zero height and text empty. The pending fix waits for creation/text editing to finish, then changes the scene bindings and selection together before the event writer sees the new IDs. The actual browser regression checks positive rectangle geometry, exact text, persistence and reload. The current web check passes: ``` svelte-check found 0 errors and 0 warnings ``` The probe also used async predicates in `waitForFunction`, which treats their Promise as truthy. It now reuses the existing Notes poll helper; its focused helper regression passes. The unchanged theme harness fixtures fail seven tests with `window is not defined`; reproduced with the starting-head fixture and filed separately as #1013. Existing expectations and fixtures stay unchanged. Next: finish the production browser regression and complete/attach the macOS screenshot matrix. PNG/SVG editable metadata round trips are added to the same focused flow.
Author
Owner

Canvas core #976 — round 2

READY FOR MERGE: no

Head: c41fd1c81c998c01b609af370b1c76b275b2c0be. Branch: job/canvas-core-976. The required single origin/dev fetch and merge completed at 90710eaaa. No push or deploy. The working tree is clean.

Built

Committed and verified the preserved server patch: author-bound whole-element events, bounded read-only Yjs replicas, checked source writes, external reconciliation, font metadata and API/tool contracts. Added C10 per-open modes, the per-Canvas font switch, transient cursors, keyboard/read-mode element actions, whole-Canvas Note preview modules and source-preserving decoration tests. Fixed active element ID normalization that detached drawing/text updates, restored the hand-drawn base for new app-font text, preserved New Canvas title/edit navigation state, and fixed element-link selection after asynchronous scene initialization.

Browser evidence

The real HTTPS production flows passed creation, positive rectangle geometry, exact text, save/reload, desktop/phone/iPad C10 modes, two independent browser contexts editing live, shared font updates and new app-font text's portable base. The diagnostic flow that cancels Rename also passed stable element selection/Copy link and PNG/SVG embedded-scene decoding plus editable reopen. It then failed the Note embed check. This diagnostic flow does not replace the default failing Rename flow.

60 production screenshots are attached to this issue. Each captured state covers 390/820/1440, light/dark, with macOS platform and User-agent emulation: Notes New, Rename, empty Canvas, app font, both co-editing clients, drawing, menu, element Copy link and tooltip. Embedded and Files New screenshots remain missing. Claude must review visual quality; no visual approval is claimed.

Gates — verbatim output

cargo fmt --check returned exit 0 with no output. Per-crate outputs follow. Notes used one test thread after the parallel fixture failure described below.

cargo clippy -p calternal-collab --all-targets -- -D warnings; cargo test -p calternal-collab:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 41m 08s
    Finished `test` profile [unoptimized + debuginfo] target(s) in 2m 43s
test result: ok. 39 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.72s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.93s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.04s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 101.03s
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.50s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.63s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 11.68s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.75s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 53.76s
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 29.25s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

cargo clippy -p calternal-plugin-notes --all-targets -- -D warnings; cargo test -p calternal-plugin-notes:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6m 19s
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1m 04s
test result: ok. 169 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 241.28s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.65s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

cargo clippy -p calternal-tags --all-targets -- -D warnings; cargo test -p calternal-tags:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 33s
    Finished `test` profile [unoptimized + debuginfo] target(s) in 3m 06s
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.92s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

cargo clippy -p calternal-server --all-targets -- -D warnings; cargo test -p calternal-server:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 11m 54s
    Finished `test` profile [unoptimized + debuginfo] target(s) in 8m 31s
test result: ok. 107 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 18.39s

Web check, focused Vitest, production build, generated action registry, API client tests, and the new async-poll regression:

svelte-check found 0 errors and 0 warnings
 Test Files  5 passed (5)
      Tests  30 passed (30)
  Wrote site to "build"
Action registry: 334 operations, 316 generated tools
 18 pass
 0 fail
 49 expect() calls
# tests 1
# pass 1
# fail 0

The first parallel Notes run reported 168 passed; 1 failed in daily_and_composer_preserve_unrelated_bytes (404 instead of 200). That test passed alone. The fixtures share a global User writer lock. The two new Canvas fixtures now have distinct Users; existing fixtures and expectations are unchanged. The full serial suite passed. The unchanged theme harness fixtures fail seven cases with window is not defined; reproduced from starting-head fixtures and filed as #1013.

Collaboration tests were not deadlocked. The first focused run took 48m19s to build cold dependencies, then executed its tests in 2.77s. Existing build issue #1007 records this shared-host problem.

UX gaps closed

New Canvas opens its real title/edit state. Drawing and typing retain active native identities until completion. New IDs, bindings and selection change together. Both clients receive live changes and font choice. Switching back restores the hand-drawn base for newly created app-font text. Read mode and keyboard expose element context actions. Element links select after scene initialization. Source opens/no-ops stay on the checked event writer. Canvas text cannot become Tasks during adoption or repair. Generated callers use the same validated event contract.

Known gaps / UX gaps left

  • Merge blocker #1019: Rename can lose the stable Note lookup on reload and show two sidebar entries. The default production flow remains failing. Inspect Rename, adoption and Index identity together; do not change its expected identity.
  • Merge blocker, recorded here: a real ![[Canvas path]] Note embed renders Image not available; the preview never reaches ready. The whole-Canvas decoration modules and unit tests exist, but the real Markdown/image conversion path still needs integration. The diagnostic run timed out after 60 seconds at [data-canvas-preview="ready"].
  • The .excalidraw / .excalidraw.md unknown-field import checks and Files New flow are implemented in the probe but were not reached after the embed failure. Embedded and Files New screenshot matrices remain outstanding.
  • API/CLI/MCP/WebMCP export parity and the Share/Collaborate/public Canvas surface remain incomplete. Public Edit Canvas stays refused. Full input/offline/Undo and cross-User checks belong in the merge round.
  • Exact application-font exports are incomplete; current portable exports use standard sans-serif fallback. App-font layout across switches needs visual review.
  • The general Tags rebuild is not verified against Canvas projection; the new Notes callback does preserve Note identity, source hash and text-only Tags. Very large Canvas text still meets the existing 2 MiB Search cap despite the 4 MiB Canvas text cap.
  • Images/Home-file resolution and first-edit inline-image migration stay with #989; links/cards stay with #977; history/per-author Undo stays with #975.

Decisions

C10 and the font switch follow owner decisions. Element IDs are normalized after native creation/text editing finishes, so active pointer and text references survive. Font changes use server arrival order and appState.calternalFont; imported font IDs remain unchanged, and new text keeps the standard hand-drawn source base. Portable app-font exports currently use Helvetica fallback; this is a limitation for owner review. Whole-Canvas previews apply to standalone whole wiki references, cap at sixteen visible candidates per Note, and release offscreen live work. Mermaid remains deferred: §60 records bounded browser execution because the dependency needs DOM measurement; no unbounded server conversion was added.

For the merge round

After the blockers are fixed:

cd apps/web
bun run test -- --maxWorkers=2
bun run test:e2e
bun run test:e2e:notes
CALTERNAL_SERVER_BIN=<combined-server> bun e2e/canvas-976.mjs
cd ../..
tests/adversarial/run.sh

The default Canvas flow must prove Rename retains the stable identity, live embeds render without source edits, both portable imports retain unknown fields, and all required screenshots exist. The adversarial run must prove authz/cross-User separation, bounded updates/source, concurrency consistency and no crashes/5xx. Full release, staging and Mac interop remain merge-round work. Performance measurements were not run: the current policy permits them only for performance issues. bench/canvas-976.mjs includes cold/warm Canvas, update bursts and embedded preview paths; no Canvas baseline exists yet.

Files

.gitignore
Cargo.lock
apps/web/e2e/canvas-976.mjs
apps/web/e2e/harness.mjs
apps/web/e2e/harness.test.mjs
apps/web/e2e/notes.mjs
apps/web/package.json
apps/web/scripts/canvas-assets.mjs
apps/web/scripts/canvas-font-licenses/Assistant.txt
apps/web/scripts/canvas-font-licenses/Cascadia.txt
apps/web/scripts/canvas-font-licenses/ComicShanns.txt
apps/web/scripts/canvas-font-licenses/Excalifont.txt
apps/web/scripts/canvas-font-licenses/Liberation.txt
apps/web/scripts/canvas-font-licenses/Lilita.txt
apps/web/scripts/canvas-font-licenses/Nunito.txt
apps/web/scripts/canvas-font-licenses/Virgil.txt
apps/web/scripts/canvas-font-licenses/Xiaolai.txt
apps/web/src/lib/canvas/CanvasPreview.svelte
apps/web/src/lib/canvas/CanvasReact.tsx
apps/web/src/lib/canvas/CanvasView.svelte
apps/web/src/lib/canvas/canvas.css
apps/web/src/lib/canvas/create.test.ts
apps/web/src/lib/canvas/create.ts
apps/web/src/lib/canvas/elementIds.test.ts
apps/web/src/lib/canvas/elementIds.ts
apps/web/src/lib/canvas/embeds.test.ts
apps/web/src/lib/canvas/embeds.ts
apps/web/src/lib/canvas/scene.test.ts
apps/web/src/lib/canvas/scene.ts
apps/web/src/lib/files/FilesBrowser.svelte
apps/web/src/lib/notes/NoteView.svelte
apps/web/src/lib/notes/NotesExplorer.svelte
apps/web/src/lib/notes/api.ts
apps/web/src/lib/notes/collab.test.ts
apps/web/src/lib/notes/collab.ts
apps/web/src/lib/notes/editorHost.ts
apps/web/src/lib/search/providers.ts
apps/web/src/routes/n/[id]/+page.svelte
bench/canvas-976.mjs
bun.lock
contracts/action-overrides.json
contracts/actions.json
contracts/openapi.json
crates/calternal-collab/src/canvas.rs
crates/calternal-collab/src/lib.rs
crates/calternal-collab/src/session.rs
crates/calternal-notes-core/Cargo.toml
crates/calternal-notes-core/src/canvas.rs
crates/calternal-notes-core/src/lib.rs
crates/calternal-notes-core/src/rename.rs
crates/calternal-tags/src/index.rs
crates/calternal-tags/src/lib.rs
crates/plugins/notes/src/lib.rs
crates/plugins/notes/src/store.rs
crates/plugins/notes/src/tasks_store.rs
docs/DESIGN.md
packages/api-client/src/generated.ts
tests/adversarial/test_xuser_classification.py
tests/adversarial/xuser_matrix.py

Cleanup

     Removed 18874 files, 12.0GiB total

cargo clean completed. Removed the generated web build and .svelte-kit output. Review artifacts remain in the ignored worktree directory. The selection patch in artifacts/canvas-selection-pending.patch is now committed at the reported head; it is not pending work.

# Canvas core #976 — round 2 READY FOR MERGE: no Head: `c41fd1c81c998c01b609af370b1c76b275b2c0be`. Branch: `job/canvas-core-976`. The required single origin/dev fetch and merge completed at 90710eaaa. No push or deploy. The working tree is clean. ## Built Committed and verified the preserved server patch: author-bound whole-element events, bounded read-only Yjs replicas, checked source writes, external reconciliation, font metadata and API/tool contracts. Added C10 per-open modes, the per-Canvas font switch, transient cursors, keyboard/read-mode element actions, whole-Canvas Note preview modules and source-preserving decoration tests. Fixed active element ID normalization that detached drawing/text updates, restored the hand-drawn base for new app-font text, preserved New Canvas title/edit navigation state, and fixed element-link selection after asynchronous scene initialization. ## Browser evidence The real HTTPS production flows passed creation, positive rectangle geometry, exact text, save/reload, desktop/phone/iPad C10 modes, two independent browser contexts editing live, shared font updates and new app-font text's portable base. The diagnostic flow that cancels Rename also passed stable element selection/Copy link and PNG/SVG embedded-scene decoding plus editable reopen. It then failed the Note embed check. This diagnostic flow does not replace the default failing Rename flow. 60 production screenshots are attached to this issue. Each captured state covers 390/820/1440, light/dark, with macOS platform and User-agent emulation: Notes New, Rename, empty Canvas, app font, both co-editing clients, drawing, menu, element Copy link and tooltip. Embedded and Files New screenshots remain missing. Claude must review visual quality; no visual approval is claimed. ## Gates — verbatim output `cargo fmt --check` returned exit 0 with no output. Per-crate outputs follow. Notes used one test thread after the parallel fixture failure described below. `cargo clippy -p calternal-collab --all-targets -- -D warnings`; `cargo test -p calternal-collab`: ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 41m 08s Finished `test` profile [unoptimized + debuginfo] target(s) in 2m 43s test result: ok. 39 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.72s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.93s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.04s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 101.03s test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.50s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.63s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.01s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 11.68s test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.75s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 53.76s test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 29.25s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s ``` `cargo clippy -p calternal-plugin-notes --all-targets -- -D warnings`; `cargo test -p calternal-plugin-notes`: ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 6m 19s Finished `test` profile [unoptimized + debuginfo] target(s) in 1m 04s test result: ok. 169 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 241.28s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.65s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s ``` `cargo clippy -p calternal-tags --all-targets -- -D warnings`; `cargo test -p calternal-tags`: ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 33s Finished `test` profile [unoptimized + debuginfo] target(s) in 3m 06s test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.92s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s ``` `cargo clippy -p calternal-server --all-targets -- -D warnings`; `cargo test -p calternal-server`: ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 11m 54s Finished `test` profile [unoptimized + debuginfo] target(s) in 8m 31s test result: ok. 107 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 18.39s ``` Web check, focused Vitest, production build, generated action registry, API client tests, and the new async-poll regression: ``` svelte-check found 0 errors and 0 warnings Test Files 5 passed (5) Tests 30 passed (30) Wrote site to "build" Action registry: 334 operations, 316 generated tools 18 pass 0 fail 49 expect() calls # tests 1 # pass 1 # fail 0 ``` The first parallel Notes run reported `168 passed; 1 failed` in `daily_and_composer_preserve_unrelated_bytes` (404 instead of 200). That test passed alone. The fixtures share a global User writer lock. The two new Canvas fixtures now have distinct Users; existing fixtures and expectations are unchanged. The full serial suite passed. The unchanged theme harness fixtures fail seven cases with `window is not defined`; reproduced from starting-head fixtures and filed as #1013. Collaboration tests were not deadlocked. The first focused run took 48m19s to build cold dependencies, then executed its tests in 2.77s. Existing build issue #1007 records this shared-host problem. ## UX gaps closed New Canvas opens its real title/edit state. Drawing and typing retain active native identities until completion. New IDs, bindings and selection change together. Both clients receive live changes and font choice. Switching back restores the hand-drawn base for newly created app-font text. Read mode and keyboard expose element context actions. Element links select after scene initialization. Source opens/no-ops stay on the checked event writer. Canvas text cannot become Tasks during adoption or repair. Generated callers use the same validated event contract. ## Known gaps / UX gaps left - **Merge blocker #1019:** Rename can lose the stable Note lookup on reload and show two sidebar entries. The default production flow remains failing. Inspect Rename, adoption and Index identity together; do not change its expected identity. - **Merge blocker, recorded here:** a real `![[Canvas path]]` Note embed renders `Image not available`; the preview never reaches ready. The whole-Canvas decoration modules and unit tests exist, but the real Markdown/image conversion path still needs integration. The diagnostic run timed out after 60 seconds at `[data-canvas-preview="ready"]`. - The `.excalidraw` / `.excalidraw.md` unknown-field import checks and Files New flow are implemented in the probe but were not reached after the embed failure. Embedded and Files New screenshot matrices remain outstanding. - API/CLI/MCP/WebMCP export parity and the Share/Collaborate/public Canvas surface remain incomplete. Public Edit Canvas stays refused. Full input/offline/Undo and cross-User checks belong in the merge round. - Exact application-font exports are incomplete; current portable exports use standard sans-serif fallback. App-font layout across switches needs visual review. - The general Tags rebuild is not verified against Canvas projection; the new Notes callback does preserve Note identity, source hash and text-only Tags. Very large Canvas text still meets the existing 2 MiB Search cap despite the 4 MiB Canvas text cap. - Images/Home-file resolution and first-edit inline-image migration stay with #989; links/cards stay with #977; history/per-author Undo stays with #975. ## Decisions C10 and the font switch follow owner decisions. Element IDs are normalized after native creation/text editing finishes, so active pointer and text references survive. Font changes use server arrival order and `appState.calternalFont`; imported font IDs remain unchanged, and new text keeps the standard hand-drawn source base. Portable app-font exports currently use Helvetica fallback; this is a limitation for owner review. Whole-Canvas previews apply to standalone whole wiki references, cap at sixteen visible candidates per Note, and release offscreen live work. Mermaid remains deferred: §60 records bounded browser execution because the dependency needs DOM measurement; no unbounded server conversion was added. ## For the merge round After the blockers are fixed: ``` cd apps/web bun run test -- --maxWorkers=2 bun run test:e2e bun run test:e2e:notes CALTERNAL_SERVER_BIN=<combined-server> bun e2e/canvas-976.mjs cd ../.. tests/adversarial/run.sh ``` The default Canvas flow must prove Rename retains the stable identity, live embeds render without source edits, both portable imports retain unknown fields, and all required screenshots exist. The adversarial run must prove authz/cross-User separation, bounded updates/source, concurrency consistency and no crashes/5xx. Full release, staging and Mac interop remain merge-round work. Performance measurements were not run: the current policy permits them only for performance issues. `bench/canvas-976.mjs` includes cold/warm Canvas, update bursts and embedded preview paths; no Canvas baseline exists yet. ## Files ``` .gitignore Cargo.lock apps/web/e2e/canvas-976.mjs apps/web/e2e/harness.mjs apps/web/e2e/harness.test.mjs apps/web/e2e/notes.mjs apps/web/package.json apps/web/scripts/canvas-assets.mjs apps/web/scripts/canvas-font-licenses/Assistant.txt apps/web/scripts/canvas-font-licenses/Cascadia.txt apps/web/scripts/canvas-font-licenses/ComicShanns.txt apps/web/scripts/canvas-font-licenses/Excalifont.txt apps/web/scripts/canvas-font-licenses/Liberation.txt apps/web/scripts/canvas-font-licenses/Lilita.txt apps/web/scripts/canvas-font-licenses/Nunito.txt apps/web/scripts/canvas-font-licenses/Virgil.txt apps/web/scripts/canvas-font-licenses/Xiaolai.txt apps/web/src/lib/canvas/CanvasPreview.svelte apps/web/src/lib/canvas/CanvasReact.tsx apps/web/src/lib/canvas/CanvasView.svelte apps/web/src/lib/canvas/canvas.css apps/web/src/lib/canvas/create.test.ts apps/web/src/lib/canvas/create.ts apps/web/src/lib/canvas/elementIds.test.ts apps/web/src/lib/canvas/elementIds.ts apps/web/src/lib/canvas/embeds.test.ts apps/web/src/lib/canvas/embeds.ts apps/web/src/lib/canvas/scene.test.ts apps/web/src/lib/canvas/scene.ts apps/web/src/lib/files/FilesBrowser.svelte apps/web/src/lib/notes/NoteView.svelte apps/web/src/lib/notes/NotesExplorer.svelte apps/web/src/lib/notes/api.ts apps/web/src/lib/notes/collab.test.ts apps/web/src/lib/notes/collab.ts apps/web/src/lib/notes/editorHost.ts apps/web/src/lib/search/providers.ts apps/web/src/routes/n/[id]/+page.svelte bench/canvas-976.mjs bun.lock contracts/action-overrides.json contracts/actions.json contracts/openapi.json crates/calternal-collab/src/canvas.rs crates/calternal-collab/src/lib.rs crates/calternal-collab/src/session.rs crates/calternal-notes-core/Cargo.toml crates/calternal-notes-core/src/canvas.rs crates/calternal-notes-core/src/lib.rs crates/calternal-notes-core/src/rename.rs crates/calternal-tags/src/index.rs crates/calternal-tags/src/lib.rs crates/plugins/notes/src/lib.rs crates/plugins/notes/src/store.rs crates/plugins/notes/src/tasks_store.rs docs/DESIGN.md packages/api-client/src/generated.ts tests/adversarial/test_xuser_classification.py tests/adversarial/xuser_matrix.py ``` ## Cleanup ``` Removed 18874 files, 12.0GiB total ``` `cargo clean` completed. Removed the generated web build and `.svelte-kit` output. Review artifacts remain in the ignored worktree directory. The selection patch in `artifacts/canvas-selection-pending.patch` is now committed at the reported head; it is not pending work. ## Screenshot links - [1440-dark-app-font.png](https://git.kayg.org/attachments/37a0f218-4db0-4f61-8b18-14915d272147) - [1440-dark-coedit-first.png](https://git.kayg.org/attachments/be93f284-cc68-4772-ada2-55f4eb91194c) - [1440-dark-coedit-second.png](https://git.kayg.org/attachments/6ba1b753-f195-4619-ad68-f4c430d59735) - [1440-dark-drawing.png](https://git.kayg.org/attachments/05da59c4-c5a2-4241-a33a-5043c6428cd5) - [1440-dark-element-copy-link.png](https://git.kayg.org/attachments/5f88b047-ec41-4813-afc2-b9ddb5e29f92) - [1440-dark-empty.png](https://git.kayg.org/attachments/51c956cb-458c-41b3-8ecf-d71c73bcfa93) - [1440-dark-menu.png](https://git.kayg.org/attachments/1fe55a76-2905-449d-a733-e0196d303816) - [1440-dark-notes-new.png](https://git.kayg.org/attachments/e7ea2c7e-c8fd-4d3d-85ce-db8e1c6564bd) - [1440-dark-rename.png](https://git.kayg.org/attachments/3ec8b4b8-50ca-4e7f-bf0a-deac1bf48966) - [1440-dark-tooltip.png](https://git.kayg.org/attachments/3b2add87-da4b-4c4e-b041-a8b9e9fa2a49) - [1440-light-app-font.png](https://git.kayg.org/attachments/375b3ad0-6aa8-4f52-9ac3-3aa9a61ad505) - [1440-light-coedit-first.png](https://git.kayg.org/attachments/5646aabf-03a1-4b59-b922-5e2828d56742) - [1440-light-coedit-second.png](https://git.kayg.org/attachments/ce043927-bc1c-47d6-b724-0d9215bb4169) - [1440-light-drawing.png](https://git.kayg.org/attachments/ea68e21e-3abf-4779-b3af-07b0c713717b) - [1440-light-element-copy-link.png](https://git.kayg.org/attachments/d07e7669-6722-4bcf-a369-51685dc4f17c) - [1440-light-empty.png](https://git.kayg.org/attachments/09e98bf3-0f93-4397-81c4-bb6e42679bdc) - [1440-light-menu.png](https://git.kayg.org/attachments/df87b3a2-e3da-4a3d-aefd-665da856a5dc) - [1440-light-notes-new.png](https://git.kayg.org/attachments/cfd55fba-60d6-411f-8124-811d0b78bc20) - [1440-light-rename.png](https://git.kayg.org/attachments/dae57049-645f-4468-9004-ddd51a68cc49) - [1440-light-tooltip.png](https://git.kayg.org/attachments/91a9eb8e-c0b4-470d-b749-44d6d96bfab9) - [390-dark-app-font.png](https://git.kayg.org/attachments/549e8ecc-5db8-470b-9448-2a2f2cdcf4b9) - [390-dark-coedit-first.png](https://git.kayg.org/attachments/ce23bcb2-edcb-4949-9447-178cef9acbbc) - [390-dark-coedit-second.png](https://git.kayg.org/attachments/268117ee-93e4-4fc4-a363-bfa975f0eae8) - [390-dark-drawing.png](https://git.kayg.org/attachments/e0b6b2f7-bd01-487b-9d9e-666bfa58fae7) - [390-dark-element-copy-link.png](https://git.kayg.org/attachments/47a9f558-4f7d-48bd-b97b-62f76cc7dd5e) - [390-dark-empty.png](https://git.kayg.org/attachments/ad191980-1e13-4efc-b764-8f48f803a91c) - [390-dark-menu.png](https://git.kayg.org/attachments/26228791-cbf3-4b4f-9704-9302b59541ce) - [390-dark-notes-new.png](https://git.kayg.org/attachments/c673fb11-59c6-48c6-a3a8-a7b11abafd22) - [390-dark-rename.png](https://git.kayg.org/attachments/bd7ef7cc-eca2-4961-be7b-03c942dd05af) - [390-dark-tooltip.png](https://git.kayg.org/attachments/c8dc9ca4-e12d-4b0f-845e-83dd11c1b5c7) - [390-light-app-font.png](https://git.kayg.org/attachments/b7e7973a-1acd-45d0-ac45-2322d086b013) - [390-light-coedit-first.png](https://git.kayg.org/attachments/89cb4ec3-c598-42d0-a7db-7088dd6399a9) - [390-light-coedit-second.png](https://git.kayg.org/attachments/50eb5b20-6f7f-46cf-b8c5-fcd86332ac4b) - [390-light-drawing.png](https://git.kayg.org/attachments/9be0d712-27c5-4187-b6f5-ea2fba4fafec) - [390-light-element-copy-link.png](https://git.kayg.org/attachments/2afb8a30-d815-4803-b996-bd77c4416b82) - [390-light-empty.png](https://git.kayg.org/attachments/50b7a136-e4f9-4304-a663-ad524c569400) - [390-light-menu.png](https://git.kayg.org/attachments/b369d131-8580-4c16-9fad-ab073a07a212) - [390-light-notes-new.png](https://git.kayg.org/attachments/aedb5963-bf95-433c-9669-1957de0ac2d8) - [390-light-rename.png](https://git.kayg.org/attachments/965b900e-005a-4d2f-8b81-6ef887abd6af) - [390-light-tooltip.png](https://git.kayg.org/attachments/236d9eb6-156a-454a-9900-c5c580fab4d1) - [820-dark-app-font.png](https://git.kayg.org/attachments/098ff8a2-20b6-4966-8bfa-1aec608df915) - [820-dark-coedit-first.png](https://git.kayg.org/attachments/dcb8cd17-9333-4a3d-a7a2-899158b5af7b) - [820-dark-coedit-second.png](https://git.kayg.org/attachments/c02485a8-5765-40e8-9f6d-f2195ecd47e0) - [820-dark-drawing.png](https://git.kayg.org/attachments/d750967f-33d9-43a3-9d27-aa016963db5b) - [820-dark-element-copy-link.png](https://git.kayg.org/attachments/ea878342-f499-478d-8d9d-670466691a0d) - [820-dark-empty.png](https://git.kayg.org/attachments/72a21da2-a5e2-439d-989f-ef1b1bcc8b3e) - [820-dark-menu.png](https://git.kayg.org/attachments/8bd615bb-0016-43cd-9f2b-5e145bbe3990) - [820-dark-notes-new.png](https://git.kayg.org/attachments/e18953fc-1f89-49db-9cad-fafff3eb0349) - [820-dark-rename.png](https://git.kayg.org/attachments/bd55ba45-468c-4b85-a534-5626952f1d02) - [820-dark-tooltip.png](https://git.kayg.org/attachments/39f60bd4-7fe8-424e-8f71-61556fa133b2) - [820-light-app-font.png](https://git.kayg.org/attachments/e6ceef6d-cf69-433d-8c1b-aa14d62b1603) - [820-light-coedit-first.png](https://git.kayg.org/attachments/87428a13-c0f0-4355-a925-1b7b7278c629) - [820-light-coedit-second.png](https://git.kayg.org/attachments/be5f0568-1e13-43b8-9c6e-b09c0c9bd7c1) - [820-light-drawing.png](https://git.kayg.org/attachments/4236d434-b6cc-4fc8-8394-a567b128d295) - [820-light-element-copy-link.png](https://git.kayg.org/attachments/1ec317c0-3ef8-4483-8cd0-f36ffbde59c1) - [820-light-empty.png](https://git.kayg.org/attachments/94dad038-5f91-4e56-8a38-bd53ab44ae78) - [820-light-menu.png](https://git.kayg.org/attachments/92247a4a-55ed-4740-9c72-5ef3e7fdb9ad) - [820-light-notes-new.png](https://git.kayg.org/attachments/7d86032e-8786-4b0f-be4c-491cdbb21a6a) - [820-light-rename.png](https://git.kayg.org/attachments/584f443d-1f58-4bca-bb19-e72e203caa5e) - [820-light-tooltip.png](https://git.kayg.org/attachments/f1684828-0d83-4099-9ffc-72c3d285d4c3)
Author
Owner

Round 3 started on job/canvas-core-976 at c41fd1c81. Fix order: stable identity (#1019), live whole/partial previews, font-ready text restoration, import/export checks and production screenshot evidence. Per-branch verification policy applies; full suites and adversarial matrices remain for the merge round.

Round 3 started on job/canvas-core-976 at c41fd1c81. Fix order: stable identity (#1019), live whole/partial previews, font-ready text restoration, import/export checks and production screenshot evidence. Per-branch verification policy applies; full suites and adversarial matrices remain for the merge round.
Author
Owner

Finding: the route-level regression at 2314ff3e5 passes rename → adoption → move → live save, retaining calternal-id, title and element ID. The pure planner also preserves .excalidraw.md. Browser confirmation will use a fresh branch server. Embed failure is explained by the Note writer converting resolved wiki embeds to Markdown image nodes; existing decorations only inspected paragraphs. Font readiness and local text remeasurement are committed at 69b91f8db.

Finding: the route-level regression at 2314ff3e5 passes rename → adoption → move → live save, retaining calternal-id, title and element ID. The pure planner also preserves .excalidraw.md. Browser confirmation will use a fresh branch server. Embed failure is explained by the Note writer converting resolved wiki embeds to Markdown image nodes; existing decorations only inspected paragraphs. Font readiness and local text remeasurement are committed at 69b91f8db.
Author
Owner

Production findings: #1019 now passes the original rename/reload/co-edit/element-link flow with the read lock. Whole-Canvas embeds reach ready and update live at 390/820/1440 in light and dark without changing their containing Note. Plain .excalidraw import then found a post-save Files callback rejection: record_note_write only accepted .md, so a committed Canvas edit answered 409 and kept a pending callback. A minimal Note-format callback validator is being added in crates/plugins/files/src/lib.rs; ordinary Markdown validation and its tests remain unchanged. Authored named-frame embeds now resolve to #^ before save, so frame rename survives reload.

Production findings: #1019 now passes the original rename/reload/co-edit/element-link flow with the read lock. Whole-Canvas embeds reach ready and update live at 390/820/1440 in light and dark without changing their containing Note. Plain .excalidraw import then found a post-save Files callback rejection: record_note_write only accepted .md, so a committed Canvas edit answered 409 and kept a pending callback. A minimal Note-format callback validator is being added in crates/plugins/files/src/lib.rs; ordinary Markdown validation and its tests remain unchanged. Authored named-frame embeds now resolve to #^<element-id> before save, so frame rename survives reload.
Author
Owner

Round 3 finding: the first edit to a plain .excalidraw import installed the new source, then returned 409 because the Files post-write callback accepted only .md. The callback retained a pending write after the successful install. Commit 6c0b58ae6 accepts validated plain Canvas writes in the existing Note callback and keeps the immutable Files identity through watcher adoption. The real callback regression passed; the production import probe now checks plain JSON, Markdown JSON, compressed JSON and legacy inline images.

The required one-time origin/dev integration is complete at e0bb66b8e. Web check: svelte-check found 0 errors and 0 warnings. Focused Canvas tests: 21 passed. Notes clippy passed; merged crate tests and the updated server build are running.

Round 3 finding: the first edit to a plain `.excalidraw` import installed the new source, then returned 409 because the Files post-write callback accepted only `.md`. The callback retained a pending write after the successful install. Commit `6c0b58ae6` accepts validated plain Canvas writes in the existing Note callback and keeps the immutable Files identity through watcher adoption. The real callback regression passed; the production import probe now checks plain JSON, Markdown JSON, compressed JSON and legacy inline images. The required one-time `origin/dev` integration is complete at `e0bb66b8e`. Web check: `svelte-check found 0 errors and 0 warnings`. Focused Canvas tests: 21 passed. Notes clippy passed; merged crate tests and the updated server build are running.
Author
Owner

Round 3 resumed on job/canvas-core-976 at 34b5c1a372 (the requested checkpoint c41fd1c81 and its follow-up commits are present). I am checking remaining import/export parity, required screenshots, focused gates, and any final integration gaps.

Round 3 resumed on job/canvas-core-976 at 34b5c1a3721467a0ab18fe49db0427af6b2a70e5 (the requested checkpoint c41fd1c81 and its follow-up commits are present). I am checking remaining import/export parity, required screenshots, focused gates, and any final integration gaps.
Author
Owner

Round 3 production run: create/rename/reload, live co-edit, both font modes, bound text, element links, full Notes/drawing screenshots and the hand-tool tooltip all reached the expected states. The real parent Note flow then timed out after 60 seconds waiting for ; no embedded screenshots were written. I am isolating the Note resolver/widget startup with a small production-server flow. The native module needed a process-local loader preload; the full test child processes run without it.

Round 3 production run: create/rename/reload, live co-edit, both font modes, bound text, element links, full Notes/drawing screenshots and the hand-tool tooltip all reached the expected states. The real parent Note flow then timed out after 60 seconds waiting for ; no embedded screenshots were written. I am isolating the Note resolver/widget startup with a small production-server flow. The native module needed a process-local loader preload; the full test child processes run without it.
Author
Owner

Round 3 production run: create/rename/reload, live co-edit, both font modes, bound text, element links, full Notes/drawing screenshots and the hand-tool tooltip all reached the expected states. The real parent Note flow then timed out after 60 seconds waiting for the Canvas preview to reach its ready state; no embedded screenshots were written. I am isolating the Note resolver/widget startup with a small production-server flow. The native sharp module needed a process-local loader preload; the full test child processes run without it.

Round 3 production run: create/rename/reload, live co-edit, both font modes, bound text, element links, full Notes/drawing screenshots and the hand-tool tooltip all reached the expected states. The real parent Note flow then timed out after 60 seconds waiting for the Canvas preview to reach its ready state; no embedded screenshots were written. I am isolating the Note resolver/widget startup with a small production-server flow. The native sharp module needed a process-local loader preload; the full test child processes run without it.
Author
Owner

The fast real-server User flow now passes both live embed forms, frame rename and the plain JSON, Markdown JSON, compressed-json and legacy inline-image fixtures. After the import assertion was changed to find the stable element ID (Excalidraw fractional ordering means slot zero is not stable), the final asset assertion found real requests to esm.sh for 7,260+ Excalidraw font files. The production app must load the self-hosted font bundle instead; I am tracing the build asset rewrite.

The fast real-server User flow now passes both live embed forms, frame rename and the plain JSON, Markdown JSON, compressed-json and legacy inline-image fixtures. After the import assertion was changed to find the stable element ID (Excalidraw fractional ordering means slot zero is not stable), the final asset assertion found real requests to esm.sh for 7,260+ Excalidraw font files. The production app must load the self-hosted font bundle instead; I am tracing the build asset rewrite.
Author
Owner

Production asset tracing found that Excalidraw 0.18.1 appends its esm.sh font fallback URL even when the self-hosted asset path is configured. I added a version-checked Vite transform that rewrites only this pinned fallback to /fonts/excalidraw/, where scripts/canvas-assets.mjs copies the package fonts. The final production probe checks that no external asset requests remain.

Production asset tracing found that Excalidraw 0.18.1 appends its esm.sh font fallback URL even when the self-hosted asset path is configured. I added a version-checked Vite transform that rewrites only this pinned fallback to /fonts/excalidraw/, where scripts/canvas-assets.mjs copies the package fonts. The final production probe checks that no external asset requests remain.
Author
Owner

The first production build of that transform exposed an upstream URL invariant: ExcalidrawFontFace passes its fallback to new URL(fontPath, base), so /fonts/excalidraw/ alone throws TypeError: Invalid base URL during text entry. The transform now builds an absolute same-origin URL from window.location.origin; the focused real-server flow is rerunning against the rebuilt production app.

The first production build of that transform exposed an upstream URL invariant: `ExcalidrawFontFace` passes its fallback to `new URL(fontPath, base)`, so `/fonts/excalidraw/` alone throws `TypeError: Invalid base URL` during text entry. The transform now builds an absolute same-origin URL from `window.location.origin`; the focused real-server flow is rerunning against the rebuilt production app.
Author
Owner

Export parity finding: contracts/actions.json exposes the Canvas element event for CLI, MCP and WebMCP, but it has no PNG/SVG export action or endpoint. The UI export remains in CanvasReact.tsx through exportToBlob and exportToSvg; the production flow verifies those exports reopen with their editable scenes. The API/CLI/MCP export requirement in DESIGN §60 is still unmet. §60 does not define a headless rendering contract, so I did not add a second renderer or a new route that cannot preserve Excalidraw output parity. This remains a merge blocker.

Export parity finding: `contracts/actions.json` exposes the Canvas element event for CLI, MCP and WebMCP, but it has no PNG/SVG export action or endpoint. The UI export remains in `CanvasReact.tsx` through `exportToBlob` and `exportToSvg`; the production flow verifies those exports reopen with their editable scenes. The API/CLI/MCP export requirement in DESIGN §60 is still unmet. §60 does not define a headless rendering contract, so I did not add a second renderer or a new route that cannot preserve Excalidraw output parity. This remains a merge blocker.
Author
Owner

The production screenshot run captured the full Notes, drawing, menu, tooltip, element-link, co-edit and 3× text-crop matrix at 390/820/1440 in light and dark. After the repeated theme navigations and PNG/SVG reopens, the run reaches a Canvas error state and times out before the embedded-note captures. The screenshot-free production flow passes the PNG/SVG scene round-trips, whole and partial embeds, all four import fixtures, and the no-external-assets assertion. The late room failure is not isolated; I am preserving the error screenshot as evidence and report it as a remaining verification/UX gap.

The production screenshot run captured the full Notes, drawing, menu, tooltip, element-link, co-edit and 3× text-crop matrix at 390/820/1440 in light and dark. After the repeated theme navigations and PNG/SVG reopens, the run reaches a Canvas error state and times out before the embedded-note captures. The screenshot-free production flow passes the PNG/SVG scene round-trips, whole and partial embeds, all four import fixtures, and the no-external-assets assertion. The late room failure is not isolated; I am preserving the error screenshot as evidence and report it as a remaining verification/UX gap.
Author
Owner

Post-merge verification update (HEAD 123078dce). The earlier 820 px preview timeout came from reusing one page through multiple saved-theme route changes; the screenshot helper now opens a fresh route for each palette. The dedicated production screenshot pass completed whole and partial live Canvas previews at 390/820/1440 px in light and dark, and completed Files New captures. The ordinary focused production flow also passed after merge, including PNG/SVG download and editable reopen, JSON/Markdown/compressed/legacy-inline-image imports, live embeds, and same-origin requests. I am attaching the current production screenshots and running the final web gates now.

Post-merge verification update (HEAD 123078dce). The earlier 820 px preview timeout came from reusing one page through multiple saved-theme route changes; the screenshot helper now opens a fresh route for each palette. The dedicated production screenshot pass completed whole and partial live Canvas previews at 390/820/1440 px in light and dark, and completed Files New captures. The ordinary focused production flow also passed after merge, including PNG/SVG download and editable reopen, JSON/Markdown/compressed/legacy-inline-image imports, live embeds, and same-origin requests. I am attaching the current production screenshots and running the final web gates now.
Author
Owner

Screenshot note: a follow-up capture probe confirmed that the production 390 px Canvas is in read mode and does not mount the Hand radio; Playwright evidence is getByRole('radio', { name: /Hand/ }) timing out at that viewport. The existing 390 px toolbar tooltip captures still pass, and the dedicated Hand guidance is attached at 820/1440 in both themes. I kept the existing responsive assertion and did not invent a phone Hand control for this issue.

Screenshot note: a follow-up capture probe confirmed that the production 390 px Canvas is in read mode and does not mount the Hand radio; Playwright evidence is `getByRole('radio', { name: /Hand/ })` timing out at that viewport. The existing 390 px toolbar tooltip captures still pass, and the dedicated Hand guidance is attached at 820/1440 in both themes. I kept the existing responsive assertion and did not invent a phone Hand control for this issue.
Author
Owner

Canvas profile finding and result (commit 98cfb9857). The first locked run (load average 0.95) returned HTTP 422 while seeding the text labels: the profile generated four-byte d indices, while Canvas validation requires five bytes for that prefix. I changed the fixture to generate fixed-width valid c/d fractional indices and include the response body in seed failures.

The corrected profile passed once on perf-test under /root/perf.lock; one-minute load at start was 0.33. At 5,500 elements (500 bound labels), cold Canvas open was 4.80 s; warm open p50/p95 was 4.82/5.87 s; live-update burst p50/p95 was 2.31/2.68 s; pan/zoom Event Timing candidates were 280 ms; whole-Canvas preview cold open was 6.46 s (warm p50/p95 3.49/4.03 s); partial-preview p50/p95 was 2.89/3.37 s. Server RSS averaged 814 MB and peaked at 933 MB. The 110-element case and full JSON are in the run output. docs/perf/baseline.json has no Canvas baseline, so these numbers have no prior comparison.

Canvas profile finding and result (commit 98cfb9857). The first locked run (load average 0.95) returned HTTP 422 while seeding the text labels: the profile generated four-byte `d` indices, while Canvas validation requires five bytes for that prefix. I changed the fixture to generate fixed-width valid `c`/`d` fractional indices and include the response body in seed failures. The corrected profile passed once on `perf-test` under `/root/perf.lock`; one-minute load at start was 0.33. At 5,500 elements (500 bound labels), cold Canvas open was 4.80 s; warm open p50/p95 was 4.82/5.87 s; live-update burst p50/p95 was 2.31/2.68 s; pan/zoom Event Timing candidates were 280 ms; whole-Canvas preview cold open was 6.46 s (warm p50/p95 3.49/4.03 s); partial-preview p50/p95 was 2.89/3.37 s. Server RSS averaged 814 MB and peaked at 933 MB. The 110-element case and full JSON are in the run output. `docs/perf/baseline.json` has no Canvas baseline, so these numbers have no prior comparison.
Author
Owner

READY FOR MERGE: no

Built

  • Kept calternal-id stable through Canvas rename/move and kept /n/<id>?el=<element-id> links working, with regression coverage.
  • Added live whole and partial Canvas previews from the saved scene with Excalidraw's bundled export utility and self-hosted fonts.
  • Waited for local font faces before mounting/restoring. Re-measured text after font loads and app-font changes; centered bound labels and avoided writes for unchanged scenes.
  • Added production checks for plain .excalidraw, .excalidraw.md, compressed JSON, and legacy inline images. Verified UI PNG/SVG exports reopen as editable scenes.
  • Hid Excalidraw's stock pan hint and put pan guidance in the hand-tool tooltip. All 94 current screenshot files are attached to this issue.

Files

  • Canvas UI/rendering: apps/web/src/lib/canvas/CanvasPreview.svelte, CanvasReact.tsx, CanvasView.svelte, canvas.css, create.ts, elementIds.ts, embeds.ts, fonts.ts, scene.ts and their focused tests; apps/web/vite.config.ts; apps/web/scripts/canvas-assets.mjs and canvas-font-licenses/*.
  • Notes/Files integration and production evidence: apps/web/src/lib/files/FilesBrowser.svelte, apps/web/src/lib/notes/{NoteView.svelte,NotesExplorer.svelte,api.ts,collab.ts,editorHost.ts}, apps/web/src/lib/search/providers.ts, apps/web/src/routes/n/[id]/+page.svelte, apps/web/e2e/{canvas-976.mjs,harness.mjs,harness.test.mjs,notes.mjs}.
  • Rust identity/collaboration: crates/calternal-collab/{src/canvas.rs,src/lib.rs,src/session.rs}, crates/calternal-notes-core/{Cargo.toml,src/canvas.rs,src/lib.rs,src/rename.rs}, crates/calternal-tags/{src/index.rs,src/lib.rs}, crates/plugins/files/src/lib.rs, and crates/plugins/notes/{src/lib.rs,src/store.rs,src/tasks_store.rs}.
  • Contracts and support: contracts/{action-overrides.json,actions.json,openapi.json}, packages/api-client/src/generated.ts, docs/DESIGN.md, bench/canvas-976.mjs, Cargo.lock, and bun.lock.

Head and merge

  • Head: 98cfb9857b5f8984f3e59dc259891caa9d022bb0
  • Merged origin/dev once before final gates at f634c3f00.
  • Worktree clean. No push, deploy, or merge to dev/main was made.

Gates and focused production checks

cargo fmt --check exited 0 with no output.

Clippy completion lines, verbatim:

Finished `dev` profile [unoptimized + debuginfo] target(s) in 20.65s
Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.39s
Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.05s
Finished `dev` profile [unoptimized + debuginfo] target(s) in 18.60s
Finished `dev` profile [unoptimized + debuginfo] target(s) in 13.86s
Finished `dev` profile [unoptimized + debuginfo] target(s) in 59.93s

These were cargo clippy -p <crate> --all-targets -- -D warnings for calternal-collab, calternal-notes-core, calternal-tags, calternal-plugin-files, calternal-plugin-notes, and calternal-server, in that order.

Cargo test result lines, verbatim:

test result: ok. 44 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.38s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.82s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.27s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 39.82s
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.87s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.84s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.86s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.39s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.58s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 14.07s
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 16.07s
test result: ok. 535 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.20s
test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.64s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.85s
test result: ok. 160 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 81.13s
test result: ok. 191 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 78.87s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 162 passed; 0 failed; 5 ignored; 0 measured; 0 filtered out; finished in 11.74s

These came from cargo test -p for the same six crates. Collab and Notes core have multiple test targets, so their target summaries are listed separately.

Web gate output, verbatim:

svelte-check found 0 errors and 0 warnings

Test Files  5 passed (5)
     Tests  22 passed (22)

The Vitest command was bunx vitest run src/lib/canvas/fonts.test.ts src/lib/canvas/embeds.test.ts src/lib/canvas/elementIds.test.ts src/lib/canvas/scene.test.ts src/lib/canvas/create.test.ts --maxWorkers=2.

Production build output, verbatim:

✓ built in 28.68s
Run npm run preview to preview your production build locally.
> Using @sveltejs/adapter-static
  Wrote site to "build"
  ✔ done

The focused production contract flow passed exports, editable reopens, four import fixtures, live previews, and same-origin requests:

Canvas User flow: create, draw, save, reload, event and element-link checks passed.

The production screenshot capture passed the whole and partial embed states and Files New at all six width/theme combinations. The earlier non-embed matrix and both 3× text-crop families are attached as well.

Performance

The corrected bench/canvas-976.mjs run passed once on perf-test under /root/perf.lock; load at start was 0.33 0.70 0.96. No Canvas metric exists in docs/perf/baseline.json, so there is no prior baseline comparison.

At 5,500 elements (500 bound labels): cold open 4803.56 ms; warm p50/p95 4821.42/5872.50 ms; live-update burst p50/p95 2310.65/2683.00 ms; pan/zoom Event Timing candidates 280 ms; whole-preview cold open 6459.93 ms, warm p50/p95 3491.04/4032.10 ms; partial-preview p50/p95 2885.71/3373.62 ms; mean/peak server RSS 814/933 MB; mean/peak CPU 185.54/297.01%.

UX gaps closed

  • Rename/move preserves stable link identity.
  • Whole/partial previews update from the live room and do not edit the containing Note.
  • Font loading and font switching no longer leave clipped text; bound labels stay centered.
  • Import/export reopens remain editable and preserve the embedded scene.
  • Stock pan guidance is absent; the warm hand-tool tooltip has its shortcut and is captured on tablet and desktop.

UX gaps left / known gaps

  • Merge blocker: PNG/SVG export parity through API, CLI, MCP and WebMCP is not implemented. The current PNG/SVG path is browser UI code. There is no server-side export endpoint/action. Mermaid-to-Excalidraw tool conversion is also not present.
  • The 390 px Canvas is in read mode and does not mount a Hand radio. Its general toolbar tooltip is captured; a dedicated hand-tool tooltip capture is not possible at that width. The hand guidance is captured at 820 and 1440 px in both themes.
  • App-font PNG/SVG export uses Helvetica as a portable fallback; exact app-font export is deferred.

Decisions not set by DESIGN

  • The pinned Excalidraw font fallback is rewritten to an absolute same-origin /fonts/excalidraw/ URL because Excalidraw passes it as the base to new URL. The Vite transform fails closed if the pinned upstream source changes.
  • Screenshot theme changes use fresh pages to avoid the prior page's settings writer and Yjs room connection affecting the next capture.
  • I did not add a second, approximate server renderer for tool exports. DESIGN §60 requires parity but does not choose a headless renderer runtime; this remains an explicit owner decision and keeps this branch unready.

For the merge round

After export parity is implemented, run bun run test in apps/web, the full E2E suite, and bash tests/adversarial/run.sh from the repo root. The adversarial run must cover the Canvas event path and cross-user isolation. The merge-round runner owns the full suites under the current verification policy.

The server gates and focused web gates passed. The remaining API/CLI/MCP/WebMCP export requirement is why this report says READY FOR MERGE: no.

READY FOR MERGE: no ## Built - Kept `calternal-id` stable through Canvas rename/move and kept `/n/<id>?el=<element-id>` links working, with regression coverage. - Added live whole and partial Canvas previews from the saved scene with Excalidraw's bundled export utility and self-hosted fonts. - Waited for local font faces before mounting/restoring. Re-measured text after font loads and app-font changes; centered bound labels and avoided writes for unchanged scenes. - Added production checks for plain `.excalidraw`, `.excalidraw.md`, compressed JSON, and legacy inline images. Verified UI PNG/SVG exports reopen as editable scenes. - Hid Excalidraw's stock pan hint and put pan guidance in the hand-tool tooltip. All 94 current screenshot files are attached to this issue. ## Files - Canvas UI/rendering: `apps/web/src/lib/canvas/CanvasPreview.svelte`, `CanvasReact.tsx`, `CanvasView.svelte`, `canvas.css`, `create.ts`, `elementIds.ts`, `embeds.ts`, `fonts.ts`, `scene.ts` and their focused tests; `apps/web/vite.config.ts`; `apps/web/scripts/canvas-assets.mjs` and `canvas-font-licenses/*`. - Notes/Files integration and production evidence: `apps/web/src/lib/files/FilesBrowser.svelte`, `apps/web/src/lib/notes/{NoteView.svelte,NotesExplorer.svelte,api.ts,collab.ts,editorHost.ts}`, `apps/web/src/lib/search/providers.ts`, `apps/web/src/routes/n/[id]/+page.svelte`, `apps/web/e2e/{canvas-976.mjs,harness.mjs,harness.test.mjs,notes.mjs}`. - Rust identity/collaboration: `crates/calternal-collab/{src/canvas.rs,src/lib.rs,src/session.rs}`, `crates/calternal-notes-core/{Cargo.toml,src/canvas.rs,src/lib.rs,src/rename.rs}`, `crates/calternal-tags/{src/index.rs,src/lib.rs}`, `crates/plugins/files/src/lib.rs`, and `crates/plugins/notes/{src/lib.rs,src/store.rs,src/tasks_store.rs}`. - Contracts and support: `contracts/{action-overrides.json,actions.json,openapi.json}`, `packages/api-client/src/generated.ts`, `docs/DESIGN.md`, `bench/canvas-976.mjs`, `Cargo.lock`, and `bun.lock`. ## Head and merge - Head: `98cfb9857b5f8984f3e59dc259891caa9d022bb0` - Merged `origin/dev` once before final gates at `f634c3f00`. - Worktree clean. No push, deploy, or merge to `dev`/`main` was made. ## Gates and focused production checks `cargo fmt --check` exited 0 with no output. Clippy completion lines, verbatim: ```text Finished `dev` profile [unoptimized + debuginfo] target(s) in 20.65s Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.39s Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.05s Finished `dev` profile [unoptimized + debuginfo] target(s) in 18.60s Finished `dev` profile [unoptimized + debuginfo] target(s) in 13.86s Finished `dev` profile [unoptimized + debuginfo] target(s) in 59.93s ``` These were `cargo clippy -p <crate> --all-targets -- -D warnings` for `calternal-collab`, `calternal-notes-core`, `calternal-tags`, `calternal-plugin-files`, `calternal-plugin-notes`, and `calternal-server`, in that order. Cargo test result lines, verbatim: ```text test result: ok. 44 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.38s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.82s test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.27s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 39.82s test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.87s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.84s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.86s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.39s test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.58s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 14.07s test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 16.07s test result: ok. 535 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.20s test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.64s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.85s test result: ok. 160 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 81.13s test result: ok. 191 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 78.87s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s test result: ok. 162 passed; 0 failed; 5 ignored; 0 measured; 0 filtered out; finished in 11.74s ``` These came from `cargo test -p` for the same six crates. Collab and Notes core have multiple test targets, so their target summaries are listed separately. Web gate output, verbatim: ```text svelte-check found 0 errors and 0 warnings Test Files 5 passed (5) Tests 22 passed (22) ``` The Vitest command was `bunx vitest run src/lib/canvas/fonts.test.ts src/lib/canvas/embeds.test.ts src/lib/canvas/elementIds.test.ts src/lib/canvas/scene.test.ts src/lib/canvas/create.test.ts --maxWorkers=2`. Production build output, verbatim: ```text ✓ built in 28.68s Run npm run preview to preview your production build locally. > Using @sveltejs/adapter-static Wrote site to "build" ✔ done ``` The focused production contract flow passed exports, editable reopens, four import fixtures, live previews, and same-origin requests: ```text Canvas User flow: create, draw, save, reload, event and element-link checks passed. ``` The production screenshot capture passed the whole and partial embed states and Files New at all six width/theme combinations. The earlier non-embed matrix and both 3× text-crop families are attached as well. ## Performance The corrected `bench/canvas-976.mjs` run passed once on `perf-test` under `/root/perf.lock`; load at start was `0.33 0.70 0.96`. No Canvas metric exists in `docs/perf/baseline.json`, so there is no prior baseline comparison. At 5,500 elements (500 bound labels): cold open `4803.56 ms`; warm p50/p95 `4821.42/5872.50 ms`; live-update burst p50/p95 `2310.65/2683.00 ms`; pan/zoom Event Timing candidates `280 ms`; whole-preview cold open `6459.93 ms`, warm p50/p95 `3491.04/4032.10 ms`; partial-preview p50/p95 `2885.71/3373.62 ms`; mean/peak server RSS `814/933 MB`; mean/peak CPU `185.54/297.01%`. ## UX gaps closed - Rename/move preserves stable link identity. - Whole/partial previews update from the live room and do not edit the containing Note. - Font loading and font switching no longer leave clipped text; bound labels stay centered. - Import/export reopens remain editable and preserve the embedded scene. - Stock pan guidance is absent; the warm hand-tool tooltip has its shortcut and is captured on tablet and desktop. ## UX gaps left / known gaps - **Merge blocker:** PNG/SVG export parity through API, CLI, MCP and WebMCP is not implemented. The current PNG/SVG path is browser UI code. There is no server-side export endpoint/action. Mermaid-to-Excalidraw tool conversion is also not present. - The 390 px Canvas is in read mode and does not mount a Hand radio. Its general toolbar tooltip is captured; a dedicated hand-tool tooltip capture is not possible at that width. The hand guidance is captured at 820 and 1440 px in both themes. - App-font PNG/SVG export uses Helvetica as a portable fallback; exact app-font export is deferred. ## Decisions not set by DESIGN - The pinned Excalidraw font fallback is rewritten to an absolute same-origin `/fonts/excalidraw/` URL because Excalidraw passes it as the base to `new URL`. The Vite transform fails closed if the pinned upstream source changes. - Screenshot theme changes use fresh pages to avoid the prior page's settings writer and Yjs room connection affecting the next capture. - I did not add a second, approximate server renderer for tool exports. DESIGN §60 requires parity but does not choose a headless renderer runtime; this remains an explicit owner decision and keeps this branch unready. ## For the merge round After export parity is implemented, run `bun run test` in `apps/web`, the full E2E suite, and `bash tests/adversarial/run.sh` from the repo root. The adversarial run must cover the Canvas event path and cross-user isolation. The merge-round runner owns the full suites under the current verification policy. The server gates and focused web gates passed. The remaining API/CLI/MCP/WebMCP export requirement is why this report says **READY FOR MERGE: no**.
Author
Owner

Round 4 starts on job/canvas-core-976, base/head 98cfb9857.

I am tracing the app-font drawing mismatch and adding one registry export action for API, CLI, MCP and WebMCP. I will record the renderer choice in DESIGN §60. No push, deploy or merge to dev will run. Final verification follows the per-branch policy; full matrices remain for the merge round.

Round 4 starts on `job/canvas-core-976`, base/head `98cfb9857`. I am tracing the app-font drawing mismatch and adding one registry export action for API, CLI, MCP and WebMCP. I will record the renderer choice in DESIGN §60. No push, deploy or merge to dev will run. Final verification follows the per-branch policy; full matrices remain for the merge round.
Author
Owner

Round 4 finding: the later drawing screenshot is captured after the test switches the Canvas back to hand-drawn. Its App font text label describes when the text was created, not the active font choice. I will add a direct app-font assertion and new evidence.

Renderer decision: use the pinned Excalidraw exporter in a network-free bubblewrap PID namespace. A pure-Rust scene→SVG builder would have to reimplement seeded rough paths, bound-label layout and fonts. resvg alone cannot build those paths from scene JSON. The fixed HTML bundles self-hosted fonts and uses the same font readiness/remeasure helpers as the editor. Only loaded faces enter exported SVG. Chromium headless shell supplies rasterization. Excalidraw is MIT; Chromium is BSD with its packaged third-party notices; the existing font notices are OFL or MIT-compatible. No new renderer crate is needed. The export embeds the original validated scene separately, retaining Unicode and unknown fields.

Round 4 finding: the later `drawing` screenshot is captured after the test switches the Canvas back to hand-drawn. Its `App font text` label describes when the text was created, not the active font choice. I will add a direct app-font assertion and new evidence. Renderer decision: use the pinned Excalidraw exporter in a network-free bubblewrap PID namespace. A pure-Rust scene→SVG builder would have to reimplement seeded rough paths, bound-label layout and fonts. resvg alone cannot build those paths from scene JSON. The fixed HTML bundles self-hosted fonts and uses the same font readiness/remeasure helpers as the editor. Only loaded faces enter exported SVG. Chromium headless shell supplies rasterization. Excalidraw is MIT; Chromium is BSD with its packaged third-party notices; the existing font notices are OFL or MIT-compatible. No new renderer crate is needed. The export embeds the original validated scene separately, retaining Unicode and unknown fields.
Author
Owner

Font check: I inspected both round-3 crops. 1440-dark-app-font-text-3x.png uses the app font for both Canvas plan and App font text. 1440-dark-drawing-text-3x.png uses hand-drawn for both. The test explicitly selects Hand-drawn after its app-font screenshot pass (apps/web/e2e/canvas-976.mjs). This explains the reported crop; it is not a failed font switch. The round-4 test also asserts the computed textarea family before finishing new app-font text and checks the exported SVG family.

Font check: I inspected both round-3 crops. `1440-dark-app-font-text-3x.png` uses the app font for both `Canvas plan` and `App font text`. `1440-dark-drawing-text-3x.png` uses hand-drawn for both. The test explicitly selects Hand-drawn after its app-font screenshot pass (`apps/web/e2e/canvas-976.mjs`). This explains the reported crop; it is not a failed font switch. The round-4 test also asserts the computed textarea family before finishing new app-font text and checks the exported SVG family.
Author
Owner

READY FOR MERGE: yes

Branch: job/canvas-core-976. Base: 98cfb9857. Head: beb3bbf4b0.
Fetched origin once and merged origin/dev before final gates (8170cc3ee). No push, deployment or merge into dev was done.

Built

  • PNG and SVG exports now use the registered export_canvas action through API, CLI, MCP and WebMCP. The app uses this action after it commits pending edits.
  • Each export holds the original scene, including Unicode, tombstones and opaque metadata. Tests export and reopen both formats losslessly on all four surfaces.
  • Server rendering uses the pinned editor exporter in a fixed offline Chromium runtime. A single permit, timeout, private namespaces and bounded input/output limit work. SVG output removes active markup and remote references. Rust attaches editable metadata after rendering.
  • The route checks authorization, returns private download responses, and supports bounded chunks with revision ETags for MCP.
  • Self-hosted font bytes and notices are embedded in SVG. The final frame-label bounds are checked before PNG allocation.
  • Added focused hostile-input tests and extended the Canvas benchmark profile. Performance measurements are deferred by the current verification policy.

Font finding and UX gaps closed
The per-Canvas App font switch applies. The new test checks Google Sans in the drawing textarea and in the exported SVG. The round-3 drawing screenshot was taken after the test switched back to Hand-drawn; its label was misleading. The separate app-font crop and this round's six production screenshots show the App font state. Local export now waits for pending scene edits to be committed, so another client can export the same revision.

Files
Rust: crates/calternal-notes-core/src/canvas_export.rs, src/lib.rs and Cargo.toml; crates/plugins/notes/src/canvas_export.rs, src/lib.rs and Cargo.toml; Cargo.lock.
Web: apps/web/src/lib/canvas/renderer.ts, CanvasReact.tsx and fonts.ts; apps/web/scripts/canvas-renderer.mjs; apps/web/package.json; apps/web/e2e/canvas-export-976.mjs, canvas-export-fixture.mjs and canvas-976.mjs.
Runtime: deploy/canvas-renderer and deploy/Containerfile.runtime.
Contracts/docs: contracts/action-overrides.json, actions.json and openapi.json; packages/api-client/src/generated.ts; docs/DESIGN.md §60 and docs/parity-matrix.md.
Tests/profile: tests/adversarial/canvas_export_inputs.mjs and xuser_matrix.py; bench/canvas-976.mjs.

Decisions

  • Use the server's isolated browser renderer. resvg can rasterize SVG, but it does not produce the editor's seeded rough paths and font layout from the scene. The pinned editor exporter preserves that fidelity. §60 records the boundary and licenses: Excalidraw MIT, Chromium BSD and bundled OFL fonts are compatible with AGPL distribution. SVG includes font notices; the packaged runtime retains Chromium notices.
  • Export the original scene as metadata and draw a sanitized projection. Reopening preserves data that must not become executable SVG.
  • For a User's system-font choice, server exports use self-hosted Google Sans. A server cannot reproduce an arbitrary client system font. Inter and Google Sans choices use their exact self-hosted fonts.
  • Chunk replies use the complete export's ETag. Clients must compare it before joining bytes from separate requests.

Gates (verbatim output; all commands exited 0)
cargo fmt --check produced no output.

cargo clippy -p calternal-notes-core --all-targets -- -D warnings

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.31s

cargo clippy -p calternal-plugin-notes --all-targets -- -D warnings

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.41s

cargo clippy -p calternal-server --all-targets -- -D warnings

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3m 39s

cargo test -p calternal-notes-core -- --test-threads=4

test result: ok. 537 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.05s
test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.51s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.97s
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

cargo test -p calternal-plugin-notes -- --test-threads=4

test result: ok. 192 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 129.95s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.36s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

cargo test -p calternal-server -- --test-threads=4

test result: ok. 162 passed; 0 failed; 5 ignored; 0 measured; 0 filtered out; finished in 13.18s

bun run check

svelte-check found 0 errors and 0 warnings

bunx vitest run src/lib/canvas/fonts.test.ts src/lib/canvas/scene.test.ts --maxWorkers=2

 Test Files  2 passed (2)
      Tests  16 passed (16)
   Duration  1.45s (environment 81%, transform 9%, tests 6%, import 3%, worker 1%)

Production web build and offline renderer build passed. Registry and parity checks passed:

Action registry: 342 operations, 324 generated tools
Parity matrix: 342 API actions, 126 shortcuts, 2 static commands, 148 menu actions, 51 settings groups, 0 actions with adapter gaps

Registry unit tests: 12 passed. XUser classification tests: 9 passed.

Focused regression: node apps/web/e2e/canvas-export-976.mjs (own production web/server build and sandboxed renderer)

PASS api png: lossless editable scene and passive drawing
PASS api svg: lossless editable scene and passive drawing
PASS cli png: lossless editable scene and passive drawing
PASS cli svg: lossless editable scene and passive drawing
PASS mcp png: lossless editable scene and passive drawing
PASS mcp svg: lossless editable scene and passive drawing
PASS webmcp png: lossless editable scene and passive drawing
PASS webmcp svg: lossless editable scene and passive drawing
PASS MCP chunk reconstruction with matching ETags
PASS app-font textarea and six macOS production screenshots
PASS generated frame-label bounds before raster allocation
PASS focused export adversarial: huge geometry refused; resource paint stripped

Evidence: production app, macOS platform, all required widths and themes. Screenshots are attachments only.

Known gaps / UX gaps left
No blocker remains in the scoped checks. The packaged runtime image, full suites, staging and real Mac interop were not run in this job, as required by the current verification policy. Renderer installation must be verified in the candidate image. Existing ignored tests remain ignored (1 Notes; 5 Server). The system-font fallback is documented above. No new UI interaction gap was found in this export slice.

For the merge round

  • CALTERNAL_BUILD_BRANCH=<merge-round-branch> scripts/staging-724-build.sh: build the release candidate and runtime image, including the offline renderer and Debian Chromium executable.
  • bun run --cwd apps/web test: run the full web suite on the combined branch.
  • node apps/web/e2e/canvas-976.mjs: run the full Canvas UI flow on the combined production build.
  • node apps/web/e2e/canvas-export-976.mjs: rerun the focused export test with the packaged renderer on PATH. Prove both formats work with runtime defaults, including fonts and editable metadata.
  • tests/adversarial/run.sh and the merge round's XUser/authz matrices: run the full local hostile-input and cross-User checks. The focused export probe in this job passed.
  • Run staging/o2 and real Mac checks under the required VM lock using the existing macdav-lab method. Do not treat emulated screenshot evidence as real Apple-client interop.
  • The extended bench/canvas-976.mjs profile measures 100/5000-element SVG/PNG export latency and an eight-request burst with CPU/RSS sampling. Run it in the scheduled performance round on the perf VM under /root/perf.lock, recording load inside the lock. No measurement was run here because #976 is not a performance issue. docs/perf/baseline.json has no Canvas export baseline.

Cleanup
Removed 19393 files, 12.1GiB total
Web build, renderer build and .svelte-kit output were removed. Evidence remains under ignored artifacts/. No review artifact was committed.

READY FOR MERGE: yes Branch: job/canvas-core-976. Base: 98cfb9857. Head: beb3bbf4b0487200af4a596b771e6690c452bfc3. Fetched origin once and merged origin/dev before final gates (8170cc3ee). No push, deployment or merge into dev was done. Built - PNG and SVG exports now use the registered export_canvas action through API, CLI, MCP and WebMCP. The app uses this action after it commits pending edits. - Each export holds the original scene, including Unicode, tombstones and opaque metadata. Tests export and reopen both formats losslessly on all four surfaces. - Server rendering uses the pinned editor exporter in a fixed offline Chromium runtime. A single permit, timeout, private namespaces and bounded input/output limit work. SVG output removes active markup and remote references. Rust attaches editable metadata after rendering. - The route checks authorization, returns private download responses, and supports bounded chunks with revision ETags for MCP. - Self-hosted font bytes and notices are embedded in SVG. The final frame-label bounds are checked before PNG allocation. - Added focused hostile-input tests and extended the Canvas benchmark profile. Performance measurements are deferred by the current verification policy. Font finding and UX gaps closed The per-Canvas App font switch applies. The new test checks Google Sans in the drawing textarea and in the exported SVG. The round-3 drawing screenshot was taken after the test switched back to Hand-drawn; its label was misleading. The separate app-font crop and this round's six production screenshots show the App font state. Local export now waits for pending scene edits to be committed, so another client can export the same revision. Files Rust: crates/calternal-notes-core/src/canvas_export.rs, src/lib.rs and Cargo.toml; crates/plugins/notes/src/canvas_export.rs, src/lib.rs and Cargo.toml; Cargo.lock. Web: apps/web/src/lib/canvas/renderer.ts, CanvasReact.tsx and fonts.ts; apps/web/scripts/canvas-renderer.mjs; apps/web/package.json; apps/web/e2e/canvas-export-976.mjs, canvas-export-fixture.mjs and canvas-976.mjs. Runtime: deploy/canvas-renderer and deploy/Containerfile.runtime. Contracts/docs: contracts/action-overrides.json, actions.json and openapi.json; packages/api-client/src/generated.ts; docs/DESIGN.md §60 and docs/parity-matrix.md. Tests/profile: tests/adversarial/canvas_export_inputs.mjs and xuser_matrix.py; bench/canvas-976.mjs. Decisions - Use the server's isolated browser renderer. resvg can rasterize SVG, but it does not produce the editor's seeded rough paths and font layout from the scene. The pinned editor exporter preserves that fidelity. §60 records the boundary and licenses: Excalidraw MIT, Chromium BSD and bundled OFL fonts are compatible with AGPL distribution. SVG includes font notices; the packaged runtime retains Chromium notices. - Export the original scene as metadata and draw a sanitized projection. Reopening preserves data that must not become executable SVG. - For a User's system-font choice, server exports use self-hosted Google Sans. A server cannot reproduce an arbitrary client system font. Inter and Google Sans choices use their exact self-hosted fonts. - Chunk replies use the complete export's ETag. Clients must compare it before joining bytes from separate requests. Gates (verbatim output; all commands exited 0) cargo fmt --check produced no output. cargo clippy -p calternal-notes-core --all-targets -- -D warnings ```text Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.31s ``` cargo clippy -p calternal-plugin-notes --all-targets -- -D warnings ```text Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.41s ``` cargo clippy -p calternal-server --all-targets -- -D warnings ```text Finished `dev` profile [unoptimized + debuginfo] target(s) in 3m 39s ``` cargo test -p calternal-notes-core -- --test-threads=4 ```text test result: ok. 537 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.05s test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.51s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.97s test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s ``` cargo test -p calternal-plugin-notes -- --test-threads=4 ```text test result: ok. 192 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 129.95s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.36s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s ``` cargo test -p calternal-server -- --test-threads=4 ```text test result: ok. 162 passed; 0 failed; 5 ignored; 0 measured; 0 filtered out; finished in 13.18s ``` bun run check ```text svelte-check found 0 errors and 0 warnings ``` bunx vitest run src/lib/canvas/fonts.test.ts src/lib/canvas/scene.test.ts --maxWorkers=2 ```text Test Files 2 passed (2) Tests 16 passed (16) Duration 1.45s (environment 81%, transform 9%, tests 6%, import 3%, worker 1%) ``` Production web build and offline renderer build passed. Registry and parity checks passed: ```text Action registry: 342 operations, 324 generated tools Parity matrix: 342 API actions, 126 shortcuts, 2 static commands, 148 menu actions, 51 settings groups, 0 actions with adapter gaps ``` Registry unit tests: 12 passed. XUser classification tests: 9 passed. Focused regression: node apps/web/e2e/canvas-export-976.mjs (own production web/server build and sandboxed renderer) ```text PASS api png: lossless editable scene and passive drawing PASS api svg: lossless editable scene and passive drawing PASS cli png: lossless editable scene and passive drawing PASS cli svg: lossless editable scene and passive drawing PASS mcp png: lossless editable scene and passive drawing PASS mcp svg: lossless editable scene and passive drawing PASS webmcp png: lossless editable scene and passive drawing PASS webmcp svg: lossless editable scene and passive drawing PASS MCP chunk reconstruction with matching ETags PASS app-font textarea and six macOS production screenshots PASS generated frame-label bounds before raster allocation PASS focused export adversarial: huge geometry refused; resource paint stripped ``` Evidence: production app, macOS platform, all required widths and themes. Screenshots are attachments only. - [1440-dark-app-font.png](https://git.kayg.org/attachments/3e83d989-c5f2-465e-baa4-1cb630b47786) - [1440-light-app-font.png](https://git.kayg.org/attachments/ff20ea2b-be0f-4903-affe-13d202d760a1) - [390-dark-app-font.png](https://git.kayg.org/attachments/e457e423-5d88-4030-8b48-1adc0c8f48b7) - [390-light-app-font.png](https://git.kayg.org/attachments/31aafcc7-cdad-48ad-9543-fefdc25ec663) - [820-dark-app-font.png](https://git.kayg.org/attachments/64a1bad0-785b-42df-8016-1151cff1db4a) - [820-light-app-font.png](https://git.kayg.org/attachments/8bd54684-6d96-4afd-a1bb-ef3301fff8d0) Known gaps / UX gaps left No blocker remains in the scoped checks. The packaged runtime image, full suites, staging and real Mac interop were not run in this job, as required by the current verification policy. Renderer installation must be verified in the candidate image. Existing ignored tests remain ignored (1 Notes; 5 Server). The system-font fallback is documented above. No new UI interaction gap was found in this export slice. For the merge round - `CALTERNAL_BUILD_BRANCH=<merge-round-branch> scripts/staging-724-build.sh`: build the release candidate and runtime image, including the offline renderer and Debian Chromium executable. - `bun run --cwd apps/web test`: run the full web suite on the combined branch. - `node apps/web/e2e/canvas-976.mjs`: run the full Canvas UI flow on the combined production build. - `node apps/web/e2e/canvas-export-976.mjs`: rerun the focused export test with the packaged renderer on PATH. Prove both formats work with runtime defaults, including fonts and editable metadata. - `tests/adversarial/run.sh` and the merge round's XUser/authz matrices: run the full local hostile-input and cross-User checks. The focused export probe in this job passed. - Run staging/o2 and real Mac checks under the required VM lock using the existing macdav-lab method. Do not treat emulated screenshot evidence as real Apple-client interop. - The extended `bench/canvas-976.mjs` profile measures 100/5000-element SVG/PNG export latency and an eight-request burst with CPU/RSS sampling. Run it in the scheduled performance round on the perf VM under `/root/perf.lock`, recording load inside the lock. No measurement was run here because #976 is not a performance issue. docs/perf/baseline.json has no Canvas export baseline. Cleanup Removed 19393 files, 12.1GiB total Web build, renderer build and .svelte-kit output were removed. Evidence remains under ignored artifacts/. No review artifact was committed.
Author
Owner

Merge round 7c starts on job/merge-round-7c, base 4082669f7. The current owner job authorizes integration and full verification; it supersedes the original read-only #867 brief.

Canvas order: core → files → collab → Sketch → Pencil → cards. Migrations follow the combined 7b schema. No pushes or deploys. Final report will include verbatim gates, screenshots, defensive renderer review and staging readiness.

Merge round 7c starts on `job/merge-round-7c`, base `4082669f7`. The current owner job authorizes integration and full verification; it supersedes the original read-only #867 brief. Canvas order: core → files → collab → Sketch → Pencil → cards. Migrations follow the combined 7b schema. No pushes or deploys. Final report will include verbatim gates, screenshots, defensive renderer review and staging readiness.
Author
Owner

Finished merge-round-7c integration at 094d22e44507bf8bdd87dd8ffd460c254cb6329c. READY FOR STAGING: no.

Core production visual flow passes, including open modes, fonts, co-editing, touch/Pencil input and element links. Export/import adapter parity is not complete.

Production macOS evidence is attached to #867: 390/820/1440, light/dark. Screenshots remain outside git.

Focused Canvas viewer authority regression:

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 248 filtered out; finished in 2.52s

Svelte check:

svelte-check found 0 errors and 4 warnings in 3 files

The full report, renderer boundary review, migration upgrade evidence, exact gate excerpts and decisions are in docs/audits/merge-round-7c.md and the final #867 comment. Staging blockers include the Notes process SIGSEGV after 278 passing assertions (#1069), stale performance exception pins, the retained thumbnail test conflict, Sketch save and unfinished verification. No pushes or deployments.

Finished merge-round-7c integration at `094d22e44507bf8bdd87dd8ffd460c254cb6329c`. READY FOR STAGING: no. Core production visual flow passes, including open modes, fonts, co-editing, touch/Pencil input and element links. Export/import adapter parity is not complete. Production macOS evidence is attached to [#867](https://git.kayg.org/kayg/calternal/issues/867): 390/820/1440, light/dark. Screenshots remain outside git. Focused Canvas viewer authority regression: ```text test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 248 filtered out; finished in 2.52s ``` Svelte check: ```text svelte-check found 0 errors and 4 warnings in 3 files ``` The full report, renderer boundary review, migration upgrade evidence, exact gate excerpts and decisions are in `docs/audits/merge-round-7c.md` and the final #867 comment. Staging blockers include the Notes process SIGSEGV after 278 passing assertions (#1069), stale performance exception pins, the retained thumbnail test conflict, Sketch save and unfinished verification. No pushes or deployments.
Author
Owner

Starting #976 styling review on branch job/canvas-visual, based at 8910a6814bb8c85854327885482c3e38ca7f9a57 (merge round 7c). Scope: Canvas surface/text styling and the Notes sidebar header control. I will keep the pre-existing untracked review-dark-sticky.png as review input.

Starting #976 styling review on branch `job/canvas-visual`, based at `8910a6814bb8c85854327885482c3e38ca7f9a57` (merge round 7c). Scope: Canvas surface/text styling and the Notes sidebar header control. I will keep the pre-existing untracked `review-dark-sticky.png` as review input.
Author
Owner

Finding evidence for #976:

  • The production Canvas E2E reproduced a bound label inside a 210 px rectangle. After font loading and text refresh, “Details from the drawing” measured 162 px wide and 50 px high, stayed bound, and wrapped within the rectangle.
  • The Notes “New” action lacked the shared pg-btn class while it was a PillGroup child. It now uses the shared segment reset and hit-area recipe, removing the stray native button edge.
  • Production screenshots and role assertions passed at 390, 820, and 1440 px in paper, noir, and tokyo-night palettes under macOS emulation. Evidence is in artifacts/canvas-976-visual/.
Finding evidence for #976: - The production Canvas E2E reproduced a bound label inside a 210 px rectangle. After font loading and text refresh, “Details from the drawing” measured 162 px wide and 50 px high, stayed bound, and wrapped within the rectangle. - The Notes “New” action lacked the shared `pg-btn` class while it was a PillGroup child. It now uses the shared segment reset and hit-area recipe, removing the stray native button edge. - Production screenshots and role assertions passed at 390, 820, and 1440 px in paper, noir, and tokyo-night palettes under macOS emulation. Evidence is in `artifacts/canvas-976-visual/`.
Author
Owner

The visual pass found that the Excalidraw root token check missed the visible wrapper fill: .canvas-note still used --paper, which is pure black in Noir. I changed the Canvas plane and renderer default to the shared --surface role, and the production E2E now compares the actual plane fill with the resolved surface token. The final macOS-emulated screenshot flow passed for paper, noir and tokyo-night at 390, 820 and 1440 px.

Full 21-image screenshot matrix (drawing views, sticky crops and Notes header crops): canvas-976-review.zip

1440 px Noir Canvas with sticky and second shape

Zoomed sticky crop · Zoomed Notes header crop

The visual pass found that the Excalidraw root token check missed the visible wrapper fill: `.canvas-note` still used `--paper`, which is pure black in Noir. I changed the Canvas plane and renderer default to the shared `--surface` role, and the production E2E now compares the actual plane fill with the resolved surface token. The final macOS-emulated screenshot flow passed for paper, noir and tokyo-night at 390, 820 and 1440 px. Full 21-image screenshot matrix (drawing views, sticky crops and Notes header crops): [canvas-976-review.zip](https://git.kayg.org/attachments/9aaa545b-6266-4149-befb-faad6b0d8aff) ![1440 px Noir Canvas with sticky and second shape](https://git.kayg.org/attachments/f87637eb-37de-48c2-a95e-a73c3210e618) [Zoomed sticky crop](https://git.kayg.org/attachments/c34c9a41-a80c-497b-9e9d-7a800b50f556) · [Zoomed Notes header crop](https://git.kayg.org/attachments/9c6ab0b0-69fe-461f-89f8-9ba6b6712805)
Author
Owner

Finished #976. Branch head: 998afb37f6ae7b4b3b275778f43035fe07d76e1f.

Built the Canvas styling and Notes sidebar fixes. The drawing plane now uses the app --surface role, renderer colors use app roles, bound text waits for fonts before measurement, and the Notes New control uses the shared PillGroup segment. Production screenshots cover paper, noir and tokyo-night at 390, 820 and 1440 px with macOS emulation.

Files changed: apps/web/src/lib/canvas/canvas.css, CanvasView.svelte, fonts.ts, fonts.test.ts, apps/web/src/lib/notes/NotesExplorer.svelte, apps/web/e2e/canvas-976.mjs, and the contracts/perf registry, exceptions and adoption records.

UX gaps closed: the Noir black slab now follows the shared surface role; the bound label wraps inside its sticky after app fonts load; the Notes header no longer has a stray separator and its New control uses shared button geometry. The screenshot fixture removes the pressure test stroke and moves its second shape clear of the label.

UX gaps left: none found within #976.

Decisions: use --surface for the Canvas plane. DESIGN §60 calls for role-token theming but does not select a specific fill role; Noir's --paper is pure black and caused the hard slab.

Gates (verbatim):

bun run check:

perf-lint: PASS; 0 violations; 21911 scoped exceptions
svelte-check found 73 errors and 4 warnings in 13 files
error: script "check" exited with code 1

The Svelte errors are in merged History, Mail, Money, WebMCP and Admin files; none are in the Canvas or Notes files changed for #976.

Focused Vitest:

Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  06:50:02
   Duration  1.13s (environment 86%, tests 6%, transform 5%, import 2%, worker 1%)

Canvas production E2E:

Canvas issue visual flow: production screenshots passed at 390, 820 and 1440 px in paper, noir and tokyo-night macOS emulation.

Known gaps: the required web check remains red from the unrelated merged diagnostics above. The merged calternal-server build also fails in Mail integration symbols (remote_content_allowed, set_remote_content_allowed, crate::remote, and sanitize); this job changed no Rust. The full web suite and full merge-round E2E/adversarial suites remain for the merge round. Production build passed. cargo clean removed 8628 files, 7.2GiB; web build output was removed.

Screenshot attachment: full 21-image matrix; 1440 noir view, sticky crop, Notes header crop.

READY FOR MERGE: no — bun run check fails on the unrelated merged Svelte diagnostics, and the merged server build is blocked by Mail integration errors.

Finished #976. Branch head: `998afb37f6ae7b4b3b275778f43035fe07d76e1f`. Built the Canvas styling and Notes sidebar fixes. The drawing plane now uses the app `--surface` role, renderer colors use app roles, bound text waits for fonts before measurement, and the Notes New control uses the shared PillGroup segment. Production screenshots cover paper, noir and tokyo-night at 390, 820 and 1440 px with macOS emulation. Files changed: `apps/web/src/lib/canvas/canvas.css`, `CanvasView.svelte`, `fonts.ts`, `fonts.test.ts`, `apps/web/src/lib/notes/NotesExplorer.svelte`, `apps/web/e2e/canvas-976.mjs`, and the `contracts/perf` registry, exceptions and adoption records. **UX gaps closed:** the Noir black slab now follows the shared surface role; the bound label wraps inside its sticky after app fonts load; the Notes header no longer has a stray separator and its New control uses shared button geometry. The screenshot fixture removes the pressure test stroke and moves its second shape clear of the label. **UX gaps left:** none found within #976. **Decisions:** use `--surface` for the Canvas plane. DESIGN §60 calls for role-token theming but does not select a specific fill role; Noir's `--paper` is pure black and caused the hard slab. **Gates (verbatim):** `bun run check`: ``` perf-lint: PASS; 0 violations; 21911 scoped exceptions svelte-check found 73 errors and 4 warnings in 13 files error: script "check" exited with code 1 ``` The Svelte errors are in merged History, Mail, Money, WebMCP and Admin files; none are in the Canvas or Notes files changed for #976. Focused Vitest: ``` Test Files 1 passed (1) Tests 3 passed (3) Start at 06:50:02 Duration 1.13s (environment 86%, tests 6%, transform 5%, import 2%, worker 1%) ``` Canvas production E2E: ``` Canvas issue visual flow: production screenshots passed at 390, 820 and 1440 px in paper, noir and tokyo-night macOS emulation. ``` **Known gaps:** the required web check remains red from the unrelated merged diagnostics above. The merged `calternal-server` build also fails in Mail integration symbols (`remote_content_allowed`, `set_remote_content_allowed`, `crate::remote`, and `sanitize`); this job changed no Rust. The full web suite and full merge-round E2E/adversarial suites remain for the merge round. Production build passed. `cargo clean` removed 8628 files, 7.2GiB; web build output was removed. Screenshot attachment: [full 21-image matrix](https://git.kayg.org/attachments/9aaa545b-6266-4149-befb-faad6b0d8aff); [1440 noir view](https://git.kayg.org/attachments/f87637eb-37de-48c2-a95e-a73c3210e618), [sticky crop](https://git.kayg.org/attachments/c34c9a41-a80c-497b-9e9d-7a800b50f556), [Notes header crop](https://git.kayg.org/attachments/9c6ab0b0-69fe-461f-89f8-9ba6b6712805). **READY FOR MERGE: no** — `bun run check` fails on the unrelated merged Svelte diagnostics, and the merged server build is blocked by Mail integration errors.
Author
Owner

Starting canvasvis2-976 on branch job/canvas-visual. Current HEAD: 998afb37f6. Original base: 8910a6814b; rebasing/merging onto origin/dev df92d4da12 as requested.

Starting canvasvis2-976 on branch job/canvas-visual. Current HEAD: 998afb37f6ae7b4b3b275778f43035fe07d76e1f. Original base: 8910a6814bb8c85854327885482c3e38ca7f9a57; rebasing/merging onto origin/dev df92d4da129bfead879d417aef6e0514fc56ee96 as requested.
Author
Owner

Finding: the previous merge commit 01f94f051 merged a mixed tree; CONTEXT.md
Cargo.lock
apps/web/e2e/canvas-976.mjs
apps/web/e2e/mail-layouts.mjs
apps/web/e2e/mail-proxy-486.mjs
apps/web/src/lib/actions/edgeResize.test.ts
apps/web/src/lib/actions/edgeResize.ts
apps/web/src/lib/canvas/CanvasView.svelte
apps/web/src/lib/canvas/canvas.css
apps/web/src/lib/canvas/fonts.test.ts
apps/web/src/lib/canvas/fonts.ts
apps/web/src/lib/components/OverlaySurface.svelte.test.ts
apps/web/src/lib/mail/MailSidebar.svelte
apps/web/src/lib/mail/MailSidebar.svelte.test.ts
apps/web/src/lib/mail/MailView.svelte
apps/web/src/lib/mail/live.test.ts
apps/web/src/lib/mail/live.ts
apps/web/src/lib/navigation.test.ts
apps/web/src/lib/navigation.ts
apps/web/src/lib/notes/NotesExplorer.svelte
apps/web/src/lib/plugins/user-enable.test.ts
apps/web/src/lib/plugins/user-enable.ts
apps/web/src/routes/settings/[...path]/+page.svelte
apps/web/src/routes/settings/account/AppPasswordsGroup.svelte
apps/web/src/routes/settings/account/AppPasswordsGroup.svelte.test.ts
bench/blaze.md
bench/blaze.mjs
bench/blaze.test.mjs
bench/mail-folder-page.py
bench/mail-sync.py
contracts/actions.json
contracts/openapi.json
contracts/perf/adoption-1058.json
contracts/perf/exceptions.json
contracts/perf/ratchet.json
contracts/perf/registry.json
crates/calternal-auth/migrations/0014_mail_app_password_usage.sql
crates/calternal-auth/src/api.rs
crates/calternal-auth/src/lib.rs
crates/calternal-auth/src/store.rs
crates/calternal-db/src/jobs.rs
crates/calternal-imap/src/lib.rs
crates/calternal-imap/src/mime.rs
crates/calternal-imap/src/session.rs
crates/calternal-imap/src/store.rs
crates/calternal-imap/src/wire.rs
crates/calternal-imap/tests/mail.rs
crates/calternal-imap/tests/mime.rs
crates/calternal-imap/tests/session.rs
crates/calternal-imap/tests/wire.rs
crates/calternal-server/src/device_imap.rs
crates/calternal-server/src/integrations.rs
crates/calternal-server/src/integrations_review.rs
crates/calternal-server/src/notes_imap.rs
crates/calternal-server/src/notes_submission.rs
crates/calternal-server/src/upgrade_tests.rs
crates/calternal-server/src/wire.rs
crates/calternal-server/src/wire/groups.rs
crates/plugins/files/src/index.rs
crates/plugins/files/src/lib.rs
crates/plugins/mail/Cargo.toml
crates/plugins/mail/migrations/0012_mail_proxy.sql
crates/plugins/mail/migrations/0013_folder_page_index.sql
crates/plugins/mail/migrations/0014_proxy_metadata.sql
crates/plugins/mail/migrations/0015_proxy_mutations.sql
crates/plugins/mail/migrations/0016_proxy_transfers.sql
crates/plugins/mail/migrations/0017_proxy_projection.sql
crates/plugins/mail/src/cache.rs
crates/plugins/mail/src/cache/store.rs
crates/plugins/mail/src/lib.rs
crates/plugins/mail/src/proxy.rs
crates/plugins/mail/src/proxy_mutations.rs
crates/plugins/mail/src/proxy_tests.rs
crates/plugins/mail/src/proxy_transfers.rs
crates/plugins/mail/src/routes.rs
crates/plugins/mail/src/sync.rs
crates/plugins/mail/vendor/async-imap/src/types/fetch.rs
crates/plugins/notes/src/imap.rs
docs/DESIGN.md
docs/audits/mailround2-1038.md
docs/audits/merge-round-9.md
packages/api-client/src/generated.ts
packages/ui/src/components/OverlaySurface.svelte
tests/adversarial/apple_mail_accept.mjs
tests/adversarial/apple_mail_native.applescript
tests/adversarial/apple_mail_receipts.py
tests/adversarial/apple_mail_vnc.py
tests/adversarial/mail-sync.md
tests/adversarial/mail_fault_provider.py
tests/adversarial/mail_proxy.py
tests/adversarial/mail_stress.mjs
tests/adversarial/mail_sync_provider.py
tests/adversarial/test_apple_mail_receipts.py
tests/adversarial/test_dav_probe.py
tests/adversarial/test_mail_fault_provider.py
tests/adversarial/test_mail_proxy.py
tests/adversarial/test_mail_sync_provider.py
tests/adversarial/webdav.py shows 98 files, including Mail server/auth changes. The requested production base is origin/dev df92d4da1. I am rebuilding this job branch from that base and carrying forward only the two Canvas commits' renderer styling, Notes sidebar header, and Canvas review/e2e changes; the unrelated Mail changes will come from origin/dev itself.

Finding: the previous merge commit 01f94f051 merged a mixed tree; CONTEXT.md Cargo.lock apps/web/e2e/canvas-976.mjs apps/web/e2e/mail-layouts.mjs apps/web/e2e/mail-proxy-486.mjs apps/web/src/lib/actions/edgeResize.test.ts apps/web/src/lib/actions/edgeResize.ts apps/web/src/lib/canvas/CanvasView.svelte apps/web/src/lib/canvas/canvas.css apps/web/src/lib/canvas/fonts.test.ts apps/web/src/lib/canvas/fonts.ts apps/web/src/lib/components/OverlaySurface.svelte.test.ts apps/web/src/lib/mail/MailSidebar.svelte apps/web/src/lib/mail/MailSidebar.svelte.test.ts apps/web/src/lib/mail/MailView.svelte apps/web/src/lib/mail/live.test.ts apps/web/src/lib/mail/live.ts apps/web/src/lib/navigation.test.ts apps/web/src/lib/navigation.ts apps/web/src/lib/notes/NotesExplorer.svelte apps/web/src/lib/plugins/user-enable.test.ts apps/web/src/lib/plugins/user-enable.ts apps/web/src/routes/settings/[...path]/+page.svelte apps/web/src/routes/settings/account/AppPasswordsGroup.svelte apps/web/src/routes/settings/account/AppPasswordsGroup.svelte.test.ts bench/blaze.md bench/blaze.mjs bench/blaze.test.mjs bench/mail-folder-page.py bench/mail-sync.py contracts/actions.json contracts/openapi.json contracts/perf/adoption-1058.json contracts/perf/exceptions.json contracts/perf/ratchet.json contracts/perf/registry.json crates/calternal-auth/migrations/0014_mail_app_password_usage.sql crates/calternal-auth/src/api.rs crates/calternal-auth/src/lib.rs crates/calternal-auth/src/store.rs crates/calternal-db/src/jobs.rs crates/calternal-imap/src/lib.rs crates/calternal-imap/src/mime.rs crates/calternal-imap/src/session.rs crates/calternal-imap/src/store.rs crates/calternal-imap/src/wire.rs crates/calternal-imap/tests/mail.rs crates/calternal-imap/tests/mime.rs crates/calternal-imap/tests/session.rs crates/calternal-imap/tests/wire.rs crates/calternal-server/src/device_imap.rs crates/calternal-server/src/integrations.rs crates/calternal-server/src/integrations_review.rs crates/calternal-server/src/notes_imap.rs crates/calternal-server/src/notes_submission.rs crates/calternal-server/src/upgrade_tests.rs crates/calternal-server/src/wire.rs crates/calternal-server/src/wire/groups.rs crates/plugins/files/src/index.rs crates/plugins/files/src/lib.rs crates/plugins/mail/Cargo.toml crates/plugins/mail/migrations/0012_mail_proxy.sql crates/plugins/mail/migrations/0013_folder_page_index.sql crates/plugins/mail/migrations/0014_proxy_metadata.sql crates/plugins/mail/migrations/0015_proxy_mutations.sql crates/plugins/mail/migrations/0016_proxy_transfers.sql crates/plugins/mail/migrations/0017_proxy_projection.sql crates/plugins/mail/src/cache.rs crates/plugins/mail/src/cache/store.rs crates/plugins/mail/src/lib.rs crates/plugins/mail/src/proxy.rs crates/plugins/mail/src/proxy_mutations.rs crates/plugins/mail/src/proxy_tests.rs crates/plugins/mail/src/proxy_transfers.rs crates/plugins/mail/src/routes.rs crates/plugins/mail/src/sync.rs crates/plugins/mail/vendor/async-imap/src/types/fetch.rs crates/plugins/notes/src/imap.rs docs/DESIGN.md docs/audits/mailround2-1038.md docs/audits/merge-round-9.md packages/api-client/src/generated.ts packages/ui/src/components/OverlaySurface.svelte tests/adversarial/apple_mail_accept.mjs tests/adversarial/apple_mail_native.applescript tests/adversarial/apple_mail_receipts.py tests/adversarial/apple_mail_vnc.py tests/adversarial/mail-sync.md tests/adversarial/mail_fault_provider.py tests/adversarial/mail_proxy.py tests/adversarial/mail_stress.mjs tests/adversarial/mail_sync_provider.py tests/adversarial/test_apple_mail_receipts.py tests/adversarial/test_dav_probe.py tests/adversarial/test_mail_fault_provider.py tests/adversarial/test_mail_proxy.py tests/adversarial/test_mail_sync_provider.py tests/adversarial/webdav.py shows 98 files, including Mail server/auth changes. The requested production base is origin/dev df92d4da1. I am rebuilding this job branch from that base and carrying forward only the two Canvas commits' renderer styling, Notes sidebar header, and Canvas review/e2e changes; the unrelated Mail changes will come from origin/dev itself.
Author
Owner

Finding and fix: the production Canvas E2E could not start because failed to load its native module (). The sticky and Notes header evidence now uses Playwright's native clipped screenshots with no image-processing runtime. passed.

Finding and fix: the production Canvas E2E could not start because failed to load its native module (). The sticky and Notes header evidence now uses Playwright's native clipped screenshots with no image-processing runtime. passed.
Author
Owner

Finding and fix: the production Canvas E2E could not start because the sharp native module failed to load (ERR_DLOPEN_FAILED: libstdc++.so.6). The sticky and Notes header evidence now uses Playwright's native clipped screenshots with no image-processing runtime. node --check apps/web/e2e/canvas-976.mjs && git diff --check passed.

Finding and fix: the production Canvas E2E could not start because the sharp native module failed to load (ERR_DLOPEN_FAILED: libstdc++.so.6). The sticky and Notes header evidence now uses Playwright's native clipped screenshots with no image-processing runtime. `node --check apps/web/e2e/canvas-976.mjs && git diff --check` passed.
Author
Owner

Final merge finding: the newly merged dev sidebar change updated its Svelte render fingerprint, but 12 existing pinned exceptions still used the old hash. bun run check rejected the stale entries. I updated only those 12 hashes to the current parser fingerprint (d34ed2212da5b551b001994a3c85d82df3b7ac39ac8aac87f96992d25e290f0d); limits and exception reasons are unchanged. I am rerunning the final-head gates now.

Final merge finding: the newly merged dev sidebar change updated its Svelte render fingerprint, but 12 existing pinned exceptions still used the old hash. `bun run check` rejected the stale entries. I updated only those 12 hashes to the current parser fingerprint (`d34ed2212da5b551b001994a3c85d82df3b7ac39ac8aac87f96992d25e290f0d`); limits and exception reasons are unchanged. I am rerunning the final-head gates now.
Author
Owner

Final-head gate finding: after the merged sidebar and Canvas contracts passed perf lint, bun run check stopped at apps/web/src/routes/layout.css:93 because the visibility exit transition used literal linear. The UI already defines --ease-linear in packages/ui/src/tokens.css and uses it for a zero-duration visibility transition. I changed the merged sidebar transition to var(--ease-linear), which preserves its timing while satisfying the shared motion-token contract.

Final-head gate finding: after the merged sidebar and Canvas contracts passed perf lint, `bun run check` stopped at `apps/web/src/routes/layout.css:93` because the visibility exit transition used literal `linear`. The UI already defines `--ease-linear` in `packages/ui/src/tokens.css` and uses it for a zero-duration visibility transition. I changed the merged sidebar transition to `var(--ease-linear)`, which preserves its timing while satisfying the shared motion-token contract.
Author
Owner

READY FOR MERGE: yes — final web gates and focused Canvas production flows pass. The final captures are attached for the orchestrator’s visual review.

What changed

  • CanvasView, Excalidraw controls, and the drawing plane use the shared surface, accent, and selection roles. The renderer stays transparent so Noir does not paint a black slab.
  • Canvas text waits for the active font set before renderer measurement and restoration. The bound label stays centered without changing source revisions.
  • The Notes sidebar New action uses the shared PillGroup segment styling.
  • The production E2E capture uses Playwright’s native crop APIs; it no longer depends on Sharp’s unavailable native module.
  • Merged current origin/dev at 27644444a91dad244073113b0ec8ec7a61f2a212. The branch contains the Canvas/Notes changes, their performance fingerprints, and a shared easing-token correction for the merged sidebar exit rule. No mixed Mail changes remain.

Files

  • apps/web/src/lib/canvas/CanvasView.svelte
  • apps/web/src/lib/canvas/canvas.css
  • apps/web/src/lib/canvas/fonts.ts
  • apps/web/src/lib/canvas/fonts.test.ts
  • apps/web/src/lib/notes/NotesExplorer.svelte
  • apps/web/e2e/canvas-976.mjs
  • apps/web/src/routes/layout.css
  • contracts/perf/adoption-1058.json
  • contracts/perf/registry.json
  • contracts/perf/exceptions.json

Final 1440 px captures

Palette Drawing Sticky crop Notes sidebar header
paper drawing sticky crop Notes header crop
noir drawing sticky crop Notes header crop
tokyo-night drawing sticky crop Notes header crop

Download the complete 117-capture set

Verification

bun run check (exit 0; output excerpt verbatim):

perf-lint: PASS; 0 violations; 21977 scoped exceptions
User browser caches use userStorage; only documented device/public-link exceptions remain.
Glass alpha, blur and backdrop-filter roles use packages/ui/src/tokens.css.
Text sizes and UI shape values use shared role tokens.
Keyboard focus rings use the shared focus tokens.
UI transitions and animation options use shared motion tokens or documented exceptions.
svelte-check found 0 errors and 4 warnings in 3 files

The four warnings are two empty focus rulesets in AttachmentDeck.svelte and AgendaList.svelte, and unused .note-page-lede / .note-state selectors in the Notes route.

Test Files 1 passed (1)
Tests 3 passed (3)
✓ built in 2m 12s
Compressed 877 static variants; saved 20840581 bytes.
Finished `release` profile [optimized] target(s) in 5m 36s
Canvas issue visual flow: production screenshots passed at 390, 820 and 1440 px in paper, noir and tokyo-night macOS emulation.
Import verified json
Import verified markdown
Import verified compressed
Import verified inline
Canvas User flow: create, draw, save, reload, event and element-link checks passed.
Removed 9238 files, 3.4GiB total

The Canvas E2E visual pass captured all requested widths and themes. The focused production contract flow also passed PNG/SVG export and scene import with the sandboxed renderer wrapper. No Rust source or route changed, so Rust lint/test gates were not applicable; the requested production server build passed.

UX gaps closed

  • Surface and selection roles now stay consistent across Paper, Noir, and Tokyo Night.
  • Bound text measures after font readiness and survives forced echo, reload, Tab close, and rename.
  • Notes sidebar New and More controls share the standard segmented header geometry.
  • Evidence covers 390, 820, and 1440 px in light/dark macOS emulation, plus all three requested 1440 px palettes.

UX gaps left

None in the changed flows. The four existing Svelte warnings above remain. The full web test suite and full web E2E suite remain for the merge round under the shared verification policy:

  • cd apps/web && bun run test — run the complete web unit suite on the combined branch.
  • cd apps/web && bun run test:e2e — run the complete web E2E shell suite on the combined branch.

Decisions

No new product design decision was needed; the Canvas surface role and Notes header recipe follow the issue-approved design. For screenshot cropping, Playwright’s built-in screenshots replaced Sharp after the host could not load Sharp’s native module. The merged sidebar’s literal linear easing was changed to the existing var(--ease-linear) token; its timing is unchanged.

Branch: job/canvas-visual
Commits: 02aa9d25b, 934487721, bd5e4911f, merge 9b44d28bb, 70c2dd331, efc38366e
HEAD: efc38366ea694aeed92167217961aae130ca8ec5

**READY FOR MERGE: yes** — final web gates and focused Canvas production flows pass. The final captures are attached for the orchestrator’s visual review. ### What changed - CanvasView, Excalidraw controls, and the drawing plane use the shared surface, accent, and selection roles. The renderer stays transparent so Noir does not paint a black slab. - Canvas text waits for the active font set before renderer measurement and restoration. The bound label stays centered without changing source revisions. - The Notes sidebar New action uses the shared PillGroup segment styling. - The production E2E capture uses Playwright’s native crop APIs; it no longer depends on Sharp’s unavailable native module. - Merged current `origin/dev` at `27644444a91dad244073113b0ec8ec7a61f2a212`. The branch contains the Canvas/Notes changes, their performance fingerprints, and a shared easing-token correction for the merged sidebar exit rule. No mixed Mail changes remain. ### Files - `apps/web/src/lib/canvas/CanvasView.svelte` - `apps/web/src/lib/canvas/canvas.css` - `apps/web/src/lib/canvas/fonts.ts` - `apps/web/src/lib/canvas/fonts.test.ts` - `apps/web/src/lib/notes/NotesExplorer.svelte` - `apps/web/e2e/canvas-976.mjs` - `apps/web/src/routes/layout.css` - `contracts/perf/adoption-1058.json` - `contracts/perf/registry.json` - `contracts/perf/exceptions.json` ### Final 1440 px captures | Palette | Drawing | Sticky crop | Notes sidebar header | |---|---|---|---| | paper | [drawing](https://git.kayg.org/attachments/6c45c03e-41e0-4995-b3c1-ad678978a240) | [sticky crop](https://git.kayg.org/attachments/76fcad1b-a958-45c4-800e-5b41348b2a16) | [Notes header crop](https://git.kayg.org/attachments/0e74e90b-cd25-40b8-8da1-bbd8ad88f9c6) | | noir | [drawing](https://git.kayg.org/attachments/33541e2f-f6e2-4484-9993-912b7c6a5787) | [sticky crop](https://git.kayg.org/attachments/6b921df0-9685-4fca-976d-cb3e76ae0524) | [Notes header crop](https://git.kayg.org/attachments/ffcf62f4-17c0-404c-9866-0b7a109a4a7d) | | tokyo-night | [drawing](https://git.kayg.org/attachments/7f5a0ac0-9765-4147-b20b-65c07dc6b73d) | [sticky crop](https://git.kayg.org/attachments/1b5dc046-cafb-4907-9f2d-627b4f4f21a2) | [Notes header crop](https://git.kayg.org/attachments/5eacb388-9632-405b-a952-36e133ea4dc3) | [Download the complete 117-capture set](https://git.kayg.org/attachments/a569a5ee-4d29-4e50-ac2d-35c1ea2f5532) ### Verification `bun run check` (exit 0; output excerpt verbatim): ```text perf-lint: PASS; 0 violations; 21977 scoped exceptions User browser caches use userStorage; only documented device/public-link exceptions remain. Glass alpha, blur and backdrop-filter roles use packages/ui/src/tokens.css. Text sizes and UI shape values use shared role tokens. Keyboard focus rings use the shared focus tokens. UI transitions and animation options use shared motion tokens or documented exceptions. svelte-check found 0 errors and 4 warnings in 3 files ``` The four warnings are two empty focus rulesets in `AttachmentDeck.svelte` and `AgendaList.svelte`, and unused `.note-page-lede` / `.note-state` selectors in the Notes route. ```text Test Files 1 passed (1) Tests 3 passed (3) ✓ built in 2m 12s Compressed 877 static variants; saved 20840581 bytes. Finished `release` profile [optimized] target(s) in 5m 36s Canvas issue visual flow: production screenshots passed at 390, 820 and 1440 px in paper, noir and tokyo-night macOS emulation. Import verified json Import verified markdown Import verified compressed Import verified inline Canvas User flow: create, draw, save, reload, event and element-link checks passed. Removed 9238 files, 3.4GiB total ``` The Canvas E2E visual pass captured all requested widths and themes. The focused production contract flow also passed PNG/SVG export and scene import with the sandboxed renderer wrapper. No Rust source or route changed, so Rust lint/test gates were not applicable; the requested production server build passed. ### UX gaps closed - Surface and selection roles now stay consistent across Paper, Noir, and Tokyo Night. - Bound text measures after font readiness and survives forced echo, reload, Tab close, and rename. - Notes sidebar New and More controls share the standard segmented header geometry. - Evidence covers 390, 820, and 1440 px in light/dark macOS emulation, plus all three requested 1440 px palettes. ### UX gaps left None in the changed flows. The four existing Svelte warnings above remain. The full web test suite and full web E2E suite remain for the merge round under the shared verification policy: - `cd apps/web && bun run test` — run the complete web unit suite on the combined branch. - `cd apps/web && bun run test:e2e` — run the complete web E2E shell suite on the combined branch. ### Decisions No new product design decision was needed; the Canvas surface role and Notes header recipe follow the issue-approved design. For screenshot cropping, Playwright’s built-in screenshots replaced Sharp after the host could not load Sharp’s native module. The merged sidebar’s literal `linear` easing was changed to the existing `var(--ease-linear)` token; its timing is unchanged. Branch: `job/canvas-visual` Commits: `02aa9d25b`, `934487721`, `bd5e4911f`, merge `9b44d28bb`, `70c2dd331`, `efc38366e` HEAD: `efc38366ea694aeed92167217961aae130ca8ec5`
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#976
No description provided.