@ai-matrx/canvas 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ Owner feedback after using the canvas live (Arman, 2026-10-02): a stable canvas that never dismounts, no UI traps, readable tabs, the shell's motion, content that knows its shape, and the pieces web and desktop both need.
6
+
7
+ - **Stable bodies (never dismount).** Every open item's body is rendered ONCE, in a body layer at the column root keyed by the item's instance, and placed over its pane's body slot by measured rect (DOM bodies and native bodies alike: a body on a slot). A tab switch hides it (`visibility` + `content-visibility: hidden` + `inert`: state and scroll kept); a split, a move between panes, a width drag and expand re-place it; putting the canvas away keeps it. A body mounts the first time it is shown and stays mounted until its tab closes. A rekeyed draft keeps its body (`CanvasItem.instance`). Moving a tab to another window remounts it (a different column). Bodies re-render only when their own item, pane, focus, visibility or presentation changes.
8
+ - **`unmountWhenHidden`** (kind field): opt out for genuinely heavy hidden bodies. **`keepAlive` is deprecated and does nothing** — staying mounted is the default.
9
+ - **`CanvasKindProps.isVisible`**: on screen right now (active tab, canvas open) — pause costly work while false.
10
+ - **The column never unmounts.** Put away, it is 0 wide, `inert`, `aria-hidden` and hidden after the slide; `useCanvasColumnWidth()` still reads 0.
11
+ - **No UI traps.** Every pane header has a **"+" New tab** (opens the launcher as a tab; a pane holds one). A lone tab shown as a title has **its own close**. **Tab history:** `controller.navigate(itemId, { kind, key, title?, data? })` replaces a tab's content in place and records where it was (`CanvasItem.history`, JSON, persisted, at most `CANVAS_HISTORY_LIMIT` = 50); the header shows **Back** while the active tab has history; `controller.back(itemId)`. Choosing in the launcher navigates the launcher tab (Back returns to it); choosing in an empty pane's launcher does the same. Identity still holds: navigating to an item open elsewhere focuses it there — a launcher tab then closes, any other tab keeps what it shows; Back to an item opened elsewhere since focuses it and closes the step. Reducer actions `navigate` / `back`, `controller.newTab(paneId?)`, selectors `selectCanvasCanGoBack` / `selectCanvasItemHistory`, `CANVAS_LAUNCHER_KIND`.
12
+ - **Dense tabs.** 12.5px text, 14px icon, 6px side padding, 26px tall; tabs shrink evenly from 8rem to a 3.25rem floor, the active tab keeps icon + ~10 letters, the close is overlaid (no width while hidden; the title fades under it), the strip scrolls sideways with the wheel and keeps the active tab in view.
13
+ - **Motion.** Open, put away, expand and restore animate the column's width on `--mxc-motion-duration` / `--mxc-motion-ease` (tokens.css: 600ms, `cubic-bezier(0.4, 0, 0.2, 1)` — the AI Matrx shell sidebar's slide); the content keeps its width so it slides instead of reflowing; off while an edge is dragged and under `prefers-reduced-motion`. Native views are re-measured every frame of the slide.
14
+ - **Presentation primitive.** `CanvasKindProps.presentation = { host: "canvas", width, height, orientation: "portrait" | "landscape" | "square", isNarrow, isFullscreen, paneCount }`, live from the slot; `useCanvasPresentation()` gives any nested component the same value (null outside the canvas); `preferredFlowDirection(presentation)` → `"vertical" | "horizontal"`; pure `canvasPresentation`, `canvasOrientation`, `CANVAS_NARROW_WIDTH` in the core.
15
+ - **Contextual launcher entries.** `useCanvasLauncherEntry({ kind, key, data?, title?, icon? })` offers a page's own thing in the launcher while it is mounted ("On this page", before the global kinds); entries for unregistered kinds are not offered.
16
+ - **`preferredTarget`** (kind field): `"split-down" | "split-right" | "focused"` — where a NEW item of the kind opens while the canvas already shows something; a pane already holding the kind takes it; an explicit `target` wins. `open` and `toggleKind` honour it (controller option `preferredTarget`).
17
+ - **`CanvasKindIcons`**: a header's cluster of kind toggles (`kinds: (string | { kind, key?, data?, title?, whenVisible?, label? })[]`, `variant`), each pressed while its item is in front; unregistered kinds draw nothing.
18
+ - **Web split-down is 70/30** (`CANVAS_WEB_LAYOUT_RULES.stackSplit` 0.5 → 0.7), like the desktop: what opens below is the smaller pane.
19
+
20
+ ### Consumer action
21
+
22
+ - A host that reflows beside the docked column (`right: var(--shell-canvas-w)`, a grid track) transitions that property with `var(--mxc-motion-duration) var(--mxc-motion-ease)`, turns the transition off under `:root[data-mxc-resizing]` and under `prefers-reduced-motion: reduce` (README § Motion). A shell that hid the app in full screen keeps its docked inset and lets the column grow over it.
23
+ - Delete `keepAlive` from kinds (no-op). Add `unmountWhenHidden: true` only for a body that must not stay mounted.
24
+ - Code that builds `CanvasKindProps` itself passes `presentation` and `isVisible`.
25
+ - Code that read `.mxc-item[hidden]` or expected `.mxc-column` to be absent when put away reads `[data-visible]` on `.mxc-item-host` and `[data-open]` on `.mxc-column`.
26
+ - A host-side "open new tab below" rule (web `toolCanvas` `target: "split-down"`) becomes the kind's `preferredTarget`.
27
+ - Code iterating `state.items` skips `CANVAS_LAUNCHER_KIND` ("New tab") items where a launcher is not a real thing.
28
+ - Desktop: the toolbar icon cluster can be `CanvasKindIcons`; a kind body that drew its own back button for in-canvas navigation can use `controller.navigate` / Back.
29
+
30
+ ## 0.4.0
31
+
32
+ - **`CanvasKind.Toolbar`** — a toolbar ROW between the pane header and the body, rendered for the active item only (a browser's back / forward / reload / address / screenshot / open in browser / new tab), so the header keeps just the tab strip and pane controls. Always DOM and never covered by a native view: a `body: "native"` frame is measured below it. A toolbar that throws renders nothing instead of taking the pane down. Requested by the desktop session.
33
+
34
+ ### Consumer action
35
+
36
+ - Kinds that crammed a toolbar into `HeaderLeading` (the desktop browser) move it to `Toolbar`.
37
+
3
38
  ## 0.3.0
4
39
 
5
40
  - **Toggle or focus a kind** — what a toolbar icon or a quick-tool launcher does. `toggleKind(controller, { kind, key?, data?, title?, whenVisible? })`: absent → open and reveal; behind another tab or canvas put away → bring it forward; in front → close it (default) or put the canvas away (`whenVisible: "hide"`). `data` seeds only a new item. `selectCanvasKindVisibility(state, kind, key?)` returns `"absent" | "background" | "visible"` (a primitive — safe in useSyncExternalStore / useSelector), and the React hook `useCanvasKindToggle(input)` gives `{ visibility, isOpen, isVisible, toggle }` for an icon button's active state. Key defaults to `CANVAS_DEFAULT_KEY` ("default").
package/README.md CHANGED
@@ -29,7 +29,6 @@ defineCanvasKind<{ sessionKey: string }>({
29
29
  restore: "session", // closing detaches; a restore or a move reattaches
30
30
  onAttach: ({ sessionKey, reason }) => attach(sessionKey, reason),
31
31
  onDetach: ({ sessionKey }) => detach(sessionKey), // never kill here
32
- keepAlive: true,
33
32
  HeaderLeading: NewTerminalButton, // "+ new terminal", a browser bar…
34
33
  HeaderAction: ClearButton,
35
34
  unavailable: () => (backendUp() ? null : "Terminal · next release"),
@@ -38,6 +37,72 @@ defineCanvasKind<{ sessionKey: string }>({
38
37
 
39
38
  `body: "native"` makes the body a placeholder whose frame the host receives (below).
40
39
 
40
+ ## Bodies never dismount
41
+
42
+ Every open item's body is rendered once, in the column's body layer, and placed over its pane by measured rect. Switching tabs hides it; splitting, moving a tab between panes, dragging the width, expanding and putting the canvas away only re-place or hide it. React state, focus and scroll survive everything but closing the tab. A body mounts the first time it is shown. `isVisible` tells it whether it is on screen (pause polling while false); `unmountWhenHidden: true` opts a genuinely heavy body out.
43
+
44
+ ## Content that knows its shape
45
+
46
+ ```tsx
47
+ function GraphView({ presentation }: CanvasKindProps) { // or useCanvasPresentation() anywhere inside the body
48
+ return <Graph direction={preferredFlowDirection(presentation) === "vertical" ? "TB" : "LR"} />;
49
+ }
50
+ ```
51
+
52
+ `presentation = { host: "canvas", width, height, orientation: "portrait" | "landscape" | "square", isNarrow, isFullscreen, paneCount }`, live. `useCanvasPresentation()` returns it to any nested component and `null` outside the canvas. When you have a lot of vertical space and little horizontal space, adapt: lay graphs, timelines and card rows out top to bottom, stack side-by-side columns, shorten toolbars.
53
+
54
+ ## Tabs, New tab and Back
55
+
56
+ Every pane header has "+" (a launcher tab). Choosing in the launcher NAVIGATES that tab in place, so the header offers Back. Do the same from a kind:
57
+
58
+ ```ts
59
+ canvas.navigate(item.id, { kind: "doc", key: docId, data: { docId } }); // records history; Back returns
60
+ canvas.back(item.id);
61
+ ```
62
+
63
+ Navigating to something already open elsewhere focuses it there (a launcher tab closes; any other tab keeps what it shows). History is plain JSON and persists. A lone tab shown as a title keeps its own close.
64
+
65
+ ## A page's own launcher entries
66
+
67
+ ```tsx
68
+ useCanvasLauncherEntry({ kind: "documents", key: conversationId, data: { conversationId }, title: "This chat's documents" });
69
+ ```
70
+
71
+ Shown first ("On this page") while the calling component is mounted.
72
+
73
+ ## Where a new item opens
74
+
75
+ ```tsx
76
+ defineCanvasKind({ id: "notifications", preferredTarget: "split-down", /* … */ });
77
+ ```
78
+
79
+ While the canvas already shows something, a NEW item of the kind opens below (the layout rules' split share, 70/30) or beside; a pane already showing the kind takes it. An explicit `target` wins. `open` and `toggleKind` honour it.
80
+
81
+ ## A header's tool icons
82
+
83
+ ```tsx
84
+ <CanvasKindIcons kinds={["terminal", "review", { kind: "browser", label: "Browser" }]} variant="glass" />
85
+ ```
86
+
87
+ Each icon toggles or focuses its kind and is pressed while its item is in front.
88
+
89
+ ## Motion
90
+
91
+ The column animates its width on `--mxc-motion-duration` / `--mxc-motion-ease` (tokens.css: 600ms, `cubic-bezier(0.4, 0, 0.2, 1)`), with its content keeping its width so it slides. A host that reflows beside it moves in lockstep on the same pair:
92
+
93
+ ```css
94
+ .app-shell { right: var(--canvas-w); transition: right var(--mxc-motion-duration) var(--mxc-motion-ease); }
95
+ :root[data-mxc-resizing] .app-shell { transition: none; } /* the edge is being dragged */
96
+ @media (prefers-reduced-motion: reduce) { .app-shell { transition: none; } }
97
+ ```
98
+
99
+ `<CanvasFrame>` needs nothing: its main area is a flex sibling and follows the column every frame.
100
+
101
+ ## A toolbar row under the header
102
+
103
+ A kind's `Toolbar` renders between the pane header and its body for the active
104
+ item (a browser bar). It is DOM, and a native body's frame is measured below it.
105
+
41
106
  ## Toolbar icons: toggle or focus a kind
42
107
 
43
108
  ```tsx
package/dist/index.cjs CHANGED
@@ -23,15 +23,20 @@ __export(src_exports, {
23
23
  CANVAS_DEFAULT_KEY: () => CANVAS_DEFAULT_KEY,
24
24
  CANVAS_DEFAULT_WIDTH: () => CANVAS_DEFAULT_WIDTH,
25
25
  CANVAS_DESKTOP_LAYOUT_RULES: () => CANVAS_DESKTOP_LAYOUT_RULES,
26
+ CANVAS_HISTORY_LIMIT: () => CANVAS_HISTORY_LIMIT,
27
+ CANVAS_LAUNCHER_KIND: () => CANVAS_LAUNCHER_KIND,
26
28
  CANVAS_MAIN_WINDOW: () => CANVAS_MAIN_WINDOW,
27
29
  CANVAS_MIN_SPLIT_FRACTION: () => CANVAS_MIN_SPLIT_FRACTION,
28
30
  CANVAS_MIN_WIDTH: () => CANVAS_MIN_WIDTH,
31
+ CANVAS_NARROW_WIDTH: () => CANVAS_NARROW_WIDTH,
29
32
  CANVAS_SHORTCUTS: () => CANVAS_SHORTCUTS,
30
33
  CANVAS_STORAGE_KEY: () => CANVAS_STORAGE_KEY,
31
34
  CANVAS_WEB_LAYOUT_RULES: () => CANVAS_WEB_LAYOUT_RULES,
32
35
  bindCanvasToReduxStore: () => bindCanvasToReduxStore,
33
36
  canvasActions: () => canvasActions,
34
37
  canvasItemId: () => canvasItemId,
38
+ canvasOrientation: () => canvasOrientation,
39
+ canvasPresentation: () => canvasPresentation,
35
40
  canvasReducer: () => canvasReducer,
36
41
  canvasShortcutHint: () => canvasShortcutHint,
37
42
  canvasWindowFrame: () => canvasWindowFrame,
@@ -57,10 +62,12 @@ __export(src_exports, {
57
62
  matchesCanvasAccelerator: () => matchesCanvasAccelerator,
58
63
  normalizeSizes: () => normalizeSizes,
59
64
  parseCanvasItemId: () => parseCanvasItemId,
65
+ preferredFlowDirection: () => preferredFlowDirection,
60
66
  resolveCanvasLayoutRules: () => resolveCanvasLayoutRules,
61
67
  runCanvasCommand: () => runCanvasCommand,
62
68
  sanitizeCanvasSnapshot: () => sanitizeCanvasSnapshot,
63
69
  selectCanvasActiveItem: () => selectCanvasActiveItem,
70
+ selectCanvasCanGoBack: () => selectCanvasCanGoBack,
64
71
  selectCanvasExtraWindow: () => selectCanvasExtraWindow,
65
72
  selectCanvasFocusedPaneId: () => selectCanvasFocusedPaneId,
66
73
  selectCanvasIsFullscreen: () => selectCanvasIsFullscreen,
@@ -68,6 +75,7 @@ __export(src_exports, {
68
75
  selectCanvasIsOpen: () => selectCanvasIsOpen,
69
76
  selectCanvasItem: () => selectCanvasItem,
70
77
  selectCanvasItemCount: () => selectCanvasItemCount,
78
+ selectCanvasItemHistory: () => selectCanvasItemHistory,
71
79
  selectCanvasKindVisibility: () => selectCanvasKindVisibility,
72
80
  selectCanvasLayout: () => selectCanvasLayout,
73
81
  selectCanvasPane: () => selectCanvasPane,
@@ -85,6 +93,8 @@ module.exports = __toCommonJS(src_exports);
85
93
 
86
94
  // src/core/types.ts
87
95
  var CANVAS_MAIN_WINDOW = "main";
96
+ var CANVAS_LAUNCHER_KIND = "canvas.launcher";
97
+ var CANVAS_HISTORY_LIMIT = 50;
88
98
  var CANVAS_MIN_WIDTH = 360;
89
99
  var CANVAS_DEFAULT_WIDTH = 640;
90
100
  var CANVAS_MIN_SPLIT_FRACTION = 0.12;
@@ -250,6 +260,10 @@ var canvasActions = {
250
260
  }),
251
261
  /** Gives an item a new identity in place (e.g. a draft that was just saved and now has an id). */
252
262
  rekey: (itemId, key) => ({ type: `${P}rekey`, payload: { itemId, key } }),
263
+ /** Replaces a tab's content with another item in place, remembering what it showed (Back returns). */
264
+ navigate: (itemId, to) => ({ type: `${P}navigate`, payload: { itemId, to, now: Date.now() } }),
265
+ /** Returns a tab to what it showed before its last navigate. */
266
+ back: (itemId) => ({ type: `${P}back`, payload: { itemId, now: Date.now() } }),
253
267
  closeItem: (itemId) => ({ type: `${P}closeItem`, payload: { itemId } }),
254
268
  closeOthers: (itemId) => ({ type: `${P}closeOthers`, payload: { itemId } }),
255
269
  activate: (itemId) => ({ type: `${P}activate`, payload: { itemId } }),
@@ -446,6 +460,63 @@ function sendHome(state, itemId, home, activate) {
446
460
  next = insertInto(next, homeFrame.focusedPaneId, itemId, activate);
447
461
  return activate ? withFrame(next, target, { focusedPaneId: homeFrame.focusedPaneId }) : next;
448
462
  }
463
+ function newItem(kind, key, title, data, now, history) {
464
+ const id = canvasItemId(kind, key);
465
+ return {
466
+ id,
467
+ kind,
468
+ key,
469
+ title,
470
+ data,
471
+ openedAt: now,
472
+ updatedAt: now,
473
+ instance: `${now.toString(36)}:${id}`,
474
+ ...history && history.length > 0 ? { history: history.slice(-CANVAS_HISTORY_LIMIT) } : {}
475
+ };
476
+ }
477
+ function historyEntry(item) {
478
+ return { kind: item.kind, key: item.key, title: item.title, data: item.data };
479
+ }
480
+ function replaceInPlace(state, current, next) {
481
+ const pane = paneOf(state, current.id);
482
+ if (!pane) return state;
483
+ const items = { ...state.items, [next.id]: next };
484
+ delete items[current.id];
485
+ const withItems = { ...state, items };
486
+ const replaced = withPane(withItems, {
487
+ ...pane,
488
+ itemIds: pane.itemIds.map((id) => id === current.id ? next.id : id),
489
+ activeItemId: pane.activeItemId === current.id ? next.id : pane.activeItemId
490
+ });
491
+ return focus(replaced, pane.id);
492
+ }
493
+ function focusExisting(state, itemId) {
494
+ const pane = paneOf(state, itemId);
495
+ if (!pane) return state;
496
+ const next = focus(withPane(state, { ...pane, activeItemId: itemId }), pane.id);
497
+ const windowId = canvasWindowOfPane(next, pane.id);
498
+ return windowId ? withFrame(next, windowId, { isOpen: true }) : next;
499
+ }
500
+ function sanitizeHistory(raw) {
501
+ if (!Array.isArray(raw)) return [];
502
+ const out = [];
503
+ for (const entry of raw) {
504
+ if (!entry || typeof entry !== "object") continue;
505
+ const e = entry;
506
+ if (typeof e.kind !== "string" || typeof e.key !== "string") continue;
507
+ out.push({ kind: e.kind, key: e.key, title: typeof e.title === "string" ? e.title : null, data: e.data ?? null });
508
+ }
509
+ return out.slice(-CANVAS_HISTORY_LIMIT);
510
+ }
511
+ function sanitizeItem(item) {
512
+ const history = sanitizeHistory(item.history);
513
+ const { history: _history, instance, ...rest } = item;
514
+ return {
515
+ ...rest,
516
+ ...typeof instance === "string" && instance ? { instance } : {},
517
+ ...history.length > 0 ? { history } : {}
518
+ };
519
+ }
449
520
  function clampWidth(width) {
450
521
  return Number.isFinite(width) ? Math.max(CANVAS_MIN_WIDTH, Math.round(width)) : null;
451
522
  }
@@ -493,7 +564,7 @@ function sanitizeCanvasSnapshot(raw) {
493
564
  const items = {};
494
565
  for (const [id, item] of Object.entries(s.items)) {
495
566
  if (item && typeof item.kind === "string" && typeof item.key === "string" && canvasItemId(item.kind, item.key) === id) {
496
- items[id] = item;
567
+ items[id] = sanitizeItem(item);
497
568
  }
498
569
  }
499
570
  const placed = /* @__PURE__ */ new Set();
@@ -567,7 +638,7 @@ function canvasReducer(state = createInitialCanvasState(), action) {
567
638
  next = insertInto(next, frame2.focusedPaneId, itemId, true);
568
639
  }
569
640
  } else {
570
- const item = { id: itemId, kind, key, title: title ?? null, data: data ?? null, openedAt: now, updatedAt: now };
641
+ const item = newItem(kind, key, title ?? null, data ?? null, now);
571
642
  next = ensureWindow({ ...next, items: { ...next.items, [itemId]: item } }, revealIn);
572
643
  const frame2 = canvasWindowFrame(next, revealIn) ?? next;
573
644
  let paneId = frame2.focusedPaneId;
@@ -616,6 +687,35 @@ function canvasReducer(state = createInitialCanvasState(), action) {
616
687
  }
617
688
  return { ...state, items, panes };
618
689
  }
690
+ case `${P}navigate`: {
691
+ const { itemId, to, now } = action.payload;
692
+ const current = state.items[itemId];
693
+ if (!current || !paneOf(state, itemId)) return state;
694
+ const targetId = canvasItemId(to.kind, to.key);
695
+ if (targetId === itemId) {
696
+ const refresh = { type: `${P}update`, payload: { itemId, data: to.data, title: to.title, now } };
697
+ return canvasReducer(state, refresh);
698
+ }
699
+ if (state.items[targetId]) {
700
+ const focused = focusExisting(state, targetId);
701
+ return current.kind === CANVAS_LAUNCHER_KIND ? canvasReducer(focused, canvasActions.closeItem(itemId)) : focused;
702
+ }
703
+ const history = [...current.history ?? [], historyEntry(current)];
704
+ return replaceInPlace(state, current, newItem(to.kind, to.key, to.title ?? null, to.data ?? null, now, history));
705
+ }
706
+ case `${P}back`: {
707
+ const { itemId, now } = action.payload;
708
+ const current = state.items[itemId];
709
+ const history = current?.history ?? [];
710
+ const entry = history[history.length - 1];
711
+ if (!current || !entry || !paneOf(state, itemId)) return state;
712
+ const targetId = canvasItemId(entry.kind, entry.key);
713
+ if (targetId === itemId) return state;
714
+ if (state.items[targetId]) {
715
+ return focusExisting(canvasReducer(state, canvasActions.closeItem(itemId)), targetId);
716
+ }
717
+ return replaceInPlace(state, current, newItem(entry.kind, entry.key, entry.title, entry.data, now, history.slice(0, -1)));
718
+ }
619
719
  case `${P}closeItem`: {
620
720
  const { itemId } = action.payload;
621
721
  if (!state.items[itemId]) return state;
@@ -946,7 +1046,8 @@ var CANVAS_WEB_LAYOUT_RULES = {
946
1046
  defaultWidth: CANVAS_DEFAULT_WIDTH,
947
1047
  minWidth: CANVAS_MIN_WIDTH,
948
1048
  centreMinWidth: 420,
949
- stackSplit: 0.5,
1049
+ // Split down 70/30 like the desktop: what opens below (notifications, a feed) is the smaller pane.
1050
+ stackSplit: 0.7,
950
1051
  sideSplit: 0.5,
951
1052
  paneMinWidth: 0
952
1053
  };
@@ -1079,7 +1180,8 @@ function createCanvasController(options) {
1079
1180
  presentationListeners: /* @__PURE__ */ new Set(),
1080
1181
  views: /* @__PURE__ */ new Map(),
1081
1182
  started: /* @__PURE__ */ new Map(),
1082
- sessionStop: null
1183
+ sessionStop: null,
1184
+ launcherSeq: 0
1083
1185
  };
1084
1186
  return view(shared, canvasWindowId(options.windowId ?? CANVAS_MAIN_WINDOW));
1085
1187
  }
@@ -1094,6 +1196,36 @@ function view(shared, windowId) {
1094
1196
  for (const listener of [...shared.presentationListeners]) listener();
1095
1197
  };
1096
1198
  const shareFor = (orientation) => orientation === "vertical" ? rules.stackSplit : rules.sideSplit;
1199
+ const acceptable = (input) => {
1200
+ if (input.kind !== CANVAS_LAUNCHER_KIND && options.isKnownKind && !options.isKnownKind(input.kind)) {
1201
+ onError({ code: "unknown-kind", message: `No canvas kind "${input.kind}" is registered.`, detail: input });
1202
+ return false;
1203
+ }
1204
+ if (input.data !== void 0) {
1205
+ const bad = findNonJson(input.data);
1206
+ if (bad) {
1207
+ onError({
1208
+ code: "non-json-data",
1209
+ message: `Canvas data for "${input.kind}" must be plain JSON; ${bad} is not.`,
1210
+ detail: { kind: input.kind, key: input.key }
1211
+ });
1212
+ return false;
1213
+ }
1214
+ }
1215
+ return true;
1216
+ };
1217
+ const preferredTargetFor = (input) => {
1218
+ const preferred = options.preferredTarget?.(input.kind);
1219
+ if (!preferred || preferred === "focused") return "focused";
1220
+ const state = store.getState();
1221
+ if (canvasItemId(input.kind, input.key) in state.items) return "focused";
1222
+ const frame2 = canvasWindowFrame(state, input.windowId ?? windowId);
1223
+ if (!frame2?.isOpen) return "focused";
1224
+ const panes = listPaneIds(frame2.layout);
1225
+ const holding = panes.find((paneId) => state.panes[paneId]?.itemIds.some((id) => state.items[id]?.kind === input.kind));
1226
+ if (holding) return { paneId: holding };
1227
+ return state.panes[frame2.focusedPaneId]?.activeItemId ? preferred : "focused";
1228
+ };
1097
1229
  const controller = {
1098
1230
  store,
1099
1231
  windowId,
@@ -1101,24 +1233,11 @@ function view(shared, windowId) {
1101
1233
  forWindow: (id) => view(shared, canvasWindowId(id)),
1102
1234
  getState: () => store.getState(),
1103
1235
  open(input) {
1104
- if (options.isKnownKind && !options.isKnownKind(input.kind)) {
1105
- onError({ code: "unknown-kind", message: `No canvas kind "${input.kind}" is registered.`, detail: input });
1106
- return null;
1107
- }
1108
- if (input.data !== void 0) {
1109
- const bad = findNonJson(input.data);
1110
- if (bad) {
1111
- onError({
1112
- code: "non-json-data",
1113
- message: `Canvas data for "${input.kind}" must be plain JSON; ${bad} is not.`,
1114
- detail: { kind: input.kind, key: input.key }
1115
- });
1116
- return null;
1117
- }
1118
- }
1119
- const target = input.target ?? "focused";
1236
+ if (!acceptable(input)) return null;
1237
+ const target = input.target ?? preferredTargetFor(input);
1120
1238
  const splitShare = target === "split-down" ? rules.stackSplit : target === "split-right" ? rules.sideSplit : void 0;
1121
- const resolved = input.windowId || windowId === CANVAS_MAIN_WINDOW ? input : { ...input, windowId };
1239
+ const withTarget = input.target === target ? input : { ...input, target };
1240
+ const resolved = withTarget.windowId || windowId === CANVAS_MAIN_WINDOW ? withTarget : { ...withTarget, windowId };
1122
1241
  dispatch(canvasActions.open(resolved, splitShare === void 0 ? void 0 : { splitShare }));
1123
1242
  return canvasItemId(input.kind, input.key);
1124
1243
  },
@@ -1134,6 +1253,34 @@ function view(shared, windowId) {
1134
1253
  dispatch(canvasActions.update(itemId, patch));
1135
1254
  return true;
1136
1255
  },
1256
+ navigate(itemId, to) {
1257
+ if (!store.getState().items[itemId] || !acceptable(to)) return null;
1258
+ dispatch(canvasActions.navigate(itemId, to));
1259
+ const shown = canvasItemId(to.kind, to.key);
1260
+ return shown in store.getState().items ? shown : null;
1261
+ },
1262
+ back(itemId) {
1263
+ const history = store.getState().items[itemId]?.history ?? [];
1264
+ const entry = history[history.length - 1];
1265
+ if (!entry) return null;
1266
+ dispatch(canvasActions.back(itemId));
1267
+ return canvasItemId(entry.kind, entry.key);
1268
+ },
1269
+ newTab(paneId) {
1270
+ const state = store.getState();
1271
+ const frame2 = canvasWindowFrame(state, windowId);
1272
+ const target = paneId && state.panes[paneId] ? paneId : frame2?.focusedPaneId ?? state.focusedPaneId;
1273
+ const existing = state.panes[target]?.itemIds.find((id) => state.items[id]?.kind === CANVAS_LAUNCHER_KIND);
1274
+ if (existing) {
1275
+ dispatch(canvasActions.activate(existing));
1276
+ dispatch(canvasActions.setOpen(true, canvasWindowOfItem(store.getState(), existing) ?? windowId));
1277
+ return existing;
1278
+ }
1279
+ shared.launcherSeq += 1;
1280
+ const key = `${Date.now().toString(36)}-${shared.launcherSeq}`;
1281
+ dispatch(canvasActions.open({ kind: CANVAS_LAUNCHER_KIND, key, target: { paneId: target }, ...windowId === CANVAS_MAIN_WINDOW ? {} : { windowId } }));
1282
+ return canvasItemId(CANVAS_LAUNCHER_KIND, key);
1283
+ },
1137
1284
  rekey(itemId, key) {
1138
1285
  const item = store.getState().items[itemId];
1139
1286
  if (!item) return null;
@@ -1397,6 +1544,41 @@ function selectCanvasWindows(s) {
1397
1544
  }
1398
1545
  var selectCanvasExtraWindow = (s, windowId) => s.windows?.[windowId];
1399
1546
  var selectCanvasWindowOfItem = canvasWindowOfItem;
1547
+ var NO_HISTORY = Object.freeze([]);
1548
+ function selectCanvasItemHistory(s, itemId) {
1549
+ return s.items[itemId]?.history ?? NO_HISTORY;
1550
+ }
1551
+ var selectCanvasCanGoBack = (s, itemId) => (s.items[itemId]?.history?.length ?? 0) > 0;
1552
+
1553
+ // src/core/presentation.ts
1554
+ var CANVAS_NARROW_WIDTH = 480;
1555
+ var SQUARE_BAND = 1.2;
1556
+ function canvasOrientation(width, height) {
1557
+ if (!(width > 0) || !(height > 0)) return "square";
1558
+ const ratio = width / height;
1559
+ if (ratio >= SQUARE_BAND) return "landscape";
1560
+ if (ratio <= 1 / SQUARE_BAND) return "portrait";
1561
+ return "square";
1562
+ }
1563
+ function canvasPresentation(input) {
1564
+ const width = Math.max(0, Math.round(input.width));
1565
+ const height = Math.max(0, Math.round(input.height));
1566
+ return {
1567
+ host: "canvas",
1568
+ width,
1569
+ height,
1570
+ orientation: canvasOrientation(width, height),
1571
+ isNarrow: width > 0 && width < CANVAS_NARROW_WIDTH,
1572
+ isFullscreen: input.isFullscreen,
1573
+ paneCount: Math.max(1, input.paneCount)
1574
+ };
1575
+ }
1576
+ function preferredFlowDirection(presentation) {
1577
+ if (!presentation) return "horizontal";
1578
+ if (presentation.orientation === "portrait") return "vertical";
1579
+ if (presentation.orientation === "landscape") return "horizontal";
1580
+ return presentation.isNarrow ? "vertical" : "horizontal";
1581
+ }
1400
1582
 
1401
1583
  // src/core/toggleKind.ts
1402
1584
  var CANVAS_DEFAULT_KEY = "default";