@grafloria/element 0.4.21 → 0.4.22

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.21",
3
+ "version": "0.4.22",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
package/src/index.d.ts CHANGED
@@ -80,7 +80,7 @@ export type { ErColumn, ErEntitySpec, ErRelationshipSpec, ErCardinality, ErSide,
80
80
  export { erTable, umlClass, erTables, umlClasses, CardHandle, ErTable, ErField, ErColumnList, UmlClass, UmlMemberList, } from './lib/diagram-kit/index.js';
81
81
  export type { HandleApi } from './lib/diagram-kit/index.js';
82
82
  export { bindDashboardGrid, rowHeightFor, boardHeightFor, columnUnitFor, cellToRect, pointToCell, sizeToSpan, gridItemFromCell, cellFromGridItem, buildCommitCommands, ensureDashboardKitStyles, DASHBOARD_KIT_STYLE_ID, dashboard, defaultWidgetRenderer, renderKpiWidget, renderLineWidget, renderBarWidget, renderDonutWidget, renderFunnelWidget, renderTableWidget, BUILT_IN_WIDGET_KINDS, } from './lib/dashboard-kit/index.js';
83
- export type { DashboardGridApi, DashboardGridOptions, DashboardGridHandle, CellRect, WorldRect, DashboardGridGeometry, TileDelta, DashboardOptions, DashboardSpec, DashboardSnapshot, DragHandleOption, DragGripOptions, DashboardHandle, DashboardViewSpec, DashboardWidgetSpec, WidgetHandle, WidgetRenderer, KpiWidgetData, LineWidgetData, LineSeries, BarWidgetData, DonutWidgetData, FunnelWidgetData, TableWidgetData, } from './lib/dashboard-kit/index.js';
83
+ export type { DashboardGridApi, DashboardGridOptions, DashboardGridHandle, CellRect, WorldRect, DashboardGridGeometry, TileDelta, DashboardOptions, DashboardSpec, DashboardSnapshot, DragHandleOption, DragGripOptions, DashboardHandle, DashboardViewSpec, DashboardWidgetSpec, SectionCaption, SectionCaptionOptions, SectionCaptionAction, SectionCaptionFont, WidgetHandle, WidgetRenderer, KpiWidgetData, LineWidgetData, LineSeries, BarWidgetData, DonutWidgetData, FunnelWidgetData, TableWidgetData, } from './lib/dashboard-kit/index.js';
84
84
  export { bindStencilPalette, bindShapeDataPanel, ensureStencilKitStyles } from './lib/stencil-kit/index.js';
85
85
  export type { StencilPaletteApi, StencilPaletteOptions, StencilPaletteHandle, ShapeDataPanelApi, ShapeDataPanelOptions, ShapeDataPanelHandle, } from './lib/stencil-kit/index.js';
86
86
  export * from '@grafloria/engine';
@@ -0,0 +1,123 @@
1
+ /**
2
+ * SECTION CAPTIONS — the header a section (container widget) may carry,
3
+ * painted by the kit on the slab overlay it already owns.
4
+ *
5
+ * Three things only the kit can do for a section header, and this module is
6
+ * where they are decided: RESERVE the pixels (`captionReserve` — the nested
7
+ * board's frame starts below the band, so no child may take it), PAINT it
8
+ * (`paintCaptionBand` — one DOM shape, one set of CSS variables, one theme),
9
+ * and ROUTE its presses (`captionPassThrough` — a button or an input in the
10
+ * band is content, everything else is the band, which selects the section).
11
+ * Persistence is the container's business: the caption rides on the group's
12
+ * `containerWidget` metadata beside `layout` and `sizing`, and `toJSON()`
13
+ * writes it back.
14
+ *
15
+ * Plan and defaults: documentation/api-architecture/section-caption-plan.html.
16
+ */
17
+ export interface SectionCaptionAction {
18
+ id: string;
19
+ /** Accessible name; painted as the glyph when there is no `icon`. */
20
+ label: string;
21
+ /** A glyph (emoji or short text). */
22
+ icon?: string;
23
+ /** Tooltip; default: the label. */
24
+ title?: string;
25
+ disabled?: boolean;
26
+ }
27
+ export interface SectionCaptionFont {
28
+ size?: number;
29
+ weight?: number | string;
30
+ family?: string;
31
+ color?: string;
32
+ transform?: 'none' | 'uppercase';
33
+ }
34
+ export interface SectionCaptionOptions {
35
+ /** Default: the container's `title`. */
36
+ text?: string;
37
+ /** A muted second line; the band grows to 44 px. */
38
+ subtitle?: string;
39
+ /** An ⓘ after the text: tooltip and the section's accessible description. */
40
+ description?: string;
41
+ /** A glyph before the text (emoji or short text). */
42
+ icon?: string;
43
+ /** 'inside' (default): a band inside the frame, reserved. 'tab': above the frame, nothing reserved. */
44
+ position?: 'inside' | 'tab';
45
+ /** Horizontal alignment (default 'start', mirrored on RTL). */
46
+ align?: 'start' | 'center' | 'end';
47
+ /** Vertical alignment within the band (default 'center'). */
48
+ valign?: 'top' | 'center' | 'bottom';
49
+ /** Band height, px (default 28; 44 with a subtitle). */
50
+ height?: number;
51
+ font?: SectionCaptionFont;
52
+ /** Inside the band: px, or [vertical, horizontal] (default [0, 10]). */
53
+ padding?: number | [number, number];
54
+ /** Between the band and the frame: px, or [vertical, horizontal] (default 0). Adds to the reserve. */
55
+ margin?: number | [number, number];
56
+ /** Band fill (default: the theme's). */
57
+ background?: string;
58
+ /** The band's bottom rule, a CSS border value (default none). */
59
+ border?: string;
60
+ /** 'always' (default); 'design' is not painted (nor reserved) under static; 'hover' overlays the content, nothing reserved. */
61
+ show?: 'always' | 'design' | 'hover';
62
+ /** Buttons at the end of the band; a press fires `onCaptionAction` and never selects. */
63
+ actions?: SectionCaptionAction[];
64
+ /** Presses inside descendants matching this selector are content, never the band's. */
65
+ passThrough?: string;
66
+ /** Your class on the band. */
67
+ className?: string;
68
+ }
69
+ /** `false`/absent: no band. `true`: the title. A string: that text. */
70
+ export type SectionCaption = false | true | string | SectionCaptionOptions;
71
+ export declare const CAPTION_HEIGHT = 28;
72
+ export declare const CAPTION_HEIGHT_SUBTITLE = 44;
73
+ export declare const CAPTION_HEIGHT_TIGHT = 22;
74
+ /** A section shorter than this steps its band down to the tight tier. */
75
+ export declare const CAPTION_TIGHT_BELOW = 90;
76
+ export declare const CAPTION_PASS_THROUGH = "button, a, input, select, textarea, [data-axdb-pass]";
77
+ /** The caption as options, or null when there is none. `title` fills the text. */
78
+ export declare function normalizeCaption(c: SectionCaption | undefined, title?: string): SectionCaptionOptions | null;
79
+ /** The caption a section GROUP carries, from its persisted `containerWidget` metadata. */
80
+ export declare function captionOfGroup(grp: {
81
+ getMetadata(key: string): unknown;
82
+ name?: string;
83
+ } | undefined): SectionCaptionOptions | null;
84
+ export declare function pairOf(v: number | [number, number] | undefined, dflt: [number, number]): [number, number];
85
+ /** Painted at all? `show: 'design'` disappears under static. */
86
+ export declare function captionPainted(c: SectionCaptionOptions | null, isStatic: boolean): boolean;
87
+ /** The band's height for a section of `sectionH` px. */
88
+ export declare function captionBandHeight(c: SectionCaptionOptions, sectionH: number): number;
89
+ /** Pixels the nested board's frame gives up at the top: the band plus its vertical margins. */
90
+ export declare function captionReserve(c: SectionCaptionOptions | null, ctx: {
91
+ static: boolean;
92
+ sectionH: number;
93
+ }): number;
94
+ /** Identity of a painted band: repaint only when this changes (the tier is a class toggle, not a repaint). */
95
+ export declare function captionKey(c: SectionCaptionOptions, ctx: {
96
+ rtl: boolean;
97
+ static: boolean;
98
+ }): string;
99
+ /** Is a section of `sectionH` px in the tight tier? */
100
+ export declare const captionTight: (sectionH: number) => boolean;
101
+ /** The per-sync geometry of a painted band: height and tier follow the section's live size. */
102
+ export declare function sizeCaptionBand(band: HTMLElement, c: SectionCaptionOptions, sectionH: number): void;
103
+ /**
104
+ * Is a press on `target`, inside `band`, content rather than the band? An
105
+ * action button is always content: no tool claims it, so the browser's own
106
+ * `click` reaches it — from a mouse, a touch, or Enter/Space on the keyboard
107
+ * (a pointerdown-only action never fired from the keyboard, and the
108
+ * interaction gate's DEAD-BUTTON check, which clicks, called it dead).
109
+ */
110
+ export declare function captionPassThrough(target: Element | null, band: Element, c: SectionCaptionOptions | null): boolean;
111
+ /**
112
+ * Paint the band: geometry (inline, so the reserve and the pixels agree),
113
+ * classes for alignment and mode, CSS variables for typography and box, and
114
+ * the default content — icon, text, subtitle, ⓘ, actions. With `render` the
115
+ * content is yours: the band is handed over empty and keeps its press rules.
116
+ */
117
+ export declare function paintCaptionBand(band: HTMLElement, c: SectionCaptionOptions, ctx: {
118
+ rtl: boolean;
119
+ static: boolean;
120
+ sectionH: number;
121
+ render?: (host: HTMLElement) => void;
122
+ onAction?: (actionId: string) => void;
123
+ }): void;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * SECTION CAPTIONS — the header a section (container widget) may carry,
3
+ * painted by the kit on the slab overlay it already owns.
4
+ *
5
+ * Three things only the kit can do for a section header, and this module is
6
+ * where they are decided: RESERVE the pixels (`captionReserve` — the nested
7
+ * board's frame starts below the band, so no child may take it), PAINT it
8
+ * (`paintCaptionBand` — one DOM shape, one set of CSS variables, one theme),
9
+ * and ROUTE its presses (`captionPassThrough` — a button or an input in the
10
+ * band is content, everything else is the band, which selects the section).
11
+ * Persistence is the container's business: the caption rides on the group's
12
+ * `containerWidget` metadata beside `layout` and `sizing`, and `toJSON()`
13
+ * writes it back.
14
+ *
15
+ * Plan and defaults: documentation/api-architecture/section-caption-plan.html.
16
+ */
17
+ export const CAPTION_HEIGHT = 28;
18
+ export const CAPTION_HEIGHT_SUBTITLE = 44;
19
+ export const CAPTION_HEIGHT_TIGHT = 22;
20
+ /** A section shorter than this steps its band down to the tight tier. */
21
+ export const CAPTION_TIGHT_BELOW = 90;
22
+ export const CAPTION_PASS_THROUGH = 'button, a, input, select, textarea, [data-axdb-pass]';
23
+ /** The caption as options, or null when there is none. `title` fills the text. */
24
+ export function normalizeCaption(c, title) {
25
+ if (c === undefined || c === false)
26
+ return null;
27
+ const o = c === true ? {} : typeof c === 'string' ? { text: c } : Object.assign({}, c);
28
+ if (o.text === undefined && title !== undefined)
29
+ o.text = title;
30
+ return o;
31
+ }
32
+ /** The caption a section GROUP carries, from its persisted `containerWidget` metadata. */
33
+ export function captionOfGroup(grp) {
34
+ var _a;
35
+ if (!grp)
36
+ return null;
37
+ const meta = grp.getMetadata('containerWidget');
38
+ if (!meta || meta.caption === undefined)
39
+ return null;
40
+ return normalizeCaption(meta.caption, (_a = meta.title) !== null && _a !== void 0 ? _a : grp.name);
41
+ }
42
+ export function pairOf(v, dflt) {
43
+ if (v === undefined)
44
+ return dflt;
45
+ return typeof v === 'number' ? [v, v] : [v[0], v[1]];
46
+ }
47
+ /** Painted at all? `show: 'design'` disappears under static. */
48
+ export function captionPainted(c, isStatic) {
49
+ if (!c)
50
+ return false;
51
+ return !(c.show === 'design' && isStatic);
52
+ }
53
+ /** The band's height for a section of `sectionH` px. */
54
+ export function captionBandHeight(c, sectionH) {
55
+ var _a;
56
+ if (sectionH > 0 && sectionH < CAPTION_TIGHT_BELOW)
57
+ return CAPTION_HEIGHT_TIGHT;
58
+ return (_a = c.height) !== null && _a !== void 0 ? _a : (c.subtitle ? CAPTION_HEIGHT_SUBTITLE : CAPTION_HEIGHT);
59
+ }
60
+ /** Pixels the nested board's frame gives up at the top: the band plus its vertical margins. */
61
+ export function captionReserve(c, ctx) {
62
+ if (!c || !captionPainted(c, ctx.static))
63
+ return 0;
64
+ if (c.position === 'tab' || c.show === 'hover')
65
+ return 0;
66
+ const [mv] = pairOf(c.margin, [0, 0]);
67
+ return captionBandHeight(c, ctx.sectionH) + 2 * mv;
68
+ }
69
+ /** Identity of a painted band: repaint only when this changes (the tier is a class toggle, not a repaint). */
70
+ export function captionKey(c, ctx) {
71
+ return JSON.stringify([c, ctx.rtl, ctx.static]);
72
+ }
73
+ /** Is a section of `sectionH` px in the tight tier? */
74
+ export const captionTight = (sectionH) => sectionH > 0 && sectionH < CAPTION_TIGHT_BELOW;
75
+ /** The per-sync geometry of a painted band: height and tier follow the section's live size. */
76
+ export function sizeCaptionBand(band, c, sectionH) {
77
+ const h = captionBandHeight(c, sectionH);
78
+ band.classList.toggle('axdb-slab-h--tight', captionTight(sectionH));
79
+ band.style.height = `${h}px`;
80
+ if (c.position === 'tab')
81
+ band.style.top = `${-h}px`;
82
+ }
83
+ /**
84
+ * Is a press on `target`, inside `band`, content rather than the band? An
85
+ * action button is always content: no tool claims it, so the browser's own
86
+ * `click` reaches it — from a mouse, a touch, or Enter/Space on the keyboard
87
+ * (a pointerdown-only action never fired from the keyboard, and the
88
+ * interaction gate's DEAD-BUTTON check, which clicks, called it dead).
89
+ */
90
+ export function captionPassThrough(target, band, c) {
91
+ var _a;
92
+ if (!target || !band.contains(target))
93
+ return false;
94
+ const sel = `${(_a = c === null || c === void 0 ? void 0 : c.passThrough) !== null && _a !== void 0 ? _a : CAPTION_PASS_THROUGH}, .axdb-slab-h-action`;
95
+ const hit = target.closest(sel);
96
+ return !!hit && band.contains(hit) && hit !== band;
97
+ }
98
+ const el = (doc, cls, text) => {
99
+ const e = doc.createElement('div');
100
+ e.className = cls;
101
+ if (text !== undefined)
102
+ e.textContent = text;
103
+ return e;
104
+ };
105
+ /**
106
+ * Paint the band: geometry (inline, so the reserve and the pixels agree),
107
+ * classes for alignment and mode, CSS variables for typography and box, and
108
+ * the default content — icon, text, subtitle, ⓘ, actions. With `render` the
109
+ * content is yours: the band is handed over empty and keeps its press rules.
110
+ */
111
+ export function paintCaptionBand(band, c, ctx) {
112
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
113
+ const doc = band.ownerDocument;
114
+ const h = captionBandHeight(c, ctx.sectionH);
115
+ const [mv, mh] = pairOf(c.margin, [0, 0]);
116
+ const [pv, ph] = pairOf(c.padding, [0, 10]);
117
+ band.className = 'axdb-slab-h';
118
+ band.textContent = '';
119
+ const cls = (name, on) => band.classList.toggle(name, on);
120
+ cls('axdb-slab-h--tab', c.position === 'tab');
121
+ cls('axdb-slab-h--hover', c.show === 'hover');
122
+ cls('axdb-slab-h--tight', captionTight(ctx.sectionH));
123
+ cls('axdb-slab-h--center', c.align === 'center');
124
+ cls('axdb-slab-h--end', c.align === 'end');
125
+ cls('axdb-slab-h--vtop', c.valign === 'top');
126
+ cls('axdb-slab-h--vbottom', c.valign === 'bottom');
127
+ if (c.className)
128
+ for (const k of c.className.split(/\s+/).filter(Boolean))
129
+ band.classList.add(k);
130
+ band.setAttribute('dir', ctx.rtl ? 'rtl' : 'ltr');
131
+ band.style.height = `${h}px`;
132
+ band.style.top = c.position === 'tab' ? `${-h}px` : `${mv}px`;
133
+ band.style.left = `${mh}px`;
134
+ band.style.right = c.position === 'tab' ? 'auto' : `${mh}px`; // a tab is sized to its text
135
+ const v = (name, value) => {
136
+ if (value === undefined)
137
+ band.style.removeProperty(name);
138
+ else
139
+ band.style.setProperty(name, value);
140
+ };
141
+ v('--axdb-caption-pad', `${pv}px ${ph}px`);
142
+ // Clamped to the CONFIGURED band (the tight tier caps it again in CSS), so
143
+ // the text never exceeds the band whatever size the section is painted at.
144
+ const bandH = (_a = c.height) !== null && _a !== void 0 ? _a : (c.subtitle ? CAPTION_HEIGHT_SUBTITLE : CAPTION_HEIGHT);
145
+ v('--axdb-caption-font-size', ((_b = c.font) === null || _b === void 0 ? void 0 : _b.size) !== undefined ? `${Math.min(c.font.size, Math.max(8, bandH - 2 * pv - 4))}px` : undefined);
146
+ v('--axdb-caption-font-weight', ((_c = c.font) === null || _c === void 0 ? void 0 : _c.weight) !== undefined ? String(c.font.weight) : undefined);
147
+ v('--axdb-caption-font-family', (_d = c.font) === null || _d === void 0 ? void 0 : _d.family);
148
+ v('--axdb-caption-fg', (_e = c.font) === null || _e === void 0 ? void 0 : _e.color);
149
+ v('--axdb-caption-transform', (_f = c.font) === null || _f === void 0 ? void 0 : _f.transform);
150
+ v('--axdb-caption-bg', c.background);
151
+ v('--axdb-caption-border', c.border);
152
+ if (ctx.render) {
153
+ ctx.render(band);
154
+ return;
155
+ }
156
+ if (c.icon)
157
+ band.appendChild(el(doc, 'axdb-slab-h-icon', c.icon));
158
+ const body = el(doc, 'axdb-slab-h-body');
159
+ const text = el(doc, 'axdb-slab-h-text', (_g = c.text) !== null && _g !== void 0 ? _g : '');
160
+ text.setAttribute('title', (_h = c.text) !== null && _h !== void 0 ? _h : '');
161
+ body.appendChild(text);
162
+ if (c.subtitle)
163
+ body.appendChild(el(doc, 'axdb-slab-h-sub', c.subtitle));
164
+ band.appendChild(body);
165
+ if (c.description) {
166
+ const info = el(doc, 'axdb-slab-h-info', 'ⓘ');
167
+ info.setAttribute('title', c.description);
168
+ info.setAttribute('aria-label', c.description);
169
+ band.appendChild(info);
170
+ }
171
+ if ((_j = c.actions) === null || _j === void 0 ? void 0 : _j.length) {
172
+ const row = el(doc, 'axdb-slab-h-actions');
173
+ for (const a of c.actions) {
174
+ const b = doc.createElement('button');
175
+ b.type = 'button';
176
+ b.className = 'axdb-slab-h-action';
177
+ b.setAttribute('data-action', a.id);
178
+ b.setAttribute('aria-label', a.label);
179
+ b.setAttribute('title', (_k = a.title) !== null && _k !== void 0 ? _k : a.label);
180
+ b.textContent = (_l = a.icon) !== null && _l !== void 0 ? _l : a.label;
181
+ if (a.disabled)
182
+ b.disabled = true;
183
+ // Out of the tab order: a board keeps exactly ONE tab stop (the roving
184
+ // tabindex over its widgets — the accessibility scenario asserts it).
185
+ // The band becomes the section's tab stop, with its actions reachable
186
+ // from it, in the section-keyboard round; a click (mouse, touch, or a
187
+ // script) fires the action today.
188
+ b.tabIndex = -1;
189
+ b.addEventListener('click', (e) => {
190
+ var _a;
191
+ e.stopPropagation();
192
+ if (!b.disabled)
193
+ (_a = ctx.onAction) === null || _a === void 0 ? void 0 : _a.call(ctx, a.id);
194
+ });
195
+ row.appendChild(b);
196
+ }
197
+ band.appendChild(row);
198
+ }
199
+ }
200
+ //# sourceMappingURL=caption.js.map
@@ -46,6 +46,7 @@ import { GroupModel, NodeModel, type GridColumnLayout } from '@grafloria/engine'
46
46
  import { type DashboardGridHandle, type DashboardGridOptions, type DashboardResponsiveOptions } from './grid-binder.js';
47
47
  import type { DragHandleOption } from './grid-binder.js';
48
48
  import type { SplitNode } from './split-layout.js';
49
+ import type { SectionCaption } from './caption.js';
49
50
  /** A widget, declared as data. */
50
51
  export interface DashboardWidgetSpec {
51
52
  id: string;
@@ -113,6 +114,14 @@ export interface DashboardWidgetSpec {
113
114
  * `toJSON()` writes it per container.
114
115
  */
115
116
  layout?: 'grid' | 'split';
117
+ /**
118
+ * Container only: a CAPTION painted by the kit on the section's slab —
119
+ * `true` for the title, a string, or the full options (subtitle,
120
+ * description, icon, position, alignment, typography, box, show, actions,
121
+ * pass-through, className). Reserved inside the frame, selectable, themed,
122
+ * persisted by `toJSON()`; live through `setCaption()`. Default: none.
123
+ */
124
+ caption?: SectionCaption;
116
125
  /**
117
126
  * Container only, split layout: the authored splitter tree. Omit it and the
118
127
  * tree is derived from the children's cells. `toJSON()` writes it back.
@@ -258,6 +267,15 @@ export interface DashboardOptions {
258
267
  * press on a section's empty band selects the section.
259
268
  */
260
269
  onSelect?: (id: string | undefined, viewId: string) => void;
270
+ /**
271
+ * Paint a section's caption band yourself (the escape hatch `renderWidget`
272
+ * is for cards): the band arrives empty, sized and themed, and keeps its
273
+ * press rules — a press on it selects the section, a pass-through element
274
+ * (a button, an input, `[data-axdb-pass]`) reaches your content.
275
+ */
276
+ renderCaption?: (widget: DashboardWidgetSpec, host: HTMLElement) => void;
277
+ /** A press on a caption action (`caption.actions`): the section, the action id, the view. */
278
+ onCaptionAction?: (sectionId: string, actionId: string, viewId: string) => void;
261
279
  /** Fires after any committed gesture, with the view whose layout changed. */
262
280
  onLayoutChange?: (viewId: string, widgets: DashboardWidgetSpec[]) => void;
263
281
  /** Extra binder options, merged last (escape hatch to the layer below). */
@@ -325,6 +343,15 @@ export interface DashboardHandle {
325
343
  */
326
344
  setLayout(layout: 'grid' | 'split', viewId?: string): void;
327
345
  getLayout(viewId?: string): 'grid' | 'split';
346
+ /**
347
+ * Set a SECTION's caption live — `false` removes it, `true` is the title, a
348
+ * string or the options. Repaints the band, gives the reserve back or takes
349
+ * it, persists on the section, one undo step. False for anything that is
350
+ * not a container.
351
+ */
352
+ setCaption(id: string, caption: SectionCaption): boolean;
353
+ /** The caption as authored or last set; undefined when the section has none. */
354
+ getCaption(id: string): SectionCaption | undefined;
328
355
  /** Live sizing/float switches — the two prototype toggles. */
329
356
  setSizing(mode: 'fit' | 'grow'): void;
330
357
  getSizing(): 'fit' | 'grow';
@@ -500,18 +527,6 @@ export interface DashboardApiRef {
500
527
  onChange?(listener: (state: unknown) => void): () => void;
501
528
  };
502
529
  }
503
- /**
504
- * Everything one `DashboardHandle` closes over, gathered into ONE object so a
505
- * single builder can serve both `dashboard()` (context built from the authored
506
- * literal) and `fromDocument()` (context reconstructed from the loaded model).
507
- *
508
- * `active` and `apiRef` are the two MUTABLE cells: the handle READS them on
509
- * every call and the builder WRITES them (showView reassigns `active`, the
510
- * caller's finalize sets `apiRef`). They live here rather than as free `let`s
511
- * precisely because there are now two call sites — a boxed cell one builder
512
- * reads and writes is the whole reason a second handle implementation, which
513
- * would silently drift, is not needed.
514
- */
515
530
  export interface DashboardHandleContext {
516
531
  /** The views — MUTATED in place by addWidget (push) and remove (filter). */
517
532
  views: DashboardViewSpec[];
@@ -290,6 +290,37 @@ function assignCells(widgets, columns) {
290
290
  w.rows = rows;
291
291
  }
292
292
  }
293
+ /**
294
+ * Everything one `DashboardHandle` closes over, gathered into ONE object so a
295
+ * single builder can serve both `dashboard()` (context built from the authored
296
+ * literal) and `fromDocument()` (context reconstructed from the loaded model).
297
+ *
298
+ * `active` and `apiRef` are the two MUTABLE cells: the handle READS them on
299
+ * every call and the builder WRITES them (showView reassigns `active`, the
300
+ * caller's finalize sets `apiRef`). They live here rather than as free `let`s
301
+ * precisely because there are now two call sites — a boxed cell one builder
302
+ * reads and writes is the whole reason a second handle implementation, which
303
+ * would silently drift, is not needed.
304
+ */
305
+ /** `setCaption` as one history step: execute re-applies the value, undo the previous one. */
306
+ class SetCaptionCommand extends Command {
307
+ constructor(sectionId, before, after, apply) {
308
+ super('Set section caption');
309
+ this.sectionId = sectionId;
310
+ this.before = before;
311
+ this.after = after;
312
+ this.apply = apply;
313
+ }
314
+ execute() {
315
+ this.apply(this.after);
316
+ }
317
+ undo() {
318
+ this.apply(this.before);
319
+ }
320
+ serialize() {
321
+ return { id: this.id, name: this.name, timestamp: this.timestamp, data: { sectionId: this.sectionId, before: this.before, after: this.after } };
322
+ }
323
+ }
293
324
  /**
294
325
  * Build THE `DashboardHandle` — the one and only implementation, shared by
295
326
  * `dashboard()` and `fromDocument()`. Reads/writes the mutable `ctx.active` /
@@ -564,6 +595,41 @@ export function createDashboardHandle(ctx) {
564
595
  reportChanged();
565
596
  },
566
597
  getLayout: (viewId) => { var _a; return (_a = ctx.layoutOf.get(viewId !== null && viewId !== void 0 ? viewId : ctx.active)) !== null && _a !== void 0 ? _a : 'grid'; },
598
+ setCaption(id, caption) {
599
+ const cg = ctx.boardGroups.get(id);
600
+ const w = specById.get(id);
601
+ if (!cg || !w || !w.widgets || views.some((v) => v.id === id))
602
+ return false;
603
+ const before = w.caption;
604
+ if (JSON.stringify(before !== null && before !== void 0 ? before : null) === JSON.stringify(caption !== null && caption !== void 0 ? caption : null))
605
+ return true;
606
+ const apply = (c) => {
607
+ var _a, _b, _c, _d, _e;
608
+ if (c === undefined)
609
+ delete w.caption;
610
+ else
611
+ w.caption = c;
612
+ const cw = (_a = cg.getMetadata('containerWidget')) !== null && _a !== void 0 ? _a : {};
613
+ const next = Object.assign({}, cw);
614
+ if (c === undefined)
615
+ delete next['caption'];
616
+ else
617
+ next['caption'] = c;
618
+ cg.setMetadata('containerWidget', next);
619
+ // The section's own board re-projects under its new frame; the parent
620
+ // repaints the slab (the band lives there).
621
+ (_b = binders.get(id)) === null || _b === void 0 ? void 0 : _b.sync();
622
+ (_d = binders.get((_c = viewOfWidget.get(id)) !== null && _c !== void 0 ? _c : '')) === null || _d === void 0 ? void 0 : _d.sync();
623
+ (_e = ctx.apiRef) === null || _e === void 0 ? void 0 : _e.renderNow();
624
+ };
625
+ // Applied NOW (a caller reads the band right after), then recorded as
626
+ // one history step whose execute re-applies the same value.
627
+ apply(caption);
628
+ execCommand(new SetCaptionCommand(id, before, caption, apply));
629
+ reportChanged();
630
+ return true;
631
+ },
632
+ getCaption: (id) => { var _a; return (_a = specById.get(id)) === null || _a === void 0 ? void 0 : _a.caption; },
567
633
  setSizing(mode) {
568
634
  var _a;
569
635
  for (const b of binders.values())
@@ -1194,7 +1260,7 @@ export function dashboard(options) {
1194
1260
  cg.setMetadata('gridItem', gridItemFromCell({ x: w.x, y: w.y, w: w.span, h: w.rows }));
1195
1261
  // The container's own spec fields, persisted ON the group — a
1196
1262
  // reloaded document has no authored literal to read them from.
1197
- 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 } : {})));
1263
+ cg.setMetadata('containerWidget', Object.assign(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 } : {})), (w.caption !== undefined ? { caption: w.caption } : {})));
1198
1264
  // Item 7: the container's own layout and bound, persisted like a view's.
1199
1265
  ctx.layoutOf.set(w.id, (_c = w.layout) !== null && _c !== void 0 ? _c : 'grid');
1200
1266
  if (w.layout === 'split' && w.tree !== undefined)
@@ -1257,14 +1323,27 @@ export function dashboard(options) {
1257
1323
  * switched to static, RTL or drag-by-header LIVE snapped back to its
1258
1324
  * authored options the moment its layout changed (s34 caught it).
1259
1325
  */
1326
+ /** The caption escape hatches, with the spec and the view filled in. */
1327
+ function captionHooks(viewId) {
1328
+ const render = options.renderCaption;
1329
+ return Object.assign(Object.assign({}, (render
1330
+ ? {
1331
+ renderCaption: (sectionId, host) => {
1332
+ const spec = specById.get(sectionId);
1333
+ if (spec)
1334
+ render(spec, host);
1335
+ },
1336
+ }
1337
+ : {})), { onCaptionAction: (sectionId, actionId) => { var _a; return (_a = options.onCaptionAction) === null || _a === void 0 ? void 0 : _a.call(options, sectionId, actionId, viewId); } });
1338
+ }
1260
1339
  function bindView(v, g, viewLayout, live) {
1261
1340
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
1262
- const common = Object.assign(Object.assign(Object.assign({ gap, padding: gap, rtl: (_b = (_a = live === null || live === void 0 ? void 0 : live.rtl) !== null && _a !== void 0 ? _a : options.rtl) !== null && _b !== void 0 ? _b : false, fluid: mode === 'fluid', static: (_d = (_c = live === null || live === void 0 ? void 0 : live.static) !== null && _c !== void 0 ? _c : options.static) !== null && _d !== void 0 ? _d : false, dragHandle: (_f = (_e = live === null || live === void 0 ? void 0 : live.dragHandle) !== null && _e !== void 0 ? _e : options.dragHandle) !== null && _f !== void 0 ? _f : false }, (options.squeeze !== undefined ? { squeeze: options.squeeze } : {})), ((_g = options.binder) !== null && _g !== void 0 ? _g : {})), { onGesture: (e) => {
1341
+ const common = Object.assign(Object.assign(Object.assign(Object.assign({ gap, padding: gap, rtl: (_b = (_a = live === null || live === void 0 ? void 0 : live.rtl) !== null && _a !== void 0 ? _a : options.rtl) !== null && _b !== void 0 ? _b : false, fluid: mode === 'fluid', static: (_d = (_c = live === null || live === void 0 ? void 0 : live.static) !== null && _c !== void 0 ? _c : options.static) !== null && _d !== void 0 ? _d : false, dragHandle: (_f = (_e = live === null || live === void 0 ? void 0 : live.dragHandle) !== null && _e !== void 0 ? _e : options.dragHandle) !== null && _f !== void 0 ? _f : false }, (options.squeeze !== undefined ? { squeeze: options.squeeze } : {})), ((_g = options.binder) !== null && _g !== void 0 ? _g : {})), { onGesture: (e) => {
1263
1342
  var _a, _b;
1264
1343
  if (e.type === 'commit')
1265
1344
  reportChanged();
1266
1345
  (_b = (_a = options.binder) === null || _a === void 0 ? void 0 : _a.onGesture) === null || _b === void 0 ? void 0 : _b.call(_a, e);
1267
- }, onSelect: (id) => { var _a; return (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, id, v.id); } });
1346
+ }, onSelect: (id) => { var _a; return (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, id, v.id); } }), captionHooks(v.id));
1268
1347
  if (viewLayout === 'split') {
1269
1348
  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
1349
  }
@@ -1272,18 +1351,18 @@ export function dashboard(options) {
1272
1351
  }
1273
1352
  /** Bind (or re-bind) a container's inner grid on its group. */
1274
1353
  function bindContainer(cg, w, viewId, innerRows) {
1275
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
1354
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
1276
1355
  void viewId;
1277
1356
  // The LIVE switches ride on the view's binder when it exists (a
1278
1357
  // container bound after a setStatic/setRtl/setDragHandle must match).
1279
1358
  const vb = binders.get((_a = ctx.viewOfBoard.get(w.id)) !== null && _a !== void 0 ? _a : ctx.active);
1280
- 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) => {
1359
+ const inner = Object.assign(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) => {
1281
1360
  var _a, _b;
1282
1361
  if (e.type === 'commit')
1283
1362
  reportChanged();
1284
1363
  (_b = (_a = options.binder) === null || _a === void 0 ? void 0 : _a.onGesture) === null || _b === void 0 ? void 0 : _b.call(_a, e);
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
- if (((_h = ctx.layoutOf.get(w.id)) !== null && _h !== void 0 ? _h : w.layout) === 'split') {
1364
+ }, 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); } }), captionHooks((_h = ctx.viewOfBoard.get(w.id)) !== null && _h !== void 0 ? _h : ctx.active));
1365
+ if (((_j = ctx.layoutOf.get(w.id)) !== null && _j !== void 0 ? _j : w.layout) === 'split') {
1287
1366
  // A splitter tree covering the pane; the pane's frame is the parent's
1288
1367
  // slab, so no design height of its own.
1289
1368
  binders.set(w.id, bindDashboardSplit(a, cg, Object.assign(Object.assign({}, inner), (w.tree !== undefined ? { tree: w.tree } : {}))));
@@ -1292,7 +1371,7 @@ export function dashboard(options) {
1292
1371
  binders.set(w.id, bindDashboardGrid(a, cg, Object.assign(Object.assign({}, inner), { sizing: 'fit', designHeight: 0,
1293
1372
  // The DESIGN: authored, else what the group was mounted with, else
1294
1373
  // the children's extent — the binder's live bound follows the cells.
1295
- maxRows: (_l = (_j = innerRows !== null && innerRows !== void 0 ? innerRows : w.maxRows) !== null && _j !== void 0 ? _j : (_k = cg.getMetadata('containerWidget')) === null || _k === void 0 ? void 0 : _k.maxRows) !== null && _l !== void 0 ? _l : rowExtentOf((_m = w.widgets) !== null && _m !== void 0 ? _m : []), float: false, escalate: w.sizing !== 'fit' })));
1374
+ maxRows: (_m = (_k = innerRows !== null && innerRows !== void 0 ? innerRows : w.maxRows) !== null && _k !== void 0 ? _k : (_l = cg.getMetadata('containerWidget')) === null || _l === void 0 ? void 0 : _l.maxRows) !== null && _m !== void 0 ? _m : rowExtentOf((_o = w.widgets) !== null && _o !== void 0 ? _o : []), float: false, escalate: w.sizing !== 'fit' })));
1296
1375
  }
1297
1376
  /**
1298
1377
  * One reporter for every binder on a view — the view's own and each
@@ -198,6 +198,14 @@ export interface DashboardGridOptions {
198
198
  /** Inject the hover-revealed corner resize handle into member hosts (default true). */
199
199
  /** The selection on this board changed: the selected member — a widget or a SECTION (container) — or undefined. */
200
200
  onSelect?: (id: string | undefined) => void;
201
+ /**
202
+ * SECTION CAPTIONS (0.4.22). Paint a member section's caption band yourself:
203
+ * the band is handed over empty, sized and themed, and keeps its press rules.
204
+ * Default: the kit's icon · text · subtitle · ⓘ · actions.
205
+ */
206
+ renderCaption?: (sectionId: string, host: HTMLElement) => void;
207
+ /** A press on a caption action button (see `SectionCaptionOptions.actions`). */
208
+ onCaptionAction?: (sectionId: string, actionId: string) => void;
201
209
  resizeHandles?: boolean;
202
210
  /**
203
211
  * FLUID board: the group's frame follows the CANVAS CONTAINER — width
@@ -54,6 +54,7 @@ import { AddToGroupCommand, BatchCommand, Command, GridPackEngine, RemoveFromGro
54
54
  import { LiveRegionController, registerTool } from '@grafloria/renderer';
55
55
  import { buildCommitCommands, cellFromGridItem, cellToRect, columnUnitFor, gridItemFromCell, pointToCell, rowHeightFor, sizeToSpan, } from './grid-mapping.js';
56
56
  import { ensureDashboardKitStyles } from './styles.js';
57
+ import { captionOfGroup, captionPainted, captionPassThrough, captionKey, captionReserve, paintCaptionBand, sizeCaptionBand } from './caption.js';
57
58
  /** The class of the painted grip element (a child of the node host). */
58
59
  export const GRIP_CLASS = 'axdb-grip';
59
60
  /** The container class that turns the caption strip's grip dots on. */
@@ -103,12 +104,24 @@ export function syncGrip(host, cfg, movable) {
103
104
  * diagram's object, not a namesake from another model.
104
105
  */
105
106
  export function ownsPress(container, diagram, ev, hit) {
106
- var _a;
107
+ var _a, _b, _c, _d;
107
108
  const t = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target;
108
109
  if (typeof Node !== 'undefined' && t instanceof Node && !container.contains(t))
109
110
  return false;
110
111
  if (hit.node && diagram.getNode(hit.node.id) !== hit.node)
111
112
  return false;
113
+ // A press on a PASS-THROUGH element of a caption band (a button, an input,
114
+ // anything the caption's `passThrough` names) is the content's, not any
115
+ // tool's: no selection, no drag, no resize — the DOM handles it.
116
+ if (typeof Element !== 'undefined' && t instanceof Element) {
117
+ const band = t.closest('.axdb-slab > .axdb-slab-h');
118
+ const sid = (_b = band === null || band === void 0 ? void 0 : band.parentElement) === null || _b === void 0 ? void 0 : _b.getAttribute('data-slab-id');
119
+ if (band && sid) {
120
+ const grp = (_d = (_c = diagram).getGroup) === null || _d === void 0 ? void 0 : _d.call(_c, sid);
121
+ if (captionPassThrough(t, band, captionOfGroup(grp)))
122
+ return false;
123
+ }
124
+ }
112
125
  return true;
113
126
  }
114
127
  export function gripHostOf(target) {
@@ -434,14 +447,22 @@ export function bindDashboardGrid(api, group, options = {}) {
434
447
  let adoptedGhostId = null;
435
448
  let glideTimer = null;
436
449
  let ghostTimer = null;
450
+ /**
451
+ * OUR OWN CAPTION RESERVE: a section carrying a caption gives the band's
452
+ * pixels up at the top of its frame, so no child may take them — and the
453
+ * band is outside `containsWorld`, so a press on it is the PARENT's (it
454
+ * selects the section) rather than an empty press of this board.
455
+ */
456
+ const ownReserve = () => { var _a, _b; return captionReserve(captionOfGroup(group), { static: isStatic, sectionH: (_b = (_a = group.size) === null || _a === void 0 ? void 0 : _a.height) !== null && _b !== void 0 ? _b : 0 }); };
437
457
  const frame = () => {
438
458
  var _a, _b, _c, _d;
439
- return ({
459
+ const r = ownReserve();
460
+ return {
440
461
  x: group.position.x,
441
- y: group.position.y,
462
+ y: group.position.y + r,
442
463
  width: (_b = (_a = group.size) === null || _a === void 0 ? void 0 : _a.width) !== null && _b !== void 0 ? _b : 0,
443
- height: (_d = (_c = group.size) === null || _c === void 0 ? void 0 : _c.height) !== null && _d !== void 0 ? _d : 0,
444
- });
464
+ height: Math.max(0, ((_d = (_c = group.size) === null || _c === void 0 ? void 0 : _c.height) !== null && _d !== void 0 ? _d : 0) - r),
465
+ };
445
466
  };
446
467
  /** Entity size with GroupModel's optionality flattened away. */
447
468
  const sizeOf = (e) => { var _a; return (_a = e.size) !== null && _a !== void 0 ? _a : { width: 0, height: 0 }; };
@@ -621,6 +642,12 @@ export function bindDashboardGrid(api, group, options = {}) {
621
642
  writing = false;
622
643
  }
623
644
  syncPlaceholder();
645
+ // The section overlays are projected chrome like the placeholder: they
646
+ // follow every frame write, not only a rebuild. Painted at bind time only,
647
+ // a section two levels down kept the geometry of its parent's placeholder
648
+ // frame (100 × 34) after the view board had laid the parent out (the kit
649
+ // lab's L47, 2026-09-08).
650
+ syncSlabs();
624
651
  };
625
652
  // -- placeholder / ghost chrome --------------------------------------------
626
653
  /** The placeholder exists ONLY while a gesture is live — so at any moment
@@ -1145,6 +1172,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1145
1172
  el.classList.toggle('axdb-slab--selected', selectedId === id);
1146
1173
  el.classList.toggle('axdb-slab--static', isStatic);
1147
1174
  (_b = el.querySelector(':scope > .axdb-rs')) === null || _b === void 0 ? void 0 : _b.classList.toggle('axdb-rs--rtl', rtl);
1175
+ syncCaption(el, id, grp, sz.height);
1148
1176
  }
1149
1177
  for (const [id, el] of slabEls) {
1150
1178
  if (!seen.has(id)) {
@@ -1153,6 +1181,38 @@ export function bindDashboardGrid(api, group, options = {}) {
1153
1181
  }
1154
1182
  }
1155
1183
  };
1184
+ /**
1185
+ * THE CAPTION BAND of a section, on its slab overlay. Painted from the
1186
+ * group's persisted caption; repainted only when its identity changes (the
1187
+ * options, RTL, static, the tier) so a custom `renderCaption` is not run
1188
+ * per frame. The band takes the pointer (the slab itself does not): a press
1189
+ * on it selects the section, an action fires, pass-through reaches content.
1190
+ */
1191
+ const syncCaption = (el, id, grp, sectionH) => {
1192
+ const cap = captionOfGroup(grp);
1193
+ let band = el.querySelector(':scope > .axdb-slab-h');
1194
+ if (!cap || !captionPainted(cap, isStatic)) {
1195
+ band === null || band === void 0 ? void 0 : band.remove();
1196
+ el.removeAttribute('aria-label');
1197
+ return;
1198
+ }
1199
+ const ctx = { rtl, static: isStatic, sectionH };
1200
+ const key = captionKey(cap, ctx);
1201
+ if (band && band.getAttribute('data-key') === key) {
1202
+ sizeCaptionBand(band, cap, sectionH); // the tier follows the live size
1203
+ return;
1204
+ }
1205
+ band === null || band === void 0 ? void 0 : band.remove();
1206
+ band = document.createElement('div');
1207
+ el.prepend(band);
1208
+ const render = options.renderCaption;
1209
+ paintCaptionBand(band, cap, Object.assign(Object.assign(Object.assign({}, ctx), (render ? { render: (host) => render(id, host) } : {})), { onAction: (actionId) => { var _a; return (_a = options.onCaptionAction) === null || _a === void 0 ? void 0 : _a.call(options, id, actionId); } }));
1210
+ band.setAttribute('data-key', key);
1211
+ if (cap.text)
1212
+ el.setAttribute('aria-label', cap.text);
1213
+ else
1214
+ el.removeAttribute('aria-label');
1215
+ };
1156
1216
  const insideMemberGroupFrame = (x, y) => {
1157
1217
  var _a;
1158
1218
  for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
@@ -2135,15 +2195,22 @@ export function bindDashboardGrid(api, group, options = {}) {
2135
2195
  id: `dashboard-grid:${group.id}:${++binderSeq}`,
2136
2196
  priority: 2, // point-specific claim — outranks mode-style tools (see ext/tools.ts)
2137
2197
  hitTest(ev, hit) {
2138
- var _a, _b, _c, _d;
2198
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
2139
2199
  if (disposed)
2140
2200
  return false;
2141
2201
  if (gesture || slabGesture || forwardSlab)
2142
2202
  return true; // own the rest of an in-flight gesture
2143
2203
  if (!ownsPress(api.container, diagram, ev, hit))
2144
2204
  return false;
2205
+ // A press on one of OUR sections' caption bands is ours by the DOM: a
2206
+ // 'tab' band sits above the frame, over the gap or the tile above,
2207
+ // where the geometry says otherwise.
2208
+ const bandTarget = (_c = (_b = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target) === null || _b === void 0 ? void 0 : _b.closest) === null || _c === void 0 ? void 0 : _c.call(_b, '.axdb-slab > .axdb-slab-h');
2209
+ const bandId = (_d = bandTarget === null || bandTarget === void 0 ? void 0 : bandTarget.parentElement) === null || _d === void 0 ? void 0 : _d.getAttribute('data-slab-id');
2210
+ if (bandId && ((_e = group.members) !== null && _e !== void 0 ? _e : new Set()).has(bandId))
2211
+ return true;
2145
2212
  if (hit.node) {
2146
- if (((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(hit.node.id))
2213
+ if (((_f = group.members) !== null && _f !== void 0 ? _f : new Set()).has(hit.node.id))
2147
2214
  return true;
2148
2215
  // A press on a tile that belongs to a NESTED board must reach that
2149
2216
  // board's tool. The dead-zone claim below deadens the slab's EMPTY
@@ -2152,7 +2219,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2152
2219
  // parent's tool won the registration-order tie and the child's resize
2153
2220
  // never armed; grid-options binds parent-first and worked by
2154
2221
  // accident).
2155
- for (const p of (_b = BOARD_REGISTRY.get(api.container)) !== null && _b !== void 0 ? _b : []) {
2222
+ for (const p of (_g = BOARD_REGISTRY.get(api.container)) !== null && _g !== void 0 ? _g : []) {
2156
2223
  if (p !== selfPeer && p.hasItem(hit.node.id))
2157
2224
  return false;
2158
2225
  }
@@ -2165,8 +2232,8 @@ export function bindDashboardGrid(api, group, options = {}) {
2165
2232
  // divider press in a split section (dead dividers) and, near the
2166
2233
  // section's edge, turned it into a section resize (the width changed
2167
2234
  // while a control's height was being dragged — Quantia, Groups page).
2168
- for (const p of (_c = BOARD_REGISTRY.get(api.container)) !== null && _c !== void 0 ? _c : []) {
2169
- if (p !== selfPeer && ((_d = group.members) !== null && _d !== void 0 ? _d : new Set()).has(p.group.id) && p.containsWorld(ev.world.x, ev.world.y))
2235
+ for (const p of (_h = BOARD_REGISTRY.get(api.container)) !== null && _h !== void 0 ? _h : []) {
2236
+ if (p !== selfPeer && ((_j = group.members) !== null && _j !== void 0 ? _j : new Set()).has(p.group.id) && p.containsWorld(ev.world.x, ev.world.y))
2170
2237
  return false;
2171
2238
  }
2172
2239
  // Claim (and deaden) empty presses inside a member group's frame so the
@@ -2178,7 +2245,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2178
2245
  return insideMemberGroupFrame(ev.world.x, ev.world.y) || worldInsideBoard(ev.world.x, ev.world.y);
2179
2246
  },
2180
2247
  onPointerDown(ev, hit) {
2181
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
2248
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y;
2182
2249
  if (gesture)
2183
2250
  return; // mid-palette
2184
2251
  const target = ((_b = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target) !== null && _b !== void 0 ? _b : null);
@@ -2191,12 +2258,21 @@ export function bindDashboardGrid(api, group, options = {}) {
2191
2258
  // A SECTION's corner handle sits on top of whatever tile shares that
2192
2259
  // corner; the DOM target names the section, the hit test the tile.
2193
2260
  const sectionHandle = (_e = target === null || target === void 0 ? void 0 : target.closest) === null || _e === void 0 ? void 0 : _e.call(target, '.axdb-slab > .axdb-rs');
2194
- if ((!hit.node && !onGrip) || sectionHandle) {
2195
- // A press on a SECTION its empty band, its corner handle or its
2196
- // frame edge selects the section; the handle or an edge resizes it.
2197
- const slabHandle = (_f = target === null || target === void 0 ? void 0 : target.closest) === null || _f === void 0 ? void 0 : _f.call(target, '.axdb-slab > .axdb-rs');
2198
- const slabId = (_h = (_g = slabHandle === null || slabHandle === void 0 ? void 0 : slabHandle.parentElement) === null || _g === void 0 ? void 0 : _g.getAttribute('data-slab-id')) !== null && _h !== void 0 ? _h : memberGroupAt(ev.world.x, ev.world.y);
2199
- const grp = slabId && ((_j = group.members) !== null && _j !== void 0 ? _j : new Set()).has(slabId) ? diagram.getGroup(slabId) : undefined;
2261
+ // A SECTION's CAPTION BAND names its section the same way — a 'tab'
2262
+ // band sits above the frame, over the gap or the tile above. An action
2263
+ // button in it fires and does nothing else.
2264
+ const captionBand = (_f = target === null || target === void 0 ? void 0 : target.closest) === null || _f === void 0 ? void 0 : _f.call(target, '.axdb-slab > .axdb-slab-h');
2265
+ const captionId = (_h = (_g = captionBand === null || captionBand === void 0 ? void 0 : captionBand.parentElement) === null || _g === void 0 ? void 0 : _g.getAttribute('data-slab-id')) !== null && _h !== void 0 ? _h : null;
2266
+ const ownCaption = !!captionId && ((_j = group.members) !== null && _j !== void 0 ? _j : new Set()).has(captionId);
2267
+ // (An action button never reaches here: it is pass-through, and its own
2268
+ // `click` fires onCaptionAction — see captionPassThrough.)
2269
+ if ((!hit.node && !onGrip) || sectionHandle || ownCaption) {
2270
+ // A press on a SECTION — its empty band, its caption, its corner
2271
+ // handle or its frame edge — selects the section; the handle or an
2272
+ // edge resizes it.
2273
+ const slabHandle = (_k = target === null || target === void 0 ? void 0 : target.closest) === null || _k === void 0 ? void 0 : _k.call(target, '.axdb-slab > .axdb-rs');
2274
+ const slabId = (_o = (_m = (_l = slabHandle === null || slabHandle === void 0 ? void 0 : slabHandle.parentElement) === null || _l === void 0 ? void 0 : _l.getAttribute('data-slab-id')) !== null && _m !== void 0 ? _m : (ownCaption ? captionId : null)) !== null && _o !== void 0 ? _o : memberGroupAt(ev.world.x, ev.world.y);
2275
+ const grp = slabId && ((_p = group.members) !== null && _p !== void 0 ? _p : new Set()).has(slabId) ? diagram.getGroup(slabId) : undefined;
2200
2276
  if (slabId && grp) {
2201
2277
  selectWidget(slabId);
2202
2278
  api.render();
@@ -2215,7 +2291,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2215
2291
  // forwards the pointer sequence.
2216
2292
  const parent = parentPeer();
2217
2293
  if ((parent === null || parent === void 0 ? void 0 : parent.selectMember) && worldInsideBoard(ev.world.x, ev.world.y)) {
2218
- const ownHandle = ((_k = slabHandle === null || slabHandle === void 0 ? void 0 : slabHandle.parentElement) === null || _k === void 0 ? void 0 : _k.getAttribute('data-slab-id')) === group.id;
2294
+ const ownHandle = ((_q = slabHandle === null || slabHandle === void 0 ? void 0 : slabHandle.parentElement) === null || _q === void 0 ? void 0 : _q.getAttribute('data-slab-id')) === group.id;
2219
2295
  parent.selectMember(group.id);
2220
2296
  if (!isStatic && parent.beginSlabResize) {
2221
2297
  const edges = ownHandle
@@ -2228,7 +2304,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2228
2304
  }
2229
2305
  // The board's own empty area: a void click. Nothing to drag, and the
2230
2306
  // selection clears exactly as a click outside any board would.
2231
- (_m = (_l = diagram).clearSelection) === null || _m === void 0 ? void 0 : _m.call(_l);
2307
+ (_s = (_r = diagram).clearSelection) === null || _s === void 0 ? void 0 : _s.call(_r);
2232
2308
  selectWidget(undefined);
2233
2309
  api.render();
2234
2310
  return;
@@ -2237,17 +2313,17 @@ export function bindDashboardGrid(api, group, options = {}) {
2237
2313
  if (!node)
2238
2314
  return;
2239
2315
  selectWidget(node.id); // a press selects, whether or not it starts a gesture
2240
- if (((_o = node.state) === null || _o === void 0 ? void 0 : _o.locked) === true)
2316
+ if (((_t = node.state) === null || _t === void 0 ? void 0 : _t.locked) === true)
2241
2317
  return; // pinned: refuse; click still focuses
2242
2318
  if (isStatic)
2243
2319
  return; // a static board: claimed and deadened, click still focuses
2244
2320
  // Which edges did the press take? The corner handle names its own (s+e,
2245
2321
  // or s+w on RTL); a bare press within EDGE_GRIP of the tile's border
2246
2322
  // takes that border; anywhere else is a move. A grip press is a move.
2247
- const onHandle = !!((_p = target === null || target === void 0 ? void 0 : target.closest) === null || _p === void 0 ? void 0 : _p.call(target, '.axdb-rs'));
2323
+ const onHandle = !!((_u = target === null || target === void 0 ? void 0 : target.closest) === null || _u === void 0 ? void 0 : _u.call(target, '.axdb-rs'));
2248
2324
  const hostEl = hostOf(node.id);
2249
- const resizable = ((_q = node.getMetadata) === null || _q === void 0 ? void 0 : _q.call(node, 'widgetResizable')) !== false;
2250
- const movable = ((_r = node.getMetadata) === null || _r === void 0 ? void 0 : _r.call(node, 'widgetMovable')) !== false;
2325
+ const resizable = ((_v = node.getMetadata) === null || _v === void 0 ? void 0 : _v.call(node, 'widgetResizable')) !== false;
2326
+ const movable = ((_w = node.getMetadata) === null || _w === void 0 ? void 0 : _w.call(node, 'widgetMovable')) !== false;
2251
2327
  // `ev.screen` is ELEMENT-LOCAL px; host rects are in client px. Compare
2252
2328
  // like with like — the source event's clientX/Y when there is one, else
2253
2329
  // the container's origin plus the local offset.
@@ -2295,7 +2371,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2295
2371
  startSize: { width: node.size.width, height: node.size.height },
2296
2372
  startPos: { x: node.position.x, y: node.position.y },
2297
2373
  edges,
2298
- spans: { w: (_s = it === null || it === void 0 ? void 0 : it.w) !== null && _s !== void 0 ? _s : 1, h: (_t = it === null || it === void 0 ? void 0 : it.h) !== null && _t !== void 0 ? _t : 1 },
2374
+ spans: { w: (_x = it === null || it === void 0 ? void 0 : it.w) !== null && _x !== void 0 ? _x : 1, h: (_y = it === null || it === void 0 ? void 0 : it.h) !== null && _y !== void 0 ? _y : 1 },
2299
2375
  removedFromBoard: false,
2300
2376
  leg: null,
2301
2377
  lastWorld: null,
@@ -2832,6 +2908,9 @@ export function bindDashboardGrid(api, group, options = {}) {
2832
2908
  isStatic = on;
2833
2909
  if (gesture)
2834
2910
  cancelActiveGesture(false);
2911
+ // A `show: 'design'` caption leaves under static and its reserve with
2912
+ // it (or comes back): the frame moved, re-project the tiles.
2913
+ project();
2835
2914
  syncHandles();
2836
2915
  api.renderNow();
2837
2916
  },
@@ -3,5 +3,6 @@ export { bindDashboardSplit, SetSplitTreeCommand, SPLIT_TREE_KEY, type Dashboard
3
3
  export { projectSplit, dividersOf, addSplitLeaf, removeSplitLeaf, insertSplitLeaf, moveSplitDivider, splitFromCells, cellsFromSplit, splitLeaves, type SplitNode, type SplitGroup, type SplitLeaf, type SplitDir, type SplitSide, type SplitDivider, } from './split-layout.js';
4
4
  export { rowHeightFor, boardHeightFor, columnUnitFor, cellToRect, pointToCell, sizeToSpan, spanWidthPx, gridItemFromCell, cellFromGridItem, buildCommitCommands, type CellRect, type WorldRect, type DashboardGridGeometry, type TileDelta, } from './grid-mapping.js';
5
5
  export { ensureDashboardKitStyles, DASHBOARD_KIT_STYLE_ID } from './styles.js';
6
+ export { normalizeCaption, captionReserve, captionBandHeight, paintCaptionBand, CAPTION_HEIGHT, CAPTION_HEIGHT_SUBTITLE, CAPTION_HEIGHT_TIGHT, CAPTION_PASS_THROUGH, type SectionCaption, type SectionCaptionOptions, type SectionCaptionAction, type SectionCaptionFont, } from './caption.js';
6
7
  export { dashboard, type DashboardOptions, type DashboardSpec, type DashboardSnapshot, type DashboardHandle, type DashboardViewSpec, type DashboardWidgetSpec, type WidgetHandle, } from './dashboard.js';
7
8
  export { defaultWidgetRenderer, renderKpiWidget, renderLineWidget, renderBarWidget, renderDonutWidget, renderFunnelWidget, renderTableWidget, BUILT_IN_WIDGET_KINDS, type WidgetRenderer, type KpiWidgetData, type LineWidgetData, type LineSeries, type BarWidgetData, type DonutWidgetData, type FunnelWidgetData, type TableWidgetData, } from './widgets.js';
@@ -3,6 +3,7 @@ export { bindDashboardSplit, SetSplitTreeCommand, SPLIT_TREE_KEY, } from './spli
3
3
  export { projectSplit, dividersOf, addSplitLeaf, removeSplitLeaf, insertSplitLeaf, moveSplitDivider, splitFromCells, cellsFromSplit, splitLeaves, } from './split-layout.js';
4
4
  export { rowHeightFor, boardHeightFor, columnUnitFor, cellToRect, pointToCell, sizeToSpan, spanWidthPx, gridItemFromCell, cellFromGridItem, buildCommitCommands, } from './grid-mapping.js';
5
5
  export { ensureDashboardKitStyles, DASHBOARD_KIT_STYLE_ID } from './styles.js';
6
+ export { normalizeCaption, captionReserve, captionBandHeight, paintCaptionBand, CAPTION_HEIGHT, CAPTION_HEIGHT_SUBTITLE, CAPTION_HEIGHT_TIGHT, CAPTION_PASS_THROUGH, } from './caption.js';
6
7
  // The DATA-FIRST authoring API (the erDiagram/umlDiagram equivalent).
7
8
  export { dashboard, } from './dashboard.js';
8
9
  // The built-in renderers behind `kind` — dashboard()'s default renderWidget.
@@ -29,6 +29,7 @@ import { anyEdge, clearOtherSelections, dragHandleSelector, gripHostOf, gripOf,
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';
32
+ import { captionOfGroup, captionReserve } from './caption.js';
32
33
  /** Group metadata key the tree persists under. */
33
34
  export const SPLIT_TREE_KEY = 'dashboardTree';
34
35
  /**
@@ -104,14 +105,17 @@ export function bindDashboardSplit(api, group, options = {}) {
104
105
  let focusedId;
105
106
  const live = liveRegionFor(api.container);
106
107
  // -- geometry ---------------------------------------------------------------
108
+ /** Our own caption band's pixels, given up at the top of the pane (see grid-binder). */
109
+ const ownReserve = () => { var _a, _b; return captionReserve(captionOfGroup(group), { static: isStatic, sectionH: (_b = (_a = group.size) === null || _a === void 0 ? void 0 : _a.height) !== null && _b !== void 0 ? _b : 0 }); };
107
110
  const frame = () => {
108
111
  var _a, _b, _c, _d;
109
- return ({
112
+ const r = ownReserve();
113
+ return {
110
114
  x: group.position.x,
111
- y: group.position.y,
115
+ y: group.position.y + r,
112
116
  width: (_b = (_a = group.size) === null || _a === void 0 ? void 0 : _a.width) !== null && _b !== void 0 ? _b : designW,
113
- height: (_d = (_c = group.size) === null || _c === void 0 ? void 0 : _c.height) !== null && _d !== void 0 ? _d : designH,
114
- });
117
+ height: Math.max(0, ((_d = (_c = group.size) === null || _c === void 0 ? void 0 : _c.height) !== null && _d !== void 0 ? _d : designH) - r),
118
+ };
115
119
  };
116
120
  const containerBox = () => ({
117
121
  w: api.container.clientWidth || 0,
@@ -365,6 +365,61 @@ const CSS = `
365
365
  .grafloria-html-layer > .axdb-slab.axdb-slab--selected > .axdb-rs { pointer-events: auto; opacity: 1; }
366
366
  .grafloria-html-layer > .axdb-slab.axdb-slab--static > .axdb-rs { display: none; }
367
367
 
368
+ /* SECTION CAPTION (0.4.22): the band on the slab. Geometry is inline (the
369
+ reserve and the pixels come from one function); everything visual is a
370
+ variable the options write and a theme may override. The band takes the
371
+ pointer — the slab does not — so a press on it is the section's. */
372
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h {
373
+ position: absolute; box-sizing: border-box; pointer-events: auto; z-index: 1;
374
+ display: flex; align-items: center; gap: 6px; min-width: 0;
375
+ padding: var(--axdb-caption-pad, 0 10px);
376
+ background: var(--axdb-caption-bg, rgba(31, 36, 48, .045));
377
+ border-bottom: var(--axdb-caption-border, none);
378
+ color: var(--axdb-caption-fg, #1f2430);
379
+ font: var(--axdb-caption-font-weight, 600) var(--axdb-caption-font-size, 13px)/1.2 var(--axdb-caption-font-family, system-ui, -apple-system, "Segoe UI", sans-serif);
380
+ text-transform: var(--axdb-caption-transform, none);
381
+ letter-spacing: .01em;
382
+ border-radius: var(--axdb-rs-radius, 3px) var(--axdb-rs-radius, 3px) 0 0;
383
+ cursor: default; user-select: none; -webkit-user-select: none; overflow: hidden;
384
+ transition: opacity .12s;
385
+ }
386
+ /* The modifiers carry the band's own selector so they outrank its defaults. */
387
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--center { justify-content: center; }
388
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--end { justify-content: flex-end; }
389
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--vtop { align-items: flex-start; }
390
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--vbottom { align-items: flex-end; }
391
+ /* 'tab': above the frame, sized to its text */
392
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--tab { right: auto; max-width: 100%; }
393
+ /* 'hover': an overlay that appears with the pointer or the selection */
394
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--hover { opacity: 0; }
395
+ .grafloria-html-layer > .axdb-slab:hover > .axdb-slab-h.axdb-slab-h--hover,
396
+ .grafloria-html-layer > .axdb-slab.axdb-slab--selected > .axdb-slab-h.axdb-slab-h--hover,
397
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--hover:focus-within { opacity: 1; }
398
+ /* the tight tier: a section under 90 px */
399
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--tight { font-size: min(var(--axdb-caption-font-size, 12px), 12px); gap: 4px; }
400
+ .axdb-slab-h--tight .axdb-slab-h-sub, .axdb-slab-h--tight .axdb-slab-h-actions { display: none; }
401
+ .axdb-slab-h-icon { flex: none; }
402
+ .axdb-slab-h-body { display: flex; flex-direction: column; justify-content: center; min-width: 0; flex: 0 1 auto; }
403
+ .axdb-slab-h-text, .axdb-slab-h-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
404
+ .axdb-slab-h-sub { font-size: .85em; font-weight: 500; opacity: .72; }
405
+ .axdb-slab-h-info { flex: none; opacity: .6; font-size: .9em; cursor: help; }
406
+ .axdb-slab-h-actions { flex: none; display: flex; gap: 2px; margin-inline-start: auto; opacity: 0; transition: opacity .12s; }
407
+ .axdb-slab-h--center .axdb-slab-h-actions, .axdb-slab-h--end .axdb-slab-h-actions { margin-inline-start: 0; }
408
+ .axdb-slab-h:hover > .axdb-slab-h-actions, .axdb-slab--selected > .axdb-slab-h > .axdb-slab-h-actions, .axdb-slab-h:focus-within > .axdb-slab-h-actions { opacity: 1; }
409
+ .axdb-slab-h-action {
410
+ all: unset; box-sizing: border-box; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center;
411
+ border-radius: 4px; cursor: pointer; font-size: 14px; line-height: 1; color: inherit;
412
+ }
413
+ .axdb-slab-h-action:hover { background: rgba(31, 36, 48, .08); }
414
+ .axdb-slab-h-action:focus-visible { outline: 2px solid var(--axdb-accent-ring, rgba(59, 82, 217, .55)); outline-offset: -2px; }
415
+ .axdb-slab-h-action[disabled] { opacity: .4; cursor: default; }
416
+ .axdb-slab-h-action[disabled]:hover { background: none; }
417
+ .grafloria-html-layer > .axdb-slab.axdb-slab--static > .axdb-slab-h { cursor: default; }
418
+ @media (prefers-color-scheme: dark) {
419
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h { background: var(--axdb-caption-bg, rgba(236, 238, 244, .06)); color: var(--axdb-caption-fg, #eceef4); }
420
+ .axdb-slab-h-action:hover { background: rgba(236, 238, 244, .1); }
421
+ }
422
+
368
423
  /* legend chips, shared by line and donut */
369
424
  .axdb-lg { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 9px; }
370
425
  .axdb-lg--col { flex-direction: column; flex-wrap: nowrap; gap: 6px; margin-top: 0; }