@grafloria/element 0.4.12 → 0.4.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grafloria/element",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -106,6 +106,25 @@ export interface DashboardWidgetSpec {
106
106
  * extent of the declared children.
107
107
  */
108
108
  maxRows?: number;
109
+ /**
110
+ * Container only: the inner board's layout, exactly as a view's. `'grid'`
111
+ * (default) packs the children in cells; `'split'` is a splitter tree that
112
+ * always covers the pane. Switch live with `setLayout(mode, containerId)`;
113
+ * `toJSON()` writes it per container.
114
+ */
115
+ layout?: 'grid' | 'split';
116
+ /**
117
+ * Container only, split layout: the authored splitter tree. Omit it and the
118
+ * tree is derived from the children's cells. `toJSON()` writes it back.
119
+ */
120
+ tree?: SplitNode | null;
121
+ /**
122
+ * Container only: what a pull past the pane's rows does. `'grow'` (default):
123
+ * the container's slab grows a row in the parent — the ratchet above.
124
+ * `'fit'`: the pane is the bound — a child that needs a row the pane does
125
+ * not hold is refused where it stands, and nothing outside the pane moves.
126
+ */
127
+ sizing?: 'fit' | 'grow';
109
128
  }
110
129
  /** One board. Multiple views are the tab pattern: only one is on-camera. */
111
130
  export interface DashboardViewSpec {
@@ -282,6 +301,14 @@ export interface DashboardHandle {
282
301
  * and a quiet ring; a void click clears. False when the id is unknown.
283
302
  */
284
303
  focusWidget(id: string): boolean;
304
+ /**
305
+ * SELECT a widget WITHOUT moving keyboard focus — the ring and the grip,
306
+ * nothing else; what a mouse press does. `undefined` clears the selection
307
+ * on every view. False when the id is unknown.
308
+ */
309
+ selectWidget(id: string | undefined): boolean;
310
+ /** The selected widget, if any (across views: only the on-camera one can be). */
311
+ getSelectedWidget(): string | undefined;
285
312
  /** Every widget handle of a view (default: the active one). */
286
313
  widgetsOf(viewId?: string): WidgetHandle[];
287
314
  /**
@@ -42,7 +42,7 @@
42
42
  * Cells are the truth and live in the existing `GridItemConfig`, so save/load
43
43
  * round-trips with no extra work — same as every other kit.
44
44
  */
45
- import { __awaiter } from "tslib";
45
+ import { __awaiter, __rest } from "tslib";
46
46
  import { BatchCommand, BringNodeToFrontCommand, Command, GroupModel, NodeModel, RemoveFromGroupCommand, RemoveGroupCommand, RemoveNodeCommand, SendNodeToBackCommand, } from '@grafloria/engine';
47
47
  import { bindDashboardGrid, } from './grid-binder.js';
48
48
  import { bindDashboardSplit, SPLIT_TREE_KEY } from './split-binder.js';
@@ -358,7 +358,7 @@ export function createDashboardHandle(ctx) {
358
358
  * tile under the container it is actually in, in every one of those states.
359
359
  */
360
360
  const treeOf = (boardId) => {
361
- var _a, _b, _c, _d;
361
+ var _a, _b, _c, _d, _e, _f;
362
362
  const g = ctx.boardGroups.get(boardId);
363
363
  const b = binders.get(boardId);
364
364
  if (!g)
@@ -373,7 +373,14 @@ export function createDashboardHandle(ctx) {
373
373
  const cell = cellOf(memberId);
374
374
  const at = cell ? { x: cell.x, y: cell.y, span: cell.w, rows: cell.h } : {};
375
375
  if (ctx.boardGroups.has(memberId)) {
376
- entries.push(Object.assign(Object.assign(Object.assign({ id: memberId }, (spec !== null && spec !== void 0 ? spec : {})), at), { widgets: treeOf(memberId) }));
376
+ // The container's LIVE layout and, under split, its live tree the
377
+ // authored `tree` is stale the moment a divider moves.
378
+ const _g = spec !== null && spec !== void 0 ? spec : {}, { tree: _authored } = _g, rest = __rest(_g, ["tree"]);
379
+ void _authored;
380
+ const clayout = (_c = (_b = ctx.layoutOf.get(memberId)) !== null && _b !== void 0 ? _b : spec === null || spec === void 0 ? void 0 : spec.layout) !== null && _c !== void 0 ? _c : 'grid';
381
+ const cb = binders.get(memberId);
382
+ const ctree = clayout === 'split' && (cb === null || cb === void 0 ? void 0 : cb.getSplitTree) ? cb.getSplitTree() : undefined;
383
+ entries.push(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ id: memberId }, rest), at), { layout: clayout }), (ctree !== undefined ? { tree: ctree } : {})), { widgets: treeOf(memberId) }));
377
384
  }
378
385
  else if (spec) {
379
386
  // `pinned` is read from the NODE's lock, not the authored spec: pin()
@@ -381,7 +388,7 @@ export function createDashboardHandle(ctx) {
381
388
  // the user left it (D5). Written only when true, so an unpinned
382
389
  // widget serialises exactly as it always did.
383
390
  const entry = Object.assign(Object.assign({}, spec), at);
384
- if (((_d = (_c = (_b = ctx.apiRef) === null || _b === void 0 ? void 0 : _b.getModel().getNode(memberId)) === null || _c === void 0 ? void 0 : _c.state) === null || _d === void 0 ? void 0 : _d.locked) === true)
391
+ if (((_f = (_e = (_d = ctx.apiRef) === null || _d === void 0 ? void 0 : _d.getModel().getNode(memberId)) === null || _e === void 0 ? void 0 : _e.state) === null || _f === void 0 ? void 0 : _f.locked) === true)
385
392
  entry.pinned = true;
386
393
  else
387
394
  delete entry.pinned;
@@ -520,6 +527,24 @@ export function createDashboardHandle(ctx) {
520
527
  queueMicrotask(() => retry(4));
521
528
  return true;
522
529
  },
530
+ selectWidget(id) {
531
+ var _a;
532
+ if (id === undefined) {
533
+ for (const b of binders.values())
534
+ b.selectWidget(undefined);
535
+ return true;
536
+ }
537
+ const b = binders.get((_a = viewOfWidget.get(id)) !== null && _a !== void 0 ? _a : '');
538
+ return !!b && b.selectWidget(id);
539
+ },
540
+ getSelectedWidget() {
541
+ for (const b of binders.values()) {
542
+ const s = b.getSelectedWidget();
543
+ if (s)
544
+ return s;
545
+ }
546
+ return undefined;
547
+ },
523
548
  widgetsOf(viewId) {
524
549
  var _a;
525
550
  const v = views.find((x) => x.id === (viewId !== null && viewId !== void 0 ? viewId : ctx.active));
@@ -528,7 +553,8 @@ export function createDashboardHandle(ctx) {
528
553
  setLayout(layout, viewId) {
529
554
  var _a, _b, _c;
530
555
  const vid = viewId !== null && viewId !== void 0 ? viewId : ctx.active;
531
- if (!views.some((v) => v.id === vid))
556
+ // A view, or a CONTAINER (item 7): the same switch one level down.
557
+ if (!views.some((v) => v.id === vid) && !ctx.boardGroups.has(vid))
532
558
  return;
533
559
  if (((_a = ctx.layoutOf.get(vid)) !== null && _a !== void 0 ? _a : 'grid') === layout)
534
560
  return;
@@ -1043,8 +1069,12 @@ export function dashboard(options) {
1043
1069
  // its tree from exactly those, so any stale tree is cleared first;
1044
1070
  // split → grid rebuilds from them, so the column cache goes too.
1045
1071
  ctx.rebindView = (viewId, next) => {
1046
- var _a, _b;
1072
+ var _a, _b, _c;
1047
1073
  const v = views.find((x) => x.id === viewId);
1074
+ if (!v) {
1075
+ rebindContainerLayout(viewId, next);
1076
+ return;
1077
+ }
1048
1078
  const g = groups.get(viewId);
1049
1079
  const b = binders.get(viewId);
1050
1080
  if (!v || !g || !b)
@@ -1052,6 +1082,7 @@ export function dashboard(options) {
1052
1082
  const cells = b.saveLayout().cells;
1053
1083
  const live = { rtl: b.getRtl(), static: b.getStatic(), dragHandle: b.getDragHandle() };
1054
1084
  const focused = b.getFocusedWidget();
1085
+ const selected = b.getSelectedWidget();
1055
1086
  b.dispose();
1056
1087
  const write = (fn) => (model.runSystemWrite ? model.runSystemWrite(fn) : fn());
1057
1088
  write(() => {
@@ -1072,9 +1103,13 @@ export function dashboard(options) {
1072
1103
  binders.set(viewId, bindView(v, g, next, live));
1073
1104
  (_a = binders.get(viewId)) === null || _a === void 0 ? void 0 : _a.sync();
1074
1105
  // The selected widget (and its grip) survives the switch, as it does in
1075
- // the DevExpress designer — the host used to have to restate it.
1106
+ // the DevExpress designer — the host used to have to restate it. A
1107
+ // MOUSE selection is not a focus (the press never focuses the host),
1108
+ // so it is carried on its own: the kit lab's L21 lost it (2026-09-08).
1076
1109
  if (focused)
1077
1110
  (_b = binders.get(viewId)) === null || _b === void 0 ? void 0 : _b.focusWidget(focused);
1111
+ else if (selected)
1112
+ (_c = binders.get(viewId)) === null || _c === void 0 ? void 0 : _c.selectWidget(selected);
1078
1113
  };
1079
1114
  handle.showView(ctx.active);
1080
1115
  ctx.rebindContainer = (id) => {
@@ -1086,6 +1121,56 @@ export function dashboard(options) {
1086
1121
  ctx.boardGroups.set(id, g);
1087
1122
  bindContainer(g, w, (_a = ctx.viewOfBoard.get(id)) !== null && _a !== void 0 ? _a : ctx.active);
1088
1123
  };
1124
+ /**
1125
+ * LIVE LAYOUT SWITCH ON A CONTAINER (item 7) — the view's contract one
1126
+ * level down: cells persisted where the grid reads them, the tree and
1127
+ * column cache cleared, the container's `layout` flipped on its spec and
1128
+ * its metadata, a fresh binder on the same group, selection carried.
1129
+ */
1130
+ function rebindContainerLayout(id, next) {
1131
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
1132
+ const cg = ctx.boardGroups.get(id);
1133
+ const w = specById.get(id);
1134
+ const b = binders.get(id);
1135
+ if (!cg || !w || !w.widgets || !b)
1136
+ return;
1137
+ const cells = b.saveLayout().cells;
1138
+ const focused = b.getFocusedWidget();
1139
+ const selected = b.getSelectedWidget();
1140
+ b.dispose();
1141
+ const write = (fn) => (model.runSystemWrite ? model.runSystemWrite(fn) : fn());
1142
+ write(() => {
1143
+ var _a, _b, _c;
1144
+ for (const [cid, cell] of cells) {
1145
+ const n = model.getNode(cid);
1146
+ if (n)
1147
+ n.setMetadata('gridItem', gridItemFromCell(cell));
1148
+ else
1149
+ (_a = model.getGroup(cid)) === null || _a === void 0 ? void 0 : _a.setMetadata('gridItem', gridItemFromCell(cell));
1150
+ }
1151
+ cg.setMetadata(SPLIT_TREE_KEY, undefined);
1152
+ cg.setMetadata('dashboardLayouts', undefined);
1153
+ const board = (_b = cg.getMetadata('dashboardBoard')) !== null && _b !== void 0 ? _b : {};
1154
+ cg.setMetadata('dashboardBoard', Object.assign(Object.assign({}, board), { layout: next }));
1155
+ const cw = (_c = cg.getMetadata('containerWidget')) !== null && _c !== void 0 ? _c : {};
1156
+ cg.setMetadata('containerWidget', Object.assign(Object.assign({}, cw), { layout: next }));
1157
+ });
1158
+ w.layout = next;
1159
+ delete w.tree;
1160
+ ctx.layoutOf.set(id, next);
1161
+ // The inner bound is the slab's LIVE row count, not the authored design:
1162
+ // a child escalation grew to two rows must keep them through a
1163
+ // split → grid round trip (the visual gate caught it collapsing to one
1164
+ // inside a two-row slab).
1165
+ const slabRows = (_c = (_b = binders.get((_a = ctx.viewOfWidget.get(id)) !== null && _a !== void 0 ? _a : '')) === null || _b === void 0 ? void 0 : _b.cellOf(id)) === null || _c === void 0 ? void 0 : _c.h;
1166
+ const authored = (_d = w.maxRows) !== null && _d !== void 0 ? _d : rowExtentOf((_e = w.widgets) !== null && _e !== void 0 ? _e : []);
1167
+ bindContainer(cg, w, (_f = ctx.viewOfBoard.get(id)) !== null && _f !== void 0 ? _f : ctx.active, Math.max(authored, slabRows !== null && slabRows !== void 0 ? slabRows : 0));
1168
+ (_g = binders.get(id)) === null || _g === void 0 ? void 0 : _g.sync();
1169
+ if (focused)
1170
+ (_h = binders.get(id)) === null || _h === void 0 ? void 0 : _h.focusWidget(focused);
1171
+ else if (selected)
1172
+ (_j = binders.get(id)) === null || _j === void 0 ? void 0 : _j.selectWidget(selected);
1173
+ }
1089
1174
  (_m = ctx.attachHistory) === null || _m === void 0 ? void 0 : _m.call(ctx);
1090
1175
  return;
1091
1176
  /**
@@ -1098,7 +1183,7 @@ export function dashboard(options) {
1098
1183
  * hit-testing and the height-escalation ratchet all apply unchanged.
1099
1184
  */
1100
1185
  function mountBoard(boardId, viewId, widgets, boardGroup) {
1101
- var _a, _b, _c, _d, _e;
1186
+ var _a, _b, _c, _d, _e, _f, _g;
1102
1187
  for (const w of widgets) {
1103
1188
  if (w.widgets) {
1104
1189
  const innerColumns = innerColumnsOf(w);
@@ -1110,7 +1195,11 @@ export function dashboard(options) {
1110
1195
  cg.setMetadata('gridItem', gridItemFromCell({ x: w.x, y: w.y, w: w.span, h: w.rows }));
1111
1196
  // The container's own spec fields, persisted ON the group — a
1112
1197
  // reloaded document has no authored literal to read them from.
1113
- cg.setMetadata('containerWidget', Object.assign(Object.assign(Object.assign(Object.assign({}, (w.kind !== undefined ? { kind: w.kind } : {})), (w.title !== undefined ? { title: w.title } : {})), { columns: innerColumns, maxRows: innerRows }), (w.data !== undefined ? { data: w.data } : {})));
1198
+ cg.setMetadata('containerWidget', Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, (w.kind !== undefined ? { kind: w.kind } : {})), (w.title !== undefined ? { title: w.title } : {})), { columns: innerColumns, maxRows: innerRows }), (w.data !== undefined ? { data: w.data } : {})), (w.layout !== undefined ? { layout: w.layout } : {})), (w.sizing !== undefined ? { sizing: w.sizing } : {})));
1199
+ // Item 7: the container's own layout and bound, persisted like a view's.
1200
+ ctx.layoutOf.set(w.id, (_c = w.layout) !== null && _c !== void 0 ? _c : 'grid');
1201
+ if (w.layout === 'split' && w.tree !== undefined)
1202
+ cg.setMetadata(SPLIT_TREE_KEY, w.tree);
1114
1203
  cg.setMetadata('dashboardBoard', {
1115
1204
  columns: innerColumns,
1116
1205
  gap,
@@ -1123,7 +1212,9 @@ export function dashboard(options) {
1123
1212
  designHeight: 0,
1124
1213
  maxRows: innerRows,
1125
1214
  float: false,
1126
- rtl: (_c = options.rtl) !== null && _c !== void 0 ? _c : false,
1215
+ rtl: (_d = options.rtl) !== null && _d !== void 0 ? _d : false,
1216
+ layout: (_e = w.layout) !== null && _e !== void 0 ? _e : 'grid',
1217
+ escalate: w.sizing !== 'fit',
1127
1218
  });
1128
1219
  cg.size = { width: 100, height: rowHeight, depth: 0 };
1129
1220
  boardGroup.addMember(w.id);
@@ -1143,7 +1234,7 @@ export function dashboard(options) {
1143
1234
  // and made toJSON() → dashboard() NOT round-trip (a saved layout
1144
1235
  // rebuilt back into its declaration order rather than its cells).
1145
1236
  if (w.x !== undefined && w.y !== undefined) {
1146
- n.setGridItem(gridItemFromCell({ x: w.x, y: w.y, w: (_d = w.span) !== null && _d !== void 0 ? _d : 3, h: (_e = w.rows) !== null && _e !== void 0 ? _e : 1 }));
1237
+ n.setGridItem(gridItemFromCell({ x: w.x, y: w.y, w: (_f = w.span) !== null && _f !== void 0 ? _f : 3, h: (_g = w.rows) !== null && _g !== void 0 ? _g : 1 }));
1147
1238
  }
1148
1239
  if (w.pinned)
1149
1240
  n.setState({ locked: true });
@@ -1181,14 +1272,25 @@ export function dashboard(options) {
1181
1272
  return bindDashboardGrid(a, g, Object.assign(Object.assign(Object.assign({}, common), { columns: (_j = v.columns) !== null && _j !== void 0 ? _j : columns, sizing, baseRowHeight: rowHeight, designHeight: viewH(v), float: (_k = options.float) !== null && _k !== void 0 ? _k : false, overflow }), (options.responsive ? { responsive: options.responsive } : {})));
1182
1273
  }
1183
1274
  /** Bind (or re-bind) a container's inner grid on its group. */
1184
- function bindContainer(cg, w, viewId) {
1185
- var _a, _b, _c, _d, _e;
1186
- binders.set(w.id, bindDashboardGrid(a, cg, Object.assign(Object.assign({ columns: innerColumnsOf(w), gap, padding: 0, sizing: 'fit', baseRowHeight: rowHeight, designHeight: 0, maxRows: (_a = w.maxRows) !== null && _a !== void 0 ? _a : rowExtentOf((_b = w.widgets) !== null && _b !== void 0 ? _b : []), float: false, rtl: (_c = options.rtl) !== null && _c !== void 0 ? _c : false, static: (_d = options.static) !== null && _d !== void 0 ? _d : false, dragHandle: (_e = options.dragHandle) !== null && _e !== void 0 ? _e : false }, (options.squeeze !== undefined ? { squeeze: options.squeeze } : {})), { onGesture: (e) => {
1275
+ function bindContainer(cg, w, viewId, innerRows) {
1276
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
1277
+ void viewId;
1278
+ // The LIVE switches ride on the view's binder when it exists (a
1279
+ // container bound after a setStatic/setRtl/setDragHandle must match).
1280
+ const vb = binders.get((_a = ctx.viewOfBoard.get(w.id)) !== null && _a !== void 0 ? _a : ctx.active);
1281
+ const inner = Object.assign(Object.assign({ columns: innerColumnsOf(w), gap, padding: 0, baseRowHeight: rowHeight, rtl: (_c = (_b = vb === null || vb === void 0 ? void 0 : vb.getRtl()) !== null && _b !== void 0 ? _b : options.rtl) !== null && _c !== void 0 ? _c : false, static: (_e = (_d = vb === null || vb === void 0 ? void 0 : vb.getStatic()) !== null && _d !== void 0 ? _d : options.static) !== null && _e !== void 0 ? _e : false, dragHandle: (_g = (_f = vb === null || vb === void 0 ? void 0 : vb.getDragHandle()) !== null && _f !== void 0 ? _f : options.dragHandle) !== null && _g !== void 0 ? _g : false }, (options.squeeze !== undefined ? { squeeze: options.squeeze } : {})), { onGesture: (e) => {
1187
1282
  var _a, _b;
1188
1283
  if (e.type === 'commit')
1189
1284
  reportChanged();
1190
1285
  (_b = (_a = options.binder) === null || _a === void 0 ? void 0 : _a.onGesture) === null || _b === void 0 ? void 0 : _b.call(_a, e);
1191
- } })));
1286
+ } });
1287
+ if (((_h = ctx.layoutOf.get(w.id)) !== null && _h !== void 0 ? _h : w.layout) === 'split') {
1288
+ // A splitter tree covering the pane; the pane's frame is the parent's
1289
+ // slab, so no design height of its own.
1290
+ binders.set(w.id, bindDashboardSplit(a, cg, Object.assign(Object.assign({}, inner), (w.tree !== undefined ? { tree: w.tree } : {}))));
1291
+ return;
1292
+ }
1293
+ binders.set(w.id, bindDashboardGrid(a, cg, Object.assign(Object.assign({}, inner), { sizing: 'fit', designHeight: 0, maxRows: (_j = innerRows !== null && innerRows !== void 0 ? innerRows : w.maxRows) !== null && _j !== void 0 ? _j : rowExtentOf((_k = w.widgets) !== null && _k !== void 0 ? _k : []), float: false, escalate: w.sizing !== 'fit' })));
1192
1294
  }
1193
1295
  /**
1194
1296
  * One reporter for every binder on a view — the view's own and each
@@ -50,6 +50,7 @@
50
50
  * second `bindDashboardGrid` on the section for a nested pack grid.
51
51
  */
52
52
  import { Command, type DiagramModel, type GridColumnLayout, type GroupModel, type NodeModel } from '@grafloria/engine';
53
+ import { type ToolPointerEvent } from '@grafloria/renderer';
53
54
  import { type CellRect, type WorldRect } from './grid-mapping.js';
54
55
  /** The slice of a DiagramInstance the binder needs (structural, test-friendly). */
55
56
  export interface DashboardGridApi {
@@ -147,6 +148,13 @@ export interface DashboardGridOptions {
147
148
  * row instead), and the strip can never be squeezed.
148
149
  */
149
150
  maxRows?: number;
151
+ /**
152
+ * Nested boards only. `true` (default): a child pulled clearly past the
153
+ * bound GROWS the container's slab in the parent (height escalation).
154
+ * `false`: the pane is the bound — the pull is refused where it stands, the
155
+ * container's `sizing: 'fit'`.
156
+ */
157
+ escalate?: boolean;
150
158
  /**
151
159
  * What dragging a tile OUT of the board means (default 'cancel' — the tile
152
160
  * snaps back on release). 'remove' dims the ghost outside the board and a
@@ -261,6 +269,23 @@ export declare const gripOf: (v: DragHandleOption) => DragGripOptions | null;
261
269
  */
262
270
  export declare function syncGrip(host: HTMLElement, cfg: DragGripOptions | null, movable: boolean): void;
263
271
  /** The member host a press on a painted grip belongs to (the grip may sit OUTSIDE the host's box). */
272
+ /**
273
+ * Is this press OURS? The renderer's tool registry is PAGE-GLOBAL: every
274
+ * registered tool is asked about every press on every canvas, ties going to
275
+ * the first registered. Two boards on one page sharing widget ids — a designer
276
+ * beside its preview, a page of examples — had the FIRST board's tool claim
277
+ * the second's presses: it selected its own tile and moved nothing (kit lab,
278
+ * 2026-09-08: 16 of 23 boards dead to the mouse). A tool claims only a press
279
+ * whose DOM target sits in its own container and whose hit node is its own
280
+ * diagram's object, not a namesake from another model.
281
+ */
282
+ export declare function ownsPress(container: HTMLElement, diagram: {
283
+ getNode(id: string): unknown;
284
+ }, ev: ToolPointerEvent, hit: {
285
+ node?: {
286
+ id: string;
287
+ };
288
+ }): boolean;
264
289
  export declare function gripHostOf(target: Element | null): HTMLElement | null;
265
290
  /**
266
291
  * Did a press land on the drag handle? A press INSIDE the handle element
@@ -313,6 +338,14 @@ export interface DashboardGridHandle {
313
338
  focusWidget(id: string): boolean;
314
339
  /** The member the roving tabindex currently rests on. */
315
340
  getFocusedWidget(): string | undefined;
341
+ /**
342
+ * SELECT a member without moving keyboard focus — what a mouse press does
343
+ * (the renderer cancels the press's default, so a click never focuses the
344
+ * host). `undefined` clears. False for a non-member.
345
+ */
346
+ selectWidget(id: string | undefined): boolean;
347
+ /** The selected member: the one wearing the ring and, in grip mode, the grip. */
348
+ getSelectedWidget(): string | undefined;
316
349
  /**
317
350
  * The layout to PERSIST — from the engine's LARGEST cached column count, so
318
351
  * saving while the board is narrow still saves the wide layout the user
@@ -380,4 +413,83 @@ export interface DashboardGridHandle {
380
413
  }, event: PointerEvent): void;
381
414
  dispose(): void;
382
415
  }
416
+ /**
417
+ * CROSS-CONTAINER HANDOFF. Binders on the same canvas register here; a move
418
+ * gesture whose pointer enters ANOTHER registered board (deepest wins — the
419
+ * nested KPI section beats the tab that contains it) hands the tile off: the
420
+ * source engine drops it (survivors settle home), the target engine ADOPTS it
421
+ * (gateless first placement, then the normal live-push loop), and release
422
+ * commits ONE batch across both boards — displaced tiles on each side,
423
+ * RemoveFromGroup + AddToGroup, the tile's new cells and geometry. This is
424
+ * what makes "drag Total Revenue under Top reps" MOVE the KPI to the main
425
+ * board rather than snapping it home (live review: parking was a guard, not
426
+ * the feature).
427
+ */
428
+ interface BinderPeer {
429
+ group: GroupModel;
430
+ /** True when this board's engine holds `id` as an item (member lookup). */
431
+ hasItem(id: string): boolean;
432
+ /** The member's current cell in this board (undefined when absent). */
433
+ memberCell(id: string): CellRect | undefined;
434
+ /**
435
+ * Grow/shrink a member's row span by `dRows` — the parent half of nested
436
+ * HEIGHT ESCALATION: pulling a KPI taller than its one-row strip grows the
437
+ * STRIP's slab in the board that contains it (live report: "i cant
438
+ * increase height"). Returns the cell+frame before/after when accepted.
439
+ */
440
+ resizeMemberBy(id: string, dRows: number): {
441
+ changed: boolean;
442
+ cellBefore?: CellRect;
443
+ cellAfter?: CellRect;
444
+ frameBefore?: WorldRect;
445
+ frameAfter?: WorldRect;
446
+ };
447
+ containsWorld(x: number, y: number): boolean;
448
+ /**
449
+ * Containment plus ONE extra row of grace below the frame — gridstack's
450
+ * `_extraDragRow`: dropping "under the last row" appends a row rather than
451
+ * counting as off-board. Consulted only when NO strict frame matched, so a
452
+ * nested strip's band can never steal a point that strictly belongs to the
453
+ * board below it.
454
+ */
455
+ containsWorldExtended(x: number, y: number): boolean;
456
+ frameArea(): number;
457
+ adopt(node: NodeModel, world: {
458
+ x: number;
459
+ y: number;
460
+ }, pxSize: {
461
+ width: number;
462
+ height: number;
463
+ }): AdoptedLeg | null;
464
+ }
465
+ interface AdoptedLeg {
466
+ groupId: string;
467
+ /** Drive the target engine from the source binder's pointer stream. */
468
+ move(world: {
469
+ x: number;
470
+ y: number;
471
+ }): void;
472
+ /** Undo the adoption: target board back to its pre-entry layout. */
473
+ abort(): void;
474
+ /**
475
+ * Close the leg for commit: returns the target-side displaced commands, the
476
+ * tile's final cell and its projected rect. Null when the tile is somehow
477
+ * gone (treat as abort).
478
+ */
479
+ finalize(): {
480
+ commands: Command[];
481
+ cell: CellRect;
482
+ rect: WorldRect;
483
+ } | null;
484
+ }
485
+ /**
486
+ * Register a board that is NOT a grid (the split binder on a container) as a
487
+ * peer on its canvas, so the parent grid's hitTest defers a press on one of
488
+ * its tiles to it ("a press on a tile that belongs to a NESTED board must
489
+ * reach that board's tool") whatever the registration order. Returns the
490
+ * unregister. A split board adopts nothing and grows no slab: its `adopt`
491
+ * answers null and `resizeMemberBy` answers unchanged.
492
+ */
493
+ export declare function registerBoardPeer(container: HTMLElement, peer: BinderPeer): () => void;
494
+ export type { BinderPeer };
383
495
  export declare function bindDashboardGrid(api: DashboardGridApi, group: GroupModel, options?: DashboardGridOptions): DashboardGridHandle;
@@ -92,6 +92,25 @@ export function syncGrip(host, cfg, movable) {
92
92
  host.classList.add(`axdb-gp-${(_d = cfg.placement) !== null && _d !== void 0 ? _d : 'inside'}`, `axdb-gp-${(_e = cfg.position) !== null && _e !== void 0 ? _e : 'left'}`);
93
93
  }
94
94
  /** The member host a press on a painted grip belongs to (the grip may sit OUTSIDE the host's box). */
95
+ /**
96
+ * Is this press OURS? The renderer's tool registry is PAGE-GLOBAL: every
97
+ * registered tool is asked about every press on every canvas, ties going to
98
+ * the first registered. Two boards on one page sharing widget ids — a designer
99
+ * beside its preview, a page of examples — had the FIRST board's tool claim
100
+ * the second's presses: it selected its own tile and moved nothing (kit lab,
101
+ * 2026-09-08: 16 of 23 boards dead to the mouse). A tool claims only a press
102
+ * whose DOM target sits in its own container and whose hit node is its own
103
+ * diagram's object, not a namesake from another model.
104
+ */
105
+ export function ownsPress(container, diagram, ev, hit) {
106
+ var _a;
107
+ const t = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target;
108
+ if (typeof Node !== 'undefined' && t instanceof Node && !container.contains(t))
109
+ return false;
110
+ if (hit.node && diagram.getNode(hit.node.id) !== hit.node)
111
+ return false;
112
+ return true;
113
+ }
95
114
  export function gripHostOf(target) {
96
115
  var _a, _b;
97
116
  const grip = (_a = target === null || target === void 0 ? void 0 : target.closest) === null || _a === void 0 ? void 0 : _a.call(target, '.' + GRIP_CLASS);
@@ -126,6 +145,26 @@ export const CAPTION_BAND = 28;
126
145
  * page (review D12).
127
146
  */
128
147
  const BOARD_REGISTRY = new WeakMap();
148
+ /**
149
+ * Register a board that is NOT a grid (the split binder on a container) as a
150
+ * peer on its canvas, so the parent grid's hitTest defers a press on one of
151
+ * its tiles to it ("a press on a tile that belongs to a NESTED board must
152
+ * reach that board's tool") whatever the registration order. Returns the
153
+ * unregister. A split board adopts nothing and grows no slab: its `adopt`
154
+ * answers null and `resizeMemberBy` answers unchanged.
155
+ */
156
+ export function registerBoardPeer(container, peer) {
157
+ let set = BOARD_REGISTRY.get(container);
158
+ if (!set) {
159
+ set = new Set();
160
+ BOARD_REGISTRY.set(container, set);
161
+ }
162
+ set.add(peer);
163
+ const s = set;
164
+ return () => {
165
+ s.delete(peer);
166
+ };
167
+ }
129
168
  /**
130
169
  * ONE aria-live region per canvas, shared by every board on it — the
131
170
  * renderer's own controller (coalescing, de-duplicating), so a dashboard
@@ -243,6 +282,8 @@ export function bindDashboardGrid(api, group, options = {}) {
243
282
  let float = (_f = options.float) !== null && _f !== void 0 ? _f : false;
244
283
  /** The AUTHORED bound — a nested strip's design (row-first push, escalation). */
245
284
  const maxRows = options.maxRows;
285
+ /** May a pull past the bound grow the slab in the parent? `false` = the pane is the bound. */
286
+ const escalate = options.escalate !== false;
246
287
  const dragOut = (_g = options.dragOut) !== null && _g !== void 0 ? _g : 'cancel';
247
288
  const wantHandles = options.resizeHandles !== false;
248
289
  const fluid = options.fluid === true;
@@ -575,15 +616,35 @@ export function bindDashboardGrid(api, group, options = {}) {
575
616
  clearTimeout(glideTimer);
576
617
  glideTimer = setTimeout(() => { var _a; return (_a = htmlLayer()) === null || _a === void 0 ? void 0 : _a.classList.remove('axdb-glide'); }, GLIDE_OFF_DELAY);
577
618
  };
619
+ /** The host whose ghost class the pending timer will lift. */
620
+ let ghostHost = null;
621
+ /**
622
+ * Lift a pending ghost NOW. One timer serves every host, so superseding it
623
+ * (a new gesture within 60 ms of the last drop — ③ then ④ in the
624
+ * nested-containers checks — or a dispose on a rebind) used to clear the
625
+ * timer and leave the previous tile lifted for good: a permanent drop
626
+ * shadow the visual gate finally caught.
627
+ */
628
+ const flushGhost = () => {
629
+ if (ghostTimer)
630
+ clearTimeout(ghostTimer);
631
+ ghostTimer = null;
632
+ ghostHost === null || ghostHost === void 0 ? void 0 : ghostHost.classList.remove('axdb-ghost', 'axdb-out');
633
+ ghostHost = null;
634
+ };
578
635
  const setGhost = (id, on) => {
579
636
  const host = hostOf(id);
580
637
  if (!host)
581
638
  return;
639
+ if (ghostHost && ghostHost !== host)
640
+ flushGhost();
582
641
  if (on) {
583
642
  if (ghostTimer)
584
643
  clearTimeout(ghostTimer);
644
+ ghostTimer = null;
585
645
  host.classList.add('axdb-ghost');
586
646
  host.classList.remove('axdb-out');
647
+ ghostHost = host;
587
648
  }
588
649
  else {
589
650
  host.classList.remove('axdb-out');
@@ -591,7 +652,13 @@ export function bindDashboardGrid(api, group, options = {}) {
591
652
  // placeholder is INSTANT (gridstack-style), then let glides resume.
592
653
  if (ghostTimer)
593
654
  clearTimeout(ghostTimer);
594
- ghostTimer = setTimeout(() => host.classList.remove('axdb-ghost'), 60);
655
+ ghostHost = host;
656
+ ghostTimer = setTimeout(() => {
657
+ host.classList.remove('axdb-ghost');
658
+ ghostTimer = null;
659
+ if (ghostHost === host)
660
+ ghostHost = null;
661
+ }, 60);
595
662
  }
596
663
  };
597
664
  // -- resize handles ---------------------------------------------------------
@@ -1308,7 +1375,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1308
1375
  // the strip's CURRENT slab rows, whatever gesture created them; the
1309
1376
  // ledger just accumulates this gesture's net change for the one-batch
1310
1377
  // commit and for Escape.
1311
- if (maxRows !== undefined && g.kind === 'resize') {
1378
+ if (maxRows !== undefined && escalate && g.kind === 'resize') {
1312
1379
  const parent = parentPeer();
1313
1380
  if (parent) {
1314
1381
  const visual = boardVisualHeight();
@@ -1673,6 +1740,8 @@ export function bindDashboardGrid(api, group, options = {}) {
1673
1740
  return false;
1674
1741
  if (gesture)
1675
1742
  return true; // own the rest of an in-flight gesture
1743
+ if (!ownsPress(api.container, diagram, ev, hit))
1744
+ return false;
1676
1745
  if (hit.node) {
1677
1746
  if (((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(hit.node.id))
1678
1747
  return true;
@@ -1825,8 +1894,10 @@ export function bindDashboardGrid(api, group, options = {}) {
1825
1894
  }
1826
1895
  }
1827
1896
  }
1828
- if (hoverHost && hoverHost !== host)
1897
+ if (hoverHost && hoverHost !== host) {
1829
1898
  hoverHost.style.cursor = '';
1899
+ hoverHost.removeAttribute('data-axdb-edge'); // the affordance follows the pointer off a tile
1900
+ }
1830
1901
  hoverHost = host;
1831
1902
  if (!host)
1832
1903
  return;
@@ -2262,6 +2333,16 @@ export function bindDashboardGrid(api, group, options = {}) {
2262
2333
  return true;
2263
2334
  },
2264
2335
  getFocusedWidget: () => focusedId,
2336
+ selectWidget(id) {
2337
+ var _a;
2338
+ if (disposed)
2339
+ return false;
2340
+ if (id !== undefined && (!((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id) || !diagram.getNode(id)))
2341
+ return false;
2342
+ selectWidget(id);
2343
+ return true;
2344
+ },
2345
+ getSelectedWidget: () => selectedId,
2265
2346
  setStatic(on) {
2266
2347
  if (on === isStatic)
2267
2348
  return;
@@ -2412,8 +2493,10 @@ export function bindDashboardGrid(api, group, options = {}) {
2412
2493
  placeholder = null;
2413
2494
  if (glideTimer)
2414
2495
  clearTimeout(glideTimer);
2415
- if (ghostTimer)
2416
- clearTimeout(ghostTimer);
2496
+ // A rebind inside the 60 ms window (a layout switch right after an undo)
2497
+ // disposed this binder with the timer pending — the host kept its
2498
+ // lifted ghost for good (visual gate, nested-containers ⑤).
2499
+ flushGhost();
2417
2500
  (_d = htmlLayer()) === null || _d === void 0 ? void 0 : _d.classList.remove('axdb-glide');
2418
2501
  api.container.style.cursor = '';
2419
2502
  },
@@ -25,7 +25,7 @@
25
25
  import { __awaiter } from "tslib";
26
26
  import { Command } from '@grafloria/engine';
27
27
  import { LiveRegionController, registerTool } from '@grafloria/renderer';
28
- import { dragHandleSelector, gripHostOf, gripOf, normalizeDragHandle, pressOnDragHandle, syncGrip, DRAG_HANDLE_CLASS } from './grid-binder.js';
28
+ import { dragHandleSelector, gripHostOf, gripOf, normalizeDragHandle, ownsPress, pressOnDragHandle, registerBoardPeer, syncGrip, DRAG_HANDLE_CLASS } from './grid-binder.js';
29
29
  import { cellFromGridItem } from './grid-mapping.js';
30
30
  import { addSplitLeaf, cellsFromSplit, cloneSplit, dividersOf, groupRectsOf, insertSplitLeaf, moveSplitDivider, normalizeSplit, pathToLeaf, projectSplit, removeSplitLeaf, splitFromCells, splitLeaves, } from './split-layout.js';
31
31
  import { ensureDashboardKitStyles } from './styles.js';
@@ -631,6 +631,8 @@ export function bindDashboardSplit(api, group, options = {}) {
631
631
  return false;
632
632
  if (gesture)
633
633
  return true;
634
+ if (!ownsPress(api.container, diagram, ev, hit))
635
+ return false;
634
636
  if (hit.node)
635
637
  return ((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(hit.node.id);
636
638
  return worldInsideBoard(ev.world.x, ev.world.y);
@@ -1025,6 +1027,16 @@ export function bindDashboardSplit(api, group, options = {}) {
1025
1027
  return true;
1026
1028
  },
1027
1029
  getFocusedWidget: () => focusedId,
1030
+ selectWidget(id) {
1031
+ var _a;
1032
+ if (disposed)
1033
+ return false;
1034
+ if (id !== undefined && (!((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id) || !diagram.getNode(id)))
1035
+ return false;
1036
+ selectWidget(id);
1037
+ return true;
1038
+ },
1039
+ getSelectedWidget: () => selectedId,
1028
1040
  saveLayout() {
1029
1041
  return { columns, cells: cellsFromSplit(readTree(), columns, rowsGuess()) };
1030
1042
  },
@@ -1154,6 +1166,26 @@ export function bindDashboardSplit(api, group, options = {}) {
1154
1166
  applyFluidFrame();
1155
1167
  project(reconcile());
1156
1168
  api.renderNow();
1169
+ // A split board on a CONTAINER (item 7) sits inside a parent grid: register
1170
+ // as a peer so the parent's hitTest hands presses on our tiles to us.
1171
+ const unregisterPeer = registerBoardPeer(api.container, {
1172
+ group,
1173
+ hasItem: (id) => { var _a; return ((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id); },
1174
+ memberCell: (id) => handle.cellOf(id),
1175
+ resizeMemberBy: () => ({ changed: false }),
1176
+ containsWorld: (x, y) => worldInsideBoard(x, y),
1177
+ containsWorldExtended: (x, y) => worldInsideBoard(x, y),
1178
+ frameArea: () => {
1179
+ const f = frame();
1180
+ return f.width * f.height;
1181
+ },
1182
+ adopt: () => null,
1183
+ });
1184
+ const disposeHandle = handle.dispose.bind(handle);
1185
+ handle.dispose = () => {
1186
+ unregisterPeer();
1187
+ disposeHandle();
1188
+ };
1157
1189
  return handle;
1158
1190
  }
1159
1191
  //# sourceMappingURL=split-binder.js.map
@@ -244,6 +244,11 @@ const CSS = `
244
244
  .grafloria-node-host.axdb-gp-inside.axdb-gp-right .axdb-widget > .axdb-widget-h { padding-right: 32px; }
245
245
  .grafloria-node-host.axdb-gp-inside.axdb-gp-center .axdb-widget > .axdb-widget-h { padding-top: 12px; }
246
246
  .axdb-widget-b { flex: 1; min-height: 0; position: relative; }
247
+ /* A drag across a STATIC board (nothing prevents the press's default there)
248
+ used to select every label on it; kit cards are not prose. Tables stay
249
+ copyable — a figure in a grid is the one thing a viewer selects. */
250
+ .axdb-widget { user-select: none; -webkit-user-select: none; }
251
+ .axdb-widget .axdb-table { user-select: text; -webkit-user-select: text; }
247
252
  .axdb-widget-b > svg { display: block; width: 100%; height: 100%; }
248
253
  .axdb-widget-b.axdb-scroll { overflow: auto; }
249
254
  /* A chart WITH a legend under it: the plot yields height, the legend keeps its
@@ -332,6 +337,9 @@ const CSS = `
332
337
  tile gets a bigger ring, not dead card), square, capped so its centre figure
333
338
  stays a figure and not a headline. */
334
339
  .axdb-widget-b.axdb-donut { display: flex; align-items: center; gap: 10px; }
340
+ /* A legend taller than a SHORT body (a 2-row donut on a squeezed board) is
341
+ clipped at its own foot, not centred over the title above it. */
342
+ .axdb-widget-b.axdb-donut > .axdb-lg--col { max-height: 100%; min-height: 0; overflow: hidden; }
335
343
  .axdb-widget-b.axdb-donut > svg {
336
344
  flex: 0 0 auto; width: auto; height: 100%; max-height: 260px; max-width: 60%; aspect-ratio: 1 / 1;
337
345
  }
package/src/lib/load.js CHANGED
@@ -206,7 +206,7 @@ export function fromDocument(document, options = {}) {
206
206
  // a fixed world — it stays one. Fluid is only what was saved fluid.
207
207
  mode: (firstBoard === null || firstBoard === void 0 ? void 0 : firstBoard.fluid) === true ? 'fluid' : 'fixed',
208
208
  overflow: (_p = firstBoard === null || firstBoard === void 0 ? void 0 : firstBoard.overflow) !== null && _p !== void 0 ? _p : 'bounded',
209
- layoutOf: new Map(viewGroups.map((g) => { var _a, _b; return [g.id, ((_b = (_a = g.getMetadata('dashboardBoard')) === null || _a === void 0 ? void 0 : _a.layout) !== null && _b !== void 0 ? _b : 'grid')]; })),
209
+ layoutOf: new Map(dashGroups.map((g) => { var _a, _b; return [g.id, ((_b = (_a = g.getMetadata('dashboardBoard')) === null || _a === void 0 ? void 0 : _a.layout) !== null && _b !== void 0 ? _b : 'grid')]; })),
210
210
  // responsive is NOT in the document (a runtime seam), so it is deliberately
211
211
  // absent from the round-trip; width/height/columns/gap/sizing/float/rtl are.
212
212
  optionsBase: firstBoard