@grafloria/element 0.4.17 → 0.4.19

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.17",
3
+ "version": "0.4.19",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -252,6 +252,12 @@ export interface DashboardOptions {
252
252
  * hook). Omit for a titled placeholder frame.
253
253
  */
254
254
  renderWidget?: (widget: DashboardWidgetSpec, host: HTMLElement) => void;
255
+ /**
256
+ * The selection changed on a board: the selected id — a widget or a SECTION
257
+ * (container) — or undefined when cleared, and the view it belongs to. A
258
+ * press on a section's empty band selects the section.
259
+ */
260
+ onSelect?: (id: string | undefined, viewId: string) => void;
255
261
  /** Fires after any committed gesture, with the view whose layout changed. */
256
262
  onLayoutChange?: (viewId: string, widgets: DashboardWidgetSpec[]) => void;
257
263
  /** Extra binder options, merged last (escape hatch to the layer below). */
@@ -1264,7 +1264,7 @@ export function dashboard(options) {
1264
1264
  if (e.type === 'commit')
1265
1265
  reportChanged();
1266
1266
  (_b = (_a = options.binder) === null || _a === void 0 ? void 0 : _a.onGesture) === null || _b === void 0 ? void 0 : _b.call(_a, e);
1267
- } });
1267
+ }, onSelect: (id) => { var _a; return (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, id, v.id); } });
1268
1268
  if (viewLayout === 'split') {
1269
1269
  return bindDashboardSplit(a, g, Object.assign(Object.assign(Object.assign({}, common), { columns: (_h = v.columns) !== null && _h !== void 0 ? _h : columns, baseRowHeight: rowHeight, designHeight: viewH(v) }), (v.tree !== undefined ? { tree: v.tree } : {})));
1270
1270
  }
@@ -1282,7 +1282,7 @@ export function dashboard(options) {
1282
1282
  if (e.type === 'commit')
1283
1283
  reportChanged();
1284
1284
  (_b = (_a = options.binder) === null || _a === void 0 ? void 0 : _a.onGesture) === null || _b === void 0 ? void 0 : _b.call(_a, e);
1285
- } });
1285
+ }, onSelect: (id) => { var _a, _b; return (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, id, (_b = ctx.viewOfBoard.get(w.id)) !== null && _b !== void 0 ? _b : ctx.active); } });
1286
1286
  if (((_h = ctx.layoutOf.get(w.id)) !== null && _h !== void 0 ? _h : w.layout) === 'split') {
1287
1287
  // A splitter tree covering the pane; the pane's frame is the parent's
1288
1288
  // slab, so no design height of its own.
@@ -196,6 +196,8 @@ export interface DashboardGridOptions {
196
196
  changed: boolean;
197
197
  }) => void;
198
198
  /** Inject the hover-revealed corner resize handle into member hosts (default true). */
199
+ /** The selection on this board changed: the selected member — a widget or a SECTION (container) — or undefined. */
200
+ onSelect?: (id: string | undefined) => void;
199
201
  resizeHandles?: boolean;
200
202
  /**
201
203
  * FLUID board: the group's frame follows the CANVAS CONTAINER — width
@@ -429,6 +431,27 @@ interface BinderPeer {
429
431
  group: GroupModel;
430
432
  /** True when this board's engine holds `id` as an item (member lookup). */
431
433
  hasItem(id: string): boolean;
434
+ /**
435
+ * Drop this board's selection: a press that selects a tile on ONE board of
436
+ * a canvas clears the others, so a section and its parent never show two
437
+ * rings at once (Quantia Groups page: three tiles selected across three
438
+ * boards after three clicks).
439
+ */
440
+ clearSelection?(): void;
441
+ /** A nested board's live row bound — what its slab must never shrink below. */
442
+ innerRows?(): number;
443
+ /**
444
+ * SECTION PRESSES. A press on a section's empty band lands on the NESTED
445
+ * board's tool (it claims its own frame), which knows nothing to select;
446
+ * the section is a member of the PARENT. The child asks the parent to
447
+ * select it and, on an edge or the corner handle, to run the section
448
+ * resize — and forwards the rest of the pointer sequence.
449
+ */
450
+ selectMember?(id: string): void;
451
+ beginSlabResize?(id: string, edges: ResizeEdges, ev: ToolPointerEvent): boolean;
452
+ slabMove?(ev: ToolPointerEvent): void;
453
+ slabUp?(): void;
454
+ slabCancel?(): void;
432
455
  /** The member's current cell in this board (undefined when absent). */
433
456
  memberCell(id: string): CellRect | undefined;
434
457
  /**
@@ -490,6 +513,14 @@ interface AdoptedLeg {
490
513
  * unregister. A split board adopts nothing and grows no slab: its `adopt`
491
514
  * answers null and `resizeMemberBy` answers unchanged.
492
515
  */
516
+ /** Clear the selection on every OTHER board of the canvas — one selection per canvas. */
517
+ export declare function clearOtherSelections(container: HTMLElement, self: BinderPeer | null): void;
493
518
  export declare function registerBoardPeer(container: HTMLElement, peer: BinderPeer): () => void;
494
519
  export type { BinderPeer };
520
+ interface ResizeEdges {
521
+ n: boolean;
522
+ e: boolean;
523
+ s: boolean;
524
+ w: boolean;
525
+ }
495
526
  export declare function bindDashboardGrid(api: DashboardGridApi, group: GroupModel, options?: DashboardGridOptions): DashboardGridHandle;
@@ -153,6 +153,13 @@ const BOARD_REGISTRY = new WeakMap();
153
153
  * unregister. A split board adopts nothing and grows no slab: its `adopt`
154
154
  * answers null and `resizeMemberBy` answers unchanged.
155
155
  */
156
+ /** Clear the selection on every OTHER board of the canvas — one selection per canvas. */
157
+ export function clearOtherSelections(container, self) {
158
+ var _a, _b;
159
+ for (const p of (_a = BOARD_REGISTRY.get(container)) !== null && _a !== void 0 ? _a : [])
160
+ if (p !== self)
161
+ (_b = p.clearSelection) === null || _b === void 0 ? void 0 : _b.call(p);
162
+ }
156
163
  export function registerBoardPeer(container, peer) {
157
164
  let set = BOARD_REGISTRY.get(container);
158
165
  if (!set) {
@@ -458,6 +465,19 @@ export function bindDashboardGrid(api, group, options = {}) {
458
465
  /** The engine item a member should enter as (cells from GridItemConfig /
459
466
  * group metadata; spans falling back to metadata columnSpan/rowSpan; last
460
467
  * resort: adopt from the current pixel geometry). */
468
+ /**
469
+ * A SECTION's floor: its nested board's live rows (the peer), else the rows
470
+ * it was mounted with — a section resized by hand never squeezes its
471
+ * children below their cells.
472
+ */
473
+ const innerRowsOf = (id) => {
474
+ var _a, _b, _c, _d;
475
+ for (const p of (_a = BOARD_REGISTRY.get(api.container)) !== null && _a !== void 0 ? _a : [])
476
+ if (p.group.id === id && p.innerRows)
477
+ return Math.max(1, p.innerRows());
478
+ const meta = (_c = (_b = diagram.getGroup(id)) === null || _b === void 0 ? void 0 : _b.getMetadata) === null || _c === void 0 ? void 0 : _c.call(_b, 'containerWidget');
479
+ return Math.max(1, (_d = meta === null || meta === void 0 ? void 0 : meta.maxRows) !== null && _d !== void 0 ? _d : 1);
480
+ };
461
481
  const itemFor = (id) => {
462
482
  var _a, _b, _c, _d, _e, _f, _g;
463
483
  const node = diagram.getNode(id);
@@ -701,19 +721,27 @@ export function bindDashboardGrid(api, group, options = {}) {
701
721
  */
702
722
  let selectedId;
703
723
  const selectWidget = (id) => {
724
+ var _a;
725
+ if (id !== undefined)
726
+ clearOtherSelections(api.container, selfPeerRef);
704
727
  if (id === selectedId)
705
728
  return;
706
729
  selectedId = id;
707
730
  syncA11y();
731
+ syncSlabs();
732
+ (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, id);
708
733
  };
734
+ /** Set once the peer object exists (below); `selectWidget` runs before that only on boot. */
735
+ let selfPeerRef = null;
709
736
  const syncA11y = (only) => {
710
- var _a, _b;
737
+ var _a, _b, _c;
711
738
  if (disposed)
712
739
  return;
713
740
  const members = [...((_a = group.members) !== null && _a !== void 0 ? _a : [])].filter((id) => !!diagram.getNode(id));
714
741
  if (focusedId && !members.includes(focusedId))
715
742
  focusedId = undefined;
716
- if (selectedId && !members.includes(selectedId))
743
+ // A selected SECTION is a group member, not a node: keep it as long as it is a member.
744
+ if (selectedId && !members.includes(selectedId) && !(((_b = group.members) === null || _b === void 0 ? void 0 : _b.has(selectedId)) && memberEntity(selectedId)))
717
745
  selectedId = undefined;
718
746
  const stop = focusedId !== null && focusedId !== void 0 ? focusedId : members[0];
719
747
  for (const id of members) {
@@ -727,7 +755,7 @@ export function bindDashboardGrid(api, group, options = {}) {
727
755
  const bits = [nameOf(node)];
728
756
  if (cell)
729
757
  bits.push(describeCell(cell));
730
- if (((_b = node.state) === null || _b === void 0 ? void 0 : _b.locked) === true)
758
+ if (((_c = node.state) === null || _c === void 0 ? void 0 : _c.locked) === true)
731
759
  bits.push('pinned');
732
760
  host.setAttribute('role', 'group');
733
761
  host.setAttribute('aria-roledescription', 'dashboard widget');
@@ -748,6 +776,7 @@ export function bindDashboardGrid(api, group, options = {}) {
748
776
  if (disposed)
749
777
  return;
750
778
  ensureStaticGuard();
779
+ syncSlabs();
751
780
  const grip = gripOf(dragHandle);
752
781
  for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
753
782
  if (only && !only.has(id))
@@ -964,6 +993,7 @@ export function bindDashboardGrid(api, group, options = {}) {
964
993
  finally {
965
994
  writing = false;
966
995
  }
996
+ syncSlabs();
967
997
  };
968
998
  const applyColumns = (n, layout) => {
969
999
  var _a;
@@ -1033,6 +1063,80 @@ export function bindDashboardGrid(api, group, options = {}) {
1033
1063
  const band = rowHeightFor(geom(), rows()) + gap;
1034
1064
  return x >= f.x && x <= f.x + f.width && y >= f.y && y <= f.y + boardVisualHeight() + band;
1035
1065
  };
1066
+ /** The member SECTION whose frame holds the world point, if any. */
1067
+ const memberGroupAt = (x, y) => {
1068
+ var _a;
1069
+ for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
1070
+ const grp = diagram.getGroup(id);
1071
+ if (!grp)
1072
+ continue;
1073
+ const p = grp.position;
1074
+ const s = sizeOf(grp);
1075
+ if (x >= p.x && x <= p.x + s.width && y >= p.y && y <= p.y + s.height)
1076
+ return id;
1077
+ }
1078
+ return null;
1079
+ };
1080
+ /** Which of a section frame's edges a world point is within EDGE_GRIP of. */
1081
+ const slabEdgesNear = (grp, x, y) => {
1082
+ const p = grp.position;
1083
+ const s = sizeOf(grp);
1084
+ return { n: y - p.y <= EDGE_GRIP, s: p.y + s.height - y <= EDGE_GRIP, w: x - p.x <= EDGE_GRIP, e: p.x + s.width - x <= EDGE_GRIP };
1085
+ };
1086
+ /**
1087
+ * SECTION CHROME. A section (member group) paints no card of its own, so it
1088
+ * had nothing to press: a click on its empty band cleared the selection and
1089
+ * its frame had no handle and no edge — a section with many children could
1090
+ * not be selected at all, and could only be resized by pulling a child
1091
+ * (Quantia, Groups page). Every section gets a pointer-transparent overlay
1092
+ * in the HTML layer that wears the selection ring and, while selected, the
1093
+ * corner handle; its frame edges answer the resize cursor.
1094
+ */
1095
+ const slabEls = new Map();
1096
+ let slabLayer = null;
1097
+ const syncSlabs = () => {
1098
+ var _a, _b;
1099
+ if (disposed)
1100
+ return;
1101
+ const layer = (slabLayer === null || slabLayer === void 0 ? void 0 : slabLayer.isConnected) ? slabLayer : (slabLayer = htmlLayer());
1102
+ if (!layer)
1103
+ return;
1104
+ const seen = new Set();
1105
+ for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
1106
+ const grp = diagram.getGroup(id);
1107
+ if (!grp || diagram.getNode(id))
1108
+ continue;
1109
+ seen.add(id);
1110
+ let el = slabEls.get(id);
1111
+ if (!el || el.parentElement !== layer) {
1112
+ el === null || el === void 0 ? void 0 : el.remove();
1113
+ el = document.createElement('div');
1114
+ el.className = 'axdb-slab';
1115
+ el.setAttribute('data-slab-id', id);
1116
+ const rs = document.createElement('div');
1117
+ rs.className = 'axdb-rs';
1118
+ rs.setAttribute('title', 'Resize section');
1119
+ el.appendChild(rs);
1120
+ layer.appendChild(el);
1121
+ slabEls.set(id, el);
1122
+ }
1123
+ const p = grp.position;
1124
+ const sz = sizeOf(grp);
1125
+ el.style.left = `${p.x}px`;
1126
+ el.style.top = `${p.y}px`;
1127
+ el.style.width = `${sz.width}px`;
1128
+ el.style.height = `${sz.height}px`;
1129
+ el.classList.toggle('axdb-slab--selected', selectedId === id);
1130
+ el.classList.toggle('axdb-slab--static', isStatic);
1131
+ (_b = el.querySelector(':scope > .axdb-rs')) === null || _b === void 0 ? void 0 : _b.classList.toggle('axdb-rs--rtl', rtl);
1132
+ }
1133
+ for (const [id, el] of slabEls) {
1134
+ if (!seen.has(id)) {
1135
+ el.remove();
1136
+ slabEls.delete(id);
1137
+ }
1138
+ }
1139
+ };
1036
1140
  const insideMemberGroupFrame = (x, y) => {
1037
1141
  var _a;
1038
1142
  for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
@@ -1046,6 +1150,131 @@ export function bindDashboardGrid(api, group, options = {}) {
1046
1150
  }
1047
1151
  return false;
1048
1152
  };
1153
+ let slabGesture = null;
1154
+ /** The PARENT running a resize of OUR section from a press this tool claimed. */
1155
+ let forwardSlab = null;
1156
+ const frameOfGroup = (grp) => ({ x: grp.position.x, y: grp.position.y, width: sizeOf(grp).width, height: sizeOf(grp).height });
1157
+ const beginSlabResize = (id, edges, ev) => {
1158
+ const grp = diagram.getGroup(id);
1159
+ const it = engine.getItem(id);
1160
+ if (!grp || !it || gesture || slabGesture)
1161
+ return;
1162
+ engine.beginGesture();
1163
+ const snap = snapshotAll();
1164
+ slabGesture = {
1165
+ id,
1166
+ edges,
1167
+ pointerId: typeof PointerEvent !== 'undefined' && ev.source instanceof PointerEvent ? ev.source.pointerId : null,
1168
+ started: false,
1169
+ downScreen: { x: ev.screen.x, y: ev.screen.y },
1170
+ startCells: snap.cells,
1171
+ startGeom: snap.geoms,
1172
+ cellBefore: { x: it.x, y: it.y, w: it.w, h: it.h },
1173
+ frameBefore: frameOfGroup(grp),
1174
+ grab: {
1175
+ dx: edges.e ? grp.position.x + sizeOf(grp).width - ev.world.x : edges.w ? grp.position.x - ev.world.x : 0,
1176
+ dy: edges.s ? grp.position.y + sizeOf(grp).height - ev.world.y : edges.n ? grp.position.y - ev.world.y : 0,
1177
+ },
1178
+ };
1179
+ capturePointer(slabGesture.pointerId);
1180
+ api.container.style.cursor = cursorFor(edges);
1181
+ };
1182
+ const slabMove = (ev) => {
1183
+ const g = slabGesture;
1184
+ if (!g)
1185
+ return;
1186
+ if (!g.started) {
1187
+ if (Math.abs(ev.screen.x - g.downScreen.x) + Math.abs(ev.screen.y - g.downScreen.y) < DRAG_THRESHOLD)
1188
+ return;
1189
+ g.started = true;
1190
+ armGlide();
1191
+ }
1192
+ const it = engine.getItem(g.id);
1193
+ if (!it)
1194
+ return;
1195
+ const f = frame();
1196
+ const gg = geom();
1197
+ const cu = columnUnitFor(gg, f.width);
1198
+ const rh = rowHeightFor(gg, rows());
1199
+ // The grid line nearest the pointer, in cells (mirrored on RTL).
1200
+ const colAt = (wx) => Math.round((rtl ? f.x + f.width - padding - wx : wx - f.x - padding) / (cu + gap));
1201
+ const rowAt = (wy) => Math.round((wy - f.y - padding) / (rh + gap));
1202
+ let { x, y, w, h } = it;
1203
+ const px = ev.world.x + g.grab.dx;
1204
+ const py = ev.world.y + g.grab.dy;
1205
+ if (g.edges.e)
1206
+ w = Math.max(1, colAt(px) - x);
1207
+ if (g.edges.s)
1208
+ h = Math.max(1, rowAt(py) - y);
1209
+ if (g.edges.w) {
1210
+ const nx = Math.max(0, Math.min(x + w - 1, colAt(px)));
1211
+ w = x + w - nx;
1212
+ x = nx;
1213
+ }
1214
+ if (g.edges.n) {
1215
+ const ny = Math.max(0, Math.min(y + h - 1, rowAt(py)));
1216
+ h = y + h - ny;
1217
+ y = ny;
1218
+ }
1219
+ w = Math.max(1, Math.min(w, columns - x));
1220
+ // Never below the children's rows (the nested board's live bound).
1221
+ const floor = innerRowsOf(g.id);
1222
+ if (h < floor) {
1223
+ if (g.edges.n)
1224
+ y = y + h - floor;
1225
+ h = floor;
1226
+ }
1227
+ let changed = false;
1228
+ if (x !== it.x || y !== it.y)
1229
+ changed = engine.moveCheck(g.id, x, y, { gate: false }).changed || changed;
1230
+ if (w !== it.w || h !== it.h)
1231
+ changed = engine.resizeCheck(g.id, w, h).changed || changed;
1232
+ if (changed)
1233
+ project();
1234
+ };
1235
+ const slabUp = () => {
1236
+ var _a;
1237
+ const g = slabGesture;
1238
+ if (!g)
1239
+ return;
1240
+ slabGesture = null;
1241
+ releasePointer(g.pointerId);
1242
+ api.container.style.cursor = '';
1243
+ if (!g.started) {
1244
+ engine.endGesture();
1245
+ return;
1246
+ }
1247
+ engine.endGesture();
1248
+ project();
1249
+ const it = engine.getItem(g.id);
1250
+ const grp = diagram.getGroup(g.id);
1251
+ const commands = buildCommitCommands(deltasSince(g.startCells, g.startGeom, g.id));
1252
+ const b = g.cellBefore;
1253
+ if (it && grp && (b.x !== it.x || b.y !== it.y || b.w !== it.w || b.h !== it.h)) {
1254
+ commands.push(new SetGroupCellCommand(g.id, b, { x: it.x, y: it.y, w: it.w, h: it.h }, g.frameBefore, frameOfGroup(grp)));
1255
+ }
1256
+ const changed = execute('Resize section', commands);
1257
+ disarmGlideSoon();
1258
+ syncHandles();
1259
+ api.renderNow();
1260
+ (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: 'commit', kind: 'resize', nodeId: g.id, changed });
1261
+ };
1262
+ const slabCancel = () => {
1263
+ var _a;
1264
+ const g = slabGesture;
1265
+ if (!g)
1266
+ return;
1267
+ slabGesture = null;
1268
+ releasePointer(g.pointerId);
1269
+ api.container.style.cursor = '';
1270
+ if (g.started)
1271
+ engine.cancelGesture();
1272
+ else
1273
+ engine.endGesture();
1274
+ project();
1275
+ disarmGlideSoon();
1276
+ (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: 'cancel', kind: 'resize', nodeId: g.id, changed: false });
1277
+ };
1049
1278
  const capturePointer = (pointerId) => {
1050
1279
  var _a, _b;
1051
1280
  if (pointerId === null)
@@ -1152,7 +1381,13 @@ export function bindDashboardGrid(api, group, options = {}) {
1152
1381
  (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: 'commit', kind: g.kind, nodeId: g.id, changed });
1153
1382
  };
1154
1383
  const cancelActiveGesture = (notify = true) => {
1155
- var _a, _b, _c;
1384
+ var _a, _b, _c, _d;
1385
+ if (forwardSlab) {
1386
+ (_a = forwardSlab.slabCancel) === null || _a === void 0 ? void 0 : _a.call(forwardSlab);
1387
+ forwardSlab = null;
1388
+ }
1389
+ if (slabGesture)
1390
+ slabCancel();
1156
1391
  const g = gesture;
1157
1392
  if (!g)
1158
1393
  return;
@@ -1173,7 +1408,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1173
1408
  engine.endGesture();
1174
1409
  const items = [];
1175
1410
  for (const [id, c] of g.startCells) {
1176
- const lockedNode = ((_b = (_a = diagram.getNode(id)) === null || _a === void 0 ? void 0 : _a.state) === null || _b === void 0 ? void 0 : _b.locked) === true;
1411
+ const lockedNode = ((_c = (_b = diagram.getNode(id)) === null || _b === void 0 ? void 0 : _b.state) === null || _c === void 0 ? void 0 : _c.locked) === true;
1177
1412
  items.push(Object.assign(Object.assign({ id }, c), { locked: lockedNode || isGroupMember(id) }));
1178
1413
  }
1179
1414
  engine = engineFrom(items);
@@ -1221,7 +1456,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1221
1456
  enforceBoardHeight();
1222
1457
  api.renderNow();
1223
1458
  if (notify) {
1224
- (_c = options.onGesture) === null || _c === void 0 ? void 0 : _c.call(options, { type: 'cancel', kind: g.kind, nodeId: g.id, changed: false });
1459
+ (_d = options.onGesture) === null || _d === void 0 ? void 0 : _d.call(options, { type: 'cancel', kind: g.kind, nodeId: g.id, changed: false });
1225
1460
  }
1226
1461
  };
1227
1462
  /** Centre a w×h-span tile's top-left under the cursor, in world px. */
@@ -1790,6 +2025,32 @@ export function bindDashboardGrid(api, group, options = {}) {
1790
2025
  };
1791
2026
  const selfPeer = {
1792
2027
  group,
2028
+ clearSelection: () => {
2029
+ var _a;
2030
+ if (selectedId === undefined)
2031
+ return;
2032
+ selectedId = undefined;
2033
+ syncA11y();
2034
+ syncSlabs();
2035
+ (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, undefined);
2036
+ },
2037
+ innerRows: () => maxRows !== null && maxRows !== void 0 ? maxRows : rows(),
2038
+ selectMember: (id) => {
2039
+ var _a;
2040
+ if (((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id)) {
2041
+ selectWidget(id);
2042
+ api.render();
2043
+ }
2044
+ },
2045
+ beginSlabResize: (id, edges, ev) => {
2046
+ if (isStatic)
2047
+ return false;
2048
+ beginSlabResize(id, edges, ev);
2049
+ return (slabGesture === null || slabGesture === void 0 ? void 0 : slabGesture.id) === id;
2050
+ },
2051
+ slabMove: (ev) => slabMove(ev),
2052
+ slabUp: () => slabUp(),
2053
+ slabCancel: () => slabCancel(),
1793
2054
  hasItem: (id) => !!engine.getItem(id),
1794
2055
  memberCell: (id) => {
1795
2056
  const it = engine.getItem(id);
@@ -1826,6 +2087,9 @@ export function bindDashboardGrid(api, group, options = {}) {
1826
2087
  adopt,
1827
2088
  };
1828
2089
  peersOnCanvas().add(selfPeer);
2090
+ selfPeerRef = selfPeer;
2091
+ // The board's gap, for chrome that must fit BETWEEN tiles (the outside grip tab).
2092
+ api.container.style.setProperty('--axdb-gap', `${gap}px`);
1829
2093
  const tool = {
1830
2094
  id: `dashboard-grid:${group.id}:${++binderSeq}`,
1831
2095
  priority: 2, // point-specific claim — outranks mode-style tools (see ext/tools.ts)
@@ -1833,7 +2097,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1833
2097
  var _a, _b;
1834
2098
  if (disposed)
1835
2099
  return false;
1836
- if (gesture)
2100
+ if (gesture || slabGesture || forwardSlab)
1837
2101
  return true; // own the rest of an in-flight gesture
1838
2102
  if (!ownsPress(api.container, diagram, ev, hit))
1839
2103
  return false;
@@ -1862,7 +2126,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1862
2126
  return insideMemberGroupFrame(ev.world.x, ev.world.y) || worldInsideBoard(ev.world.x, ev.world.y);
1863
2127
  },
1864
2128
  onPointerDown(ev, hit) {
1865
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
2129
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s;
1866
2130
  if (gesture)
1867
2131
  return; // mid-palette
1868
2132
  const target = ((_b = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target) !== null && _b !== void 0 ? _b : null);
@@ -1873,9 +2137,43 @@ export function bindDashboardGrid(api, group, options = {}) {
1873
2137
  const gripId = (_c = gripHost === null || gripHost === void 0 ? void 0 : gripHost.getAttribute('data-node-id')) !== null && _c !== void 0 ? _c : null;
1874
2138
  const onGrip = !!gripId && ((_d = group.members) !== null && _d !== void 0 ? _d : new Set()).has(gripId);
1875
2139
  if (!hit.node && !onGrip) {
2140
+ // A press on a SECTION — its empty band, its corner handle or its
2141
+ // frame edge — selects the section; the handle or an edge resizes it.
2142
+ const slabHandle = (_e = target === null || target === void 0 ? void 0 : target.closest) === null || _e === void 0 ? void 0 : _e.call(target, '.axdb-slab > .axdb-rs');
2143
+ const slabId = (_g = (_f = slabHandle === null || slabHandle === void 0 ? void 0 : slabHandle.parentElement) === null || _f === void 0 ? void 0 : _f.getAttribute('data-slab-id')) !== null && _g !== void 0 ? _g : memberGroupAt(ev.world.x, ev.world.y);
2144
+ const grp = slabId && ((_h = group.members) !== null && _h !== void 0 ? _h : new Set()).has(slabId) ? diagram.getGroup(slabId) : undefined;
2145
+ if (slabId && grp) {
2146
+ selectWidget(slabId);
2147
+ api.render();
2148
+ if (isStatic)
2149
+ return;
2150
+ const edges = slabHandle
2151
+ ? rtl ? { n: false, e: false, s: true, w: true } : { n: false, e: true, s: true, w: false }
2152
+ : slabEdgesNear(grp, ev.world.x, ev.world.y);
2153
+ if (anyEdge(edges))
2154
+ beginSlabResize(slabId, edges, ev);
2155
+ return;
2156
+ }
2157
+ // OUR OWN empty band, and we are a section of a parent board: the
2158
+ // press selects the section there, and its edge or corner handle
2159
+ // starts the section resize, which the parent runs while this tool
2160
+ // forwards the pointer sequence.
2161
+ const parent = parentPeer();
2162
+ if ((parent === null || parent === void 0 ? void 0 : parent.selectMember) && worldInsideBoard(ev.world.x, ev.world.y)) {
2163
+ const ownHandle = ((_j = slabHandle === null || slabHandle === void 0 ? void 0 : slabHandle.parentElement) === null || _j === void 0 ? void 0 : _j.getAttribute('data-slab-id')) === group.id;
2164
+ parent.selectMember(group.id);
2165
+ if (!isStatic && parent.beginSlabResize) {
2166
+ const edges = ownHandle
2167
+ ? rtl ? { n: false, e: false, s: true, w: true } : { n: false, e: true, s: true, w: false }
2168
+ : slabEdgesNear(group, ev.world.x, ev.world.y);
2169
+ if (anyEdge(edges) && parent.beginSlabResize(group.id, edges, ev))
2170
+ forwardSlab = parent;
2171
+ }
2172
+ return;
2173
+ }
1876
2174
  // The board's own empty area: a void click. Nothing to drag, and the
1877
2175
  // selection clears exactly as a click outside any board would.
1878
- (_f = (_e = diagram).clearSelection) === null || _f === void 0 ? void 0 : _f.call(_e);
2176
+ (_l = (_k = diagram).clearSelection) === null || _l === void 0 ? void 0 : _l.call(_k);
1879
2177
  selectWidget(undefined);
1880
2178
  api.render();
1881
2179
  return;
@@ -1884,17 +2182,17 @@ export function bindDashboardGrid(api, group, options = {}) {
1884
2182
  if (!node)
1885
2183
  return;
1886
2184
  selectWidget(node.id); // a press selects, whether or not it starts a gesture
1887
- if (((_g = node.state) === null || _g === void 0 ? void 0 : _g.locked) === true)
2185
+ if (((_m = node.state) === null || _m === void 0 ? void 0 : _m.locked) === true)
1888
2186
  return; // pinned: refuse; click still focuses
1889
2187
  if (isStatic)
1890
2188
  return; // a static board: claimed and deadened, click still focuses
1891
2189
  // Which edges did the press take? The corner handle names its own (s+e,
1892
2190
  // or s+w on RTL); a bare press within EDGE_GRIP of the tile's border
1893
2191
  // takes that border; anywhere else is a move. A grip press is a move.
1894
- const onHandle = !!((_h = target === null || target === void 0 ? void 0 : target.closest) === null || _h === void 0 ? void 0 : _h.call(target, '.axdb-rs'));
2192
+ const onHandle = !!((_o = target === null || target === void 0 ? void 0 : target.closest) === null || _o === void 0 ? void 0 : _o.call(target, '.axdb-rs'));
1895
2193
  const hostEl = hostOf(node.id);
1896
- const resizable = ((_j = node.getMetadata) === null || _j === void 0 ? void 0 : _j.call(node, 'widgetResizable')) !== false;
1897
- const movable = ((_k = node.getMetadata) === null || _k === void 0 ? void 0 : _k.call(node, 'widgetMovable')) !== false;
2194
+ const resizable = ((_p = node.getMetadata) === null || _p === void 0 ? void 0 : _p.call(node, 'widgetResizable')) !== false;
2195
+ const movable = ((_q = node.getMetadata) === null || _q === void 0 ? void 0 : _q.call(node, 'widgetMovable')) !== false;
1898
2196
  // `ev.screen` is ELEMENT-LOCAL px; host rects are in client px. Compare
1899
2197
  // like with like — the source event's clientX/Y when there is one, else
1900
2198
  // the container's origin plus the local offset.
@@ -1942,7 +2240,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1942
2240
  startSize: { width: node.size.width, height: node.size.height },
1943
2241
  startPos: { x: node.position.x, y: node.position.y },
1944
2242
  edges,
1945
- spans: { w: (_l = it === null || it === void 0 ? void 0 : it.w) !== null && _l !== void 0 ? _l : 1, h: (_m = it === null || it === void 0 ? void 0 : it.h) !== null && _m !== void 0 ? _m : 1 },
2243
+ spans: { w: (_r = it === null || it === void 0 ? void 0 : it.w) !== null && _r !== void 0 ? _r : 1, h: (_s = it === null || it === void 0 ? void 0 : it.h) !== null && _s !== void 0 ? _s : 1 },
1946
2244
  removedFromBoard: false,
1947
2245
  leg: null,
1948
2246
  lastWorld: null,
@@ -1953,10 +2251,24 @@ export function bindDashboardGrid(api, group, options = {}) {
1953
2251
  };
1954
2252
  },
1955
2253
  onPointerMove(ev) {
1956
- onToolMove(ev);
2254
+ var _a;
2255
+ if (forwardSlab)
2256
+ (_a = forwardSlab.slabMove) === null || _a === void 0 ? void 0 : _a.call(forwardSlab, ev);
2257
+ else if (slabGesture)
2258
+ slabMove(ev);
2259
+ else
2260
+ onToolMove(ev);
1957
2261
  },
1958
2262
  onPointerUp() {
1959
- onToolUp();
2263
+ var _a;
2264
+ if (forwardSlab) {
2265
+ (_a = forwardSlab.slabUp) === null || _a === void 0 ? void 0 : _a.call(forwardSlab);
2266
+ forwardSlab = null;
2267
+ }
2268
+ else if (slabGesture)
2269
+ slabUp();
2270
+ else
2271
+ onToolUp();
1960
2272
  },
1961
2273
  onCancel() {
1962
2274
  cancelActiveGesture();
@@ -1970,7 +2282,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1970
2282
  */
1971
2283
  let hoverHost = null;
1972
2284
  const onHover = (e) => {
1973
- var _a, _b, _c, _d, _e, _f, _g;
2285
+ var _a, _b, _c, _d, _e, _f, _g, _h;
1974
2286
  if (disposed || gesture)
1975
2287
  return;
1976
2288
  // The event may target the host, its content, or (when a host's content
@@ -1994,13 +2306,23 @@ export function bindDashboardGrid(api, group, options = {}) {
1994
2306
  hoverHost.removeAttribute('data-axdb-edge'); // the affordance follows the pointer off a tile
1995
2307
  }
1996
2308
  hoverHost = host;
1997
- if (!host)
2309
+ if (!host) {
2310
+ // No tile under the pointer: a SECTION's frame edge still says resize.
2311
+ const wpt = ((_d = api.viewport) === null || _d === void 0 ? void 0 : _d.clientToWorld) ? api.viewport.clientToWorld(e.clientX, e.clientY, api.container.getBoundingClientRect()) : null;
2312
+ const sid = wpt ? memberGroupAt(wpt.x, wpt.y) : null;
2313
+ const grp = sid ? diagram.getGroup(sid) : undefined;
2314
+ const c = grp && wpt && !isStatic ? cursorFor(slabEdgesNear(grp, wpt.x, wpt.y)) : '';
2315
+ if (!slabGesture)
2316
+ api.container.style.cursor = c;
1998
2317
  return;
1999
- const id = (_d = host.getAttribute('data-node-id')) !== null && _d !== void 0 ? _d : '';
2000
- if (!((_e = group.members) !== null && _e !== void 0 ? _e : new Set()).has(id))
2318
+ }
2319
+ if (!slabGesture && api.container.style.cursor)
2320
+ api.container.style.cursor = '';
2321
+ const id = (_e = host.getAttribute('data-node-id')) !== null && _e !== void 0 ? _e : '';
2322
+ if (!((_f = group.members) !== null && _f !== void 0 ? _f : new Set()).has(id))
2001
2323
  return;
2002
2324
  const node = diagram.getNode(id);
2003
- const resizable = !!node && !isStatic && ((_f = node.state) === null || _f === void 0 ? void 0 : _f.locked) !== true && ((_g = node.getMetadata) === null || _g === void 0 ? void 0 : _g.call(node, 'widgetResizable')) !== false;
2325
+ const resizable = !!node && !isStatic && ((_g = node.state) === null || _g === void 0 ? void 0 : _g.locked) !== true && ((_h = node.getMetadata) === null || _h === void 0 ? void 0 : _h.call(node, 'widgetResizable')) !== false;
2004
2326
  const cursor = resizable ? cursorFor(edgesNear(host, e.clientX, e.clientY)) : '';
2005
2327
  // The affordance rides on an ATTRIBUTE, not the inline style: a repaint
2006
2328
  // rewrites the host's style and used to clear the cursor mid-hover, and
@@ -2070,7 +2392,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2070
2392
  return;
2071
2393
  if (focusedId !== hit.id || selectedId !== hit.id) {
2072
2394
  focusedId = hit.id;
2073
- selectedId = hit.id;
2395
+ selectWidget(hit.id);
2074
2396
  syncA11y();
2075
2397
  }
2076
2398
  };
@@ -2334,6 +2656,17 @@ export function bindDashboardGrid(api, group, options = {}) {
2334
2656
  writing = false;
2335
2657
  }
2336
2658
  const commands = buildCommitCommands(deltasSince(snap.cells, snap.geoms));
2659
+ if (isGroupMember(id)) {
2660
+ // A SECTION: its cell lives in group metadata and its frame is written
2661
+ // by project(); the node commands above skip groups (D5 of the review).
2662
+ const it = engine.getItem(id);
2663
+ const grp = diagram.getGroup(id);
2664
+ const cb = snap.cells.get(id);
2665
+ const gb = snap.geoms.get(id);
2666
+ if (it && grp && cb && gb && (cb.x !== it.x || cb.y !== it.y || cb.w !== it.w || cb.h !== it.h)) {
2667
+ commands.push(new SetGroupCellCommand(id, cb, { x: it.x, y: it.y, w: it.w, h: it.h }, { x: gb.pos.x, y: gb.pos.y, width: gb.size.width, height: gb.size.height }, frameOfGroup(grp)));
2668
+ }
2669
+ }
2337
2670
  engine.endGesture();
2338
2671
  disarmGlideSoon();
2339
2672
  enforceBoardHeight();
@@ -2422,7 +2755,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2422
2755
  if (disposed || !((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id) || !diagram.getNode(id))
2423
2756
  return false;
2424
2757
  focusedId = id;
2425
- selectedId = id;
2758
+ selectWidget(id);
2426
2759
  syncA11y();
2427
2760
  (_c = (_b = hostOf(id)) === null || _b === void 0 ? void 0 : _b.focus) === null || _c === void 0 ? void 0 : _c.call(_b, { preventScroll: true });
2428
2761
  return true;
@@ -2432,7 +2765,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2432
2765
  var _a;
2433
2766
  if (disposed)
2434
2767
  return false;
2435
- if (id !== undefined && (!((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id) || !diagram.getNode(id)))
2768
+ if (id !== undefined && (!((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id) || !memberEntity(id)))
2436
2769
  return false;
2437
2770
  selectWidget(id);
2438
2771
  return true;
@@ -2561,7 +2894,8 @@ export function bindDashboardGrid(api, group, options = {}) {
2561
2894
  return programmatic('Move widget', id, () => engine.moveCheck(id, x, y).changed);
2562
2895
  },
2563
2896
  resizeTo(id, w, h) {
2564
- return programmatic('Resize widget', id, () => engine.resizeCheck(id, w, h).changed);
2897
+ const hh = isGroupMember(id) ? Math.max(h, innerRowsOf(id)) : h; // a section: never below its children
2898
+ return programmatic('Resize widget', id, () => engine.resizeCheck(id, w, hh).changed);
2565
2899
  },
2566
2900
  beginPaletteDrag,
2567
2901
  dispose() {
@@ -2588,6 +2922,9 @@ export function bindDashboardGrid(api, group, options = {}) {
2588
2922
  placeholder = null;
2589
2923
  if (glideTimer)
2590
2924
  clearTimeout(glideTimer);
2925
+ for (const el of slabEls.values())
2926
+ el.remove();
2927
+ slabEls.clear();
2591
2928
  // A rebind inside the 60 ms window (a layout switch right after an undo)
2592
2929
  // disposed this binder with the timer pending — the host kept its
2593
2930
  // lifted ghost for good (visual gate, nested-containers ⑤).
@@ -27,7 +27,7 @@ import type { DashboardGridApi, DashboardGridHandle, DashboardGridOptions } from
27
27
  import { type SplitNode } from './split-layout.js';
28
28
  /** Group metadata key the tree persists under. */
29
29
  export declare const SPLIT_TREE_KEY = "dashboardTree";
30
- export interface DashboardSplitOptions extends Pick<DashboardGridOptions, 'columns' | 'gap' | 'padding' | 'rtl' | 'fluid' | 'static' | 'dragHandle' | 'squeeze' | 'designHeight' | 'baseRowHeight' | 'dragOut' | 'removeZone' | 'onRemoveRequest' | 'onDropIn' | 'onGesture'> {
30
+ export interface DashboardSplitOptions extends Pick<DashboardGridOptions, 'columns' | 'gap' | 'padding' | 'rtl' | 'fluid' | 'static' | 'dragHandle' | 'squeeze' | 'designHeight' | 'baseRowHeight' | 'dragOut' | 'removeZone' | 'onRemoveRequest' | 'onDropIn' | 'onGesture' | 'onSelect'> {
31
31
  /** An authored tree. Default: the persisted one, else derived from the members' cells. */
32
32
  tree?: SplitNode | null;
33
33
  }
@@ -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, ownsPress, pressOnDragHandle, registerBoardPeer, syncGrip, DRAG_HANDLE_CLASS } from './grid-binder.js';
28
+ import { clearOtherSelections, 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';
@@ -372,11 +372,16 @@ export function bindDashboardSplit(api, group, options = {}) {
372
372
  /** The selected widget (see grid-binder): stamped `axdb-selected`, shows the grip. */
373
373
  let selectedId;
374
374
  const selectWidget = (id) => {
375
+ var _a;
376
+ if (id !== undefined)
377
+ clearOtherSelections(api.container, selfPeerRef);
375
378
  if (id === selectedId)
376
379
  return;
377
380
  selectedId = id;
378
381
  syncA11y();
382
+ (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, id);
379
383
  };
384
+ let selfPeerRef = null;
380
385
  // Static boards let content be clicked — see grid-binder's staticGuard.
381
386
  const staticGuard = (e) => {
382
387
  var _a, _b, _c, _d;
@@ -823,7 +828,7 @@ export function bindDashboardSplit(api, group, options = {}) {
823
828
  return;
824
829
  if (focusedId !== hit.id || selectedId !== hit.id) {
825
830
  focusedId = hit.id;
826
- selectedId = hit.id;
831
+ selectWidget(hit.id);
827
832
  syncA11y();
828
833
  }
829
834
  };
@@ -967,7 +972,26 @@ export function bindDashboardSplit(api, group, options = {}) {
967
972
  if (layerEl && hostObserver)
968
973
  hostObserver.observe(layerEl, { childList: true });
969
974
  // -- the handle -------------------------------------------------------------
970
- const rowsGuess = () => Math.max(1, Math.round(frame().height / (baseRowHeight + gap)));
975
+ /**
976
+ * The row count the tree's cells are written in. A grid-authored board (or
977
+ * one switched from the grid) carries its members' cells: their extent IS
978
+ * the row count, and switching back must return the same cells — the guess
979
+ * below quantised three 14-row sections on a 758-px fit board to 5 rows
980
+ * (base row height 130), and the grid came back with 14-row inner grids
981
+ * inside 5-row slabs (Quantia, Groups page, Split → Grid). The guess stays
982
+ * for a tree-authored board that never had cells.
983
+ */
984
+ const rowsAtBind = (() => {
985
+ let max = 0;
986
+ for (const id of members()) {
987
+ const c = persistedCell(id);
988
+ if (!c)
989
+ return undefined;
990
+ max = Math.max(max, c.y + c.h);
991
+ }
992
+ return max > 0 ? max : undefined;
993
+ })();
994
+ const rowsGuess = () => rowsAtBind !== null && rowsAtBind !== void 0 ? rowsAtBind : Math.max(1, Math.round(frame().height / (baseRowHeight + gap)));
971
995
  const handle = {
972
996
  sync() {
973
997
  if (disposed)
@@ -1021,7 +1045,7 @@ export function bindDashboardSplit(api, group, options = {}) {
1021
1045
  if (!((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id) || !diagram.getNode(id))
1022
1046
  return false;
1023
1047
  focusedId = id;
1024
- selectedId = id;
1048
+ selectWidget(id);
1025
1049
  syncA11y();
1026
1050
  (_c = (_b = hostOf(id)) === null || _b === void 0 ? void 0 : _b.focus) === null || _c === void 0 ? void 0 : _c.call(_b, { preventScroll: true });
1027
1051
  return true;
@@ -1168,8 +1192,16 @@ export function bindDashboardSplit(api, group, options = {}) {
1168
1192
  api.renderNow();
1169
1193
  // A split board on a CONTAINER (item 7) sits inside a parent grid: register
1170
1194
  // as a peer so the parent's hitTest hands presses on our tiles to us.
1171
- const unregisterPeer = registerBoardPeer(api.container, {
1195
+ const selfPeer = {
1172
1196
  group,
1197
+ clearSelection: () => {
1198
+ var _a;
1199
+ if (selectedId === undefined)
1200
+ return;
1201
+ selectedId = undefined;
1202
+ syncA11y();
1203
+ (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, undefined);
1204
+ },
1173
1205
  hasItem: (id) => { var _a; return ((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id); },
1174
1206
  memberCell: (id) => handle.cellOf(id),
1175
1207
  resizeMemberBy: () => ({ changed: false }),
@@ -1180,7 +1212,10 @@ export function bindDashboardSplit(api, group, options = {}) {
1180
1212
  return f.width * f.height;
1181
1213
  },
1182
1214
  adopt: () => null,
1183
- });
1215
+ };
1216
+ const unregisterPeer = registerBoardPeer(api.container, selfPeer);
1217
+ selfPeerRef = selfPeer;
1218
+ api.container.style.setProperty('--axdb-gap', `${gap}px`);
1184
1219
  const disposeHandle = handle.dispose.bind(handle);
1185
1220
  handle.dispose = () => {
1186
1221
  unregisterPeer();
@@ -68,7 +68,7 @@ const CSS = `
68
68
  }
69
69
 
70
70
  /* ===== corner resize handle (hover-revealed, radius-matched) ===== */
71
- .grafloria-node-host > .axdb-rs {
71
+ :is(.grafloria-node-host, .axdb-slab) > .axdb-rs {
72
72
  position: absolute;
73
73
  right: 0;
74
74
  bottom: 0;
@@ -88,7 +88,7 @@ const CSS = `
88
88
  @media (hover: none) { .grafloria-node-host > .axdb-rs { opacity: .8; } }
89
89
  .grafloria-node-host > .axdb-rs:hover { border-color: #3b52d9; }
90
90
  /* RTL boards grow leftwards, so the grab corner mirrors with them. */
91
- .grafloria-node-host > .axdb-rs.axdb-rs--rtl {
91
+ :is(.grafloria-node-host, .axdb-slab) > .axdb-rs.axdb-rs--rtl {
92
92
  right: auto;
93
93
  left: 0;
94
94
  cursor: nesw-resize;
@@ -235,7 +235,10 @@ const CSS = `
235
235
  .grafloria-node-host > .axdb-grip--inside { top: 2px; }
236
236
  }
237
237
  /* OUTSIDE: a tab on the card's top edge, its corners in line with the card's. */
238
- .grafloria-node-host > .axdb-grip--outside { top: -11px; height: 11px; border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom: 0; box-shadow: 0 -1px 2px rgba(16, 24, 40, .08); }
238
+ /* The OUTSIDE tab lives in the gap between tiles: never taller than the gap
239
+ less a pixel (an 11-px tab in a 10-px gap sat on the tile above), never
240
+ shorter than 6 px so the dots still read. */
241
+ .grafloria-node-host > .axdb-grip--outside { top: calc(-1 * clamp(6px, var(--axdb-gap, 11px) - 1px, 11px)); height: clamp(6px, calc(var(--axdb-gap, 11px) - 1px), 11px); border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom: 0; box-shadow: 0 -1px 2px rgba(16, 24, 40, .08); }
239
242
  .grafloria-node-host > .axdb-grip--outside.axdb-grip--left { left: 10px; }
240
243
  .grafloria-node-host > .axdb-grip--outside.axdb-grip--right { right: 10px; }
241
244
  .grafloria-node-host > .axdb-grip--center { left: 50%; transform: translateX(-50%); }
@@ -352,6 +355,14 @@ const CSS = `
352
355
  .axdb-tier-2 .axdb-xt, .axdb-tier-2 .axdb-vt,
353
356
  .axdb-lg--off { display: none; }
354
357
 
358
+ /* SECTION CHROME: a pointer-transparent overlay on every member group. It
359
+ wears the selection ring and, while selected, the corner handle. */
360
+ .grafloria-html-layer > .axdb-slab { position: absolute; pointer-events: none; border-radius: var(--axdb-rs-radius, 3px); z-index: 4; }
361
+ .grafloria-html-layer > .axdb-slab.axdb-slab--selected { box-shadow: 0 0 0 1.5px var(--axdb-accent-ring, rgba(59, 82, 217, .55)); }
362
+ .grafloria-html-layer > .axdb-slab > .axdb-rs { pointer-events: auto; opacity: 0; }
363
+ .grafloria-html-layer > .axdb-slab.axdb-slab--selected > .axdb-rs { opacity: 1; }
364
+ .grafloria-html-layer > .axdb-slab.axdb-slab--static > .axdb-rs { display: none; }
365
+
355
366
  /* legend chips, shared by line and donut */
356
367
  .axdb-lg { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 9px; }
357
368
  .axdb-lg--col { flex-direction: column; flex-wrap: nowrap; gap: 6px; margin-top: 0; }