@grafloria/element 0.4.21 → 0.4.23

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.23",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
package/src/index.d.ts CHANGED
@@ -79,8 +79,8 @@ export type { JoinColumn, JoinEnd, MatchTier, JoinGuidanceApi, JoinGuidanceOptio
79
79
  export type { ErColumn, ErEntitySpec, ErRelationshipSpec, ErCardinality, ErSide, ErDiagramOptions, UmlClassSpec, UmlRelationshipSpec, UmlRelationKind, UmlSide, UmlDiagramOptions, ErEntityDelta, UmlClassDelta, CardEditingHandle, } from './lib/diagram-kit/index.js';
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
- 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';
82
+ export { bindDashboardGrid, normalizeCaption, captionReserve, captionBandHeight, paintCaptionBand, CAPTION_HEIGHT, CAPTION_HEIGHT_SUBTITLE, CAPTION_HEIGHT_TIGHT, CAPTION_PASS_THROUGH, 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, 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';
package/src/index.js CHANGED
@@ -230,7 +230,7 @@ export { erTable, umlClass, erTables, umlClasses, CardHandle, ErTable, ErField,
230
230
  // GridItemConfig. Element-local code, so the Phase-0 wholesale re-export
231
231
  // below does NOT cover it — this curated block is its only door.
232
232
  // ===========================================================================
233
- export { bindDashboardGrid, rowHeightFor, boardHeightFor, columnUnitFor, cellToRect, pointToCell, sizeToSpan, gridItemFromCell, cellFromGridItem, buildCommitCommands, ensureDashboardKitStyles, DASHBOARD_KIT_STYLE_ID,
233
+ export { bindDashboardGrid, normalizeCaption, captionReserve, captionBandHeight, paintCaptionBand, CAPTION_HEIGHT, CAPTION_HEIGHT_SUBTITLE, CAPTION_HEIGHT_TIGHT, CAPTION_PASS_THROUGH, rowHeightFor, boardHeightFor, columnUnitFor, cellToRect, pointToCell, sizeToSpan, gridItemFromCell, cellFromGridItem, buildCommitCommands, ensureDashboardKitStyles, DASHBOARD_KIT_STYLE_ID,
234
234
  // The DATA-FIRST authoring API — the erDiagram()/umlDiagram() equivalent.
235
235
  dashboard,
236
236
  // …and the built-in renderers behind `kind`, so a dashboard is useful with
@@ -0,0 +1,144 @@
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
+ /**
44
+ * 'inside' (default): a band across the top of the frame. 'tab': the same
45
+ * band sized to its text, a label chip at the leading corner. BOTH reserve
46
+ * their height inside the SECTION's own cell — a header that hangs over the
47
+ * neighbour above is the overlap bug this feature exists to end.
48
+ */
49
+ position?: 'inside' | 'tab';
50
+ /** Horizontal alignment (default 'start', mirrored on RTL). */
51
+ align?: 'start' | 'center' | 'end';
52
+ /** Vertical alignment within the band (default 'center'). */
53
+ valign?: 'top' | 'center' | 'bottom';
54
+ /** Band height, px (default 28; 44 with a subtitle). */
55
+ height?: number;
56
+ font?: SectionCaptionFont;
57
+ /** Inside the band: px, or [vertical, horizontal] (default [0, 10]). */
58
+ padding?: number | [number, number];
59
+ /** Between the band and the frame: px, or [vertical, horizontal] (default 0). Adds to the reserve. */
60
+ margin?: number | [number, number];
61
+ /** Band fill (default: the theme's). */
62
+ background?: string;
63
+ /** The band's bottom rule, a CSS border value (default none). */
64
+ border?: string;
65
+ /** 'always' (default); 'design' is not painted (nor reserved) under static; 'hover' overlays the content, nothing reserved. */
66
+ show?: 'always' | 'design' | 'hover';
67
+ /** Buttons at the end of the band; a press fires `onCaptionAction` and never selects. */
68
+ actions?: SectionCaptionAction[];
69
+ /** Presses inside descendants matching this selector are content, never the band's. */
70
+ passThrough?: string;
71
+ /** Your class on the band. */
72
+ className?: string;
73
+ }
74
+ /** `false`/absent: no band. `true`: the title. A string: that text. */
75
+ export type SectionCaption = false | true | string | SectionCaptionOptions;
76
+ export declare const CAPTION_HEIGHT = 28;
77
+ export declare const CAPTION_HEIGHT_SUBTITLE = 44;
78
+ export declare const CAPTION_HEIGHT_TIGHT = 22;
79
+ /** A section shorter than this steps its band down to the tight tier. */
80
+ export declare const CAPTION_TIGHT_BELOW = 90;
81
+ /** A band never paints thinner than this. */
82
+ export declare const CAPTION_MIN_HEIGHT = 16;
83
+ /** …nor takes the last pixels: the children keep at least this much. */
84
+ export declare const CAPTION_MIN_CONTENT = 20;
85
+ export declare const CAPTION_PASS_THROUGH = "button, a, input, select, textarea, [data-axdb-pass]";
86
+ /** The caption as options, or null when there is none. `title` fills the text. */
87
+ export declare function normalizeCaption(c: SectionCaption | undefined, title?: string): SectionCaptionOptions | null;
88
+ /** The caption a section GROUP carries, from its persisted `containerWidget` metadata. */
89
+ export declare function captionOfGroup(grp: {
90
+ getMetadata(key: string): unknown;
91
+ name?: string;
92
+ } | undefined): SectionCaptionOptions | null;
93
+ export declare function pairOf(v: number | [number, number] | undefined, dflt: [number, number]): [number, number];
94
+ /** Painted at all? `show: 'design'` disappears under static. */
95
+ export declare function captionPainted(c: SectionCaptionOptions | null, isStatic: boolean): boolean;
96
+ /**
97
+ * The band's height for a section of `sectionH` px: the authored height (or
98
+ * the tier), CLAMPED so the children always keep `CAPTION_MIN_CONTENT` px. A
99
+ * one-row section used to hand its whole 34 px to a 22 px band and paint a
100
+ * 12 px sliver of a child.
101
+ */
102
+ export declare function captionBandHeight(c: SectionCaptionOptions, sectionH: number): number;
103
+ /**
104
+ * Pixels the nested board's frame gives up at the top: the band plus its
105
+ * vertical margins. A TAB reserves too — it is a narrower band, not a header
106
+ * hanging over whatever the parent board put above the section (the stress
107
+ * page had one lying across a chart's legend, and one clipped off the top of
108
+ * the canvas). Only `show: 'hover'` reserves nothing: it is an overlay by
109
+ * definition, and it paints opaque so the content beneath stays readable.
110
+ */
111
+ export declare function captionReserve(c: SectionCaptionOptions | null, ctx: {
112
+ static: boolean;
113
+ sectionH: number;
114
+ }): number;
115
+ /** Identity of a painted band: repaint only when this changes (the tier is a class toggle, not a repaint). */
116
+ export declare function captionKey(c: SectionCaptionOptions, ctx: {
117
+ rtl: boolean;
118
+ static: boolean;
119
+ }): string;
120
+ /** Is a section of `sectionH` px in the tight tier? */
121
+ export declare const captionTight: (sectionH: number) => boolean;
122
+ /** The per-sync geometry of a painted band: height and tier follow the section's live size. */
123
+ export declare function sizeCaptionBand(band: HTMLElement, c: SectionCaptionOptions, sectionH: number): void;
124
+ /**
125
+ * Is a press on `target`, inside `band`, content rather than the band? An
126
+ * action button is always content: no tool claims it, so the browser's own
127
+ * `click` reaches it — from a mouse, a touch, or Enter/Space on the keyboard
128
+ * (a pointerdown-only action never fired from the keyboard, and the
129
+ * interaction gate's DEAD-BUTTON check, which clicks, called it dead).
130
+ */
131
+ export declare function captionPassThrough(target: Element | null, band: Element, c: SectionCaptionOptions | null): boolean;
132
+ /**
133
+ * Paint the band: geometry (inline, so the reserve and the pixels agree),
134
+ * classes for alignment and mode, CSS variables for typography and box, and
135
+ * the default content — icon, text, subtitle, ⓘ, actions. With `render` the
136
+ * content is yours: the band is handed over empty and keeps its press rules.
137
+ */
138
+ export declare function paintCaptionBand(band: HTMLElement, c: SectionCaptionOptions, ctx: {
139
+ rtl: boolean;
140
+ static: boolean;
141
+ sectionH: number;
142
+ render?: (host: HTMLElement) => void;
143
+ onAction?: (actionId: string) => void;
144
+ }): void;
@@ -0,0 +1,221 @@
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
+ /** A band never paints thinner than this. */
23
+ export const CAPTION_MIN_HEIGHT = 16;
24
+ /** …nor takes the last pixels: the children keep at least this much. */
25
+ export const CAPTION_MIN_CONTENT = 20;
26
+ export const CAPTION_PASS_THROUGH = 'button, a, input, select, textarea, [data-axdb-pass]';
27
+ /** The caption as options, or null when there is none. `title` fills the text. */
28
+ export function normalizeCaption(c, title) {
29
+ if (c === undefined || c === false)
30
+ return null;
31
+ const o = c === true ? {} : typeof c === 'string' ? { text: c } : Object.assign({}, c);
32
+ if (o.text === undefined && title !== undefined)
33
+ o.text = title;
34
+ return o;
35
+ }
36
+ /** The caption a section GROUP carries, from its persisted `containerWidget` metadata. */
37
+ export function captionOfGroup(grp) {
38
+ var _a;
39
+ if (!grp)
40
+ return null;
41
+ const meta = grp.getMetadata('containerWidget');
42
+ if (!meta || meta.caption === undefined)
43
+ return null;
44
+ return normalizeCaption(meta.caption, (_a = meta.title) !== null && _a !== void 0 ? _a : grp.name);
45
+ }
46
+ export function pairOf(v, dflt) {
47
+ if (v === undefined)
48
+ return dflt;
49
+ return typeof v === 'number' ? [v, v] : [v[0], v[1]];
50
+ }
51
+ /** Painted at all? `show: 'design'` disappears under static. */
52
+ export function captionPainted(c, isStatic) {
53
+ if (!c)
54
+ return false;
55
+ return !(c.show === 'design' && isStatic);
56
+ }
57
+ /**
58
+ * The band's height for a section of `sectionH` px: the authored height (or
59
+ * the tier), CLAMPED so the children always keep `CAPTION_MIN_CONTENT` px. A
60
+ * one-row section used to hand its whole 34 px to a 22 px band and paint a
61
+ * 12 px sliver of a child.
62
+ */
63
+ export function captionBandHeight(c, sectionH) {
64
+ var _a;
65
+ const want = captionTight(sectionH) ? CAPTION_HEIGHT_TIGHT : (_a = c.height) !== null && _a !== void 0 ? _a : (c.subtitle ? CAPTION_HEIGHT_SUBTITLE : CAPTION_HEIGHT);
66
+ if (sectionH <= 0)
67
+ return want;
68
+ return Math.max(CAPTION_MIN_HEIGHT, Math.min(want, sectionH - CAPTION_MIN_CONTENT));
69
+ }
70
+ /**
71
+ * Pixels the nested board's frame gives up at the top: the band plus its
72
+ * vertical margins. A TAB reserves too — it is a narrower band, not a header
73
+ * hanging over whatever the parent board put above the section (the stress
74
+ * page had one lying across a chart's legend, and one clipped off the top of
75
+ * the canvas). Only `show: 'hover'` reserves nothing: it is an overlay by
76
+ * definition, and it paints opaque so the content beneath stays readable.
77
+ */
78
+ export function captionReserve(c, ctx) {
79
+ if (!c || !captionPainted(c, ctx.static))
80
+ return 0;
81
+ if (c.show === 'hover')
82
+ return 0;
83
+ const [mv] = pairOf(c.margin, [0, 0]);
84
+ return captionBandHeight(c, ctx.sectionH) + 2 * mv;
85
+ }
86
+ /** Identity of a painted band: repaint only when this changes (the tier is a class toggle, not a repaint). */
87
+ export function captionKey(c, ctx) {
88
+ return JSON.stringify([c, ctx.rtl, ctx.static]);
89
+ }
90
+ /** Is a section of `sectionH` px in the tight tier? */
91
+ export const captionTight = (sectionH) => sectionH > 0 && sectionH < CAPTION_TIGHT_BELOW;
92
+ /** Where the band's box sits horizontally: a tab hugs the LEADING edge, mirrored on RTL. */
93
+ function bandSides(c, mh, rtl) {
94
+ if (c.position !== 'tab')
95
+ return { left: `${mh}px`, right: `${mh}px` };
96
+ return rtl ? { left: 'auto', right: `${mh}px` } : { left: `${mh}px`, right: 'auto' };
97
+ }
98
+ /** The per-sync geometry of a painted band: height and tier follow the section's live size. */
99
+ export function sizeCaptionBand(band, c, sectionH) {
100
+ band.classList.toggle('axdb-slab-h--tight', captionTight(sectionH));
101
+ band.style.height = `${captionBandHeight(c, sectionH)}px`;
102
+ }
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 function captionPassThrough(target, band, c) {
111
+ var _a;
112
+ if (!target || !band.contains(target))
113
+ return false;
114
+ const sel = `${(_a = c === null || c === void 0 ? void 0 : c.passThrough) !== null && _a !== void 0 ? _a : CAPTION_PASS_THROUGH}, .axdb-slab-h-action`;
115
+ const hit = target.closest(sel);
116
+ return !!hit && band.contains(hit) && hit !== band;
117
+ }
118
+ const el = (doc, cls, text) => {
119
+ const e = doc.createElement('div');
120
+ e.className = cls;
121
+ if (text !== undefined)
122
+ e.textContent = text;
123
+ return e;
124
+ };
125
+ /**
126
+ * Paint the band: geometry (inline, so the reserve and the pixels agree),
127
+ * classes for alignment and mode, CSS variables for typography and box, and
128
+ * the default content — icon, text, subtitle, ⓘ, actions. With `render` the
129
+ * content is yours: the band is handed over empty and keeps its press rules.
130
+ */
131
+ export function paintCaptionBand(band, c, ctx) {
132
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
133
+ const doc = band.ownerDocument;
134
+ const h = captionBandHeight(c, ctx.sectionH);
135
+ const [mv, mh] = pairOf(c.margin, [0, 0]);
136
+ const [pv, ph] = pairOf(c.padding, [0, 10]);
137
+ band.className = 'axdb-slab-h';
138
+ band.textContent = '';
139
+ const cls = (name, on) => band.classList.toggle(name, on);
140
+ cls('axdb-slab-h--tab', c.position === 'tab');
141
+ cls('axdb-slab-h--hover', c.show === 'hover');
142
+ cls('axdb-slab-h--tight', captionTight(ctx.sectionH));
143
+ cls('axdb-slab-h--center', c.align === 'center');
144
+ cls('axdb-slab-h--end', c.align === 'end');
145
+ cls('axdb-slab-h--vtop', c.valign === 'top');
146
+ cls('axdb-slab-h--vbottom', c.valign === 'bottom');
147
+ if (c.className)
148
+ for (const k of c.className.split(/\s+/).filter(Boolean))
149
+ band.classList.add(k);
150
+ band.setAttribute('dir', ctx.rtl ? 'rtl' : 'ltr');
151
+ band.style.height = `${h}px`;
152
+ band.style.top = `${mv}px`;
153
+ const sides = bandSides(c, mh, ctx.rtl);
154
+ band.style.left = sides.left;
155
+ band.style.right = sides.right;
156
+ const v = (name, value) => {
157
+ if (value === undefined)
158
+ band.style.removeProperty(name);
159
+ else
160
+ band.style.setProperty(name, value);
161
+ };
162
+ v('--axdb-caption-pad', `${pv}px ${ph}px`);
163
+ // Clamped to the CONFIGURED band (the tight tier caps it again in CSS), so
164
+ // the text never exceeds the band whatever size the section is painted at.
165
+ const bandH = (_a = c.height) !== null && _a !== void 0 ? _a : (c.subtitle ? CAPTION_HEIGHT_SUBTITLE : CAPTION_HEIGHT);
166
+ 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);
167
+ v('--axdb-caption-font-weight', ((_c = c.font) === null || _c === void 0 ? void 0 : _c.weight) !== undefined ? String(c.font.weight) : undefined);
168
+ v('--axdb-caption-font-family', (_d = c.font) === null || _d === void 0 ? void 0 : _d.family);
169
+ v('--axdb-caption-fg', (_e = c.font) === null || _e === void 0 ? void 0 : _e.color);
170
+ v('--axdb-caption-transform', (_f = c.font) === null || _f === void 0 ? void 0 : _f.transform);
171
+ v('--axdb-caption-bg', c.background);
172
+ v('--axdb-caption-border', c.border);
173
+ if (ctx.render) {
174
+ ctx.render(band);
175
+ return;
176
+ }
177
+ if (c.icon)
178
+ band.appendChild(el(doc, 'axdb-slab-h-icon', c.icon));
179
+ const body = el(doc, 'axdb-slab-h-body');
180
+ const text = el(doc, 'axdb-slab-h-text', (_g = c.text) !== null && _g !== void 0 ? _g : '');
181
+ text.setAttribute('title', (_h = c.text) !== null && _h !== void 0 ? _h : '');
182
+ body.appendChild(text);
183
+ if (c.subtitle)
184
+ body.appendChild(el(doc, 'axdb-slab-h-sub', c.subtitle));
185
+ band.appendChild(body);
186
+ if (c.description) {
187
+ const info = el(doc, 'axdb-slab-h-info', 'ⓘ');
188
+ info.setAttribute('title', c.description);
189
+ info.setAttribute('aria-label', c.description);
190
+ band.appendChild(info);
191
+ }
192
+ if ((_j = c.actions) === null || _j === void 0 ? void 0 : _j.length) {
193
+ const row = el(doc, 'axdb-slab-h-actions');
194
+ for (const a of c.actions) {
195
+ const b = doc.createElement('button');
196
+ b.type = 'button';
197
+ b.className = 'axdb-slab-h-action';
198
+ b.setAttribute('data-action', a.id);
199
+ b.setAttribute('aria-label', a.label);
200
+ b.setAttribute('title', (_k = a.title) !== null && _k !== void 0 ? _k : a.label);
201
+ b.textContent = (_l = a.icon) !== null && _l !== void 0 ? _l : a.label;
202
+ if (a.disabled)
203
+ b.disabled = true;
204
+ // Out of the tab order: a board keeps exactly ONE tab stop (the roving
205
+ // tabindex over its widgets — the accessibility scenario asserts it).
206
+ // The band becomes the section's tab stop, with its actions reachable
207
+ // from it, in the section-keyboard round; a click (mouse, touch, or a
208
+ // script) fires the action today.
209
+ b.tabIndex = -1;
210
+ b.addEventListener('click', (e) => {
211
+ var _a;
212
+ e.stopPropagation();
213
+ if (!b.disabled)
214
+ (_a = ctx.onAction) === null || _a === void 0 ? void 0 : _a.call(ctx, a.id);
215
+ });
216
+ row.appendChild(b);
217
+ }
218
+ band.appendChild(row);
219
+ }
220
+ }
221
+ //# 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
@@ -428,6 +436,14 @@ export interface DashboardGridHandle {
428
436
  * the feature).
429
437
  */
430
438
  interface BinderPeer {
439
+ /**
440
+ * Does this board paint SECTION CHROME (the slab overlay that carries the
441
+ * ring, the corner handle and the caption band) for its member groups? Only
442
+ * the grid binder does. A section reserves its caption's pixels ONLY when
443
+ * its parent paints one — otherwise a captioned section inside a SPLIT
444
+ * board pushed its children down by 44 px under a band nobody drew.
445
+ */
446
+ paintsCaptions?: boolean;
431
447
  group: GroupModel;
432
448
  /** True when this board's engine holds `id` as an item (member lookup). */
433
449
  hasItem(id: string): boolean;
@@ -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,27 @@ 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 = () => {
457
+ var _a, _b, _c;
458
+ return ((_a = parentPeer()) === null || _a === void 0 ? void 0 : _a.paintsCaptions) === true
459
+ ? captionReserve(captionOfGroup(group), { static: isStatic, sectionH: (_c = (_b = group.size) === null || _b === void 0 ? void 0 : _b.height) !== null && _c !== void 0 ? _c : 0 })
460
+ : 0;
461
+ };
437
462
  const frame = () => {
438
463
  var _a, _b, _c, _d;
439
- return ({
464
+ const r = ownReserve();
465
+ return {
440
466
  x: group.position.x,
441
- y: group.position.y,
467
+ y: group.position.y + r,
442
468
  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
- });
469
+ height: Math.max(0, ((_d = (_c = group.size) === null || _c === void 0 ? void 0 : _c.height) !== null && _d !== void 0 ? _d : 0) - r),
470
+ };
445
471
  };
446
472
  /** Entity size with GroupModel's optionality flattened away. */
447
473
  const sizeOf = (e) => { var _a; return (_a = e.size) !== null && _a !== void 0 ? _a : { width: 0, height: 0 }; };
@@ -621,6 +647,12 @@ export function bindDashboardGrid(api, group, options = {}) {
621
647
  writing = false;
622
648
  }
623
649
  syncPlaceholder();
650
+ // The section overlays are projected chrome like the placeholder: they
651
+ // follow every frame write, not only a rebuild. Painted at bind time only,
652
+ // a section two levels down kept the geometry of its parent's placeholder
653
+ // frame (100 × 34) after the view board had laid the parent out (the kit
654
+ // lab's L47, 2026-09-08).
655
+ syncSlabs();
624
656
  };
625
657
  // -- placeholder / ghost chrome --------------------------------------------
626
658
  /** The placeholder exists ONLY while a gesture is live — so at any moment
@@ -1145,6 +1177,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1145
1177
  el.classList.toggle('axdb-slab--selected', selectedId === id);
1146
1178
  el.classList.toggle('axdb-slab--static', isStatic);
1147
1179
  (_b = el.querySelector(':scope > .axdb-rs')) === null || _b === void 0 ? void 0 : _b.classList.toggle('axdb-rs--rtl', rtl);
1180
+ syncCaption(el, id, grp, sz.height);
1148
1181
  }
1149
1182
  for (const [id, el] of slabEls) {
1150
1183
  if (!seen.has(id)) {
@@ -1153,6 +1186,38 @@ export function bindDashboardGrid(api, group, options = {}) {
1153
1186
  }
1154
1187
  }
1155
1188
  };
1189
+ /**
1190
+ * THE CAPTION BAND of a section, on its slab overlay. Painted from the
1191
+ * group's persisted caption; repainted only when its identity changes (the
1192
+ * options, RTL, static, the tier) so a custom `renderCaption` is not run
1193
+ * per frame. The band takes the pointer (the slab itself does not): a press
1194
+ * on it selects the section, an action fires, pass-through reaches content.
1195
+ */
1196
+ const syncCaption = (el, id, grp, sectionH) => {
1197
+ const cap = captionOfGroup(grp);
1198
+ let band = el.querySelector(':scope > .axdb-slab-h');
1199
+ if (!cap || !captionPainted(cap, isStatic)) {
1200
+ band === null || band === void 0 ? void 0 : band.remove();
1201
+ el.removeAttribute('aria-label');
1202
+ return;
1203
+ }
1204
+ const ctx = { rtl, static: isStatic, sectionH };
1205
+ const key = captionKey(cap, ctx);
1206
+ if (band && band.getAttribute('data-key') === key) {
1207
+ sizeCaptionBand(band, cap, sectionH); // the tier follows the live size
1208
+ return;
1209
+ }
1210
+ band === null || band === void 0 ? void 0 : band.remove();
1211
+ band = document.createElement('div');
1212
+ el.prepend(band);
1213
+ const render = options.renderCaption;
1214
+ 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); } }));
1215
+ band.setAttribute('data-key', key);
1216
+ if (cap.text)
1217
+ el.setAttribute('aria-label', cap.text);
1218
+ else
1219
+ el.removeAttribute('aria-label');
1220
+ };
1156
1221
  const insideMemberGroupFrame = (x, y) => {
1157
1222
  var _a;
1158
1223
  for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
@@ -2126,6 +2191,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2126
2191
  containsWorldExtended: worldInsideBoardExtended,
2127
2192
  frameArea: boardArea,
2128
2193
  adopt,
2194
+ paintsCaptions: true, // the grid binder owns the slab overlays (syncSlabs)
2129
2195
  };
2130
2196
  peersOnCanvas().add(selfPeer);
2131
2197
  selfPeerRef = selfPeer;
@@ -2135,15 +2201,22 @@ export function bindDashboardGrid(api, group, options = {}) {
2135
2201
  id: `dashboard-grid:${group.id}:${++binderSeq}`,
2136
2202
  priority: 2, // point-specific claim — outranks mode-style tools (see ext/tools.ts)
2137
2203
  hitTest(ev, hit) {
2138
- var _a, _b, _c, _d;
2204
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
2139
2205
  if (disposed)
2140
2206
  return false;
2141
2207
  if (gesture || slabGesture || forwardSlab)
2142
2208
  return true; // own the rest of an in-flight gesture
2143
2209
  if (!ownsPress(api.container, diagram, ev, hit))
2144
2210
  return false;
2211
+ // A press on one of OUR sections' caption bands is ours by the DOM: a
2212
+ // 'tab' band sits above the frame, over the gap or the tile above,
2213
+ // where the geometry says otherwise.
2214
+ 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');
2215
+ const bandId = (_d = bandTarget === null || bandTarget === void 0 ? void 0 : bandTarget.parentElement) === null || _d === void 0 ? void 0 : _d.getAttribute('data-slab-id');
2216
+ if (bandId && ((_e = group.members) !== null && _e !== void 0 ? _e : new Set()).has(bandId))
2217
+ return true;
2145
2218
  if (hit.node) {
2146
- if (((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(hit.node.id))
2219
+ if (((_f = group.members) !== null && _f !== void 0 ? _f : new Set()).has(hit.node.id))
2147
2220
  return true;
2148
2221
  // A press on a tile that belongs to a NESTED board must reach that
2149
2222
  // board's tool. The dead-zone claim below deadens the slab's EMPTY
@@ -2152,7 +2225,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2152
2225
  // parent's tool won the registration-order tie and the child's resize
2153
2226
  // never armed; grid-options binds parent-first and worked by
2154
2227
  // accident).
2155
- for (const p of (_b = BOARD_REGISTRY.get(api.container)) !== null && _b !== void 0 ? _b : []) {
2228
+ for (const p of (_g = BOARD_REGISTRY.get(api.container)) !== null && _g !== void 0 ? _g : []) {
2156
2229
  if (p !== selfPeer && p.hasItem(hit.node.id))
2157
2230
  return false;
2158
2231
  }
@@ -2165,8 +2238,8 @@ export function bindDashboardGrid(api, group, options = {}) {
2165
2238
  // divider press in a split section (dead dividers) and, near the
2166
2239
  // section's edge, turned it into a section resize (the width changed
2167
2240
  // 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))
2241
+ for (const p of (_h = BOARD_REGISTRY.get(api.container)) !== null && _h !== void 0 ? _h : []) {
2242
+ 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
2243
  return false;
2171
2244
  }
2172
2245
  // Claim (and deaden) empty presses inside a member group's frame so the
@@ -2178,7 +2251,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2178
2251
  return insideMemberGroupFrame(ev.world.x, ev.world.y) || worldInsideBoard(ev.world.x, ev.world.y);
2179
2252
  },
2180
2253
  onPointerDown(ev, hit) {
2181
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
2254
+ 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
2255
  if (gesture)
2183
2256
  return; // mid-palette
2184
2257
  const target = ((_b = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target) !== null && _b !== void 0 ? _b : null);
@@ -2191,12 +2264,21 @@ export function bindDashboardGrid(api, group, options = {}) {
2191
2264
  // A SECTION's corner handle sits on top of whatever tile shares that
2192
2265
  // corner; the DOM target names the section, the hit test the tile.
2193
2266
  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;
2267
+ // A SECTION's CAPTION BAND names its section the same way — a 'tab'
2268
+ // band sits above the frame, over the gap or the tile above. An action
2269
+ // button in it fires and does nothing else.
2270
+ 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');
2271
+ 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;
2272
+ const ownCaption = !!captionId && ((_j = group.members) !== null && _j !== void 0 ? _j : new Set()).has(captionId);
2273
+ // (An action button never reaches here: it is pass-through, and its own
2274
+ // `click` fires onCaptionAction — see captionPassThrough.)
2275
+ if ((!hit.node && !onGrip) || sectionHandle || ownCaption) {
2276
+ // A press on a SECTION — its empty band, its caption, its corner
2277
+ // handle or its frame edge — selects the section; the handle or an
2278
+ // edge resizes it.
2279
+ 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');
2280
+ 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);
2281
+ const grp = slabId && ((_p = group.members) !== null && _p !== void 0 ? _p : new Set()).has(slabId) ? diagram.getGroup(slabId) : undefined;
2200
2282
  if (slabId && grp) {
2201
2283
  selectWidget(slabId);
2202
2284
  api.render();
@@ -2215,7 +2297,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2215
2297
  // forwards the pointer sequence.
2216
2298
  const parent = parentPeer();
2217
2299
  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;
2300
+ 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
2301
  parent.selectMember(group.id);
2220
2302
  if (!isStatic && parent.beginSlabResize) {
2221
2303
  const edges = ownHandle
@@ -2228,7 +2310,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2228
2310
  }
2229
2311
  // The board's own empty area: a void click. Nothing to drag, and the
2230
2312
  // selection clears exactly as a click outside any board would.
2231
- (_m = (_l = diagram).clearSelection) === null || _m === void 0 ? void 0 : _m.call(_l);
2313
+ (_s = (_r = diagram).clearSelection) === null || _s === void 0 ? void 0 : _s.call(_r);
2232
2314
  selectWidget(undefined);
2233
2315
  api.render();
2234
2316
  return;
@@ -2237,17 +2319,17 @@ export function bindDashboardGrid(api, group, options = {}) {
2237
2319
  if (!node)
2238
2320
  return;
2239
2321
  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)
2322
+ if (((_t = node.state) === null || _t === void 0 ? void 0 : _t.locked) === true)
2241
2323
  return; // pinned: refuse; click still focuses
2242
2324
  if (isStatic)
2243
2325
  return; // a static board: claimed and deadened, click still focuses
2244
2326
  // Which edges did the press take? The corner handle names its own (s+e,
2245
2327
  // or s+w on RTL); a bare press within EDGE_GRIP of the tile's border
2246
2328
  // 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'));
2329
+ const onHandle = !!((_u = target === null || target === void 0 ? void 0 : target.closest) === null || _u === void 0 ? void 0 : _u.call(target, '.axdb-rs'));
2248
2330
  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;
2331
+ const resizable = ((_v = node.getMetadata) === null || _v === void 0 ? void 0 : _v.call(node, 'widgetResizable')) !== false;
2332
+ const movable = ((_w = node.getMetadata) === null || _w === void 0 ? void 0 : _w.call(node, 'widgetMovable')) !== false;
2251
2333
  // `ev.screen` is ELEMENT-LOCAL px; host rects are in client px. Compare
2252
2334
  // like with like — the source event's clientX/Y when there is one, else
2253
2335
  // the container's origin plus the local offset.
@@ -2295,7 +2377,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2295
2377
  startSize: { width: node.size.width, height: node.size.height },
2296
2378
  startPos: { x: node.position.x, y: node.position.y },
2297
2379
  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 },
2380
+ 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
2381
  removedFromBoard: false,
2300
2382
  leg: null,
2301
2383
  lastWorld: null,
@@ -2336,10 +2418,34 @@ export function bindDashboardGrid(api, group, options = {}) {
2336
2418
  * container; the corner handle keeps its own cursor from the stylesheet.
2337
2419
  */
2338
2420
  let hoverHost = null;
2421
+ /**
2422
+ * A `show: 'hover'` caption cannot ride CSS `:hover`: the slab overlay takes
2423
+ * no pointer (by design — it must never steal a press), and while the band
2424
+ * is hidden it takes none either, so nothing in the section is ever hovered
2425
+ * in CSS terms. The binder already tracks the pointer; it marks the section
2426
+ * under it instead.
2427
+ */
2428
+ const markHotSection = (clientX, clientY) => {
2429
+ if (!slabEls.size)
2430
+ return;
2431
+ for (const [id, el] of slabEls) {
2432
+ if (!el.querySelector(':scope > .axdb-slab-h--hover'))
2433
+ continue;
2434
+ const r = el.getBoundingClientRect();
2435
+ const hot = clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom;
2436
+ el.classList.toggle('axdb-slab--hot', hot);
2437
+ void id;
2438
+ }
2439
+ };
2440
+ const onHoverLeave = () => {
2441
+ for (const el of slabEls.values())
2442
+ el.classList.remove('axdb-slab--hot');
2443
+ };
2339
2444
  const onHover = (e) => {
2340
2445
  var _a, _b, _c, _d, _e, _f, _g, _h;
2341
2446
  if (disposed || gesture)
2342
2447
  return;
2448
+ markHotSection(e.clientX, e.clientY);
2343
2449
  // The event may target the host, its content, or (when a host's content
2344
2450
  // is pointer-transparent) the canvas under it — find the member host by
2345
2451
  // the pointer's position in that case.
@@ -2389,6 +2495,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2389
2495
  host.removeAttribute('data-axdb-edge');
2390
2496
  };
2391
2497
  api.container.addEventListener('pointermove', onHover, { passive: true });
2498
+ api.container.addEventListener('pointerleave', onHoverLeave, { passive: true });
2392
2499
  /**
2393
2500
  * STATIC BOARDS LET CONTENT BE CLICKED. The renderer prevents the default of
2394
2501
  * every press a tool claims, which cancels the compatibility mouse events —
@@ -2832,6 +2939,9 @@ export function bindDashboardGrid(api, group, options = {}) {
2832
2939
  isStatic = on;
2833
2940
  if (gesture)
2834
2941
  cancelActiveGesture(false);
2942
+ // A `show: 'design'` caption leaves under static and its reserve with
2943
+ // it (or comes back): the frame moved, re-project the tiles.
2944
+ project();
2835
2945
  syncHandles();
2836
2946
  api.renderNow();
2837
2947
  },
@@ -2962,6 +3072,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2962
3072
  peersOnCanvas().delete(selfPeer);
2963
3073
  unregisterTool();
2964
3074
  api.container.removeEventListener('pointermove', onHover);
3075
+ api.container.removeEventListener('pointerleave', onHoverLeave);
2965
3076
  guardedLayer === null || guardedLayer === void 0 ? void 0 : guardedLayer.removeEventListener('pointerdown', staticGuard);
2966
3077
  api.container.removeEventListener('focusin', onFocusIn);
2967
3078
  api.container.removeEventListener('keydown', onKey);
@@ -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,28 @@ export function bindDashboardSplit(api, group, options = {}) {
104
105
  let focusedId;
105
106
  const live = liveRegionFor(api.container);
106
107
  // -- geometry ---------------------------------------------------------------
108
+ /**
109
+ * Our own caption band's pixels, given up at the top of the pane — but only
110
+ * when the parent board actually paints one (see BinderPeer.paintsCaptions).
111
+ * A split board paints no section chrome, so a captioned section inside one
112
+ * reserves nothing until the tab-container round gives the split binder its
113
+ * own overlays.
114
+ */
115
+ const ownReserve = () => {
116
+ var _a, _b, _c;
117
+ return ((_a = parentPeerOf(api.container, group.id)) === null || _a === void 0 ? void 0 : _a.paintsCaptions) === true
118
+ ? captionReserve(captionOfGroup(group), { static: isStatic, sectionH: (_c = (_b = group.size) === null || _b === void 0 ? void 0 : _b.height) !== null && _c !== void 0 ? _c : 0 })
119
+ : 0;
120
+ };
107
121
  const frame = () => {
108
122
  var _a, _b, _c, _d;
109
- return ({
123
+ const r = ownReserve();
124
+ return {
110
125
  x: group.position.x,
111
- y: group.position.y,
126
+ y: group.position.y + r,
112
127
  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
- });
128
+ height: Math.max(0, ((_d = (_c = group.size) === null || _c === void 0 ? void 0 : _c.height) !== null && _d !== void 0 ? _d : designH) - r),
129
+ };
115
130
  };
116
131
  const containerBox = () => ({
117
132
  w: api.container.clientWidth || 0,
@@ -365,6 +365,83 @@ 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, .055));
377
+ /* A hairline by default: two bands meeting (a captioned section inside a
378
+ captioned section) read as two headers, not one grey block. */
379
+ border-bottom: var(--axdb-caption-border, 1px solid rgba(31, 36, 48, .1));
380
+ color: var(--axdb-caption-fg, #1f2430);
381
+ 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);
382
+ text-transform: var(--axdb-caption-transform, none);
383
+ letter-spacing: .01em;
384
+ border-radius: var(--axdb-rs-radius, 3px) var(--axdb-rs-radius, 3px) 0 0;
385
+ cursor: default; user-select: none; -webkit-user-select: none; overflow: hidden;
386
+ transition: opacity .12s;
387
+ }
388
+ /* The modifiers carry the band's own selector so they outrank its defaults. */
389
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--center { justify-content: center; }
390
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--end { justify-content: flex-end; }
391
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--vtop { align-items: flex-start; }
392
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--vbottom { align-items: flex-end; }
393
+ /* 'tab': the same band sized to its text, a chip at the leading corner. It
394
+ reserves its height like 'inside' — see captionReserve. */
395
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--tab {
396
+ max-width: 100%; border-radius: var(--axdb-rs-radius, 3px);
397
+ border-bottom: var(--axdb-caption-border-tab, none);
398
+ box-shadow: inset 0 0 0 1px rgba(31, 36, 48, .08);
399
+ }
400
+ /* 'hover': an overlay. It reserves nothing, so while hidden it must not take
401
+ the pointer (an invisible band swallowed clicks on the content beneath),
402
+ and while shown it paints OPAQUE — a 5% tint over a chart is a smear. */
403
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--hover {
404
+ opacity: 0; pointer-events: none;
405
+ background: var(--axdb-caption-bg, #fff);
406
+ box-shadow: 0 1px 4px rgba(31, 36, 48, .16);
407
+ }
408
+ /* .axdb-slab--hot is set by the binder from the live pointer (the overlay
409
+ takes no pointer of its own, so CSS :hover can never fire on it). */
410
+ .grafloria-html-layer > .axdb-slab.axdb-slab--hot > .axdb-slab-h.axdb-slab-h--hover,
411
+ .grafloria-html-layer > .axdb-slab.axdb-slab--selected > .axdb-slab-h.axdb-slab-h--hover { pointer-events: auto; opacity: 1; }
412
+ /* the actions of a hovered or selected section */
413
+ .grafloria-html-layer > .axdb-slab.axdb-slab--hot > .axdb-slab-h > .axdb-slab-h-actions { opacity: 1; }
414
+ /* the tight tier: a section under 90 px */
415
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--tight { font-size: min(var(--axdb-caption-font-size, 12px), 12px); gap: 4px; }
416
+ .axdb-slab-h--tight .axdb-slab-h-sub, .axdb-slab-h--tight .axdb-slab-h-actions { display: none; }
417
+ .axdb-slab-h-icon { flex: none; }
418
+ .axdb-slab-h-body { display: flex; flex-direction: column; justify-content: center; min-width: 0; flex: 0 1 auto; }
419
+ .axdb-slab-h-text, .axdb-slab-h-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
420
+ .axdb-slab-h-sub { font-size: .85em; font-weight: 500; opacity: .72; }
421
+ .axdb-slab-h-info { flex: none; opacity: .6; font-size: .9em; cursor: help; }
422
+ .axdb-slab-h-actions { flex: none; display: flex; gap: 2px; margin-inline-start: auto; opacity: 0; transition: opacity .12s; }
423
+ .axdb-slab-h--center .axdb-slab-h-actions, .axdb-slab-h--end .axdb-slab-h-actions { margin-inline-start: 0; }
424
+ .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; }
425
+ .axdb-slab-h-action {
426
+ all: unset; box-sizing: border-box; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center;
427
+ border-radius: 4px; cursor: pointer; font-size: 14px; line-height: 1; color: inherit;
428
+ }
429
+ .axdb-slab-h-action:hover { background: rgba(31, 36, 48, .08); }
430
+ .axdb-slab-h-action:focus-visible { outline: 2px solid var(--axdb-accent-ring, rgba(59, 82, 217, .55)); outline-offset: -2px; }
431
+ .axdb-slab-h-action[disabled] { opacity: .4; cursor: default; }
432
+ .axdb-slab-h-action[disabled]:hover { background: none; }
433
+ .grafloria-html-layer > .axdb-slab.axdb-slab--static > .axdb-slab-h { cursor: default; }
434
+ @media (prefers-color-scheme: dark) {
435
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h {
436
+ background: var(--axdb-caption-bg, rgba(236, 238, 244, .07));
437
+ color: var(--axdb-caption-fg, #eceef4);
438
+ border-bottom: var(--axdb-caption-border, 1px solid rgba(236, 238, 244, .12));
439
+ }
440
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--tab { box-shadow: inset 0 0 0 1px rgba(236, 238, 244, .12); }
441
+ .grafloria-html-layer > .axdb-slab > .axdb-slab-h.axdb-slab-h--hover { background: var(--axdb-caption-bg, #1a1d25); box-shadow: 0 1px 4px rgba(0, 0, 0, .5); }
442
+ .axdb-slab-h-action:hover { background: rgba(236, 238, 244, .1); }
443
+ }
444
+
368
445
  /* legend chips, shared by line and donut */
369
446
  .axdb-lg { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 9px; }
370
447
  .axdb-lg--col { flex-direction: column; flex-wrap: nowrap; gap: 6px; margin-top: 0; }