diff --git a/.gitignore b/.gitignore
index c092d45..9268e5d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,4 +33,7 @@ wwwroot/
# Server runtime data
data
+# .NET test output
+src/Tests/TestResults/
+
.agents/
diff --git a/docs/spec/canvas-authoring-dx.md b/docs/spec/canvas-authoring-dx.md
new file mode 100644
index 0000000..7a08da8
--- /dev/null
+++ b/docs/spec/canvas-authoring-dx.md
@@ -0,0 +1,187 @@
+# Canvas Authoring DX & Pane UX
+
+Implements roadmap **Phase 6** (Canvas Doc Authoring DX) and **Phase 8** (Pane UX) from
+`docs/spec/future/canvas-roadmap.md`. Full feature spec for the pane: `docs/spec/canvas-pane.md`.
+
+## Goals
+
+- Lower the bar to author an effective canvas doc: an on-theme doc with zero CSS boilerplate
+ and a first-class message helper instead of hand-rolled `postMessage`.
+- Make doc-side JS failures visible instead of silently breaking inside the iframe.
+- Always surface the active doc as a labeled tab — even when there's only one — and show how
+ fresh it is on disk.
+- No regressions to existing canvas behavior (ownership routing, morph, liveness, the beads
+ SystemView, keep-iframes-alive).
+
+## Expected Behavior
+
+All four items extend existing, well-isolated seams. Today every served doc is rewritten at the
+`` injection point in `CanvasDocServer.handleCanvasRequest`, with the per-kind injection
+chosen by `buildInjection` (`src/Server/CanvasDocServer.fs`):
+
+| Doc kind | Injected today |
+|---|---|
+| `SystemView` | `baseStyle` (scrollbar CSS only) + `linkInterceptor` |
+| `AgentDoc` | the above + `bridgeScript` + idiomorph runtime + morph controller |
+
+### 1. Base dark-theme CSS reset (Phase 6.1)
+
+`baseStyle` grows from scrollbar-only CSS to a small **zero-specificity** base reset: dark
+background/foreground, system font stack, and sensible defaults for `body`, headings, code,
+tables, and links — injected for **both** doc kinds (same slot as today).
+
+- Defaults must be overridable: a doc that sets its own styles wins. The reset is injected at the
+ `` point (`CanvasDocServer.handleCanvasRequest`) — i.e. **after** any `
` styles the
+ doc (or the `SystemView` template) already declares — so an equal-specificity element rule in the
+ doc would otherwise *lose* the source-order tiebreak to the injected reset. To guarantee the doc
+ always wins, wrap every reset selector in **`:where(...)`** so it carries **zero specificity**
+ (like the existing `*` scrollbar rule); any real doc rule — even a bare `body { }` element
+ selector — then overrides it regardless of source order. Use **no `!important`**.
+- This is also what keeps the beads `SystemView` (`BeadspaceTemplate.html`) visually unchanged: its
+ own `body { background: var(--bg-deep) }` (element specificity 0,0,1) beats the `:where(body)`
+ reset (specificity 0,0,0), so the injected dark default never paints over the dashboard.
+- **Acceptance:** (a) a canvas doc whose body is `plain text` renders dark-themed and
+ readable with no doc-authored CSS; (b) a doc that declares its own `body { background: … }` in
+ `` keeps *its* colour, not the reset's; (c) the beads `SystemView` body background is
+ unchanged (`var(--bg-deep)`).
+
+### 2. Injected `window.canvasSend(action, payload)` helper (Phase 6.2)
+
+A tiny script injected **alongside `bridgeScript` (AgentDoc only)** exposes
+`window.canvasSend(action, payload)` that wraps the existing flat message contract
+`window.parent.postMessage({ action, ...payload }, '*')`.
+
+- The message shape stays flat: `canvasSend('navigate-canvas-doc', { filename })` posts
+ `{ action: 'navigate-canvas-doc', filename }` — byte-identical in effect to the raw message the
+ pane already handles.
+- Validates serialized payload size against the client cap (`MaxPayloadBytes = 64_000`,
+ `src/Client/CanvasPane.fs`) using the **same metric the client enforces** —
+ `JSON.stringify({ action, ...payload }).length` (UTF-16 code units, the JS `String.length` the
+ client checks at the `postMessage DROPPED: payload too large` path) — so the doc-side verdict is
+ identical to the client's drop decision. Oversized messages are **not** posted; the helper logs a
+ clear console error doc-side so the author gets immediate feedback instead of the silent
+ client-side drop. (See open question on whether the cap should become a true UTF-8 byte count.)
+- `src/Extension/skill/SKILL.md` is updated to teach `canvasSend` as the **primary** API; the raw
+ `window.parent.postMessage` shape stays documented as the underlying contract / fallback.
+- **Acceptance:** a doc that calls `canvasSend('navigate-canvas-doc', { filename })` switches tabs
+ identically to the current raw-`postMessage` path.
+
+### 3. JS error overlay (Phase 6.3)
+
+An injected handler (**AgentDoc only**, alongside the bridge) installs `window.onerror` and an
+`unhandledrejection` listener that forward doc-side JS errors to the parent as a
+`{ action: 'canvas-doc-error', wt, doc, message, source, line, col }` message — where `doc` is the
+emitting doc's filename (embedded as a constant when the per-doc overlay is served) and `wt` is the
+emitting worktree (read in-iframe from `location.pathname`, mirroring the bridge heartbeat). The pane surfaces
+them as a **non-blocking, dismissible banner**, reusing the existing `canvas-error-banner` visual
+pattern (`src/Client/CanvasPane.fs`) but driven by a **distinct source** — doc JS errors, separate
+from the message-delivery failures already shown via `CanvasSendState.Failed`.
+
+- The banner must never cover doc content and must be dismissible.
+- The banner is **doc-scoped**: the stored error is stamped with the doc that **emitted** it — its
+ worktree and filename ride along in the message `wt`/`doc` fields and are validated against that
+ **emitting worktree's** docs (`isKnownCanvasDoc`) before being stored — and the pane shows the banner
+ **only while that same doc is focused**, so navigating away — by tab (`SelectDoc`), card, or keyboard
+ focus — never shows a stale error over a different doc (nor over the overview). Stamping with the
+ emitter (not the doc visible when the message is *processed*) is what keeps an async error from a
+ hidden/background iframe — visited docs stay mounted and keep running JS — attributed to the doc
+ that actually threw, and immune to a tab **or worktree** switch racing the Elmish message. An error
+ whose `wt`/`doc` is not a known doc of that worktree is dropped. `SelectDoc` additionally clears it so
+ a tab switch (and switching back) never re-shows it. It reappears only if a doc throws again.
+- **Acceptance:** a doc that throws on load shows the error text in a banner, and the pane (tabs,
+ position controls, other docs) keeps working.
+
+### 4. Always-visible doc tab with last-modified age (Phase 8)
+
+Two coupled tab-bar changes in `src/Client/CanvasPane.fs`:
+
+1. **Always render the active doc's tab — even for a single doc.** Today the `tabs` binding
+ renders tabs only when `wt.CanvasDocs.Length > 1 || hasSystemView`, so a lone **AgentDoc**
+ shows no tab and its iframe fills the pane. Change the condition so the active doc always has a
+ visible, labeled tab. (The header bar itself already always renders; only the tab buttons were
+ suppressed.)
+2. **Show on-disk freshness inside each AgentDoc tab.** Render a compact relative age next to the
+ tab label from `doc.LastModified` (already on `CanvasDoc`, `src/Shared/Types.fs`) — e.g. `3m`,
+ `2h`, `2d`. Computed from `System.DateTimeOffset.Now` at render time (same pattern as the
+ dashboard's `relativeTime System.DateTimeOffset.Now wt.LastCommitTime`), so it refreshes on the
+ pane's existing render cadence.
+
+- The age is scoped to **AgentDoc** tabs, matching the AgentDoc-only liveness dot. The
+ `SystemView` "BD" badge is data-driven and carries no authored-file age.
+- **Acceptance:** a worktree with a single canvas doc shows its tab button (not a bare iframe), and
+ each AgentDoc tab shows a compact age (`3m`, `2d`) reflecting the file's `LastModified`.
+
+## Technical Approach
+
+### Server injection — `src/Server/CanvasDocServer.fs`
+- **Base CSS reset:** extend the `baseStyle` string literal. Wrap every reset selector in
+ `:where(...)` (zero specificity), no `!important`, so doc rules and the `SystemView` template's
+ own `body`-selector rules win the cascade despite the reset being injected after them at ``.
+ This per-property override holds only for rules on the element selector itself (e.g. `body{…}`,
+ specificity 0,0,1) — a box property the `SystemView` template zeroes through a *universal*
+ `*{margin:0;padding:0}` reset (also 0,0,0) would lose the source-order tiebreak to the later
+ `:where(body){padding:1rem}`, so `BeadspaceTemplate.html` resets margin/padding on its `body`
+ selector directly. Still injected for both kinds via `buildInjection`.
+- **`canvasSend`:** add a new injected script constant (mirroring `bridgeScript`'s IIFE style) and
+ append it in the `AgentDoc` arm of `buildInjection` only. Implement the size check with the same
+ metric the client uses — `JSON.stringify({ action, ...payload }).length` compared against
+ `64_000` — so the helper never blocks a payload the client would accept nor passes one it would
+ drop.
+- **Error overlay:** add a new injected script *function* (AgentDoc arm) installing `window.onerror`
+ + `unhandledrejection` → `postMessage({ action: 'canvas-doc-error', wt, doc, ... }, '*')`. The overlay
+ is served per-doc, so `buildInjection (kind) (filename)` threads the served filename in and the
+ overlay embeds it (JSON-serialized to a safe, HTML-escaped JS string) as the `doc` field; `wt` is
+ read in-iframe from `location.pathname` (mirroring the bridge heartbeat). Together they give the
+ error its full emitter identity (worktree + filename).
+- Keep `MaxPayloadBytes` as the single source of truth for the cap; reference its value in the
+ injected helper (literal kept in sync with `CanvasPane.fs`).
+
+### Client — `src/Client/CanvasPane.fs` + `src/Client/Components.fs`
+- **Doc-error banner:** add model state for the latest doc error — a record **stamped with the doc
+ that emitted it** (`DocJsError { ScopedKey; Filename; Message }`, not a bare `Some message`). The
+ `canvas-doc-error` handler reads **both** the emitting worktree (`wt`) and doc filename (`doc`) from
+ the message and dispatches the 3-tuple `CanvasDocError (scopedKey, filename, message)`; the reducer
+ stamps it with the **emitter's** scopedKey only after validating that filename names a real doc of
+ *that* worktree (`isKnownCanvasDoc model scopedKey filename`), otherwise drops it. This attributes a
+ hidden/background iframe's async error to the doc that threw (not the active tab) and is immune to a
+ tab **or worktree** switch racing the queued message — both dimensions are captured at receipt, not
+ re-derived at process time. Carrying the emitter's worktree (not just the filename) **closes** the
+ cross-worktree misattribution outright: a background doc of worktree A throwing while the user
+ switches to worktree B that owns a same-named doc still stamps A's scopedKey, so the banner surfaces
+ only when A's doc is re-focused, never over B. Add a dismiss action and a
+ `canvas-doc-error-banner` element next to the existing `errorBanner`, reusing the banner/dismiss CSS
+ classes (add a doc-error class if a distinct accent is wanted). Wire the dismiss callback through
+ `CanvasPaneCallbacks`; the listener's own callbacks (`Dispatch`/`SelectDoc`/`OnMorphComplete`/
+ `OnDocError`) are grouped into a `MessageListenerCallbacks` record so the same-typed handlers are
+ passed by name. The banner is rendered **only when the stamp matches the focused doc**, so every
+ navigation path (tab/card/keyboard) hides a stale error with no per-reducer clear; `SelectDoc`
+ still clears the state outright so a tab switch (and back) never re-shows it. The handler (the
+ `canvas-doc-error` arm of `CanvasPane.messageListener`) is pane-internal — it must **not** be
+ forwarded to the session like a normal doc payload.
+- **Always-visible tab:** change the `tabs` condition so the active doc's tab always renders;
+ preserve the existing SystemView-first ordering and the lone-SystemView behavior.
+- **Compact age:** add `Components.relativeTimeCompact` (a sibling of `relativeTime`) returning
+ `now`/`3m`/`2h`/`2d` (no `" ago"`). Render it inside `agentTab` from
+ `System.DateTimeOffset.Now` and `d.LastModified`.
+
+### Docs — `src/Extension/skill/SKILL.md`
+- Replace the primary `postMessage` example with `canvasSend`; keep the raw contract documented as
+ the underlying mechanism.
+
+## Verification
+
+End-to-end Playwright coverage modeled on `src/Tests/BeadspaceCanvasTests.fs` (route interception,
+self-contained, CI `Category=E2E`), plus unit tests for pure logic:
+- Unit: `buildInjection` output per kind contains/omits the right injected pieces (incl. the error
+ overlay's embedded, escaped `doc` filename); `canvasSend` size-cap boundary; `relativeTimeCompact`
+ formatting across thresholds; doc-error attribution — a background/hidden doc's error is stamped
+ with the emitting doc (not the active tab) and an unknown emitter is dropped.
+- E2E: the four acceptance criteria above (themed plain doc, `canvasSend` tab switch, error banner
+ on throwing doc, single-doc tab + visible age), plus the two cascade guards for item 1 — a doc
+ that sets its own `body` background keeps it, and the beads `SystemView` body background stays
+ `var(--bg-deep)`.
+
+## Related Specs
+- `docs/spec/canvas-pane.md` — the canvas pane feature this extends.
+- `docs/spec/future/canvas-roadmap.md` — source roadmap (Phases 6 & 8).
+- `docs/spec/beadspace-canvas.md` — the beads `SystemView` that must stay visually unchanged.
diff --git a/docs/spec/canvas-pane.md b/docs/spec/canvas-pane.md
index 8f611b7..1eb1bb1 100644
--- a/docs/spec/canvas-pane.md
+++ b/docs/spec/canvas-pane.md
@@ -56,7 +56,7 @@ A `SystemView` drives its own updates: the beads dashboard polls `/beads-data` e
- Open or closed state persists in global config.
- Position selector supports left, right, top, and bottom docking, and the selected position persists.
- The pane is scoped to the focused worktree. If that worktree has docs, the pane shows its active doc.
-- Worktrees with multiple docs show tab buttons. A single `AgentDoc` skips the tab bar; a lone `SystemView` still shows its `.canvas-system-tab` entry so its beads-count badge stays visible.
+- Worktrees with multiple docs show tab buttons. The active doc's tab always renders — a lone `AgentDoc` now gets a labeled tab (with a compact last-modified age) instead of a bare iframe, and a lone `SystemView` still shows its `.canvas-system-tab` entry so its beads-count badge stays visible. (See also `docs/spec/canvas-authoring-dx.md`.)
- Selecting a tab marks that doc viewed.
- Viewed but inactive tabs render at 0.5 opacity. The active tab stays full opacity.
- The archive button moves the active doc to `.agents/canvas/archive/`. It is shown only when the active doc is an `AgentDoc` — a `SystemView` is server-regenerated, not user-owned, so it has no archive button.
diff --git a/docs/spec/future/canvas-roadmap.md b/docs/spec/future/canvas-roadmap.md
index 107455e..a8a715a 100644
--- a/docs/spec/future/canvas-roadmap.md
+++ b/docs/spec/future/canvas-roadmap.md
@@ -79,6 +79,33 @@ Playwright coverage that each bundled template renders and its interactions work
`src/Tests/BeadspaceCanvasTests.fs`, which uses Playwright **route interception** to serve a
template + mock data from disk (self-contained, no live server — runs in CI under `Category=E2E`).
+## Phase 8 — Pane UX
+
+Goal: polish the canvas pane chrome itself (independent of doc authoring/templates).
+
+### Always-visible doc tab with last-modified age — effort S
+
+Two tightly-coupled tab-bar tweaks that ship together:
+
+1. **Always render the doc tab — even for a single doc.** Today `CanvasPane.fs` (the `tabs`
+ binding, ~line 269) only renders tabs when `wt.CanvasDocs.Length > 1 || hasSystemView`,
+ so a lone **AgentDoc** shows *no* tab and its iframe fills the pane (a lone SystemView
+ already renders, to keep its beads badge). Change the condition so the active doc's tab
+ always renders.
+
+2. **Show the doc's on-disk freshness inside the tab.** Render a compact relative age next to
+ the tab label from `doc.LastModified` (already on `CanvasDoc`, `Types.fs:117`) — e.g.
+ `3m`, `2d`. A `relativeTime` formatter already exists (`Components.fs:8`) but emits the
+ `"3m ago"` long form; add/adapt a **compact** variant (`3m`, `2h`, `2d`, no `" ago"`) for
+ the tab. The age recomputes on the pane's existing render cadence (the dashboard already
+ calls `relativeTime` with `System.DateTimeOffset.Now` per render).
+
+- Scope the age to **AgentDoc** tabs (consistent with the AgentDoc-only liveness dot); the
+ SystemView "BD" badge is data-driven and carries no authored-file age.
+- Acceptance: a worktree with a single canvas doc shows its tab button (not a bare iframe),
+ and each AgentDoc tab shows a compact age label reflecting the file's `LastModified`
+ (e.g. `3m`, `2d`) that updates as the pane re-renders.
+
## Considered but not carried forward
Recorded so future readers know these were evaluated, not missed.
diff --git a/src/Client/App.fs b/src/Client/App.fs
index ed59de4..82de6cf 100644
--- a/src/Client/App.fs
+++ b/src/Client/App.fs
@@ -490,6 +490,10 @@ let update msg model =
| DismissCanvasMessageError -> CanvasUpdate.dismissCanvasMessageError model
+ | CanvasDocError (scopedKey, filename, message) -> CanvasUpdate.canvasDocError scopedKey filename message model
+
+ | DismissCanvasDocError -> CanvasUpdate.dismissCanvasDocError model
+
| MarkDocViewed (scopedKey, filename) ->
let worktree = findWorktree scopedKey model
let currentHash =
diff --git a/src/Client/AppTypes.fs b/src/Client/AppTypes.fs
index d16ee3b..b2e3e6e 100644
--- a/src/Client/AppTypes.fs
+++ b/src/Client/AppTypes.fs
@@ -79,6 +79,13 @@ type Msg =
| CanvasMessageReceived of payload: string
| CanvasSendResult of CanvasMessageResult * scopedKey: string
| DismissCanvasMessageError
+ // Doc-side JS error forwarded from an AgentDoc iframe (window.onerror / unhandledrejection via
+ // the injected errorOverlayScript). `scopedKey`+`filename` are the emitting worktree + doc
+ // (carried in the postMessage `wt`/`doc` fields) so the reducer stamps the error with the doc that
+ // actually threw, not the active tab. Carried as its own message + model field, distinct from
+ // CanvasSendState.Failed (which is a pane→session *delivery* failure, a different source).
+ | CanvasDocError of scopedKey: string * filename: string * message: string
+ | DismissCanvasDocError
| MarkDocViewed of scopedKey: string * filename: string
| LoadLastViewedHashes of Map>
| BridgeLivenessLoaded of Map
diff --git a/src/Client/CanvasAwareness.fs b/src/Client/CanvasAwareness.fs
index fc92b4a..cd0ea2e 100644
--- a/src/Client/CanvasAwareness.fs
+++ b/src/Client/CanvasAwareness.fs
@@ -2,6 +2,7 @@ module CanvasAwareness
open Shared
open Navigation
+open CanvasTypes
type CanvasEventKind = NewDoc | UpdatedDoc
diff --git a/src/Client/CanvasPane.fs b/src/Client/CanvasPane.fs
index 2625c07..eaf4aa3 100644
--- a/src/Client/CanvasPane.fs
+++ b/src/Client/CanvasPane.fs
@@ -2,10 +2,16 @@ module CanvasPane
open Shared
open Navigation
+open CanvasTypes
open Feliz
open Browser
let [] CanvasOrigin = "http://127.0.0.1:5002"
+// Doc→pane message size cap, in UTF-16 code units (JS String.length): the listener below drops a
+// message when JSON.stringify(me.data).length exceeds this. The injected window.canvasSend helper
+// enforces the SAME cap doc-side (var MAX=64000 in canvasSendScript, src/Server/CanvasDocServer.fs)
+// so its accept/drop verdict matches this check — keep the two literals in sync if you change this
+// value (CanvasDocServerTests pins the helper's copy).
let [] private MaxPayloadBytes = 64_000
let private isDocAlive (bridgeLiveness: Map) (doc: CanvasDoc) =
@@ -145,15 +151,17 @@ type CanvasPaneCallbacks =
OnOverviewDocClick: string -> string -> unit
ArchiveDoc: string -> unit
DismissError: unit -> unit
+ DismissDocError: unit -> unit
LaunchSession: unit -> unit }
-let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus * CanvasDoc) option) (allRepos: RepoModel list) (sendState: CanvasSendState) (bridgeLiveness: Map) (unviewedFilenames: Set) (visitedDocs: string list) (callbacks: CanvasPaneCallbacks) =
+let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus * CanvasDoc) option) (allRepos: RepoModel list) (sendState: CanvasSendState) (docError: DocJsError option) (bridgeLiveness: Map) (unviewedFilenames: Set) (visitedDocs: string list) (callbacks: CanvasPaneCallbacks) =
let { SetPosition = setPosition
SelectDoc = selectDoc
OnOverviewClick = onOverviewClick
OnOverviewDocClick = onOverviewDocClick
ArchiveDoc = archiveDoc
DismissError = dismissError
+ DismissDocError = dismissDocError
LaunchSession = launchSession } = callbacks
let positionButton (canvasPosition: CanvasPosition) (label: string) (title: string) =
Html.button [
@@ -223,6 +231,32 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus
]
| _ -> Html.none
+ // Doc-side JS error banner — a distinct source from CanvasSendState.Failed (which is a
+ // pane→session *delivery* failure). Rendered as a normal flex child of the column-layout pane
+ // (never absolutely positioned), so it pushes the iframe down instead of covering doc content.
+ // Doc-scoped: shown ONLY when the stored error's stamp matches the currently focused doc, so a
+ // stale error from a doc you navigated away from (tab, card, or keyboard focus) is never shown
+ // over a different doc — and never over the overview (focusedDoc = None).
+ let docErrorBanner =
+ match docError, focusedDoc with
+ | Some err, Some (wt, doc) when WorktreePath.value wt.Path = err.ScopedKey && doc.Filename = err.Filename ->
+ Html.div [
+ prop.className "canvas-doc-error-banner"
+ prop.children [
+ Html.span [
+ prop.className "canvas-doc-error-text"
+ prop.title err.Message
+ prop.text $"Doc error: {err.Message}"
+ ]
+ Html.button [
+ prop.className "canvas-doc-error-dismiss"
+ prop.onClick (fun _ -> dismissDocError ())
+ prop.text "✕"
+ ]
+ ]
+ ]
+ | _ -> Html.none
+
let waitingBanner =
match sendState with
| CanvasSendState.Waiting _ ->
@@ -244,10 +278,9 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus
| Some (wt, doc) ->
let isFocusedDocAlive = isDocAlive bridgeLiveness doc
// The SystemView (beads) entry gets a distinct affordance pinned to the far left of the
- // strip; AgentDocs keep the normal tab treatment. The strip also renders for a lone
- // SystemView so its beads-count badge stays visible, while a lone AgentDoc still shows
- // no tabs (its iframe fills the pane).
- let hasSystemView = wt.CanvasDocs |> List.exists (fun d -> d.Kind = SystemView)
+ // strip; AgentDocs keep the normal tab treatment. The strip always renders the active
+ // doc's tab — including a lone AgentDoc (so it gets a labeled tab instead of a bare
+ // iframe) and a lone SystemView (so its beads-count badge stays visible).
let agentTab (d: CanvasDoc) =
let isActive = d.Filename = doc.Filename
let isViewed = not (Set.contains d.Filename unviewedFilenames)
@@ -263,17 +296,24 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus
prop.children [
livenessDotFor bridgeLiveness d
Html.text (d.Filename.Replace(".html", ""))
+ // On-disk freshness of the authored file, refreshed on the pane's existing
+ // render cadence. Scoped to AgentDoc tabs (a SystemView is server-generated
+ // and carries no authored-file age).
+ Html.span [
+ prop.className "canvas-tab-age"
+ prop.text (Components.relativeTimeCompact System.DateTimeOffset.Now d.LastModified)
+ ]
]
]
+ // Always render the active doc's tab — no lone-AgentDoc suppression — while preserving
+ // the SystemView-first ordering (so the beads "BD" tab stays pinned left when present).
let tabs =
- if wt.CanvasDocs.Length > 1 || hasSystemView then
- wt.CanvasDocs
- |> List.sortBy (fun d -> match d.Kind with SystemView -> 0 | AgentDoc -> 1)
- |> List.map (fun d ->
- match d.Kind with
- | SystemView -> systemViewTab wt (d.Filename = doc.Filename) selectDoc d
- | AgentDoc -> agentTab d)
- else []
+ wt.CanvasDocs
+ |> List.sortBy (fun d -> match d.Kind with SystemView -> 0 | AgentDoc -> 1)
+ |> List.map (fun d ->
+ match d.Kind with
+ | SystemView -> systemViewTab wt (d.Filename = doc.Filename) selectDoc d
+ | AgentDoc -> agentTab d)
// Render iframes for all visited docs; active is visible, others are hidden.
// Ensure the active doc is always included even if not yet in visitedDocs.
let docsToRender =
@@ -297,6 +337,7 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus
React.fragment [
headerBar tabs (Some doc) (doc.Kind = AgentDoc && not isFocusedDocAlive)
errorBanner
+ docErrorBanner
waitingBanner
yield! iframes
]
@@ -304,6 +345,7 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus
React.fragment [
headerBar [] None false
errorBanner
+ docErrorBanner
waitingBanner
overviewView allRepos bridgeLiveness onOverviewClick onOverviewDocClick
]
@@ -318,29 +360,82 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus
prop.children [ content ]
]
-let messageListener (dispatch: string -> unit) (selectDoc: string -> unit) (onMorphComplete: unit -> unit) =
+/// Callbacks the pane-internal `messageListener` raises for each recognized doc→pane message.
+/// Grouped into a record (mirroring CanvasPaneCallbacks) so they are passed by name: Dispatch and
+/// SelectDoc both share the type `string -> unit`, so positional passing let a silent argument
+/// transposition compile and surface only at runtime.
+type MessageListenerCallbacks =
+ { /// Forward an unrecognized (normal) doc payload on to the session.
+ Dispatch: string -> unit
+ /// Switch the active tab to the named doc (navigate-canvas-doc).
+ SelectDoc: string -> unit
+ /// The active doc finished an idiomorph (morph-complete).
+ OnMorphComplete: unit -> unit
+ /// A doc-side JS error arrived: (emitting worktree scopedKey, emitting filename, display message).
+ OnDocError: string -> string -> string -> unit }
+
+let messageListener (callbacks: MessageListenerCallbacks) =
+ let { Dispatch = dispatch
+ SelectDoc = selectDoc
+ OnMorphComplete = onMorphComplete
+ OnDocError = onDocError } = callbacks
let handler =
fun (e: Browser.Types.Event) ->
let me = e :?> Browser.Types.MessageEvent
if me.origin = CanvasOrigin
&& Fable.Core.JsInterop.emitJsExpr me.data "$0 != null && typeof $0 === 'object' && typeof $0.action === 'string'"
then
+ // True when THIS message came from a mounted-but-HIDDEN canvas iframe (a visited doc that
+ // stays mounted and keeps running JS) rather than the active one. The origin check above
+ // already proves the sender is a canvas doc iframe, so a hidden-iframe match means a
+ // background/co-resident doc is posting. Session forwarding and navigate-canvas-doc honor
+ // only the active doc, so such a message is dropped — a hidden doc can't inject a payload
+ // (or force a tab switch) attributed to the active doc's owner session. The per-doc error
+ // path is exempt: it self-identifies via wt/doc and may legitimately report from any iframe.
+ let isFromHiddenCanvasIframe () =
+ Fable.Core.JsInterop.emitJsExpr me "Array.prototype.some.call(document.querySelectorAll('.canvas-iframe:not(.canvas-iframe-active)'), function(f){return f.contentWindow === $0.source})"
let action = Fable.Core.JsInterop.emitJsExpr me.data "$0.action"
if action = "navigate-canvas-doc" then
match Fable.Core.JsInterop.emitJsExpr me.data "$0.filename" |> Option.ofObj with
| Some filename when filename <> "" ->
- Fable.Core.JS.console.log ($"[canvas] navigate-canvas-doc: filename={filename}")
- selectDoc filename
+ if isFromHiddenCanvasIframe () then
+ Fable.Core.JS.console.warn "[canvas] navigate-canvas-doc DROPPED: from a hidden background doc iframe"
+ else
+ Fable.Core.JS.console.log ($"[canvas] navigate-canvas-doc: filename={filename}")
+ selectDoc filename
| _ -> ()
elif action = "morph-complete" then
Fable.Core.JS.console.log "[canvas] morph-complete received"
onMorphComplete ()
+ elif action = "canvas-doc-error" then
+ // Doc-side JS error from the iframe (errorOverlayScript). Pane-internal — surfaced
+ // in the doc-error banner, never forwarded to the session like a normal payload.
+ // The `wt`/`doc` fields are the emitting worktree + filename, so the reducer stamps
+ // the error with the doc that threw (not the active tab); they are re-validated
+ // against that worktree's docs there. wt/message/line/col cross an untrusted '*'
+ // boundary, so each field is read with a null-safe String() coercion and the display
+ // string "msg (line N:C)" is assembled here in F#.
+ let scopedKey = Fable.Core.JsInterop.emitJsExpr me.data "typeof $0.wt==='string'?$0.wt:''"
+ let filename = Fable.Core.JsInterop.emitJsExpr me.data "typeof $0.doc==='string'?$0.doc:''"
+ let rawMessage = Fable.Core.JsInterop.emitJsExpr me.data "$0.message==null?'Unknown error':String($0.message)"
+ let line = Fable.Core.JsInterop.emitJsExpr me.data "$0.line==null?'':String($0.line)"
+ let col = Fable.Core.JsInterop.emitJsExpr me.data "$0.col==null?'':String($0.col)"
+ let body = if rawMessage.Length > 500 then rawMessage.Substring(0, 500) else rawMessage
+ let message =
+ if line = "" then body
+ elif col = "" then $"{body} (line {line})"
+ else $"{body} (line {line}:{col})"
+ Fable.Core.JS.console.warn ($"[canvas] canvas-doc-error received from {scopedKey}/{filename}: {message}")
+ onDocError scopedKey filename message
else
let payload = Fable.Core.JS.JSON.stringify me.data
Fable.Core.JS.console.log ($"[canvas] postMessage received: origin={me.origin}, action={action}, payload length={payload.Length}")
- if payload.Length <= MaxPayloadBytes
- then dispatch payload
- else Fable.Core.JS.console.warn ($"[canvas] postMessage DROPPED: payload too large ({payload.Length} > {MaxPayloadBytes})")
+ if isFromHiddenCanvasIframe () then
+ Fable.Core.JS.console.warn ($"[canvas] postMessage DROPPED: from a hidden background doc iframe (action={action})")
+ elif payload.Length <= MaxPayloadBytes then
+ dispatch payload
+ else
+ Fable.Core.JS.console.warn ($"[canvas] postMessage DROPPED: payload too large ({payload.Length} > {MaxPayloadBytes})")
Dom.window.addEventListener ("message", handler)
diff --git a/src/Client/CanvasState.fs b/src/Client/CanvasState.fs
index 2f7c4b6..529decf 100644
--- a/src/Client/CanvasState.fs
+++ b/src/Client/CanvasState.fs
@@ -2,6 +2,7 @@ module CanvasState
open Shared
open Navigation
+open CanvasTypes
open CanvasAwareness
open Elmish
@@ -17,6 +18,14 @@ type CanvasState =
PreviousCanvasHashes: Map>
CanvasEvents: Map
CanvasSendState: CanvasSendState
+ // Latest doc-side JS error from a focused AgentDoc's iframe, stamped with the doc that EMITTED
+ // it — its filename is carried in the postMessage and validated against the focused worktree's
+ // docs (DocJsError). The banner is shown only while that same doc is focused (CanvasPane gates
+ // on it), so navigating to another doc/card auto-hides a stale error — doc-scoped without a
+ // clear in every focus reducer. SelectCanvasDoc additionally clears it so a tab switch (and
+ // switch back) never re-shows it. Distinct from CanvasSendState.Failed, which models
+ // pane→session message-delivery failures.
+ DocError: DocJsError option
BridgeLiveness: Map }
/// Initial canvas state: pane closed on the right, all maps empty, send state idle.
@@ -30,6 +39,7 @@ let empty : CanvasState =
PreviousCanvasHashes = Map.empty
CanvasEvents = Map.empty
CanvasSendState = CanvasSendState.Idle
+ DocError = None
BridgeLiveness = Map.empty }
let [] private MaxLiveIframes = 3
diff --git a/src/Client/CanvasTypes.fs b/src/Client/CanvasTypes.fs
new file mode 100644
index 0000000..cacf842
--- /dev/null
+++ b/src/Client/CanvasTypes.fs
@@ -0,0 +1,28 @@
+module CanvasTypes
+
+// Canvas-specific shared types, owned here (compiled after Navigation, before CanvasPane) rather than
+// parked in the focus/navigation-scoped Navigation module. Imported by the canvas pane, state, update
+// and awareness modules.
+
+[]
+type CanvasSendState =
+ // scopedKey identifies the target worktree the message was queued for, so the "Waiting for
+ // session…" banner can be cleared only by *that* worktree's session activity, never by an
+ // unrelated worktree's doc change.
+ | Idle
+ | Waiting of scopedKey: string
+ | Failed of message: string
+
+/// A doc-side JS error (window.onerror / unhandledrejection forwarded from an AgentDoc iframe via the
+/// injected errorOverlayScript), stamped with the worktree + the doc that EMITTED it. Both ride along
+/// in the postMessage: the worktree in the `wt` field (the overlay derives it from its own
+/// location.pathname, mirroring the bridge heartbeat) and the filename in the `doc` field (the overlay
+/// is served per-doc). They are validated against that worktree's docs before being stored, so the
+/// error is attributed to the doc that actually threw — independent of the active tab — and a
+/// stale/forged identity is dropped. The pane shows the banner only while that same doc is focused
+/// (CanvasPane.docErrorBanner gates on ScopedKey+Filename), so card/tab/keyboard navigation to any
+/// OTHER doc auto-hides a stale error — truly doc-scoped — without a clear in every focus reducer.
+type DocJsError =
+ { ScopedKey: string
+ Filename: string
+ Message: string }
diff --git a/src/Client/CanvasUpdate.fs b/src/Client/CanvasUpdate.fs
index 547dd21..58b98be 100644
--- a/src/Client/CanvasUpdate.fs
+++ b/src/Client/CanvasUpdate.fs
@@ -9,6 +9,7 @@ module CanvasUpdate
open Shared
open Navigation
+open CanvasTypes
open Elmish
open Browser
open AppTypes
@@ -63,6 +64,10 @@ let selectCanvasDoc (scopedKey: string) (filename: string) (model: Model) =
{ model with
Canvas =
{ model.Canvas with
+ // Doc-scoped error: a tab switch must never carry a stale error from the doc we're
+ // leaving into the one we're showing, so clear it here (it reappears only if the new
+ // doc throws again).
+ DocError = None
ActiveCanvasDoc = model.Canvas.ActiveCanvasDoc |> Map.add scopedKey filename
VisitedCanvasDocs = CanvasState.touchVisitedDoc scopedKey filename model.Canvas.VisitedCanvasDocs } },
Cmd.batch [
@@ -180,6 +185,28 @@ let canvasSendResult (result: CanvasMessageResult) (scopedKey: string) (model: M
let dismissCanvasMessageError (model: Model) =
{ model with Canvas = { model.Canvas with CanvasSendState = CanvasSendState.Idle } }, Cmd.none
+/// Record a doc-side JS error (window.onerror / unhandledrejection) forwarded from an AgentDoc
+/// iframe. `scopedKey` and `filename` are the EMITTING worktree + doc, carried in the postMessage
+/// `wt`/`doc` fields and threaded through the listener, so the error is stamped with the doc that
+/// actually threw — independent of the active tab. This matters because visited docs stay mounted as
+/// hidden iframes and keep running JS, so an async error from a hidden doc (even in a non-focused
+/// worktree) must not be attributed to the focused tab (focused-review A-02, C-06). The emitter is
+/// validated against that worktree's docs (isKnownCanvasDoc) before being stored, so a stale/forged
+/// identity — e.g. from an archived doc — can never raise a banner. The stamp drives doc-scoped
+/// display: the banner shows only while that doc stays focused; navigating to another doc/card hides
+/// it (the view gates on the stamp). Kept separate from CanvasSendState so the doc-error and
+/// message-delivery banners never overwrite each other; the newest error wins. If the emitter is not
+/// a known doc of a known worktree, the error is dropped. (Arrival is already logged in
+/// CanvasPane.messageListener.)
+let canvasDocError (scopedKey: string) (filename: string) (message: string) (model: Model) =
+ if isKnownCanvasDoc model scopedKey filename then
+ { model with Canvas = { model.Canvas with DocError = Some { ScopedKey = scopedKey; Filename = filename; Message = message } } }, Cmd.none
+ else
+ model, Cmd.none
+
+let dismissCanvasDocError (model: Model) =
+ { model with Canvas = { model.Canvas with DocError = None } }, Cmd.none
+
let morphActiveDoc (model: Model) =
model,
Cmd.ofEffect (fun _ ->
@@ -192,4 +219,8 @@ let morphComplete (model: Model) =
model, markVisibleDocCmd model
let messageListener (dispatch: Dispatch) =
- CanvasPane.messageListener (CanvasMessageReceived >> dispatch) (NavigateCanvasDoc >> dispatch) (fun () -> dispatch MorphComplete)
+ CanvasPane.messageListener
+ { Dispatch = CanvasMessageReceived >> dispatch
+ SelectDoc = NavigateCanvasDoc >> dispatch
+ OnMorphComplete = fun () -> dispatch MorphComplete
+ OnDocError = fun scopedKey filename message -> dispatch (CanvasDocError (scopedKey, filename, message)) }
diff --git a/src/Client/CanvasView.fs b/src/Client/CanvasView.fs
index c599af3..54b08dd 100644
--- a/src/Client/CanvasView.fs
+++ b/src/Client/CanvasView.fs
@@ -71,6 +71,7 @@ let view (model: Model) (dispatch: Dispatch) =
OnOverviewDocClick = onOverviewDocClick
ArchiveDoc = archiveCanvasDoc
DismissError = (fun () -> dispatch DismissCanvasMessageError)
+ DismissDocError = (fun () -> dispatch DismissCanvasDocError)
LaunchSession = launchCanvasSession }
- CanvasPane.view model.Canvas.CanvasPaneOpen model.Canvas.CanvasPosition (focusedWorktreeCanvasDoc model) model.Repos model.Canvas.CanvasSendState model.Canvas.BridgeLiveness focusedUnviewedFilenames focusedVisitedDocs canvasCallbacks
+ CanvasPane.view model.Canvas.CanvasPaneOpen model.Canvas.CanvasPosition (focusedWorktreeCanvasDoc model) model.Repos model.Canvas.CanvasSendState model.Canvas.DocError model.Canvas.BridgeLiveness focusedUnviewedFilenames focusedVisitedDocs canvasCallbacks
diff --git a/src/Client/Client.fsproj b/src/Client/Client.fsproj
index 6852c1a..d6cad92 100644
--- a/src/Client/Client.fsproj
+++ b/src/Client/Client.fsproj
@@ -6,6 +6,7 @@
+
diff --git a/src/Client/Components.fs b/src/Client/Components.fs
index 8645cc7..19cf893 100644
--- a/src/Client/Components.fs
+++ b/src/Client/Components.fs
@@ -5,13 +5,23 @@ open Feliz
open Fable.Core
open Fable.Core.JsInterop
-let relativeTime (now: System.DateTimeOffset) (dt: System.DateTimeOffset) =
+/// Shared time-bucketing ladder for relativeTime / relativeTimeCompact: the same four thresholds and
+/// the same int truncation, parameterised only by the sub-minute label and the per-bucket suffix.
+let private formatTimeDelta (subMinute: string) (suffix: string) (now: System.DateTimeOffset) (dt: System.DateTimeOffset) =
let diff = now - dt
match diff with
- | d when d.TotalMinutes < 1.0 -> "just now"
- | d when d.TotalMinutes < 60.0 -> $"{int d.TotalMinutes}m ago"
- | d when d.TotalHours < 24.0 -> $"{int d.TotalHours}h ago"
- | d -> $"{int d.TotalDays}d ago"
+ | d when d.TotalMinutes < 1.0 -> subMinute
+ | d when d.TotalMinutes < 60.0 -> $"{int d.TotalMinutes}m{suffix}"
+ | d when d.TotalHours < 24.0 -> $"{int d.TotalHours}h{suffix}"
+ | d -> $"{int d.TotalDays}d{suffix}"
+
+let relativeTime (now: System.DateTimeOffset) (dt: System.DateTimeOffset) =
+ formatTimeDelta "just now" " ago" now dt
+
+/// Compact sibling of relativeTime for tight UI like the canvas tab strip: renders
+/// "now"/"3m"/"2h"/"2d" (sub-minute is "now", not "just now", and the buckets carry no " ago" suffix).
+let relativeTimeCompact (now: System.DateTimeOffset) (dt: System.DateTimeOffset) =
+ formatTimeDelta "now" "" now dt
let cardTitle (wt: WorktreeStatus) =
if wt.Branch = WorktreeStatus.DetachedBranchName then WorktreePath.displayName wt.Path
diff --git a/src/Client/Navigation.fs b/src/Client/Navigation.fs
index 78020cc..bd1b3f9 100644
--- a/src/Client/Navigation.fs
+++ b/src/Client/Navigation.fs
@@ -8,15 +8,6 @@ type FocusTarget =
| RepoHeader of RepoId
| Card of path: string
-[]
-type CanvasSendState =
- // scopedKey identifies the target worktree the message was queued for, so the "Waiting for
- // session…" banner can be cleared only by *that* worktree's session activity, never by an
- // unrelated worktree's doc change.
- | Idle
- | Waiting of scopedKey: string
- | Failed of message: string
-
type RepoModel =
{ RepoId: RepoId
Name: string
diff --git a/src/Client/index.html b/src/Client/index.html
index 51c3290..d9898b3 100644
--- a/src/Client/index.html
+++ b/src/Client/index.html
@@ -537,6 +537,26 @@
font-size: 1em; padding: 0 4px; opacity: 0.6;
}
.canvas-waiting-dismiss:hover { opacity: 1; }
+ /* Doc-side JS error banner. Amber accent keeps it visually distinct from the red
+ message-delivery failure banner (.canvas-error-banner) and the blue waiting banner.
+ As a normal flex child of the column-layout .canvas-pane it pushes the iframe down
+ rather than overlaying it; the single-line text ellipsizes (full text in the title)
+ so a long error can never grow the banner over the doc content. */
+ .canvas-doc-error-banner {
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
+ background: #3a2e1e; color: #fab387; padding: 4px 12px;
+ font-size: 0.85em; line-height: 1.4; border-bottom: 1px solid #5a4420;
+ }
+ .canvas-doc-error-text {
+ flex: 1; min-width: 0;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ font-family: 'Consolas', 'Cascadia Mono', monospace;
+ }
+ .canvas-doc-error-dismiss {
+ background: none; border: none; color: #fab387; cursor: pointer;
+ font-size: 1em; padding: 0 4px; opacity: 0.6; flex-shrink: 0;
+ }
+ .canvas-doc-error-dismiss:hover { opacity: 1; }
.canvas-iframe {
width: 100%;
height: 100%;
@@ -561,6 +581,11 @@
.canvas-tab:hover { background: #45475a; border-color: #585b70; }
.canvas-tab.active { background: #2e3452; border-color: #89b4fa; color: #89b4fa; }
.canvas-tab-viewed { opacity: 0.5; }
+ .canvas-tab-age {
+ margin-left: 6px; font-size: 0.8em; color: #6c7086;
+ font-variant-numeric: tabular-nums;
+ }
+ .canvas-tab.active .canvas-tab-age { color: #7f9bd6; }
.canvas-system-tab {
display: flex; align-items: center; gap: 5px;
flex-shrink: 0; margin-right: 6px;
diff --git a/src/Extension/skill/SKILL.md b/src/Extension/skill/SKILL.md
index e73ec7f..94016bb 100644
--- a/src/Extension/skill/SKILL.md
+++ b/src/Extension/skill/SKILL.md
@@ -37,7 +37,15 @@ button:hover { background: #585b70; }
## Interactivity
-Canvas docs can send messages back to the agent session via `postMessage`. Treemon validates the origin and forwards the message.
+Canvas docs send messages back to the agent session with the injected **`canvasSend(action, payload)`** helper. Treemon validates the origin and forwards the message to the session that owns the doc.
+
+```js
+canvasSend('my-action', { payload: 'data' });
+```
+
+`canvasSend` is the primary API. It builds the flat message shape, posts it to the pane, and — before sending — checks the serialized size against the pane's limit (`JSON.stringify(message).length`, i.e. **64000 UTF-16 code units**). An oversized message is **not** sent; instead it logs a `console.error` in the doc so you get immediate feedback instead of a silent drop. `canvasSend` returns `true` when the message was posted and `false` when it was too large.
+
+The message shape is flat: `canvasSend('navigate-canvas-doc', { filename })` posts `{ action: 'navigate-canvas-doc', filename }` (which switches the active tab); `canvasSend('comment', { text })` posts `{ action: 'comment', text }`. That raw `postMessage` shape is the underlying contract and still works directly if you ever need it (e.g. the helper isn't available) — but it sends without the size check:
```js
window.parent.postMessage({ action: 'my-action', payload: 'data' }, '*');
@@ -45,13 +53,13 @@ window.parent.postMessage({ action: 'my-action', payload: 'data' }, '*');
### Don't block the conversation when the doc collects the answer
-If the canvas doc itself gathers the user's input — choices, a form, buttons, a comment box — **do not** also call `ask_user` (or any other blocking prompt). The doc's `postMessage` reply *is* the channel for the answer. Calling `ask_user` at the same time pops a separate blocking modal, freezes the session, and prevents the user from responding through the doc you just built.
+If the canvas doc itself gathers the user's input — choices, a form, buttons, a comment box — **do not** also call `ask_user` (or any other blocking prompt). The doc's `canvasSend` reply *is* the channel for the answer. Calling `ask_user` at the same time pops a separate blocking modal, freezes the session, and prevents the user from responding through the doc you just built.
-Instead: write the doc, briefly tell the user it's ready for their input, then **end your turn and leave the conversation open**. The user's selection arrives as a normal message via `postMessage`, and you continue from there. Only use `ask_user` when there is no canvas doc collecting the response.
+Instead: write the doc, briefly tell the user it's ready for their input, then **end your turn and leave the conversation open**. The user's selection arrives as a normal message via `canvasSend`, and you continue from there. Only use `ask_user` when there is no canvas doc collecting the response.
## Ownership
-When you create or update a canvas doc, your session is automatically recorded as that doc's **owner**. That ownership is what routes the user's `postMessage` replies back to *your* session — even when several agent sessions are running in the same worktree.
+When you create or update a canvas doc, your session is automatically recorded as that doc's **owner**. That ownership is what routes the user's message replies back to *your* session — even when several agent sessions are running in the same worktree.
You never need to know or send your own session ID: writing the `.html` file with the **create** or **edit** tool *is* the ownership declaration — the extension stamps in the session ID and reports it to Treemon for you. So always author canvas docs with those tools under `.agents/canvas/`. Don't shell out to write the file (e.g. redirecting command output into it); the declaration only fires for the create/edit tools, and without it the doc's messages may reach the wrong session.
@@ -59,7 +67,7 @@ Editing a doc another session created transfers ownership to you (most recent au
## Updating
-Overwrite the file — Treemon detects content changes (via hash) and reloads the pane automatically. If Treemon isn't monitoring the directory, the extension serves canvas files over HTTP and returns the browser URL in the tool result right after you create or edit a canvas file (open it for the user or share the ctrl+clickable URL). `postMessage` interactions work identically in both modes — no changes needed in your HTML.
+Overwrite the file — Treemon detects content changes (via hash) and reloads the pane automatically. If Treemon isn't monitoring the directory, the extension serves canvas files over HTTP and returns the browser URL in the tool result right after you create or edit a canvas file (open it for the user or share the ctrl+clickable URL). `canvasSend` interactions work identically in both modes — no changes needed in your HTML.
## Multiple docs
@@ -115,7 +123,7 @@ Users can archive docs to `.agents/canvas/archive/`. Don't rely on canvas docs f
function send() {
const text = document.getElementById('msg').value.trim();
if (text) {
- window.parent.postMessage({ action: 'comment', text }, '*');
+ canvasSend('comment', { text });
document.getElementById('msg').value = '';
}
}
diff --git a/src/Server/BeadspaceTemplate.html b/src/Server/BeadspaceTemplate.html
index f3aae03..0038a4d 100644
--- a/src/Server/BeadspaceTemplate.html
+++ b/src/Server/BeadspaceTemplate.html
@@ -38,6 +38,8 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
+ margin: 0;
+ padding: 0;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
background: var(--bg-deep);
color: var(--text-primary);
diff --git a/src/Server/CanvasDocServer.fs b/src/Server/CanvasDocServer.fs
index f27f43a..8af6d53 100644
--- a/src/Server/CanvasDocServer.fs
+++ b/src/Server/CanvasDocServer.fs
@@ -206,7 +206,20 @@ let private bridgeScript =
"})()" ]
|> String.concat ""
-let private baseStyle = ""
+/// Scrollbar styling + a small dark-theme base reset, injected for BOTH doc kinds at the
+/// slot (handleCanvasRequest). Because the injection lands AFTER any styles the doc/template
+/// already declares, an equal-specificity element rule here would win the source-order tiebreak and
+/// stomp the doc. So every reset selector is wrapped in :where(...) — carrying ZERO specificity like
+/// the universal `*` scrollbar rule — and no rule uses !important. Then any real doc rule (even a
+/// bare body{}) and the SystemView template's own body-SELECTOR rules (e.g. BeadspaceTemplate.html's
+/// body{background:var(--bg-deep);margin:0;padding:0}, specificity 0,0,1) override the reset
+/// (specificity 0,0,0) regardless of source order. The override is per-property and only via a rule on
+/// the `body` selector: a box property the template zeroes through the universal `*{margin:0;padding:0}`
+/// reset (also 0,0,0) would otherwise LOSE the source-order tiebreak to this later-injected
+/// :where(body){padding:1rem}, so the template resets margin/padding on `body` directly to keep its
+/// padding:0. Literal colours mirror the app theme (Catppuccin --bg-deep /
+/// --text-primary / --accent) since a plain doc never defines those CSS variables.
+let private baseStyle = ""
/// Intercepts in-doc link clicks: same-origin .html links become navigate-canvas-doc messages
/// (tab switch), everything else opens in a new tab. The target filename is taken from a.pathname
@@ -217,16 +230,100 @@ let private baseStyle = "owned