@grafloria/element 0.4.1 → 0.4.3

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.
@@ -28,6 +28,18 @@ const CSS = `
28
28
  width .28s cubic-bezier(.2, 0, .2, 1), height .28s cubic-bezier(.2, 0, .2, 1);
29
29
  }
30
30
 
31
+ /* Motion is a preference (WCAG 2.3.3): no glide for those who asked for none. */
32
+ @media (prefers-reduced-motion: reduce) {
33
+ .grafloria-html-layer.axdb-glide > .grafloria-node-host { transition: none; }
34
+ }
35
+
36
+ /* ===== keyboard focus: the roving tab stop shows where it is (WCAG 2.4.7) ===== */
37
+ .grafloria-html-layer > .grafloria-node-host:focus-visible {
38
+ outline: 2px solid #3b52d9;
39
+ outline-offset: 2px;
40
+ border-radius: var(--axdb-rs-radius, 6px);
41
+ }
42
+
31
43
  /* ===== the held tile: transition-exempt ghost, above everything ===== */
32
44
  .grafloria-html-layer > .grafloria-node-host.axdb-ghost,
33
45
  .grafloria-html-layer.axdb-glide > .grafloria-node-host.axdb-ghost {
@@ -43,7 +55,7 @@ const CSS = `
43
55
  /* ===== the placeholder: dashed slab, truthful, never animated ===== */
44
56
  .grafloria-html-layer > .axdb-ph {
45
57
  position: absolute;
46
- border-radius: var(--axdb-rs-radius, 12px);
58
+ border-radius: var(--axdb-rs-radius, 6px);
47
59
  background: rgba(30, 34, 45, .14);
48
60
  border: 2px dashed rgba(30, 34, 45, .28);
49
61
  box-sizing: border-box;
@@ -60,17 +72,20 @@ const CSS = `
60
72
  position: absolute;
61
73
  right: 0;
62
74
  bottom: 0;
63
- width: 18px;
64
- height: 18px;
75
+ width: 24px; /* WCAG 2.5.8: a 24-px minimum target (was 18) */
76
+ height: 24px;
65
77
  cursor: nwse-resize;
66
78
  border-right: 3px solid rgba(120, 130, 148, .55);
67
79
  border-bottom: 3px solid rgba(120, 130, 148, .55);
68
- border-bottom-right-radius: var(--axdb-rs-radius, 12px);
80
+ border-bottom-right-radius: var(--axdb-rs-radius, 6px);
69
81
  opacity: 0;
70
82
  transition: opacity .12s;
71
83
  z-index: 5;
72
84
  }
73
85
  .grafloria-node-host:hover > .axdb-rs { opacity: 1; }
86
+ /* A finger never hovers: on touch devices the corner handle is always visible
87
+ (review D8 — hover-only meant no resize at all on a tablet). */
88
+ @media (hover: none) { .grafloria-node-host > .axdb-rs { opacity: .8; } }
74
89
  .grafloria-node-host > .axdb-rs:hover { border-color: #3b52d9; }
75
90
  /* RTL boards grow leftwards, so the grab corner mirrors with them. */
76
91
  .grafloria-node-host > .axdb-rs.axdb-rs--rtl {
@@ -80,7 +95,7 @@ const CSS = `
80
95
  border-right: none;
81
96
  border-left: 3px solid rgba(120, 130, 148, .55);
82
97
  border-bottom-right-radius: 0;
83
- border-bottom-left-radius: var(--axdb-rs-radius, 12px);
98
+ border-bottom-left-radius: var(--axdb-rs-radius, 6px);
84
99
  }
85
100
 
86
101
  /* ===== palette drag-in chip (screen-space clone following the cursor) ===== */
@@ -90,7 +105,12 @@ const CSS = `
90
105
  pointer-events: none;
91
106
  opacity: .9;
92
107
  filter: drop-shadow(0 8px 14px rgba(16, 24, 40, .3));
108
+ transition: opacity .12s, filter .12s;
93
109
  }
110
+ /* A bounded board with no room refuses the entry: the chip dims to say so
111
+ (the same signal a tile ghost gives outside the board). Without this rule
112
+ the class was set and nothing showed — a refusal a user could not see. */
113
+ .axdb-drag-chip.axdb-out { opacity: .35; filter: grayscale(.7) drop-shadow(0 8px 14px rgba(16, 24, 40, .2)); }
94
114
 
95
115
  /* ===========================================================================
96
116
  BUILT-IN WIDGET CARDS — what widgets.ts paints when a page writes no
@@ -102,11 +122,19 @@ const CSS = `
102
122
  =========================================================================== */
103
123
  .axdb-widget {
104
124
  --axdb-ink: #1f2430;
105
- --axdb-muted: #7a8496;
125
+ /* 5.95:1 on the card — WCAG 1.4.3 for the 9–11px captions this paints
126
+ (the #7a8496 it replaced sat at 3.77:1, axe-core's first finding). */
127
+ --axdb-muted: #5a6478;
106
128
  --axdb-grid: rgba(120, 130, 148, .22);
107
129
  --axdb-card: #fff;
108
130
  --axdb-line: #e7eaf1;
109
131
  --axdb-soft: rgba(120, 130, 148, .14);
132
+ --axdb-up: #0f7a3d; /* 5.42:1 */
133
+ --axdb-down: #be123c; /* 6.29:1 */
134
+ /* The categorical palette, as tokens so the dark card can carry its own
135
+ steps: every entry clears 3:1 against its card (WCAG 1.4.11). */
136
+ --axdb-c1: #3b52d9; --axdb-c2: #0369a1; --axdb-c3: #0f766e;
137
+ --axdb-c4: #b45309; --axdb-c5: #6d28d9; --axdb-c6: #475569;
110
138
  box-sizing: border-box;
111
139
  width: 100%;
112
140
  height: 100%;
@@ -116,11 +144,29 @@ const CSS = `
116
144
  padding: 13px 15px 12px;
117
145
  background: var(--axdb-card);
118
146
  border: 1px solid var(--axdb-line);
119
- border-radius: var(--axdb-rs-radius, 12px);
147
+ border-radius: var(--axdb-rs-radius, 6px);
120
148
  box-shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .05);
121
149
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
122
150
  color: var(--axdb-ink);
123
151
  }
152
+ /* The host is the card's size container (named, so a widget's inner parts can
153
+ ask the TILE's size as well as their own body's), so the card can shed its
154
+ padding as the tile gets short instead of clipping its own content. */
155
+ .grafloria-html-layer > .grafloria-node-host { container: axdb-tile / size; }
156
+ /* Two class hops on the header: these blocks precede the header's own rule
157
+ below and would lose the cascade at equal specificity — at 54 px the margin
158
+ stayed 8 px, the body shrank to 14 px and the KPI figure hid. */
159
+ @container axdb-tile (max-height: 90px) {
160
+ .axdb-widget { padding: 8px 14px 7px; }
161
+ .axdb-widget > .axdb-widget-h { margin-bottom: 4px; }
162
+ }
163
+ @container axdb-tile (max-height: 46px) {
164
+ .axdb-widget { padding: 4px 12px 3px; }
165
+ .axdb-widget > .axdb-widget-h { margin-bottom: 0; }
166
+ }
167
+ @container axdb-tile (max-height: 26px) {
168
+ .axdb-widget { padding: 2px 12px 1px; }
169
+ }
124
170
  .axdb-widget-h {
125
171
  display: flex;
126
172
  align-items: center;
@@ -138,9 +184,15 @@ const CSS = `
138
184
  own (without this the 100%-tall svg pushes the legend out of the card). */
139
185
  .axdb-widget-b.axdb-has-lg { display: flex; flex-direction: column; }
140
186
  .axdb-widget-b.axdb-has-lg > svg { flex: 1; height: auto; min-height: 0; }
187
+ /* The data behind a chart, for readers that cannot see the chart: present in
188
+ the accessibility tree, absent from the picture (WCAG 1.1.1). */
189
+ .axdb-sr {
190
+ position: absolute; top: 0; left: 0; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
191
+ clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
192
+ }
141
193
  .axdb-widget-empty {
142
194
  display: flex; align-items: center; justify-content: center; height: 100%;
143
- font: 500 11.5px/1.3 system-ui, sans-serif; color: var(--axdb-muted); opacity: .75;
195
+ font: 500 11.5px/1.3 system-ui, sans-serif; color: var(--axdb-muted);
144
196
  }
145
197
 
146
198
  /* kpi: value + delta stack, the spark yields its height before they do */
@@ -150,17 +202,73 @@ const CSS = `
150
202
  sliced mid-glyph by the card's overflow — the audited "trimmed" widgets.
151
203
  cqh = 1% of the body's own height, clamped so full-size tiles look exactly
152
204
  as before and short tiles compress instead of cutting. */
153
- .axdb-widget-b.axdb-kpi { display: flex; flex-direction: column; container-type: size; }
154
- .axdb-kpi-v { font: 700 clamp(15px, 44cqh, 30px)/1.05 system-ui, sans-serif; letter-spacing: -.02em; color: var(--axdb-ink); }
155
- .axdb-kpi-d { margin-top: clamp(1px, 5cqh, 6px); font: 600 clamp(9px, 19cqh, 12px)/1.2 system-ui, sans-serif; }
205
+ .axdb-widget-b.axdb-kpi { display: flex; flex-direction: column; container: axdb-kpi / size; }
206
+ /* STEP DOWN, NEVER CLIP. As the body gets shorter the sparkline goes first, then the
207
+ delta, then the value the header alone at the row floor. Before this a 99-px row
208
+ drew the sparkline as a 13-px sliver and a 28-px row cut the value mid-glyph (the
209
+ fluid board, after a drag pushed the KPI row down). Thresholds are body heights. */
210
+ /* Two class hops, so these outrank ".axdb-widget-b > svg { display: block }" —
211
+ a bare .axdb-kpi-s lost that cascade and the sparkline stayed. */
212
+ @container (max-height: 78px) { .axdb-kpi > .axdb-kpi-s { display: none; } }
213
+ @container (max-height: 40px) { .axdb-kpi > .axdb-kpi-d { display: none; } }
214
+ @container (max-height: 16px) { .axdb-kpi > .axdb-kpi-v { display: none; } }
215
+ .axdb-kpi-v { font: 700 clamp(15px, 44cqh, 30px)/1.05 system-ui, sans-serif; letter-spacing: -.02em; color: var(--axdb-ink); white-space: nowrap; }
216
+ .axdb-kpi-d { margin-top: clamp(1px, 5cqh, 6px); font: 600 clamp(9px, 19cqh, 12px)/1.2 system-ui, sans-serif; white-space: nowrap; }
156
217
  .axdb-kpi-d span { color: var(--axdb-muted); font-weight: 500; }
157
- .axdb-kpi-d.up { color: #12a150; }
158
- .axdb-kpi-d.down { color: #e11d48; }
159
- .axdb-kpi-s { margin-top: auto; height: 34px; min-height: 0; flex: 0 1 34px; }
218
+ .axdb-kpi-d.up { color: var(--axdb-up); }
219
+ .axdb-kpi-d.down { color: var(--axdb-down); }
220
+ /* Grows into a tall tile (a 3-row KPI is not a number over a void) and never
221
+ takes more than two fifths of the body. */
222
+ .axdb-kpi-s { margin-top: auto; height: auto; min-height: 0; flex: 1 1 34px; max-height: 40%; }
223
+ /* SHORT AND WIDE — a one-row KPI on a fluid board. Stacked, a short tile dropped
224
+ its sparkline and left a small figure in the corner of an empty card. Here the
225
+ figure, its delta and the sparkline sit in a row and the spark fills the width.
226
+ The outer query asks the TILE (host), the inner one the body, so a strip too
227
+ thin for a readable spark (under 24 px) keeps the figure alone. 340 px is what
228
+ a figure, its delta, the gaps and a spark worth reading need — the builder's
229
+ 236-px tiles got a 40-px wedge of spark beside the delta. */
230
+ @container axdb-tile (max-height: 125px) and (min-width: 340px) {
231
+ .axdb-widget--kpi > .axdb-kpi { flex-direction: row; align-items: center; gap: 14px; }
232
+ .axdb-widget--kpi .axdb-kpi-d { margin-top: 0; }
233
+ @container axdb-kpi (min-height: 24px) {
234
+ .axdb-kpi > .axdb-kpi-s { display: block; flex: 1 1 40%; min-width: 80px; height: 100%; max-height: 100%; margin-top: 0; align-self: stretch; }
235
+ }
236
+ }
237
+ /* THE STRIP (row floor): header and figure on one line, the spark beside them
238
+ when the tile is wide enough. */
239
+ @container axdb-tile (max-height: 46px) {
240
+ .axdb-widget--kpi { flex-direction: row; align-items: center; gap: 12px; }
241
+ .axdb-widget--kpi > .axdb-widget-h { flex: none; }
242
+ /* The body is a SIZE container: in a centred row it would collapse to zero
243
+ and every height query would fire. It takes the card's full inner height. */
244
+ .axdb-widget--kpi > .axdb-kpi { flex: 1; align-self: stretch; }
245
+ }
160
246
 
161
- /* donut: ring beside its legend */
247
+ /* SPLIT LAYOUT chrome: a divider sits in the gap between two siblings (a
248
+ 24-px hit zone around a 10-px gap) and shows its line on hover or while
249
+ dragged; the insertion line marks the edge a dragged widget will land on. */
250
+ .grafloria-html-layer > .axdb-div { position: absolute; z-index: 2; pointer-events: auto; background: transparent; }
251
+ .grafloria-html-layer > .axdb-div--row { cursor: col-resize; }
252
+ .grafloria-html-layer > .axdb-div--column { cursor: row-resize; }
253
+ .grafloria-html-layer > .axdb-div::after {
254
+ content: ""; position: absolute; border-radius: 2px; background: #3b52d9; opacity: 0; transition: opacity .12s ease;
255
+ }
256
+ .grafloria-html-layer > .axdb-div--row::after { left: 50%; top: 8px; bottom: 8px; width: 4px; margin-left: -2px; }
257
+ .grafloria-html-layer > .axdb-div--column::after { top: 50%; left: 8px; right: 8px; height: 4px; margin-top: -2px; }
258
+ .grafloria-html-layer > .axdb-div:hover::after, .grafloria-html-layer > .axdb-div.axdb-active::after { opacity: .9; }
259
+ .grafloria-html-layer > .axdb-ins {
260
+ position: absolute; z-index: 3; pointer-events: none; border-radius: 2px;
261
+ background: #3b52d9; box-shadow: 0 0 0 3px rgba(59, 82, 217, .22);
262
+ }
263
+ @media (prefers-reduced-motion: reduce) { .grafloria-html-layer > .axdb-div::after { transition: none; } }
264
+
265
+ /* donut: ring beside its legend. The ring takes the body's height (a tall
266
+ tile gets a bigger ring, not dead card), square, capped so its centre figure
267
+ stays a figure and not a headline. */
162
268
  .axdb-widget-b.axdb-donut { display: flex; align-items: center; gap: 10px; }
163
- .axdb-widget-b.axdb-donut > svg { max-width: 150px; }
269
+ .axdb-widget-b.axdb-donut > svg {
270
+ flex: 0 0 auto; width: auto; height: 100%; max-height: 260px; max-width: 60%; aspect-ratio: 1 / 1;
271
+ }
164
272
 
165
273
  /* legend chips, shared by line and donut */
166
274
  .axdb-lg { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 9px; }
@@ -190,16 +298,25 @@ const CSS = `
190
298
  --axdb-card: #1a1d25;
191
299
  --axdb-line: #2b3040;
192
300
  --axdb-soft: rgba(150, 160, 182, .16);
301
+ --axdb-up: #4ade80;
302
+ --axdb-down: #fb7185;
303
+ --axdb-c1: #8b9cff; --axdb-c2: #38bdf8; --axdb-c3: #2dd4bf;
304
+ --axdb-c4: #fbbf24; --axdb-c5: #a78bfa; --axdb-c6: #94a3b8;
193
305
  }
194
306
  }
195
307
  `;
196
308
  /** Idempotently inject the kit stylesheet (safe to call per binder). */
197
- export function ensureDashboardKitStyles(doc = document) {
198
- if (doc.getElementById(DASHBOARD_KIT_STYLE_ID))
309
+ export function ensureDashboardKitStyles(doc) {
310
+ // SERVER-SAFE: `dashboard()` is a pure spec builder and runs where there is
311
+ // no document (SSR, a Node script sizing a board). A default parameter of
312
+ // `document` threw ReferenceError there — the packaging gate's raw-Node
313
+ // import caught it. No document, no stylesheet, no error.
314
+ const d = doc !== null && doc !== void 0 ? doc : (typeof document !== 'undefined' ? document : undefined);
315
+ if (!d || d.getElementById(DASHBOARD_KIT_STYLE_ID))
199
316
  return;
200
- const style = doc.createElement('style');
317
+ const style = d.createElement('style');
201
318
  style.id = DASHBOARD_KIT_STYLE_ID;
202
319
  style.textContent = CSS;
203
- doc.head.appendChild(style);
320
+ d.head.appendChild(style);
204
321
  }
205
322
  //# sourceMappingURL=styles.js.map
@@ -89,6 +89,21 @@ export interface TableWidgetData {
89
89
  }
90
90
  /** The kinds `defaultWidgetRenderer` knows; anything else gets the placeholder. */
91
91
  export declare const BUILT_IN_WIDGET_KINDS: readonly ["kpi", "line", "bar", "donut", "funnel", "table"];
92
+ /**
93
+ * The drawing box a chart should lay itself out in: the body it is painted
94
+ * into, less the legend's strip. A fixed 640×250 viewBox scaled to "meet" left
95
+ * a wide fluid tile with ~170 px of dead card either side; drawing to the
96
+ * body's own aspect fills it. Outside a layout (jsdom, a host not yet sized)
97
+ * the classic 640×250 stands in, and the size watcher below repaints once the
98
+ * real box exists.
99
+ */
100
+ export declare function chartBox(body: {
101
+ clientWidth: number;
102
+ clientHeight: number;
103
+ }, legend?: boolean): {
104
+ W: number;
105
+ H: number;
106
+ };
92
107
  /** `{ label, value, delta?, spark? }` — headline number + change + sparkline. */
93
108
  export declare const renderKpiWidget: WidgetRenderer;
94
109
  /** `{ series, labels? }` — area under the first series, a line per series. */
@@ -97,7 +112,7 @@ export declare const renderLineWidget: WidgetRenderer;
97
112
  export declare const renderBarWidget: WidgetRenderer;
98
113
  /** `{ slices: [{label, value, color?}], centerLabel? }` — ring + legend. */
99
114
  export declare const renderDonutWidget: WidgetRenderer;
100
- /** `{ stages: [{label, value}] }` — centred bars scaled against the first stage. */
115
+ /** `{ stages: [{label, value}] }` — centred bars scaled against the widest stage. */
101
116
  export declare const renderFunnelWidget: WidgetRenderer;
102
117
  /** `{ columns, rows }` — plain rows; numeric cells right-align themselves. */
103
118
  export declare const renderTableWidget: WidgetRenderer;
@@ -35,8 +35,12 @@ export const BUILT_IN_WIDGET_KINDS = ['kpi', 'line', 'bar', 'donut', 'funnel', '
35
35
  * Categorical default palette — readable on both light and dark cards. Per-mark
36
36
  * overrides win (`slices[].color`); everything else cycles this list.
37
37
  */
38
- const PALETTE = ['#3b52d9', '#0ea5e9', '#14b8a6', '#f59e0b', '#8b5cf6', '#64748b'];
39
- const colorAt = (i) => PALETTE[((i % PALETTE.length) + PALETTE.length) % PALETTE.length];
38
+ /* Every entry clears 3:1 against the light AND the dark card (WCAG 1.4.11,
39
+ non-text contrast): the sky, teal and amber of the first palette sat at
40
+ 2.9, 2.5 and 2.2 against white and were replaced by their darker steps. */
41
+ const PALETTE_SIZE = 6;
42
+ /** The i-th palette token — the stylesheet resolves it per theme (`--axdb-c1…c6`). */
43
+ const colorAt = (i) => `var(--axdb-c${(((i % PALETTE_SIZE) + PALETTE_SIZE) % PALETTE_SIZE) + 1})`;
40
44
  // -- primitives ---------------------------------------------------------------
41
45
  const esc = (v) => String(v !== null && v !== void 0 ? v : '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
42
46
  /** Finite numbers only — a NaN in the data must not become a NaN in a path. */
@@ -89,6 +93,67 @@ function empty(body, note = 'no data') {
89
93
  body.innerHTML = `<div class="axdb-widget-empty">${esc(note)}</div>`;
90
94
  }
91
95
  const data = (widget) => { var _a; return ((_a = widget.data) !== null && _a !== void 0 ? _a : {}); };
96
+ /**
97
+ * The drawing box a chart should lay itself out in: the body it is painted
98
+ * into, less the legend's strip. A fixed 640×250 viewBox scaled to "meet" left
99
+ * a wide fluid tile with ~170 px of dead card either side; drawing to the
100
+ * body's own aspect fills it. Outside a layout (jsdom, a host not yet sized)
101
+ * the classic 640×250 stands in, and the size watcher below repaints once the
102
+ * real box exists.
103
+ */
104
+ export function chartBox(body, legend = false) {
105
+ const w = body.clientWidth || 0;
106
+ const h = (body.clientHeight || 0) - (legend ? 26 : 0);
107
+ if (w < 60 || h < 40)
108
+ return { W: 640, H: 250 };
109
+ return { W: Math.round(w), H: Math.round(h) };
110
+ }
111
+ const sizeWatchers = new WeakMap();
112
+ /**
113
+ * Re-lay a chart out when the HOST's box changes (a fit-mode reflow, a
114
+ * resize, a narrower container) — coalesced to a frame, skipping sub-2-px
115
+ * jitter. `relayout` redraws ONLY the chart body: the card and anything an
116
+ * app painted onto it after `defaultWidgetRenderer` (a focus ring, a pin
117
+ * marker — the documented composition) are never touched. The first version
118
+ * re-ran the whole painter and wiped exactly those, which the save/load gate
119
+ * caught as a reloaded board reading differently from the original.
120
+ */
121
+ function watchSize(host, relayout) {
122
+ var _a;
123
+ if (typeof ResizeObserver === 'undefined')
124
+ return;
125
+ (_a = sizeWatchers.get(host)) === null || _a === void 0 ? void 0 : _a.disconnect();
126
+ let last = { w: host.clientWidth, h: host.clientHeight };
127
+ let frame = 0;
128
+ const ro = new ResizeObserver(() => {
129
+ const w = host.clientWidth;
130
+ const h = host.clientHeight;
131
+ if (Math.abs(w - last.w) < 2 && Math.abs(h - last.h) < 2)
132
+ return;
133
+ last = { w, h };
134
+ if (frame)
135
+ cancelAnimationFrame(frame);
136
+ frame = requestAnimationFrame(() => {
137
+ frame = 0;
138
+ if (host.isConnected)
139
+ relayout();
140
+ else
141
+ ro.disconnect();
142
+ });
143
+ });
144
+ ro.observe(host);
145
+ sizeWatchers.set(host, ro);
146
+ }
147
+ /**
148
+ * The chart's numbers as a visually-hidden table — what a screen reader gets
149
+ * instead of an svg it cannot read (WCAG 1.1.1). `role="img"` + aria-label on
150
+ * the svg names the chart; this carries the values.
151
+ */
152
+ const srTable = (caption, columns, rows) => `<table class="axdb-sr"><caption>${esc(caption)}</caption><thead><tr>${columns
153
+ .map((c) => `<th scope="col">${esc(c)}</th>`)
154
+ .join('')}</tr></thead><tbody>${rows
155
+ .map((r) => `<tr>${r.map((v) => `<td>${esc(v)}</td>`).join('')}</tr>`)
156
+ .join('')}</tbody></table>`;
92
157
  const legend = (items, column = false) => `<div class="axdb-lg${column ? ' axdb-lg--col' : ''}">` +
93
158
  items.map((i) => `<i><b style="background:${esc(i.color)}"></b>${esc(i.label)}</i>`).join('') +
94
159
  '</div>';
@@ -142,13 +207,20 @@ function normalizeSeries(raw) {
142
207
  }
143
208
  /** `{ series, labels? }` — area under the first series, a line per series. */
144
209
  export const renderLineWidget = (widget, host) => {
145
- const d = data(widget);
146
210
  const body = card(host, widget, titleOf(widget));
211
+ layoutLine(widget, body);
212
+ watchSize(host, () => layoutLine(widget, body));
213
+ };
214
+ /** The line chart's body, drawn to the body's current box. */
215
+ function layoutLine(widget, body) {
216
+ const d = data(widget);
147
217
  const series = normalizeSeries(d.series);
148
218
  if (!series.length)
149
219
  return empty(body);
150
- const W = 640;
151
- const H = 250;
220
+ const named = series.filter((s) => s.name);
221
+ if (named.length)
222
+ body.classList.add('axdb-has-lg');
223
+ const { W, H } = chartBox(body, named.length > 0);
152
224
  const pad = { l: 34, r: 12, t: 12, b: 22 };
153
225
  const iw = W - pad.l - pad.r;
154
226
  const ih = H - pad.t - pad.b;
@@ -168,6 +240,9 @@ export const renderLineWidget = (widget, host) => {
168
240
  })
169
241
  .join('');
170
242
  const labels = Array.isArray(d.labels) ? d.labels : [];
243
+ // The label SET must not depend on the box: a board reloaded at another size
244
+ // has to paint the same text as the original (the save/load gate's contract).
245
+ // Only the geometry follows the box.
171
246
  const every = labels.length > 8 ? 2 : 1;
172
247
  const ticks = labels
173
248
  .slice(0, count)
@@ -184,29 +259,42 @@ export const renderLineWidget = (widget, host) => {
184
259
  `L${xAt(s.values.length - 1).toFixed(1)},${(pad.t + ih).toFixed(1)} Z" ` +
185
260
  `fill="${colorAt(si)}" fill-opacity="0.10"></path>`
186
261
  : '';
262
+ // A series with ONE point has nothing for a polyline to connect, so the
263
+ // plot rendered its axes and legend around an invisible chart — the
264
+ // classic day-one-of-data tile. A single value is still data: draw it as
265
+ // a dot. (Two or more points keep exactly the look they had.)
266
+ const dots = s.values.length < 2
267
+ ? s.values
268
+ .map((v, i) => `<circle cx="${xAt(i).toFixed(1)}" cy="${yAt(v).toFixed(1)}" r="3.5" ` +
269
+ `fill="${colorAt(si)}"></circle>`)
270
+ .join('')
271
+ : '';
187
272
  return (area +
188
273
  `<polyline points="${pts}" fill="none" stroke="${colorAt(si)}" stroke-width="${si === 0 ? 2.4 : 1.8}" ` +
189
- `stroke-linejoin="round" stroke-linecap="round"></polyline>`);
274
+ `stroke-linejoin="round" stroke-linecap="round"></polyline>` +
275
+ dots);
190
276
  })
191
277
  .join('');
192
- const named = series.filter((s) => s.name);
193
- if (named.length)
194
- body.classList.add('axdb-has-lg');
195
278
  body.innerHTML =
196
279
  `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" ` +
197
280
  `aria-label="${esc(titleOf(widget))}">${grid}${ticks}${marks}</svg>` +
198
- (named.length ? legend(series.map((s, i) => { var _a; return ({ label: String((_a = s.name) !== null && _a !== void 0 ? _a : ''), color: colorAt(i) }); })) : '');
199
- };
281
+ (named.length ? legend(series.map((s, i) => { var _a; return ({ label: String((_a = s.name) !== null && _a !== void 0 ? _a : ''), color: colorAt(i) }); })) : '') +
282
+ srTable(titleOf(widget), ['', ...series.map((s, i) => { var _a; return String((_a = s.name) !== null && _a !== void 0 ? _a : `Series ${i + 1}`); })], Array.from({ length: count }, (_, i) => { var _a; return [(_a = labels[i]) !== null && _a !== void 0 ? _a : String(i + 1), ...series.map((s) => { var _a; return (_a = s.values[i]) !== null && _a !== void 0 ? _a : ''; })]; }));
283
+ }
200
284
  // -- bar ----------------------------------------------------------------------
201
285
  /** `{ bars: [{label, value}] }` — columns, value above, category below. */
202
286
  export const renderBarWidget = (widget, host) => {
203
- const d = data(widget);
204
287
  const body = card(host, widget, titleOf(widget));
288
+ layoutBar(widget, body);
289
+ watchSize(host, () => layoutBar(widget, body));
290
+ };
291
+ /** The bar chart's body, drawn to the body's current box. */
292
+ function layoutBar(widget, body) {
293
+ const d = data(widget);
205
294
  const bars = (Array.isArray(d.bars) ? d.bars : []).filter((b) => !!b);
206
295
  if (!bars.length)
207
296
  return empty(body);
208
- const W = 640;
209
- const H = 250;
297
+ const { W, H } = chartBox(body);
210
298
  const pad = { l: 34, r: 12, t: 12, b: 26 };
211
299
  const iw = W - pad.l - pad.r;
212
300
  const ih = H - pad.t - pad.b;
@@ -239,8 +327,9 @@ export const renderBarWidget = (widget, host) => {
239
327
  .join('');
240
328
  body.innerHTML =
241
329
  `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" ` +
242
- `aria-label="${esc(titleOf(widget))}">${grid}${marks}</svg>`;
243
- };
330
+ `aria-label="${esc(titleOf(widget))}">${grid}${marks}</svg>` +
331
+ srTable(titleOf(widget), ['Category', 'Value'], bars.map((b) => { var _a; return [(_a = b.label) !== null && _a !== void 0 ? _a : '', num(b.value)]; }));
332
+ }
244
333
  // -- donut --------------------------------------------------------------------
245
334
  /** One arc of the ring, as an SVG path command. */
246
335
  function arcPath(cx, cy, r, a0, a1) {
@@ -290,38 +379,57 @@ export const renderDonutWidget = (widget, host) => {
290
379
  label: `${(_a = s.label) !== null && _a !== void 0 ? _a : ''} · ${Math.round((num(s.value) / total) * 100)}%`,
291
380
  color: (_b = s.color) !== null && _b !== void 0 ? _b : colorAt(i),
292
381
  });
293
- }), true);
382
+ }), true) +
383
+ srTable(titleOf(widget), ['Slice', 'Value', 'Share'], slices.map((s) => { var _a; return [(_a = s.label) !== null && _a !== void 0 ? _a : '', num(s.value), `${Math.round((num(s.value) / total) * 100)}%`]; }));
294
384
  };
295
385
  // -- funnel -------------------------------------------------------------------
296
- /** `{ stages: [{label, value}] }` — centred bars scaled against the first stage. */
386
+ /** `{ stages: [{label, value}] }` — centred bars scaled against the widest stage. */
297
387
  export const renderFunnelWidget = (widget, host) => {
298
- const d = data(widget);
299
388
  const body = card(host, widget, titleOf(widget));
389
+ layoutFunnel(widget, body);
390
+ watchSize(host, () => layoutFunnel(widget, body));
391
+ };
392
+ /** Room an 11-px value needs, so the smallest stage's bar still holds its number. */
393
+ const valueWidth = (text) => text.length * 6.6 + 14;
394
+ /**
395
+ * The funnel's body, drawn to the body's current box. The old fixed 260-wide
396
+ * viewBox scaled bars AND type with the tile: 24-px digits in a tall tile,
397
+ * "188" spilling out of a stage narrower than its own label. Here the type is
398
+ * fixed, bars share the height between a readable 18 px and a 40 px cap, the
399
+ * stack is centred, and a bar is never narrower than its value.
400
+ */
401
+ function layoutFunnel(widget, body) {
402
+ const d = data(widget);
300
403
  const stages = (Array.isArray(d.stages) ? d.stages : []).filter((s) => !!s);
301
404
  if (!stages.length)
302
405
  return empty(body);
303
- const W = 260;
304
- const rowH = 34;
305
- const gap = 8;
306
- const H = stages.length * (rowH + gap);
406
+ const { W, H } = chartBox(body);
407
+ const n = stages.length;
408
+ const gap = 6;
409
+ const rowH = Math.max(18, Math.min(40, (H - gap * (n - 1)) / n));
410
+ const top = Math.max(0, (H - (n * rowH + (n - 1) * gap)) / 2);
411
+ const labelCol = Math.min(120, Math.max(60, W * 0.28));
412
+ const track = Math.max(40, W - labelCol - 16);
307
413
  const max = Math.max(...stages.map((s) => num(s.value)), 1);
308
- const track = W - 90;
309
414
  const marks = stages
310
415
  .map((s, i) => {
311
416
  var _a;
312
- const w = Math.max(2, (num(s.value) / max) * track);
313
- const x = (track - w) / 2 + 8;
314
- const y = i * (rowH + gap);
315
- return (`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="${rowH}" rx="6" fill="${colorAt(i)}"></rect>` +
316
- `<text x="${(x + w / 2).toFixed(1)}" y="${y + rowH / 2 + 4}" text-anchor="middle" font-size="11" ` +
317
- `font-weight="600" fill="#fff">${esc(compact(num(s.value)))}</text>` +
318
- `<text x="${W - 78}" y="${y + rowH / 2 + 4}" font-size="10.5" fill="var(--axdb-muted)">${esc((_a = s.label) !== null && _a !== void 0 ? _a : '')}</text>`);
417
+ const text = compact(num(s.value));
418
+ const w = Math.min(track, Math.max(valueWidth(text), (num(s.value) / max) * track));
419
+ const x = 8 + (track - w) / 2;
420
+ const y = top + i * (rowH + gap);
421
+ const mid = (y + rowH / 2 + 4).toFixed(1);
422
+ return (`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${rowH.toFixed(1)}" rx="6" fill="${colorAt(i)}"></rect>` +
423
+ `<text x="${(x + w / 2).toFixed(1)}" y="${mid}" text-anchor="middle" font-size="11" ` +
424
+ `font-weight="600" fill="#fff">${esc(text)}</text>` +
425
+ `<text x="${8 + track + 8}" y="${mid}" font-size="10.5" fill="var(--axdb-muted)">${esc((_a = s.label) !== null && _a !== void 0 ? _a : '')}</text>`);
319
426
  })
320
427
  .join('');
321
428
  body.innerHTML =
322
429
  `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" ` +
323
- `aria-label="${esc(titleOf(widget))}">${marks}</svg>`;
324
- };
430
+ `aria-label="${esc(titleOf(widget))}">${marks}</svg>` +
431
+ srTable(titleOf(widget), ['Stage', 'Value'], stages.map((s) => { var _a; return [(_a = s.label) !== null && _a !== void 0 ? _a : '', num(s.value)]; }));
432
+ }
325
433
  // -- table --------------------------------------------------------------------
326
434
  /** `{ columns, rows }` — plain rows; numeric cells right-align themselves. */
327
435
  export const renderTableWidget = (widget, host) => {
@@ -343,6 +451,11 @@ export const renderTableWidget = (widget, host) => {
343
451
  '</tr>')
344
452
  .join('');
345
453
  body.classList.add('axdb-scroll');
454
+ // A scrollable region must be reachable by keyboard (axe: scrollable-
455
+ // region-focusable) — it is a tab stop with the table's name.
456
+ body.setAttribute('tabindex', '0');
457
+ body.setAttribute('role', 'region');
458
+ body.setAttribute('aria-label', titleOf(widget));
346
459
  body.innerHTML = `<table class="axdb-table">${head}<tbody>${tbody}</tbody></table>`;
347
460
  };
348
461
  // -- dispatch -----------------------------------------------------------------
@@ -52,6 +52,50 @@ export interface HtmlNode {
52
52
  export declare function entityCardContent(entity: ErEntitySpec, editable?: boolean): HtmlNode;
53
53
  /** Card height for an entity — explicit height wins, else derived from columns. */
54
54
  export declare function entityAutoHeight(entity: ErEntitySpec, editable?: boolean): number;
55
+ /** Horizontal padding shared by `.axk-row`, `.axk-entity-head`, `.axk-member`
56
+ * and `.axk-uml-name` — all of them are `padding: Npx 10px`. */
57
+ export declare const CARD_PAD_X = 20;
58
+ /** `.axk-row`'s `gap: 8px`. */
59
+ export declare const ER_ROW_GAP = 8;
60
+ /** `.axk-key`'s fixed `width: 22px`. */
61
+ export declare const ER_KEY_W = 22;
62
+ /** `.axk-ty`'s `min-width: 52px` — it never measures narrower than its target. */
63
+ export declare const ER_TYPE_MIN_W = 52;
64
+ /** `.axk-col-del`'s `width: 14px` (present only when editable). */
65
+ export declare const CARD_DEL_W = 14;
66
+ /**
67
+ * Horizontal counterpart of ER_BORDER_SLACK: the html wrapper's padding
68
+ * (`html.padding ?? 4`, so 8px across) plus the card's own 1px left/right
69
+ * borders. The node's width is the OUTER box; the row only ever gets what is
70
+ * left after these, so a derived width that ignored them came out 10px short
71
+ * and truncated by a hair — which is exactly how it was first measured.
72
+ */
73
+ export declare const CARD_SLACK_X = 10;
74
+ /** Defaults, and the floor every derived width is taken against. */
75
+ export declare const ER_DEFAULT_WIDTH = 190;
76
+ export declare const UML_DEFAULT_WIDTH = 200;
77
+ /**
78
+ * Ceiling for a DERIVED width. A card that sized itself to a pathological
79
+ * identifier would dominate the diagram, so past this the stylesheet's ellipsis
80
+ * takes over — visibly, which is the point — and an author who needs the whole
81
+ * string sets an explicit `width`. An explicit width is never clamped.
82
+ */
83
+ export declare const CARD_MAX_AUTO_W = 420;
84
+ /**
85
+ * Width of `text` in CSS pixels, as the browser will really lay it out.
86
+ *
87
+ * Canvas `measureText` was calibrated against live `getBoundingClientRect()`
88
+ * widths for these exact fonts and agreed to within 0.02px, which is what makes
89
+ * deriving a card's width from its content safe rather than a guess.
90
+ *
91
+ * Outside a browser (jsdom, SSR) there is no 2d context and this falls back to
92
+ * a per-character average. The fallback only has to be roughly right: it feeds
93
+ * a `Math.max` against the default width, and anything it underestimates is
94
+ * ellipsised by the stylesheet rather than lost.
95
+ */
96
+ export declare function measureCardText(text: string, font: string, tracking?: number): number;
97
+ /** Card width for an entity — explicit width wins, else derived from content. */
98
+ export declare function entityAutoWidth(entity: ErEntitySpec, editable?: boolean): number;
55
99
  /**
56
100
  * The `.axk-uml` content tree. `editable` adds a delete control per member and
57
101
  * a trailing add affordance in each compartment.
@@ -59,6 +103,8 @@ export declare function entityAutoHeight(entity: ErEntitySpec, editable?: boolea
59
103
  export declare function classCardContent(cls: UmlClassSpec, editable?: boolean): HtmlNode;
60
104
  /** Card height for a class — explicit height wins, else derived from members. */
61
105
  export declare function classAutoHeight(cls: UmlClassSpec, editable?: boolean): number;
106
+ /** Card width for a class — explicit width wins, else derived from content. */
107
+ export declare function classAutoWidth(cls: UmlClassSpec, editable?: boolean): number;
62
108
  /**
63
109
  * Match old columns to new columns for port reconciliation. Returns a map from
64
110
  * OLD index → NEW index; an old index absent from the map is a REMOVED column