@cahyo-dimas/freeday 1.49.0 → 1.51.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,84 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.51.0] — 2026-08-21
7
+ ### Fixed
8
+ - **A cartesian chart's axis text and dots grew with the container** (#042). `renderCartesian` built
9
+ a fixed `viewBox="0 0 320 180"` and let `.fdy-chart-xy__plot` stretch it to `width:100%`, so every
10
+ size the renderer expressed in user units was multiplied by `plotWidth / 320`. `font-size:9px` on
11
+ a tick is not nine screen pixels at any width but 320px: measured on a real console it rendered
12
+ 13px at a 350px plot and **81px at a 2232px** one, making the chart's smallest, most secondary
13
+ text the largest type on the page. `r="2.2"` did the same to the dots — 4.8px across at 350px,
14
+ 30.7px at 2232px. The chart now measures `__plot` and sizes the `viewBox` to it in CSS pixels, so
15
+ one user unit is one pixel and every declared size means what it says at every width; a
16
+ `ResizeObserver` repaints it when the box changes (a collapsing sidebar moves a plot ~300px at a
17
+ fixed viewport, so this is the common case, not an edge case). A chart that measures 0 — detached,
18
+ or inside `display:none` — falls back to the old box and repaints when it is laid out.
19
+ - **A currency y-axis was clipped by a gutter sized for a percent one** (#042). `PL` was a constant
20
+ 38 user units, which was ~4 characters at one scale and something else at every other. It is now
21
+ derived from the widest formatted tick the axis will actually draw, capped at 35% of the plot.
22
+ ### Changed
23
+ - **Axis type is a real CSS size**, set by `--fdy-chart-tick-size` on `.fdy-chart-xy` (default
24
+ `var(--text-xs)`). `font-size` moved off `.fdy-chart-xy__tick` / `__xlabel` onto
25
+ `.fdy-chart-xy__plot`, which the SVG text inherits — one computed read gives the renderer the real
26
+ pixel size it needs for the y-gutter and for the x-label autoskip, so an override flows to the
27
+ text *and* to the geometry around it. Before this, chart text could not be restyled at all: any
28
+ value set was multiplied by a scale the consumer could not see or compute.
29
+ The dot radius went 2.2 → 3 and the bar corner 1.5 → 2, both retuned against a 1:1 scale.
30
+ ### Docs
31
+ - **`COMPONENTS.md` claimed a `<table>` fallback that no rendered chart has** (#041). It said every
32
+ chart is `role="img"` + `aria-label` "with a `<table>` fallback inside the element"; the renderer
33
+ builds no table and wipes the element on every render path. Worse, `role="img"` makes descendants
34
+ presentational, so the bar values, ticks and legend the renderer *does* draw are not exposed
35
+ either — the author's `aria-label` is the entire text alternative, and the docs now say so and ask
36
+ for one that carries the numbers. The four dead fallback tables in `docs/index.html` and
37
+ `docs/reference-screen.html`, which no reader has ever reached, are gone with it.
38
+ - **The kit's own chart sizing is written down** (#041): `.fdy-chart-xy__plot` is
39
+ `aspect-ratio:16/9` + `min-height:8rem`, `.fdy-bars` a fixed `9rem`, `.fdy-sparkline` an
40
+ inline-block `8rem × 2.25rem` — with the note to override those rather than the chart root, since
41
+ a root `height` fights `aspect-ratio` instead of setting it.
42
+ ### Added — guards
43
+ - **`browser/chart-scale.mjs`** — the axis label box and dot diameter are measured at plot widths of
44
+ 350 / 696 / 1400 / 2232px and must not vary by more than a pixel, must stay smaller than body
45
+ text, and the `viewBox` must equal the measured plot. A second spec widens a container and asserts
46
+ the repaint. This class of bug is invisible to a source read: `font-size:9px` and `r="2.2"` look
47
+ like correct sizes, and the one thing in the plot that *was* protected —
48
+ `vector-effect:non-scaling-stroke` on the line — is what made the other three look protected too.
49
+ Only a real layout tells them apart.
50
+
51
+ ## [1.50.0] — 2026-08-20
52
+ ### Fixed
53
+ - **A toast raised from inside a modal was painted behind it** (#027). `.fdy-toast-region` carried
54
+ `z-index: 200`, which can never win: `.fdy-modal` is a native `<dialog>` opened with `showModal()`
55
+ and therefore lives in the **top layer**, above the whole z-index universe. An error raised by an
56
+ action taken inside a dialog was dimmed by that dialog's backdrop and mostly hidden under the
57
+ dialog — so the reader saw a confirmation still asking a question they had already answered, and
58
+ no reason why. The region is now `popover="manual"` and `freeday-toast.js` calls `showPopover()`
59
+ as each toast lands (per toast, not once — the top layer is ordered by when you joined, so a
60
+ dialog opened after the last toast would otherwise sit above it). Guarded: where `showPopover` is
61
+ unavailable or throws, the region stays exactly what it was.
62
+ - **A checkbox shrank when its label was long** (#027). `.fdy-check` is an inline-flex row and its
63
+ box declared a width — but a flex item with a width is still shrinkable, so a label that wrapped
64
+ to three lines squeezed the box from 18px to 13px while leaving it 18px tall. A group of five
65
+ rendered three different sizes, none of them square. `flex: none` on `.fdy-check input`,
66
+ `.fdy-radio input`, the `.fdy-switch` track and the standalone `.fdy-checkbox` — all four are
67
+ flex children somewhere in the kit.
68
+ ### Added — guards
69
+ - **`pixelAt(x, y)` in `browser/harness.mjs`** — a 1×1 `Page.captureScreenshot`, decoded in-process.
70
+ Stacking above a modal cannot be asserted with `elementFromPoint`: a modal dialog makes the rest
71
+ of the document inert, so a hit test outside it returns the dialog whatever the paint order is,
72
+ reporting a working fix as broken. The new guard measures the colour actually composited at the
73
+ toast's centre. It waits for both fade-ins to finish first — a screenshot taken mid-animation
74
+ returns a blend of the two elements and flakes in both directions.
75
+ - **Selection controls are asserted square and equal** across a group whose labels run one to three
76
+ lines, so a box that shrank in both axes cannot pass a width-only comparison. All four controls
77
+ the fix touched are measured, each in the container it actually ships in: the standalone
78
+ `.fdy-checkbox` inside `.fdy-filter__check` — the flex row all three typed adapters render in a
79
+ filter popover and a CFL option list — and the switch track against a switch of its own, since it
80
+ is the one control that is not square. The first version of this guard held the standalone
81
+ checkbox in a plain block, where it cannot shrink at all, so removing `flex: none` from the one
82
+ box every adapter puts on screen left it passing.
83
+
6
84
  ## [1.49.0] — 2026-08-20
7
85
  ### Added
8
86
  - **`FdyTableColumn.labelHidden`** (#026). A column of row CONTROLS — an edit button, a row menu —
package/COMPONENTS.md CHANGED
@@ -858,7 +858,20 @@ Pure SVG/CSS, no dependency, re-colours with the theme. Needs `freeday-chart.js`
858
858
  validated categorical palette `--chart-1`…`--chart-8`.
859
859
  - Format: `data-fdy-format="number|percent|currency"`; legend `data-fdy-legend` (`none` to drop);
860
860
  axes `data-fdy-axes`
861
- - A11y: every chart is `role="img"` + `aria-label`, with a `<table>` fallback inside the element.
861
+ - Sizing is already set by the kit override **these**, never a `height` on the chart root (a
862
+ root height fights `aspect-ratio` instead of setting it): `.fdy-chart-xy__plot` is `width:100%`
863
+ + `aspect-ratio:16/9` + `min-height:8rem`; `.fdy-bars` is a fixed `height:9rem`; `.fdy-sparkline`
864
+ is an inline-block `8rem × 2.25rem`.
865
+ - Axis type: `--fdy-chart-tick-size` (default `var(--text-xs)`) sets the y-tick and x-label size on
866
+ `.fdy-chart-xy`. It is a real CSS size — a cartesian chart's `viewBox` is measured to its plot, so
867
+ one user unit is one pixel — and the renderer reads it back to size the y-gutter and to decide how
868
+ many x-labels fit. Set the token rather than `font-size` on `__tick`/`__xlabel`, or the gutter and
869
+ the autoskip will still be computed from the old size.
870
+ - A11y: every chart is `role="img"` + an **author-supplied** `aria-label`, and that label is the
871
+ *entire* text alternative — write one that carries the numbers, not just the shape. `role="img"`
872
+ makes descendants presentational, so rendered bar values, axis ticks and legends are not exposed
873
+ either; any `<table>`/text placed inside the element is **pre-render only** and is replaced on
874
+ render. Good: `aria-label="Monthly posting trend: Posted 62% → 88%, Draft 24% → 16%"`.
862
875
  - Internal parts (set by JS, useful for overrides): `.fdy-chart__legend` (+`--row`) `__swatch`
863
876
  `__tip`; `.fdy-bars__col` `__bar` `__track` `__label` `__val`; `.fdy-chart-xy__plot` `__grid`
864
877
  `__tick` `__xlabel` `__line` `__area` `__bar` `__dot` `__band`; `.fdy-donut__ring` `__seg`
@@ -935,6 +948,13 @@ Every field is optional; it returns the toast element. Classes (rendered for you
935
948
  `.fdy-toast-region`, `.fdy-toast` (+`--info` `--success` `--warning` `--danger`) · `__accent`
936
949
  `__body` `__title` `__text` `__close`.
937
950
 
951
+ **Above modals, and not by z-index.** The region is a `popover` and re-enters the **top layer** as
952
+ each toast lands, because `.fdy-modal` is a native `<dialog>` — see the note under Modal below. If
953
+ a toast of yours is hidden behind something, a bigger `z-index` is not the fix; check whether that
954
+ something is in the top layer too. Where the Popover API is unavailable (before Chrome 114 /
955
+ Safari 17 / Firefox 125) the region falls back to a fixed block at `z-index: 200`, which cannot
956
+ clear an open modal — the message is still announced to a screen reader.
957
+
938
958
  ## Tooltip — `.fdy-tooltip`
939
959
  Hover/focus only, never the sole carrier of information. Wrap in `.fdy-tooltip-wrap`; the trigger
940
960
  gets `aria-describedby` → the `.fdy-tooltip[role="tooltip"]` `id`.
@@ -1019,7 +1039,13 @@ so the same look serves routed sub-navigation built from plain links — see the
1019
1039
  > **Typed wrapper: `<FdyModal>`** — Vue (`:open` + `@close`) · React (`open` + `onClose`) · Blazor (`@bind-Open`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
1020
1040
 
1021
1041
  Native `<dialog>`: focus trap, Esc and backdrop come from the browser. Sizes `--sm` `--md` `--lg`
1022
- `--wide`; `--cfl` for the choose-from-list dialog. Parts `__header` `__title` `__body` `__footer`
1042
+ `--wide`; `--cfl` for the choose-from-list dialog.
1043
+
1044
+ **It is in the TOP LAYER.** `showModal()` paints the dialog above every stacking context on the
1045
+ page, so no `z-index` anywhere can put anything over it — only another top-layer element (a
1046
+ `popover`, or another modal) can. It also makes the rest of the document inert, which means
1047
+ `elementFromPoint` outside the dialog returns the dialog: do not use a hit test to check what is
1048
+ painted on top of a modal, because it will tell you the dialog is, even when it visibly is not. Parts `__header` `__title` `__body` `__footer`
1023
1049
  `__close`. The body scrolls; the footer never clips. `aria-labelledby` → the title's `id`;
1024
1050
  `data-close` on any button that should close it.
1025
1051
 
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.34.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.34.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.51.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.51.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.34.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.34.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.51.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.51.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit — one source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -10,7 +10,9 @@
10
10
  * (var(--color-<name>)) OR a slot "chart-1".."chart-8" (var(--chart-N)) to pin a series to the
11
11
  * validated palette. Both data-fdy-color and data-fdy-colors accept the chart-N form.
12
12
  * Cartesian charts (line/area, multi-series/stacked bar) draw themed axes from --chart-grid /
13
- * --chart-tick and format ticks + tooltips via data-fdy-format (number|percent|currency).
13
+ * --chart-tick and format ticks + tooltips via data-fdy-format (number|percent|currency). Their
14
+ * viewBox is sized to the measured plot (1 user unit = 1 CSS pixel) and repainted from a
15
+ * ResizeObserver, so axis type stays in real pixels and is set in CSS via --fdy-chart-tick-size.
14
16
  * Charts are an enhancement — put a table/text fallback inside the element and it will be
15
17
  * replaced on render; give the element role="img" + aria-label.
16
18
  *
@@ -50,6 +52,18 @@
50
52
  function ensureImg(el) { if (!el.hasAttribute('role')) el.setAttribute('role', 'img'); }
51
53
  function svgEl(name) { return document.createElementNS(NS, name); }
52
54
 
55
+ // Cartesian charts size their viewBox to the measured plot, so a width change needs a repaint.
56
+ // Guarded on width alone: a render can change the element's own height (the legend wrapping to a
57
+ // second row) but never its own width, so this settles instead of looping.
58
+ var cartesianRO = typeof ResizeObserver === 'function' ? new ResizeObserver(function (entries) {
59
+ entries.forEach(function (entry) {
60
+ var target = entry.target;
61
+ if (!target.isConnected) { cartesianRO.unobserve(target); return; }
62
+ var w = Math.round(entry.contentRect.width);
63
+ if (w && w !== target.__fdyChartW) renderChart(target);
64
+ });
65
+ }) : null;
66
+
53
67
  // Multi-series model: data-series (JSON) wins; else the data-values single-series shortcut.
54
68
  function getSeries(el) {
55
69
  var raw = el.getAttribute('data-series');
@@ -194,7 +208,46 @@
194
208
  var top = niceCeil(dataMax > base ? dataMax : base + 1);
195
209
  if (top <= base) top = base + 1;
196
210
 
197
- var W = 320, H = 180, PL = 38, PR = 10, PT = 10, PB = 22;
211
+ // Lay the chrome out first so the plot can be measured, then size the viewBox to it in CSS
212
+ // pixels: one user unit is one pixel, so font-size / r / stroke-width mean what they say at
213
+ // every container width. (A fixed 320x180 viewBox stretched by CSS multiplied every declared
214
+ // size by plotWidth/320 instead — a 9px axis label rendered ~39px on a 1728px viewport.)
215
+ var legendMode = el.getAttribute('data-fdy-legend') || 'auto';
216
+ var showLegend = legendMode !== 'none' && (series.length >= 2 || legendMode === 'always');
217
+ el.classList.add('fdy-chart-xy');
218
+ el.innerHTML = '';
219
+ if (showLegend) {
220
+ var legend = document.createElement('ul');
221
+ legend.className = 'fdy-chart__legend fdy-chart__legend--row';
222
+ series.forEach(function (s, si) {
223
+ var li = document.createElement('li');
224
+ var sw = document.createElement('span'); sw.className = 'fdy-chart__swatch'; sw.style.background = colorFor(si);
225
+ li.appendChild(sw);
226
+ li.appendChild(document.createTextNode(s.label || ('Seri ' + (si + 1))));
227
+ legend.appendChild(li);
228
+ });
229
+ el.appendChild(legend);
230
+ }
231
+ var plot = document.createElement('div'); plot.className = 'fdy-chart-xy__plot';
232
+ el.appendChild(plot);
233
+
234
+ // Measured while empty: .fdy-chart-xy__plot carries its own width / aspect-ratio / min-height,
235
+ // so the box is already final. A detached or display:none chart measures 0 — fall back to the
236
+ // legacy box and let the ResizeObserver repaint it once it is actually laid out.
237
+ var box = plot.getBoundingClientRect();
238
+ var W = Math.round(box.width) || 320, H = Math.round(box.height) || 180;
239
+ var FS = parseFloat(getComputedStyle(plot).fontSize) || 12; // axis type size, owned by CSS
240
+ var charW = FS * 0.6; // mono/body advance is ~0.6em
241
+
242
+ // Tick values are known before the gutter is, so size PL to the widest one: a currency axis
243
+ // needs more room than a percent axis, and a fixed gutter only ever suited one of them.
244
+ var TICKS = 4;
245
+ var tickText = [];
246
+ for (var t0 = 0; t0 <= TICKS; t0++) tickText.push(fmt(base + (top - base) * t0 / TICKS));
247
+ var widestTick = 0;
248
+ tickText.forEach(function (t) { widestTick = Math.max(widestTick, t.length * charW); });
249
+ var PL = Math.min(Math.ceil(widestTick) + 10, Math.round(W * 0.35));
250
+ var PR = 10, PT = 10, PB = Math.round(FS * 2.2);
198
251
  var plotW = W - PL - PR, plotH = H - PT - PB;
199
252
  function mapY(v) { return PT + plotH * (1 - (v - base) / (top - base)); }
200
253
  var slot = plotW / n;
@@ -206,7 +259,6 @@
206
259
  svg.setAttribute('aria-hidden', 'true');
207
260
 
208
261
  // y gridlines + formatted ticks (bottom line at `base` doubles as the zero baseline).
209
- var TICKS = 4;
210
262
  for (var k = 0; k <= TICKS; k++) {
211
263
  var val = base + (top - base) * k / TICKS;
212
264
  var gy = mapY(val);
@@ -216,9 +268,9 @@
216
268
  gl.setAttribute('class', 'fdy-chart-xy__grid');
217
269
  svg.appendChild(gl);
218
270
  var tk = svgEl('text');
219
- tk.setAttribute('x', PL - 5); tk.setAttribute('y', (gy + 3).toFixed(1));
271
+ tk.setAttribute('x', PL - 5); tk.setAttribute('y', (gy + FS * 0.35).toFixed(1));
220
272
  tk.setAttribute('text-anchor', 'end'); tk.setAttribute('class', 'fdy-chart-xy__tick');
221
- tk.textContent = fmt(val);
273
+ tk.textContent = tickText[k];
222
274
  svg.appendChild(tk);
223
275
  }
224
276
 
@@ -226,7 +278,6 @@
226
278
  // of assuming a fixed 40px slot; keep every step-th plus the last, then drop any kept label that
227
279
  // would collide with the one before it — including the forced last one (a fraction-of-a-slot
228
280
  // overlap happens when n-1 lands next to the last kept multiple of step, e.g. n=24 at most widths).
229
- var charW = 5.4; // ~9px font advance (.fdy-chart-xy__xlabel)
230
281
  var widest = 0;
231
282
  for (var wi = 0; wi < n; wi++) widest = Math.max(widest, String(labels[wi] || ('#' + (wi + 1))).length * charW);
232
283
  var minGap = widest + 8;
@@ -242,7 +293,7 @@
242
293
  }
243
294
  kept.forEach(function (xk) {
244
295
  var xt = svgEl('text');
245
- xt.setAttribute('x', xPos(xk).toFixed(1)); xt.setAttribute('y', H - 6);
296
+ xt.setAttribute('x', xPos(xk).toFixed(1)); xt.setAttribute('y', H - Math.round(FS * 0.7));
246
297
  xt.setAttribute('text-anchor', 'middle'); xt.setAttribute('class', 'fdy-chart-xy__xlabel');
247
298
  xt.textContent = labels[xk] || ('#' + (xk + 1));
248
299
  svg.appendChild(xt);
@@ -268,7 +319,7 @@
268
319
  var rect = svgEl('rect');
269
320
  rect.setAttribute('x', x.toFixed(1)); rect.setAttribute('width', Math.max(1, barW - 1).toFixed(1));
270
321
  rect.setAttribute('y', Math.min(y0, y1).toFixed(1)); rect.setAttribute('height', Math.max(0.5, Math.abs(y1 - y0)).toFixed(1));
271
- rect.setAttribute('rx', '1.5'); rect.setAttribute('class', 'fdy-chart-xy__bar');
322
+ rect.setAttribute('rx', '2'); rect.setAttribute('class', 'fdy-chart-xy__bar');
272
323
  rect.style.fill = colorFor(si);
273
324
  svg.appendChild(rect);
274
325
  });
@@ -296,33 +347,14 @@
296
347
  }
297
348
  pts.forEach(function (p) {
298
349
  var dot = svgEl('circle');
299
- dot.setAttribute('cx', p[0].toFixed(1)); dot.setAttribute('cy', p[1].toFixed(1)); dot.setAttribute('r', '2.2');
350
+ dot.setAttribute('cx', p[0].toFixed(1)); dot.setAttribute('cy', p[1].toFixed(1)); dot.setAttribute('r', '3');
300
351
  dot.setAttribute('class', 'fdy-chart-xy__dot'); dot.style.fill = colorFor(si);
301
352
  svg.appendChild(dot);
302
353
  });
303
354
  });
304
355
  }
305
356
 
306
- // Assemble: optional legend on top, plot below, tooltip layer last.
307
- var legendMode = el.getAttribute('data-fdy-legend') || 'auto';
308
- var showLegend = legendMode !== 'none' && (series.length >= 2 || legendMode === 'always');
309
- el.classList.add('fdy-chart-xy');
310
- el.innerHTML = '';
311
- if (showLegend) {
312
- var legend = document.createElement('ul');
313
- legend.className = 'fdy-chart__legend fdy-chart__legend--row';
314
- series.forEach(function (s, si) {
315
- var li = document.createElement('li');
316
- var sw = document.createElement('span'); sw.className = 'fdy-chart__swatch'; sw.style.background = colorFor(si);
317
- li.appendChild(sw);
318
- li.appendChild(document.createTextNode(s.label || ('Seri ' + (si + 1))));
319
- legend.appendChild(li);
320
- });
321
- el.appendChild(legend);
322
- }
323
- var plot = document.createElement('div'); plot.className = 'fdy-chart-xy__plot';
324
357
  plot.appendChild(svg);
325
- el.appendChild(plot);
326
358
 
327
359
  // Hover: one transparent band per category → readout of every series at that x.
328
360
  var tip = makeTip(el);
@@ -342,6 +374,8 @@
342
374
  band.addEventListener('mouseleave', function () { hideTip(tip); });
343
375
  svg.appendChild(band);
344
376
  }
377
+ el.__fdyChartW = W;
378
+ if (cartesianRO) cartesianRO.observe(el);
345
379
  ensureImg(el);
346
380
  }
347
381
 
@@ -28,11 +28,39 @@
28
28
  region.className = 'fdy-toast-region';
29
29
  region.setAttribute('aria-live', 'polite');
30
30
  region.setAttribute('aria-atomic', 'false');
31
+ /* `manual`, not `auto`: an auto popover light-dismisses on an outside click and closes
32
+ itself the moment another popover opens. A toast is dismissed by its timer or its own
33
+ close button, by nobody else. */
34
+ region.setAttribute('popover', 'manual');
31
35
  document.body.appendChild(region);
32
36
  }
33
37
  return region;
34
38
  }
35
39
 
40
+ /* Put the region in the TOP LAYER, above any open <dialog>.
41
+ *
42
+ * A modal dialog lives in the top layer, where z-index does not reach — so a toast raised by an
43
+ * action taken inside a modal was painted behind that modal's backdrop, which is where an error
44
+ * message is least useful. showPopover() joins the same layer, and membership there is ordered by
45
+ * when you joined: hide-then-show re-joins at the top, so a toast shown while a dialog is open
46
+ * lands above it rather than under the dialog that was opened after the last toast.
47
+ *
48
+ * Guarded twice on purpose. `showPopover` is absent before Chrome 114 / Safari 17 / Firefox 125,
49
+ * and the call throws if the element is disconnected or already open — in every one of those
50
+ * cases the region stays an ordinary fixed block at z-index 200, which is what it has always
51
+ * been. A notification must never be the thing that throws.
52
+ */
53
+ function raise(node) {
54
+ if (!node || typeof node.showPopover !== 'function') return;
55
+ try {
56
+ if (node.matches(':popover-open')) node.hidePopover();
57
+ node.showPopover();
58
+ } catch (e) {
59
+ /* Not reachable through the guards above, but a toast is on the error path itself: if
60
+ raising it fails the message still has to appear, one layer down. */
61
+ }
62
+ }
63
+
36
64
  function dismiss(toast) {
37
65
  if (typeof toast === 'string') toast = keyed[toast]; // dismiss by key
38
66
  if (!toast || toast.dataset.leaving === '1') return;
@@ -110,6 +138,9 @@
110
138
  keyed[opts.key] = node;
111
139
  }
112
140
  region.appendChild(node);
141
+ // Raised per toast, not once at startup: a dialog opened AFTER the region joined the top layer
142
+ // would otherwise sit above it, which is the exact case this fixes.
143
+ raise(region);
113
144
 
114
145
  var timeout = opts.timeout == null ? 4000 : opts.timeout;
115
146
  if (timeout > 0) setTimeout(function () { dismiss(node); }, timeout);
@@ -972,12 +972,12 @@ a { color: var(--color-primary); }
972
972
  /* Cartesian charts (line / area / multi-series & stacked bar) — SVG with themed axes.
973
973
  * Axis colour comes from --chart-grid / --chart-tick (both theme-aware); series colours are
974
974
  * set inline by the renderer from --chart-1..8, so the whole chart recolours on data-theme. */
975
- .fdy-chart-xy{position:relative;display:flex;flex-direction:column;gap:var(--space-3);width:100%;}
976
- .fdy-chart-xy__plot{width:100%;aspect-ratio:16/9;min-height:8rem;}
975
+ .fdy-chart-xy{position:relative;display:flex;flex-direction:column;gap:var(--space-3);width:100%;--fdy-chart-tick-size:var(--text-xs);}
976
+ .fdy-chart-xy__plot{width:100%;aspect-ratio:16/9;min-height:8rem;font-size:var(--fdy-chart-tick-size);}
977
977
  .fdy-chart-xy__plot svg{display:block;width:100%;height:100%;overflow:visible;}
978
978
  .fdy-chart-xy__grid{stroke:var(--chart-grid);stroke-width:1;shape-rendering:crispEdges;}
979
- .fdy-chart-xy__tick{fill:var(--chart-tick);font-family:var(--font-mono);font-size:9px;}
980
- .fdy-chart-xy__xlabel{fill:var(--chart-tick);font-family:var(--font-body);font-size:9px;}
979
+ .fdy-chart-xy__tick{fill:var(--chart-tick);font-family:var(--font-mono);}
980
+ .fdy-chart-xy__xlabel{fill:var(--chart-tick);font-family:var(--font-body);}
981
981
  .fdy-chart-xy__line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round;vector-effect:non-scaling-stroke;}
982
982
  .fdy-chart-xy__area{stroke:none;opacity:.14;}
983
983
  .fdy-chart-xy__dot{stroke:var(--color-surface);stroke-width:1;}
@@ -1579,8 +1579,15 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1579
1579
  .fdy-rating--sm .fdy-rating__star svg{width:1.1rem;height:1.1rem;}
1580
1580
 
1581
1581
  /* Freeday — Selection controls */
1582
+ /* `flex:none` on every box, track and standalone checkbox is load-bearing, not tidiness.
1583
+ `.fdy-check` is an inline-flex row of [box, label], and a flex item with a WIDTH is still
1584
+ shrinkable — so as soon as the label wrapped to a second line the box gave up horizontal space
1585
+ to it and rendered an 18px-tall rectangle 13px wide. Squashed, off-square, and different from
1586
+ every sibling whose label happened to be short: a form of five checkboxes showed three sizes.
1587
+ Nothing in the declarations looked wrong, because the width was never overridden — it was
1588
+ negotiated away. */
1582
1589
  .fdy-check,.fdy-radio,.fdy-switch{display:inline-flex;align-items:center;gap:var(--space-2);font-size:var(--text-sm);color:var(--color-text);cursor:pointer;}
1583
- .fdy-check input,.fdy-radio input{appearance:none;-webkit-appearance:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1590
+ .fdy-check input,.fdy-radio input{appearance:none;-webkit-appearance:none;flex:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1584
1591
  .fdy-check input{border-radius:var(--radius-sm);}
1585
1592
  .fdy-radio input{border-radius:50%;}
1586
1593
  .fdy-check input::before{content:"";width:.62rem;height:.62rem;transform:scale(0);transition:transform var(--dur-fast) var(--ease-spring);box-shadow:inset 1rem 1rem var(--color-on-primary);clip-path:polygon(14% 44%,0 65%,50% 100%,100% 16%,80% 0,43% 62%);}
@@ -1590,13 +1597,13 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1590
1597
  .fdy-check input:focus-visible,.fdy-radio input:focus-visible,.fdy-switch input:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1591
1598
  .fdy-check input:disabled,.fdy-radio input:disabled,.fdy-switch input:disabled{opacity:.5;cursor:not-allowed;}
1592
1599
  .fdy-check:has(input:disabled),.fdy-radio:has(input:disabled),.fdy-switch:has(input:disabled){cursor:not-allowed;}
1593
- .fdy-switch input{appearance:none;-webkit-appearance:none;position:relative;width:var(--control-switch-w);height:var(--control-switch-h);margin:0;border-radius:var(--radius-full);background:var(--color-control-border);cursor:pointer;transition:background var(--dur-base) var(--ease-standard);}
1600
+ .fdy-switch input{appearance:none;-webkit-appearance:none;flex:none;position:relative;width:var(--control-switch-w);height:var(--control-switch-h);margin:0;border-radius:var(--radius-full);background:var(--color-control-border);cursor:pointer;transition:background var(--dur-base) var(--ease-standard);}
1594
1601
  .fdy-switch input::before{content:"";position:absolute;top:2px;left:2px;width:calc(var(--control-switch-h) - 4px);height:calc(var(--control-switch-h) - 4px);border-radius:50%;background:var(--color-switch-thumb);box-shadow:var(--shadow-1);transition:transform var(--dur-base) var(--ease-spring);}
1595
1602
  .fdy-switch input:checked{background:var(--color-primary);}
1596
1603
  .fdy-switch input:checked::before{transform:translateX(calc(var(--control-switch-w) - var(--control-switch-h)));}
1597
1604
 
1598
1605
  /* Standalone checkbox (e.g. table row/select-all) — supports :indeterminate */
1599
- .fdy-checkbox{appearance:none;-webkit-appearance:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);border-radius:var(--radius-sm);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1606
+ .fdy-checkbox{appearance:none;-webkit-appearance:none;flex:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);border-radius:var(--radius-sm);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1600
1607
  .fdy-checkbox::before{content:"";width:.62rem;height:.62rem;transform:scale(0);transition:transform var(--dur-fast) var(--ease-spring);box-shadow:inset 1rem 1rem var(--color-on-primary);clip-path:polygon(14% 44%,0 65%,50% 100%,100% 16%,80% 0,43% 62%);}
1601
1608
  .fdy-checkbox:checked,.fdy-checkbox:indeterminate{background:var(--color-primary);border-color:var(--color-primary);}
1602
1609
  .fdy-checkbox:checked::before{transform:scale(1);}
@@ -1869,7 +1876,19 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1869
1876
  @media (prefers-reduced-motion:no-preference){.fdy-timepicker__panel:not([hidden]){animation:fdy-combo-in var(--dur-fast) var(--ease-standard);}}
1870
1877
 
1871
1878
  /* Freeday — Toast (transient notification) */
1872
- .fdy-toast-region{position:fixed;z-index:200;bottom:var(--space-5);right:var(--space-5);display:flex;flex-direction:column;gap:var(--space-3);width:min(24rem,calc(100vw - var(--space-8)));pointer-events:none;}
1879
+ /* The region is a POPOVER, not merely a high z-index.
1880
+ `.fdy-modal` is a native <dialog> opened with showModal(), which puts it in the TOP LAYER — a
1881
+ layer above the whole z-index universe, so no number here could ever beat it. A toast raised by
1882
+ an action taken inside a modal was painted behind that modal's backdrop: dimmed, unreadable, and
1883
+ most of it under the dialog itself. `freeday-toast.js` calls showPopover() as each toast is
1884
+ added, which puts the region in the top layer too, and last-in wins there.
1885
+ Everything after `pointer-events` is undoing the UA's [popover] defaults — it arrives with
1886
+ inset:0, auto margins, a border, padding, a Canvas background and overflow:auto, none of which a
1887
+ toast stack wants. Where showPopover() is unavailable the element is still an ordinary fixed
1888
+ block at z-index 200, which is exactly what it was before. */
1889
+ .fdy-toast-region{position:fixed;z-index:200;inset:auto;bottom:var(--space-5);right:var(--space-5);display:flex;flex-direction:column;gap:var(--space-3);width:min(24rem,calc(100vw - var(--space-8)));height:auto;max-height:none;margin:0;padding:0;border:0;background:transparent;color:inherit;overflow:visible;pointer-events:none;}
1890
+ /* A popover gets a ::backdrop as well; a toast must not dim the page behind it. */
1891
+ .fdy-toast-region::backdrop{background:transparent;}
1873
1892
  .fdy-toast{pointer-events:auto;display:flex;gap:var(--space-3);align-items:flex-start;padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);background:var(--color-inverse-surface);color:var(--color-inverse-text);box-shadow:var(--shadow-3);}
1874
1893
  .fdy-toast__accent{flex:none;width:.25rem;align-self:stretch;border-radius:var(--radius-full);background:var(--color-primary);}
1875
1894
  .fdy-toast--success .fdy-toast__accent{background:var(--color-success);}
package/dist/freeday.css CHANGED
@@ -589,12 +589,12 @@ a { color: var(--color-primary); }
589
589
  /* Cartesian charts (line / area / multi-series & stacked bar) — SVG with themed axes.
590
590
  * Axis colour comes from --chart-grid / --chart-tick (both theme-aware); series colours are
591
591
  * set inline by the renderer from --chart-1..8, so the whole chart recolours on data-theme. */
592
- .fdy-chart-xy{position:relative;display:flex;flex-direction:column;gap:var(--space-3);width:100%;}
593
- .fdy-chart-xy__plot{width:100%;aspect-ratio:16/9;min-height:8rem;}
592
+ .fdy-chart-xy{position:relative;display:flex;flex-direction:column;gap:var(--space-3);width:100%;--fdy-chart-tick-size:var(--text-xs);}
593
+ .fdy-chart-xy__plot{width:100%;aspect-ratio:16/9;min-height:8rem;font-size:var(--fdy-chart-tick-size);}
594
594
  .fdy-chart-xy__plot svg{display:block;width:100%;height:100%;overflow:visible;}
595
595
  .fdy-chart-xy__grid{stroke:var(--chart-grid);stroke-width:1;shape-rendering:crispEdges;}
596
- .fdy-chart-xy__tick{fill:var(--chart-tick);font-family:var(--font-mono);font-size:9px;}
597
- .fdy-chart-xy__xlabel{fill:var(--chart-tick);font-family:var(--font-body);font-size:9px;}
596
+ .fdy-chart-xy__tick{fill:var(--chart-tick);font-family:var(--font-mono);}
597
+ .fdy-chart-xy__xlabel{fill:var(--chart-tick);font-family:var(--font-body);}
598
598
  .fdy-chart-xy__line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round;vector-effect:non-scaling-stroke;}
599
599
  .fdy-chart-xy__area{stroke:none;opacity:.14;}
600
600
  .fdy-chart-xy__dot{stroke:var(--color-surface);stroke-width:1;}
@@ -1196,8 +1196,15 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1196
1196
  .fdy-rating--sm .fdy-rating__star svg{width:1.1rem;height:1.1rem;}
1197
1197
 
1198
1198
  /* Freeday — Selection controls */
1199
+ /* `flex:none` on every box, track and standalone checkbox is load-bearing, not tidiness.
1200
+ `.fdy-check` is an inline-flex row of [box, label], and a flex item with a WIDTH is still
1201
+ shrinkable — so as soon as the label wrapped to a second line the box gave up horizontal space
1202
+ to it and rendered an 18px-tall rectangle 13px wide. Squashed, off-square, and different from
1203
+ every sibling whose label happened to be short: a form of five checkboxes showed three sizes.
1204
+ Nothing in the declarations looked wrong, because the width was never overridden — it was
1205
+ negotiated away. */
1199
1206
  .fdy-check,.fdy-radio,.fdy-switch{display:inline-flex;align-items:center;gap:var(--space-2);font-size:var(--text-sm);color:var(--color-text);cursor:pointer;}
1200
- .fdy-check input,.fdy-radio input{appearance:none;-webkit-appearance:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1207
+ .fdy-check input,.fdy-radio input{appearance:none;-webkit-appearance:none;flex:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1201
1208
  .fdy-check input{border-radius:var(--radius-sm);}
1202
1209
  .fdy-radio input{border-radius:50%;}
1203
1210
  .fdy-check input::before{content:"";width:.62rem;height:.62rem;transform:scale(0);transition:transform var(--dur-fast) var(--ease-spring);box-shadow:inset 1rem 1rem var(--color-on-primary);clip-path:polygon(14% 44%,0 65%,50% 100%,100% 16%,80% 0,43% 62%);}
@@ -1207,13 +1214,13 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1207
1214
  .fdy-check input:focus-visible,.fdy-radio input:focus-visible,.fdy-switch input:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1208
1215
  .fdy-check input:disabled,.fdy-radio input:disabled,.fdy-switch input:disabled{opacity:.5;cursor:not-allowed;}
1209
1216
  .fdy-check:has(input:disabled),.fdy-radio:has(input:disabled),.fdy-switch:has(input:disabled){cursor:not-allowed;}
1210
- .fdy-switch input{appearance:none;-webkit-appearance:none;position:relative;width:var(--control-switch-w);height:var(--control-switch-h);margin:0;border-radius:var(--radius-full);background:var(--color-control-border);cursor:pointer;transition:background var(--dur-base) var(--ease-standard);}
1217
+ .fdy-switch input{appearance:none;-webkit-appearance:none;flex:none;position:relative;width:var(--control-switch-w);height:var(--control-switch-h);margin:0;border-radius:var(--radius-full);background:var(--color-control-border);cursor:pointer;transition:background var(--dur-base) var(--ease-standard);}
1211
1218
  .fdy-switch input::before{content:"";position:absolute;top:2px;left:2px;width:calc(var(--control-switch-h) - 4px);height:calc(var(--control-switch-h) - 4px);border-radius:50%;background:var(--color-switch-thumb);box-shadow:var(--shadow-1);transition:transform var(--dur-base) var(--ease-spring);}
1212
1219
  .fdy-switch input:checked{background:var(--color-primary);}
1213
1220
  .fdy-switch input:checked::before{transform:translateX(calc(var(--control-switch-w) - var(--control-switch-h)));}
1214
1221
 
1215
1222
  /* Standalone checkbox (e.g. table row/select-all) — supports :indeterminate */
1216
- .fdy-checkbox{appearance:none;-webkit-appearance:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);border-radius:var(--radius-sm);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1223
+ .fdy-checkbox{appearance:none;-webkit-appearance:none;flex:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);border-radius:var(--radius-sm);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
1217
1224
  .fdy-checkbox::before{content:"";width:.62rem;height:.62rem;transform:scale(0);transition:transform var(--dur-fast) var(--ease-spring);box-shadow:inset 1rem 1rem var(--color-on-primary);clip-path:polygon(14% 44%,0 65%,50% 100%,100% 16%,80% 0,43% 62%);}
1218
1225
  .fdy-checkbox:checked,.fdy-checkbox:indeterminate{background:var(--color-primary);border-color:var(--color-primary);}
1219
1226
  .fdy-checkbox:checked::before{transform:scale(1);}
@@ -1486,7 +1493,19 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1486
1493
  @media (prefers-reduced-motion:no-preference){.fdy-timepicker__panel:not([hidden]){animation:fdy-combo-in var(--dur-fast) var(--ease-standard);}}
1487
1494
 
1488
1495
  /* Freeday — Toast (transient notification) */
1489
- .fdy-toast-region{position:fixed;z-index:200;bottom:var(--space-5);right:var(--space-5);display:flex;flex-direction:column;gap:var(--space-3);width:min(24rem,calc(100vw - var(--space-8)));pointer-events:none;}
1496
+ /* The region is a POPOVER, not merely a high z-index.
1497
+ `.fdy-modal` is a native <dialog> opened with showModal(), which puts it in the TOP LAYER — a
1498
+ layer above the whole z-index universe, so no number here could ever beat it. A toast raised by
1499
+ an action taken inside a modal was painted behind that modal's backdrop: dimmed, unreadable, and
1500
+ most of it under the dialog itself. `freeday-toast.js` calls showPopover() as each toast is
1501
+ added, which puts the region in the top layer too, and last-in wins there.
1502
+ Everything after `pointer-events` is undoing the UA's [popover] defaults — it arrives with
1503
+ inset:0, auto margins, a border, padding, a Canvas background and overflow:auto, none of which a
1504
+ toast stack wants. Where showPopover() is unavailable the element is still an ordinary fixed
1505
+ block at z-index 200, which is exactly what it was before. */
1506
+ .fdy-toast-region{position:fixed;z-index:200;inset:auto;bottom:var(--space-5);right:var(--space-5);display:flex;flex-direction:column;gap:var(--space-3);width:min(24rem,calc(100vw - var(--space-8)));height:auto;max-height:none;margin:0;padding:0;border:0;background:transparent;color:inherit;overflow:visible;pointer-events:none;}
1507
+ /* A popover gets a ::backdrop as well; a toast must not dim the page behind it. */
1508
+ .fdy-toast-region::backdrop{background:transparent;}
1490
1509
  .fdy-toast{pointer-events:auto;display:flex;gap:var(--space-3);align-items:flex-start;padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);background:var(--color-inverse-surface);color:var(--color-inverse-text);box-shadow:var(--shadow-3);}
1491
1510
  .fdy-toast__accent{flex:none;width:.25rem;align-self:stretch;border-radius:var(--radius-full);background:var(--color-primary);}
1492
1511
  .fdy-toast--success .fdy-toast__accent{background:var(--color-success);}
package/dist/freeday.js CHANGED
@@ -874,7 +874,9 @@
874
874
  * (var(--color-<name>)) OR a slot "chart-1".."chart-8" (var(--chart-N)) to pin a series to the
875
875
  * validated palette. Both data-fdy-color and data-fdy-colors accept the chart-N form.
876
876
  * Cartesian charts (line/area, multi-series/stacked bar) draw themed axes from --chart-grid /
877
- * --chart-tick and format ticks + tooltips via data-fdy-format (number|percent|currency).
877
+ * --chart-tick and format ticks + tooltips via data-fdy-format (number|percent|currency). Their
878
+ * viewBox is sized to the measured plot (1 user unit = 1 CSS pixel) and repainted from a
879
+ * ResizeObserver, so axis type stays in real pixels and is set in CSS via --fdy-chart-tick-size.
878
880
  * Charts are an enhancement — put a table/text fallback inside the element and it will be
879
881
  * replaced on render; give the element role="img" + aria-label.
880
882
  *
@@ -914,6 +916,18 @@
914
916
  function ensureImg(el) { if (!el.hasAttribute('role')) el.setAttribute('role', 'img'); }
915
917
  function svgEl(name) { return document.createElementNS(NS, name); }
916
918
 
919
+ // Cartesian charts size their viewBox to the measured plot, so a width change needs a repaint.
920
+ // Guarded on width alone: a render can change the element's own height (the legend wrapping to a
921
+ // second row) but never its own width, so this settles instead of looping.
922
+ var cartesianRO = typeof ResizeObserver === 'function' ? new ResizeObserver(function (entries) {
923
+ entries.forEach(function (entry) {
924
+ var target = entry.target;
925
+ if (!target.isConnected) { cartesianRO.unobserve(target); return; }
926
+ var w = Math.round(entry.contentRect.width);
927
+ if (w && w !== target.__fdyChartW) renderChart(target);
928
+ });
929
+ }) : null;
930
+
917
931
  // Multi-series model: data-series (JSON) wins; else the data-values single-series shortcut.
918
932
  function getSeries(el) {
919
933
  var raw = el.getAttribute('data-series');
@@ -1058,7 +1072,46 @@
1058
1072
  var top = niceCeil(dataMax > base ? dataMax : base + 1);
1059
1073
  if (top <= base) top = base + 1;
1060
1074
 
1061
- var W = 320, H = 180, PL = 38, PR = 10, PT = 10, PB = 22;
1075
+ // Lay the chrome out first so the plot can be measured, then size the viewBox to it in CSS
1076
+ // pixels: one user unit is one pixel, so font-size / r / stroke-width mean what they say at
1077
+ // every container width. (A fixed 320x180 viewBox stretched by CSS multiplied every declared
1078
+ // size by plotWidth/320 instead — a 9px axis label rendered ~39px on a 1728px viewport.)
1079
+ var legendMode = el.getAttribute('data-fdy-legend') || 'auto';
1080
+ var showLegend = legendMode !== 'none' && (series.length >= 2 || legendMode === 'always');
1081
+ el.classList.add('fdy-chart-xy');
1082
+ el.innerHTML = '';
1083
+ if (showLegend) {
1084
+ var legend = document.createElement('ul');
1085
+ legend.className = 'fdy-chart__legend fdy-chart__legend--row';
1086
+ series.forEach(function (s, si) {
1087
+ var li = document.createElement('li');
1088
+ var sw = document.createElement('span'); sw.className = 'fdy-chart__swatch'; sw.style.background = colorFor(si);
1089
+ li.appendChild(sw);
1090
+ li.appendChild(document.createTextNode(s.label || ('Seri ' + (si + 1))));
1091
+ legend.appendChild(li);
1092
+ });
1093
+ el.appendChild(legend);
1094
+ }
1095
+ var plot = document.createElement('div'); plot.className = 'fdy-chart-xy__plot';
1096
+ el.appendChild(plot);
1097
+
1098
+ // Measured while empty: .fdy-chart-xy__plot carries its own width / aspect-ratio / min-height,
1099
+ // so the box is already final. A detached or display:none chart measures 0 — fall back to the
1100
+ // legacy box and let the ResizeObserver repaint it once it is actually laid out.
1101
+ var box = plot.getBoundingClientRect();
1102
+ var W = Math.round(box.width) || 320, H = Math.round(box.height) || 180;
1103
+ var FS = parseFloat(getComputedStyle(plot).fontSize) || 12; // axis type size, owned by CSS
1104
+ var charW = FS * 0.6; // mono/body advance is ~0.6em
1105
+
1106
+ // Tick values are known before the gutter is, so size PL to the widest one: a currency axis
1107
+ // needs more room than a percent axis, and a fixed gutter only ever suited one of them.
1108
+ var TICKS = 4;
1109
+ var tickText = [];
1110
+ for (var t0 = 0; t0 <= TICKS; t0++) tickText.push(fmt(base + (top - base) * t0 / TICKS));
1111
+ var widestTick = 0;
1112
+ tickText.forEach(function (t) { widestTick = Math.max(widestTick, t.length * charW); });
1113
+ var PL = Math.min(Math.ceil(widestTick) + 10, Math.round(W * 0.35));
1114
+ var PR = 10, PT = 10, PB = Math.round(FS * 2.2);
1062
1115
  var plotW = W - PL - PR, plotH = H - PT - PB;
1063
1116
  function mapY(v) { return PT + plotH * (1 - (v - base) / (top - base)); }
1064
1117
  var slot = plotW / n;
@@ -1070,7 +1123,6 @@
1070
1123
  svg.setAttribute('aria-hidden', 'true');
1071
1124
 
1072
1125
  // y gridlines + formatted ticks (bottom line at `base` doubles as the zero baseline).
1073
- var TICKS = 4;
1074
1126
  for (var k = 0; k <= TICKS; k++) {
1075
1127
  var val = base + (top - base) * k / TICKS;
1076
1128
  var gy = mapY(val);
@@ -1080,9 +1132,9 @@
1080
1132
  gl.setAttribute('class', 'fdy-chart-xy__grid');
1081
1133
  svg.appendChild(gl);
1082
1134
  var tk = svgEl('text');
1083
- tk.setAttribute('x', PL - 5); tk.setAttribute('y', (gy + 3).toFixed(1));
1135
+ tk.setAttribute('x', PL - 5); tk.setAttribute('y', (gy + FS * 0.35).toFixed(1));
1084
1136
  tk.setAttribute('text-anchor', 'end'); tk.setAttribute('class', 'fdy-chart-xy__tick');
1085
- tk.textContent = fmt(val);
1137
+ tk.textContent = tickText[k];
1086
1138
  svg.appendChild(tk);
1087
1139
  }
1088
1140
 
@@ -1090,7 +1142,6 @@
1090
1142
  // of assuming a fixed 40px slot; keep every step-th plus the last, then drop any kept label that
1091
1143
  // would collide with the one before it — including the forced last one (a fraction-of-a-slot
1092
1144
  // overlap happens when n-1 lands next to the last kept multiple of step, e.g. n=24 at most widths).
1093
- var charW = 5.4; // ~9px font advance (.fdy-chart-xy__xlabel)
1094
1145
  var widest = 0;
1095
1146
  for (var wi = 0; wi < n; wi++) widest = Math.max(widest, String(labels[wi] || ('#' + (wi + 1))).length * charW);
1096
1147
  var minGap = widest + 8;
@@ -1106,7 +1157,7 @@
1106
1157
  }
1107
1158
  kept.forEach(function (xk) {
1108
1159
  var xt = svgEl('text');
1109
- xt.setAttribute('x', xPos(xk).toFixed(1)); xt.setAttribute('y', H - 6);
1160
+ xt.setAttribute('x', xPos(xk).toFixed(1)); xt.setAttribute('y', H - Math.round(FS * 0.7));
1110
1161
  xt.setAttribute('text-anchor', 'middle'); xt.setAttribute('class', 'fdy-chart-xy__xlabel');
1111
1162
  xt.textContent = labels[xk] || ('#' + (xk + 1));
1112
1163
  svg.appendChild(xt);
@@ -1132,7 +1183,7 @@
1132
1183
  var rect = svgEl('rect');
1133
1184
  rect.setAttribute('x', x.toFixed(1)); rect.setAttribute('width', Math.max(1, barW - 1).toFixed(1));
1134
1185
  rect.setAttribute('y', Math.min(y0, y1).toFixed(1)); rect.setAttribute('height', Math.max(0.5, Math.abs(y1 - y0)).toFixed(1));
1135
- rect.setAttribute('rx', '1.5'); rect.setAttribute('class', 'fdy-chart-xy__bar');
1186
+ rect.setAttribute('rx', '2'); rect.setAttribute('class', 'fdy-chart-xy__bar');
1136
1187
  rect.style.fill = colorFor(si);
1137
1188
  svg.appendChild(rect);
1138
1189
  });
@@ -1160,33 +1211,14 @@
1160
1211
  }
1161
1212
  pts.forEach(function (p) {
1162
1213
  var dot = svgEl('circle');
1163
- dot.setAttribute('cx', p[0].toFixed(1)); dot.setAttribute('cy', p[1].toFixed(1)); dot.setAttribute('r', '2.2');
1214
+ dot.setAttribute('cx', p[0].toFixed(1)); dot.setAttribute('cy', p[1].toFixed(1)); dot.setAttribute('r', '3');
1164
1215
  dot.setAttribute('class', 'fdy-chart-xy__dot'); dot.style.fill = colorFor(si);
1165
1216
  svg.appendChild(dot);
1166
1217
  });
1167
1218
  });
1168
1219
  }
1169
1220
 
1170
- // Assemble: optional legend on top, plot below, tooltip layer last.
1171
- var legendMode = el.getAttribute('data-fdy-legend') || 'auto';
1172
- var showLegend = legendMode !== 'none' && (series.length >= 2 || legendMode === 'always');
1173
- el.classList.add('fdy-chart-xy');
1174
- el.innerHTML = '';
1175
- if (showLegend) {
1176
- var legend = document.createElement('ul');
1177
- legend.className = 'fdy-chart__legend fdy-chart__legend--row';
1178
- series.forEach(function (s, si) {
1179
- var li = document.createElement('li');
1180
- var sw = document.createElement('span'); sw.className = 'fdy-chart__swatch'; sw.style.background = colorFor(si);
1181
- li.appendChild(sw);
1182
- li.appendChild(document.createTextNode(s.label || ('Seri ' + (si + 1))));
1183
- legend.appendChild(li);
1184
- });
1185
- el.appendChild(legend);
1186
- }
1187
- var plot = document.createElement('div'); plot.className = 'fdy-chart-xy__plot';
1188
1221
  plot.appendChild(svg);
1189
- el.appendChild(plot);
1190
1222
 
1191
1223
  // Hover: one transparent band per category → readout of every series at that x.
1192
1224
  var tip = makeTip(el);
@@ -1206,6 +1238,8 @@
1206
1238
  band.addEventListener('mouseleave', function () { hideTip(tip); });
1207
1239
  svg.appendChild(band);
1208
1240
  }
1241
+ el.__fdyChartW = W;
1242
+ if (cartesianRO) cartesianRO.observe(el);
1209
1243
  ensureImg(el);
1210
1244
  }
1211
1245
 
@@ -3929,11 +3963,39 @@
3929
3963
  region.className = 'fdy-toast-region';
3930
3964
  region.setAttribute('aria-live', 'polite');
3931
3965
  region.setAttribute('aria-atomic', 'false');
3966
+ /* `manual`, not `auto`: an auto popover light-dismisses on an outside click and closes
3967
+ itself the moment another popover opens. A toast is dismissed by its timer or its own
3968
+ close button, by nobody else. */
3969
+ region.setAttribute('popover', 'manual');
3932
3970
  document.body.appendChild(region);
3933
3971
  }
3934
3972
  return region;
3935
3973
  }
3936
3974
 
3975
+ /* Put the region in the TOP LAYER, above any open <dialog>.
3976
+ *
3977
+ * A modal dialog lives in the top layer, where z-index does not reach — so a toast raised by an
3978
+ * action taken inside a modal was painted behind that modal's backdrop, which is where an error
3979
+ * message is least useful. showPopover() joins the same layer, and membership there is ordered by
3980
+ * when you joined: hide-then-show re-joins at the top, so a toast shown while a dialog is open
3981
+ * lands above it rather than under the dialog that was opened after the last toast.
3982
+ *
3983
+ * Guarded twice on purpose. `showPopover` is absent before Chrome 114 / Safari 17 / Firefox 125,
3984
+ * and the call throws if the element is disconnected or already open — in every one of those
3985
+ * cases the region stays an ordinary fixed block at z-index 200, which is what it has always
3986
+ * been. A notification must never be the thing that throws.
3987
+ */
3988
+ function raise(node) {
3989
+ if (!node || typeof node.showPopover !== 'function') return;
3990
+ try {
3991
+ if (node.matches(':popover-open')) node.hidePopover();
3992
+ node.showPopover();
3993
+ } catch (e) {
3994
+ /* Not reachable through the guards above, but a toast is on the error path itself: if
3995
+ raising it fails the message still has to appear, one layer down. */
3996
+ }
3997
+ }
3998
+
3937
3999
  function dismiss(toast) {
3938
4000
  if (typeof toast === 'string') toast = keyed[toast]; // dismiss by key
3939
4001
  if (!toast || toast.dataset.leaving === '1') return;
@@ -4011,6 +4073,9 @@
4011
4073
  keyed[opts.key] = node;
4012
4074
  }
4013
4075
  region.appendChild(node);
4076
+ // Raised per toast, not once at startup: a dialog opened AFTER the region joined the top layer
4077
+ // would otherwise sit above it, which is the exact case this fixes.
4078
+ raise(region);
4014
4079
 
4015
4080
  var timeout = opts.timeout == null ? 4000 : opts.timeout;
4016
4081
  if (timeout > 0) setTimeout(function () { dismiss(node); }, timeout);
@@ -171,11 +171,6 @@
171
171
  data-fdy-format="number"
172
172
  role="img"
173
173
  aria-label="Invoiced versus collected, in millions: invoiced rose from 62 to 88, collected from 54 to 81">
174
- <table>
175
- <caption>Invoiced vs collected (millions)</caption>
176
- <tr><th scope="col">Month</th><th scope="col">Invoiced</th><th scope="col">Collected</th></tr>
177
- <tr><td>Jul</td><td>88</td><td>81</td></tr>
178
- </table>
179
174
  </div>
180
175
  </div>
181
176
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.49.0",
3
+ "version": "1.51.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -92,7 +92,7 @@
92
92
  "scripts": {
93
93
  "build": "node tokens/build.mjs",
94
94
  "test": "node --test",
95
- "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.mjs browser/text-override.mjs browser/control-heights.mjs browser/cfl-multi.mjs browser/crowding.mjs",
95
+ "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.mjs browser/text-override.mjs browser/control-heights.mjs browser/cfl-multi.mjs browser/crowding.mjs browser/over-dialog.mjs browser/chart-scale.mjs",
96
96
  "prepack": "node tokens/build.mjs",
97
97
  "version": "node tokens/build.mjs && git add dist",
98
98
  "typecheck:react": "tsc -p adapters/react/tsconfig.json --noEmit"
@@ -42,12 +42,12 @@
42
42
  /* Cartesian charts (line / area / multi-series & stacked bar) — SVG with themed axes.
43
43
  * Axis colour comes from --chart-grid / --chart-tick (both theme-aware); series colours are
44
44
  * set inline by the renderer from --chart-1..8, so the whole chart recolours on data-theme. */
45
- .fdy-chart-xy{position:relative;display:flex;flex-direction:column;gap:var(--space-3);width:100%;}
46
- .fdy-chart-xy__plot{width:100%;aspect-ratio:16/9;min-height:8rem;}
45
+ .fdy-chart-xy{position:relative;display:flex;flex-direction:column;gap:var(--space-3);width:100%;--fdy-chart-tick-size:var(--text-xs);}
46
+ .fdy-chart-xy__plot{width:100%;aspect-ratio:16/9;min-height:8rem;font-size:var(--fdy-chart-tick-size);}
47
47
  .fdy-chart-xy__plot svg{display:block;width:100%;height:100%;overflow:visible;}
48
48
  .fdy-chart-xy__grid{stroke:var(--chart-grid);stroke-width:1;shape-rendering:crispEdges;}
49
- .fdy-chart-xy__tick{fill:var(--chart-tick);font-family:var(--font-mono);font-size:9px;}
50
- .fdy-chart-xy__xlabel{fill:var(--chart-tick);font-family:var(--font-body);font-size:9px;}
49
+ .fdy-chart-xy__tick{fill:var(--chart-tick);font-family:var(--font-mono);}
50
+ .fdy-chart-xy__xlabel{fill:var(--chart-tick);font-family:var(--font-body);}
51
51
  .fdy-chart-xy__line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round;vector-effect:non-scaling-stroke;}
52
52
  .fdy-chart-xy__area{stroke:none;opacity:.14;}
53
53
  .fdy-chart-xy__dot{stroke:var(--color-surface);stroke-width:1;}
@@ -1,6 +1,13 @@
1
1
  /* Freeday — Selection controls */
2
+ /* `flex:none` on every box, track and standalone checkbox is load-bearing, not tidiness.
3
+ `.fdy-check` is an inline-flex row of [box, label], and a flex item with a WIDTH is still
4
+ shrinkable — so as soon as the label wrapped to a second line the box gave up horizontal space
5
+ to it and rendered an 18px-tall rectangle 13px wide. Squashed, off-square, and different from
6
+ every sibling whose label happened to be short: a form of five checkboxes showed three sizes.
7
+ Nothing in the declarations looked wrong, because the width was never overridden — it was
8
+ negotiated away. */
2
9
  .fdy-check,.fdy-radio,.fdy-switch{display:inline-flex;align-items:center;gap:var(--space-2);font-size:var(--text-sm);color:var(--color-text);cursor:pointer;}
3
- .fdy-check input,.fdy-radio input{appearance:none;-webkit-appearance:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
10
+ .fdy-check input,.fdy-radio input{appearance:none;-webkit-appearance:none;flex:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
4
11
  .fdy-check input{border-radius:var(--radius-sm);}
5
12
  .fdy-radio input{border-radius:50%;}
6
13
  .fdy-check input::before{content:"";width:.62rem;height:.62rem;transform:scale(0);transition:transform var(--dur-fast) var(--ease-spring);box-shadow:inset 1rem 1rem var(--color-on-primary);clip-path:polygon(14% 44%,0 65%,50% 100%,100% 16%,80% 0,43% 62%);}
@@ -10,13 +17,13 @@
10
17
  .fdy-check input:focus-visible,.fdy-radio input:focus-visible,.fdy-switch input:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
11
18
  .fdy-check input:disabled,.fdy-radio input:disabled,.fdy-switch input:disabled{opacity:.5;cursor:not-allowed;}
12
19
  .fdy-check:has(input:disabled),.fdy-radio:has(input:disabled),.fdy-switch:has(input:disabled){cursor:not-allowed;}
13
- .fdy-switch input{appearance:none;-webkit-appearance:none;position:relative;width:var(--control-switch-w);height:var(--control-switch-h);margin:0;border-radius:var(--radius-full);background:var(--color-control-border);cursor:pointer;transition:background var(--dur-base) var(--ease-standard);}
20
+ .fdy-switch input{appearance:none;-webkit-appearance:none;flex:none;position:relative;width:var(--control-switch-w);height:var(--control-switch-h);margin:0;border-radius:var(--radius-full);background:var(--color-control-border);cursor:pointer;transition:background var(--dur-base) var(--ease-standard);}
14
21
  .fdy-switch input::before{content:"";position:absolute;top:2px;left:2px;width:calc(var(--control-switch-h) - 4px);height:calc(var(--control-switch-h) - 4px);border-radius:50%;background:var(--color-switch-thumb);box-shadow:var(--shadow-1);transition:transform var(--dur-base) var(--ease-spring);}
15
22
  .fdy-switch input:checked{background:var(--color-primary);}
16
23
  .fdy-switch input:checked::before{transform:translateX(calc(var(--control-switch-w) - var(--control-switch-h)));}
17
24
 
18
25
  /* Standalone checkbox (e.g. table row/select-all) — supports :indeterminate */
19
- .fdy-checkbox{appearance:none;-webkit-appearance:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);border-radius:var(--radius-sm);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
26
+ .fdy-checkbox{appearance:none;-webkit-appearance:none;flex:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);border-radius:var(--radius-sm);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
20
27
  .fdy-checkbox::before{content:"";width:.62rem;height:.62rem;transform:scale(0);transition:transform var(--dur-fast) var(--ease-spring);box-shadow:inset 1rem 1rem var(--color-on-primary);clip-path:polygon(14% 44%,0 65%,50% 100%,100% 16%,80% 0,43% 62%);}
21
28
  .fdy-checkbox:checked,.fdy-checkbox:indeterminate{background:var(--color-primary);border-color:var(--color-primary);}
22
29
  .fdy-checkbox:checked::before{transform:scale(1);}
@@ -1,5 +1,17 @@
1
1
  /* Freeday — Toast (transient notification) */
2
- .fdy-toast-region{position:fixed;z-index:200;bottom:var(--space-5);right:var(--space-5);display:flex;flex-direction:column;gap:var(--space-3);width:min(24rem,calc(100vw - var(--space-8)));pointer-events:none;}
2
+ /* The region is a POPOVER, not merely a high z-index.
3
+ `.fdy-modal` is a native <dialog> opened with showModal(), which puts it in the TOP LAYER — a
4
+ layer above the whole z-index universe, so no number here could ever beat it. A toast raised by
5
+ an action taken inside a modal was painted behind that modal's backdrop: dimmed, unreadable, and
6
+ most of it under the dialog itself. `freeday-toast.js` calls showPopover() as each toast is
7
+ added, which puts the region in the top layer too, and last-in wins there.
8
+ Everything after `pointer-events` is undoing the UA's [popover] defaults — it arrives with
9
+ inset:0, auto margins, a border, padding, a Canvas background and overflow:auto, none of which a
10
+ toast stack wants. Where showPopover() is unavailable the element is still an ordinary fixed
11
+ block at z-index 200, which is exactly what it was before. */
12
+ .fdy-toast-region{position:fixed;z-index:200;inset:auto;bottom:var(--space-5);right:var(--space-5);display:flex;flex-direction:column;gap:var(--space-3);width:min(24rem,calc(100vw - var(--space-8)));height:auto;max-height:none;margin:0;padding:0;border:0;background:transparent;color:inherit;overflow:visible;pointer-events:none;}
13
+ /* A popover gets a ::backdrop as well; a toast must not dim the page behind it. */
14
+ .fdy-toast-region::backdrop{background:transparent;}
3
15
  .fdy-toast{pointer-events:auto;display:flex;gap:var(--space-3);align-items:flex-start;padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);background:var(--color-inverse-surface);color:var(--color-inverse-text);box-shadow:var(--shadow-3);}
4
16
  .fdy-toast__accent{flex:none;width:.25rem;align-self:stretch;border-radius:var(--radius-full);background:var(--color-primary);}
5
17
  .fdy-toast--success .fdy-toast__accent{background:var(--color-success);}