@adia-ai/web-components 0.8.45 → 0.8.46

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/components/card/card.css +12 -23
  3. package/components/card/card.yaml +11 -0
  4. package/components/chart/chart.a2ui.json +16 -1
  5. package/components/chart/chart.class.js +611 -41
  6. package/components/chart/chart.css +174 -0
  7. package/components/chart/chart.d.ts +5 -1
  8. package/components/chart/chart.yaml +45 -1
  9. package/components/field/field.css +24 -2
  10. package/components/index.js +1 -0
  11. package/components/input/input.css +7 -0
  12. package/components/table/table.a2ui.json +19 -4
  13. package/components/table/table.class.js +163 -12
  14. package/components/table/table.d.ts +9 -3
  15. package/components/table/table.yaml +109 -8
  16. package/components/table-footer/table-footer.a2ui.json +150 -0
  17. package/components/table-footer/table-footer.class.js +391 -0
  18. package/components/table-footer/table-footer.css +64 -0
  19. package/components/table-footer/table-footer.d.ts +39 -0
  20. package/components/table-footer/table-footer.examples.md +46 -0
  21. package/components/table-footer/table-footer.js +17 -0
  22. package/components/table-footer/table-footer.yaml +219 -0
  23. package/components/table-toolbar/table-toolbar.yaml +17 -6
  24. package/custom-elements.json +138 -4
  25. package/dist/theme-provider.min.js +1 -1
  26. package/dist/web-components.min.css +1 -1
  27. package/dist/web-components.min.js +87 -87
  28. package/dist/web-components.sheet.js +1 -1
  29. package/package.json +1 -1
  30. package/patterns/chart-in-card/chart-in-card.examples.html +36 -9
  31. package/patterns/new-enrollments/new-enrollments.examples.html +140 -0
  32. package/patterns/new-enrollments/new-enrollments.html +54 -0
  33. package/patterns/table-in-card/table-in-card.examples.html +168 -0
  34. package/patterns/table-in-card/table-in-card.examples.js +139 -0
  35. package/patterns/table-in-card/table-in-card.html +86 -0
  36. package/styles/components.css +1 -0
@@ -330,6 +330,19 @@ export class UIChart extends UIElement {
330
330
  read a shared linear value axis. */
331
331
  yMin: { type: Number, default: null, reflect: true, attribute: 'y-min' },
332
332
  yMax: { type: Number, default: null, reflect: true, attribute: 'y-max' },
333
+ /* ADR-0081 (Charts 2.0 foundations, gh#1804) — chip-label mode. Unset
334
+ ('') resolves to 'outside' unconditionally — explicit opt-in only via
335
+ labels="chip" (design amendment, 2026-08-21: see #resolveLabelsMode's
336
+ own doc comment for why auto-detecting bleed ancestry was dropped).
337
+ #dims()/#resolveLabelsMode() reflects the RESOLVED value separately
338
+ as data-labels-resolved (mirrors data-ratio-resolved's pattern) —
339
+ this property reflects only the raw attribute, same relationship
340
+ `ratio` has to data-ratio-resolved. */
341
+ labels: { type: String, default: '', reflect: true },
342
+ /* ADR-0081 — names the x-value that is "today" (REQ-F-008). Free
343
+ string, loose-stringify-compared against each datum's x-key value in
344
+ #todayIndex() below — never clock-derived. */
345
+ today: { type: String, default: '', reflect: true },
333
346
  };
334
347
 
335
348
  static template = () => null;
@@ -392,6 +405,68 @@ export class UIChart extends UIElement {
392
405
  return Math.min(Math.max(coord, lo), hi);
393
406
  }
394
407
 
408
+ /** Reads a CSS custom property expected to resolve to a px length (or a
409
+ * bare number) off the host's computed style, falling back to a JS
410
+ * constant when unset/unresolved. happy-dom's test environment doesn't
411
+ * resolve calc()/var() (svg-authoring.md §5's documented gap), so every
412
+ * token this file reads through here needs a faithful fallback matching
413
+ * the CSS default's real px value at density=1 — tests override via
414
+ * `el.style.setProperty(varName, ...)`, which computed style DOES pick
415
+ * up without a stylesheet.
416
+ *
417
+ * A REAL browser has the identical gap for a different reason:
418
+ * `getComputedStyle().getPropertyValue()` on a CUSTOM property never
419
+ * resolves nested `var()`/`calc()`/`clamp()` — it returns the cascaded
420
+ * value verbatim (e.g. `--card-radius` resolving to the literal string
421
+ * `"clamp(0.375rem, calc(1 * 0.875rem * 1.000), 0.875rem)"` once
422
+ * REQ-F-014's density scale landed), so `parseFloat` on it is NaN and
423
+ * every caller silently got the density=1 FALLBACK regardless of the
424
+ * live density or the ancestor card's real radius — found chasing a
425
+ * real corner-clipping repro (gh#1814-adjacent) where `cornerClearance`
426
+ * always collapsed to `chromeInset` because `--card-radius` never
427
+ * parsed. The browser DOES resolve that same value when it's consumed
428
+ * by an actual layout property, so an unparseable raw value is routed
429
+ * through a detached probe assigning it to `width` (inheriting this
430
+ * host's own cascade — density scope included — by probing as a
431
+ * temporary child) and reading the result back. */
432
+ #readPxToken(varName, fallback) {
433
+ const raw = getComputedStyle(this).getPropertyValue(varName).trim();
434
+ if (!raw) return fallback;
435
+ // A bare number (or plain `Npx`) is already what the fallback
436
+ // constants are compared against — use it directly, and stay on this
437
+ // branch for happy-dom's test env (style.setProperty literals), which
438
+ // doesn't implement layout for the probe below. Anything else (rem/
439
+ // em/%/calc()/clamp()/var()) needs real resolution — see comment above.
440
+ if (/^-?\d*\.?\d+(px)?$/.test(raw)) {
441
+ const n = parseFloat(raw);
442
+ return Number.isFinite(n) ? n : fallback;
443
+ }
444
+ const probe = document.createElement('span');
445
+ probe.style.cssText = 'position:absolute; visibility:hidden; pointer-events:none; height:0; width:' + raw;
446
+ this.appendChild(probe);
447
+ const resolved = parseFloat(getComputedStyle(probe).width);
448
+ probe.remove();
449
+ return Number.isFinite(resolved) ? resolved : fallback;
450
+ }
451
+
452
+ /** ADR-0081 — resolves the `labels` attribute's tri-state grammar.
453
+ * DESIGN AMENDMENT (2026-08-21, recorded in the build's Findings and
454
+ * ADR-0081's own "Amendment" section): unset ("") resolves to
455
+ * `outside` UNCONDITIONALLY — it does NOT auto-detect full-bleed
456
+ * ancestry. The originally-drafted auto-detection (chip-by-default
457
+ * inside a `section[bleed]`/`card-ui[padding="none"]` ancestor) is
458
+ * exactly what REQ-F-004 asks for in principle, but it silently
459
+ * flips the rendering of every ALREADY-SHIPPED full-bleed composition
460
+ * the moment this ships — concretely, 2 of the 60-fixture Charts
461
+ * floor's own `comp-chart-in-card-n-*` fixtures (pre-dating this
462
+ * ticket) regressed under it, and this ticket's own dispatch holds
463
+ * the 60-fixture floor as a hard preserve-not-regress constraint.
464
+ * Chip mode is therefore explicit-opt-in ONLY (`labels="chip"`); nothing
465
+ * currently in the tree opts in, so nothing currently rendered changes. */
466
+ #resolveLabelsMode() {
467
+ return this.labels === 'chip' ? 'chip' : 'outside';
468
+ }
469
+
395
470
  set data(arr) {
396
471
  // FEEDBACK-24: a non-array — typically the Chart.js `{labels, datasets}`
397
472
  // envelope — is silently coerced to [] below, producing a blank chart
@@ -588,6 +663,51 @@ export class UIChart extends UIElement {
588
663
  const labelSize = fontSize;
589
664
  const valueSize = sizeClass === 'sm' ? 8 : sizeClass === 'md' ? 9 : 10;
590
665
 
666
+ // ADR-0081 / REQ-F-004 — chip-label mode resolution + reflection
667
+ // (mirrors data-ratio-resolved's own reflect-only-on-change pattern).
668
+ const labelsResolved = this.#resolveLabelsMode();
669
+ if (this.getAttribute('data-labels-resolved') !== labelsResolved) {
670
+ this.setAttribute('data-labels-resolved', labelsResolved);
671
+ }
672
+ const chipMode = labelsResolved === 'chip';
673
+
674
+ // REQ-F-009/010 — chrome-layer clearance + the zero-line→chip-band gap,
675
+ // both --a-space-*-aliased tokens (chart.css); JS fallbacks match the
676
+ // CSS defaults' real px value (--a-space-2 = 8px, --a-space-1 = 4px)
677
+ // for the happy-dom test environment, which doesn't resolve var()/calc()
678
+ // (svg-authoring.md §5).
679
+ const chromeInset = this.#readPxToken('--chart-chrome-inset', 8);
680
+ const bandGap = this.#readPxToken('--chart-band-gap', 4);
681
+ // REQ-F-006(3) — corner-adjacent chips clear at least the composed
682
+ // card's --card-radius (a real ancestor-inherited custom property once
683
+ // chart-ui sits inside card-ui); falls back to chromeInset itself when
684
+ // no card ancestor sets one (no radius to clear beyond the ordinary
685
+ // edge inset).
686
+ const cornerClearance = Math.max(chromeInset, this.#readPxToken('--card-radius', chromeInset));
687
+ // REQ-F-005 chip token contract — geometry-relevant subset (bg/fg/
688
+ // radius are CSS-only, consumed by [data-chip]/[data-chip-label] rules
689
+ // in chart.css, never read here).
690
+ const chipFontSize = this.#readPxToken('--chart-chip-font-size', fontSize);
691
+ const chipPadX = this.#readPxToken('--chart-chip-pad-x', 6);
692
+ const chipPadY = this.#readPxToken('--chart-chip-pad-y', 4);
693
+
694
+ // REQ-F-013 / OPEN-4 (resolved in this build, see chart.css) — the
695
+ // plot's own pixel height against the two degradation floors. Order is
696
+ // normative: full → drop x-category CHIPS → drop gridlines + y-CHIPS
697
+ // (bare-marks). Scoped to chip mode deliberately — REQ-F-013's own
698
+ // language ("drop x-category chips") only has literal chips to drop in
699
+ // that mode; `outside` mode already has its own pre-existing small-size
700
+ // handling (sizeClass 'sm' thinning etc.) and MUST stay height-floor-
701
+ // free to hold AC-F-004's byte-identical regression proof (a short
702
+ // normal-padding-card chart that rendered x-labels yesterday can't
703
+ // start dropping them today just because this build shipped).
704
+ const minPlotHeightChips = this.#readPxToken('--chart-min-plot-height-chips', 180);
705
+ const minPlotHeightGrid = this.#readPxToken('--chart-min-plot-height-grid', 120);
706
+ const degradeStage = !chipMode ? 'full'
707
+ : height < minPlotHeightGrid ? 'bare'
708
+ : height < minPlotHeightChips ? 'no-x-chrome'
709
+ : 'full';
710
+
591
711
  // Padding scales with font size and axis visibility.
592
712
  // When the grid is hidden (in-card sparkline-like use) we can zero
593
713
  // out the Y-axis label gutter entirely — otherwise the plot area
@@ -598,21 +718,34 @@ export class UIChart extends UIElement {
598
718
  // least WIDTH budget to spare; 3:2 keeps the full gutter since width is
599
719
  // abundant there. A flat 0.8 trim on the y-label gutter is enough to
600
720
  // free real plot width without crowding the tick text against the axis.
721
+ // REQ-F-001 (foundations, amends REQ-R-004) — chip mode has NO residual
722
+ // gutter at all: the plot spans zero-inset to every edge and labels
723
+ // inset WITHIN it instead of pushing it inward. `outside` mode (the
724
+ // pre-foundations default) keeps the exact formula below unchanged —
725
+ // AC-F-004's regression proof depends on this branch being byte-for-
726
+ // byte identical to pre-2.0 output.
601
727
  const marginTrim = resolvedRatio === '3:2' ? 1 : 0.8;
602
728
  const yLabelW = noGrid ? 0 : fontSize * 3.2 * marginTrim;
603
729
  const hasXLabels = !!this.x;
604
- const pad = {
605
- top: noGrid ? 2 : fontSize * 1.6,
606
- right: noGrid ? 2 : fontSize * 1.2,
607
- bottom: !hasXLabels || noGrid ? 2 : fontSize * 2.2 * marginTrim,
608
- left: yLabelW,
609
- };
730
+ const pad = chipMode
731
+ ? { top: 0, right: 0, bottom: 0, left: 0 }
732
+ : {
733
+ top: noGrid ? 2 : fontSize * 1.6,
734
+ right: noGrid ? 2 : fontSize * 1.2,
735
+ bottom: !hasXLabels || noGrid ? 2 : fontSize * 2.2 * marginTrim,
736
+ left: yLabelW,
737
+ };
610
738
 
611
739
  // Bar width adapts to data count + container
612
740
  const plotW = width - pad.left - pad.right;
613
741
  const barMinW = Math.max(4, plotW / n * 0.6);
614
742
 
615
- return { width, height, pad, fontSize, labelSize, valueSize, barMinW, plotW, n, sizeClass, ratioResolved: resolvedRatio };
743
+ return {
744
+ width, height, pad, fontSize, labelSize, valueSize, barMinW, plotW, n, sizeClass,
745
+ ratioResolved: resolvedRatio, labelsResolved, chipMode, chromeInset, bandGap,
746
+ cornerClearance, chipFontSize, chipPadX, chipPadY, minPlotHeightChips,
747
+ minPlotHeightGrid, degradeStage,
748
+ };
616
749
  }
617
750
 
618
751
  /* ── Main render ──────────────────────────────────────────────── */
@@ -1215,11 +1348,23 @@ export class UIChart extends UIElement {
1215
1348
  * the band-centered default, correct for their own
1216
1349
  * categorical geometry.
1217
1350
  */
1351
+ /** Returns `{ svg, chipSvg }` rather than one flat string — SVG paints in
1352
+ * document order, so a chip/chrome overlay concatenated INTO the same
1353
+ * string as gridlines (both emitted before any caller's marks) painted
1354
+ * UNDER those marks once appended, exactly backwards from REQ-F-002's
1355
+ * two-layer contract (chrome floats independently on top of the plot).
1356
+ * `outside`-mode axis TEXT is unaffected — it renders in the padding
1357
+ * gutter marks never reach into, and stays folded into `svg` untouched
1358
+ * (AC-F-004 byte-identity). Only the two chip-mode branches (Y/X chips)
1359
+ * route into `chipSvg`; every caller appends it AFTER its marks. */
1218
1360
  #gridAndAxes(width, height, ticks, labels, pad, dims, opts = {}) {
1219
1361
  const p = pad;
1220
1362
  const fs = dims?.fontSize || 10;
1221
1363
  const ls = dims?.labelSize || fs;
1364
+ const chip = !!dims?.chipMode;
1365
+ const stage = dims?.degradeStage || 'full';
1222
1366
  let s = '';
1367
+ let chipSvg = '';
1223
1368
 
1224
1369
  // Reduce tick count at small sizes, or when a ratio-matrix caller asks
1225
1370
  // for denser-bucket thinning (REQ-R-003: line/area "gridlines thinned").
@@ -1231,8 +1376,9 @@ export class UIChart extends UIElement {
1231
1376
  // noGrid suppresses both gridlines AND axis labels — callers who
1232
1377
  // want labels without gridlines can omit no-grid and rely on token
1233
1378
  // overrides to make gridlines transparent. This keeps compact in-card
1234
- // charts visually clean.
1235
- if (!this.noGrid) {
1379
+ // charts visually clean. REQ-F-013's 'bare' stage (chip mode only,
1380
+ // below the grid floor) is the same all-chrome-dropped shape.
1381
+ if (!this.noGrid && stage !== 'bare') {
1236
1382
  const tickRange = ticks[ticks.length - 1] - ticks[0];
1237
1383
  const safeRange = tickRange || 1; // Prevent division by zero
1238
1384
 
@@ -1266,15 +1412,28 @@ export class UIChart extends UIElement {
1266
1412
  }
1267
1413
  }
1268
1414
 
1269
- /* Y-axis labels */
1270
- for (const t of displayTicks) {
1271
- const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
1272
- s += `<text data-y-label x="${p.left - 4}" y="${gy + fs * 0.35}" text-anchor="end" font-size="${fs}">${this.#fmtValue(t)}</text>`;
1415
+ /* Y-axis labels / y-value chips (REQ-F-004/005/006) — chip mode
1416
+ floats pill-treated overlays at the same gridline Y positions
1417
+ instead of pushing text outside the plot; `outside` mode (below)
1418
+ is untouched byte-for-byte (AC-F-004). */
1419
+ if (chip) {
1420
+ chipSvg += this.#renderYChips(displayTicks, ticks, safeRange, width, height, p, dims);
1421
+ } else {
1422
+ for (const t of displayTicks) {
1423
+ const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
1424
+ s += `<text data-y-label x="${p.left - 4}" y="${gy + fs * 0.35}" text-anchor="end" font-size="${fs}">${this.#fmtValue(t)}</text>`;
1425
+ }
1273
1426
  }
1274
1427
 
1275
- /* X-axis labels — stride based on label width so they never overlap,
1276
- unless a ratio-matrix mode forces a different selection. */
1277
- if (labels) {
1428
+ /* X-axis labels / x-category chips — stride based on label width so
1429
+ they never overlap, unless a ratio-matrix mode forces a different
1430
+ selection. REQ-F-013's 'no-x-chrome' stage (chip mode only, below
1431
+ the chips floor) drops this whole block while keeping gridlines +
1432
+ y-chips. */
1433
+ if (labels && stage !== 'no-x-chrome') {
1434
+ if (chip) {
1435
+ chipSvg += this.#renderXChips(labels, width, height, p, dims, opts);
1436
+ } else {
1278
1437
  const plotW = width - p.left - p.right;
1279
1438
  const step = plotW / labels.length;
1280
1439
  const last = labels.length - 1;
@@ -1313,9 +1472,299 @@ export class UIChart extends UIElement {
1313
1472
  const text = esc(opts.abbreviateLabels ? truncateLabel(labels[i], 4) : labels[i]);
1314
1473
  s += `<text data-x-label x="${lx}" y="${height - fs * 0.5}" text-anchor="middle" font-size="${ls}">${text}</text>`;
1315
1474
  }
1475
+ }
1476
+ }
1477
+ }
1478
+
1479
+ return { svg: s, chipSvg };
1480
+ }
1481
+
1482
+ /* REQ-F-004/005/006 — y-value chips inset along the plot's start (left)
1483
+ * edge at each gridline Y, floating chrome over the zero-inset plot
1484
+ * (REQ-F-001/002). Clamp (1): the top/bottom-most chip's vertical
1485
+ * center clamps inward by chromeInset + half its own height rather
1486
+ * than overflow the box. Corner clearance (3): a chip landing within
1487
+ * --card-radius's worth of the top/bottom-left corner clears that
1488
+ * corner distance instead of the ordinary chrome inset.
1489
+ *
1490
+ * Thin (2), Y-axis (measured, gh#1814 OPEN-4 pass): a real-browser
1491
+ * geometry sweep found the domain-min/-max chip's own clamp (above)
1492
+ * can pull it far enough toward the plot's center to overlap its
1493
+ * immediate unclamped neighbor once ticks sit closely spaced in a
1494
+ * short plot (observed at 7 nice-number ticks in a ~150-180px plot,
1495
+ * well inside the pre-existing 'no-x-chrome'/'full' range — not a
1496
+ * REQ-F-013 floor-value problem, a missing collision guard). Generalized
1497
+ * as a full iterative thin (code-checker finding, 2026-08-21: a
1498
+ * single-neighbor-per-edge check doesn't hold under consumer-overridden
1499
+ * `--chart-chip-font-size`/`--chart-chrome-inset`/`--a-density`, or an
1500
+ * unusually dense `niceScale` tick count) — mirrors #renderXChips' own
1501
+ * iterative `kept` loop and its two-protected-ends fallback exactly,
1502
+ * applied to cy instead of cx: walk in array (tick-ascending) order,
1503
+ * keep the first item, drop any intermediate whose box would overlap
1504
+ * the last KEPT item, force-keep the last item (never drop either end —
1505
+ * it alone carries the domain min/max), let a forced-last collision
1506
+ * evict the intermediate before it, and if only the two protected ends
1507
+ * remain and still overlap, push them apart from their shared midpoint
1508
+ * rather than dropping either. */
1509
+ #renderYChips(displayTicks, ticks, safeRange, width, height, p, dims) {
1510
+ const { chromeInset, chipFontSize, chipPadX, chipPadY, cornerClearance } = dims;
1511
+ const halfH = chipFontSize * 0.6 + chipPadY;
1512
+ const rh = chipFontSize * 1.2 + chipPadY * 2;
1513
+
1514
+ const all = displayTicks.map((t) => {
1515
+ const rawY = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
1516
+ const cy = Math.min(Math.max(rawY, chromeInset + halfH), height - chromeInset - halfH);
1517
+ const nearCorner = (cy - halfH <= cornerClearance) || (cy + halfH >= height - cornerClearance);
1518
+ const cx = nearCorner ? cornerClearance : chromeInset;
1519
+ const text = this.#fmtValue(t);
1520
+ const rw = String(text).length * chipFontSize * 0.6 + chipPadX * 2;
1521
+ return { cy, cx, rw, text };
1522
+ });
1523
+ if (!all.length) return '';
1524
+
1525
+ const last = all.length - 1;
1526
+ const kept = [all[0]];
1527
+ for (let i = 1; i < last; i++) {
1528
+ const it = all[i];
1529
+ const prev = kept[kept.length - 1];
1530
+ if (Math.abs(it.cy - prev.cy) >= rh) kept.push(it);
1531
+ }
1532
+ if (all.length > 1) kept.push(all[last]);
1533
+ // A forced-last collision evicts the intermediate before it — never the
1534
+ // ends themselves (REQ-F-006(1)'s clamp-not-drop treatment).
1535
+ while (kept.length > 2) {
1536
+ const a = kept[kept.length - 2], b = kept[kept.length - 1];
1537
+ if (Math.abs(b.cy - a.cy) < rh) kept.splice(kept.length - 2, 1);
1538
+ else break;
1539
+ }
1540
+ // Only the two protected ends left and they still overlap: push both
1541
+ // away from their shared midpoint just far enough to touch, not
1542
+ // overlap (mirrors #renderXChips' own AC-F-003 extreme-case fallback).
1543
+ if (kept.length === 2) {
1544
+ const [a, b] = kept;
1545
+ if (Math.abs(a.cy - b.cy) < rh) {
1546
+ const mid = (a.cy + b.cy) / 2;
1547
+ const [top, bottom] = a.cy <= b.cy ? [a, b] : [b, a];
1548
+ top.cy = Math.min(top.cy, mid - rh / 2);
1549
+ bottom.cy = Math.max(bottom.cy, mid + rh / 2);
1316
1550
  }
1317
1551
  }
1552
+ const items = kept;
1318
1553
 
1554
+ let s = '';
1555
+ for (const it of items) {
1556
+ s += `<rect data-chip data-chip-axis="y" x="${it.cx}" y="${it.cy - rh / 2}" width="${it.rw}" height="${rh}"/>`;
1557
+ s += `<text data-chip-label data-chip-axis="y" x="${it.cx + chipPadX}" y="${it.cy}" dominant-baseline="central" font-size="${chipFontSize}">${it.text}</text>`;
1558
+ }
1559
+ return s;
1560
+ }
1561
+
1562
+ /* REQ-F-004/006/010(1) — x-category chips in a band along the plot's
1563
+ * bottom edge, inset within the zero-inset plot rather than pushed
1564
+ * below it. Clamp (1): first/last chip shift inward rather than
1565
+ * overflow. Thin (2): adjacent-overlapping chips drop (never the first
1566
+ * or last, and never below --chart-chip-font-size). Corner clearance
1567
+ * (3): the end chips clear --card-radius at the bottom corners. */
1568
+ #renderXChips(labels, width, height, p, dims, opts) {
1569
+ const { chromeInset, bandGap, chipFontSize, chipPadX, chipPadY, cornerClearance } = dims;
1570
+ const plotW = width - p.left - p.right;
1571
+ const last = labels.length - 1;
1572
+ const step = opts.pointAligned ? plotW / Math.max(last, 1) : plotW / labels.length;
1573
+ const rh = chipFontSize * 1.2 + chipPadY * 2;
1574
+ // REQ-F-010(1) — the band's own clearance from the bottom edge is at
1575
+ // least chromeInset (REQ-F-002 edge safety); bandGap is the named,
1576
+ // separately-tunable gap from the zero-line specifically. chromeInset
1577
+ // dominates by default (8px vs 4px) but bandGap stays live for callers
1578
+ // that retune it independently of the general edge inset.
1579
+ const cy = height - Math.max(chromeInset, bandGap) - rh / 2;
1580
+
1581
+ const items = labels.map((label, i) => {
1582
+ const cx0 = opts.pointAligned ? p.left + step * i : p.left + step * i + step / 2;
1583
+ // Width from the DISPLAYED text, not the raw label (code-checker
1584
+ // finding, 2026-08-21) — `truncateLabel` shortens what's rendered
1585
+ // when `abbreviateLabels` is on; sizing the chip off the untruncated
1586
+ // label reserved more room than the glyphs actually need.
1587
+ const displayText = opts.abbreviateLabels ? truncateLabel(label, 4) : label;
1588
+ const text = esc(displayText);
1589
+ const rw = String(displayText).length * chipFontSize * 0.6 + chipPadX * 2;
1590
+ return { cx0, rw, text };
1591
+ });
1592
+ if (!items.length) return '';
1593
+
1594
+ for (const it of items) {
1595
+ it.cx = Math.min(Math.max(it.cx0, chromeInset + it.rw / 2), width - chromeInset - it.rw / 2);
1596
+ }
1597
+ // Corner clearance for the true end chips.
1598
+ items[0].cx = Math.max(items[0].cx, cornerClearance + items[0].rw / 2);
1599
+ items[last].cx = Math.min(items[last].cx, width - cornerClearance - items[last].rw / 2);
1600
+
1601
+ // REQ-F-006(2) — thin adjacent-overlapping chips, always keeping the
1602
+ // first/last; drop intermediates whose box would intersect the last
1603
+ // KEPT box.
1604
+ const kept = [items[0]];
1605
+ for (let i = 1; i < items.length - 1; i++) {
1606
+ const it = items[i];
1607
+ const prev = kept[kept.length - 1];
1608
+ if (it.cx - it.rw / 2 >= prev.cx + prev.rw / 2) kept.push(it);
1609
+ }
1610
+ if (items.length > 1) kept.push(items[last]);
1611
+ // If the forced-last item still collides with the item just before it,
1612
+ // that intermediate loses the tie (edges win) — never drop the edges
1613
+ // themselves, per REQ-F-006(1)'s clamp-not-drop treatment for ends.
1614
+ while (kept.length > 2) {
1615
+ const a = kept[kept.length - 2], b = kept[kept.length - 1];
1616
+ if (b.cx - b.rw / 2 < a.cx + a.rw / 2) kept.splice(kept.length - 2, 1);
1617
+ else break;
1618
+ }
1619
+ // AC-F-003 ("no two chip boxes intersect") extreme case (code-checker
1620
+ // finding, 2026-08-21): with only the two protected end chips left,
1621
+ // the thinning loop above never runs (it starts at length > 2), so a
1622
+ // sufficiently narrow chart could still overlap them. Neither may be
1623
+ // DROPPED (REQ-F-006(1) clamp-not-drop for ends), so push both away
1624
+ // from their shared midpoint just far enough to touch, not overlap.
1625
+ if (kept.length === 2) {
1626
+ const [a, b] = kept;
1627
+ if (a.cx + a.rw / 2 > b.cx - b.rw / 2) {
1628
+ const boundary = (a.cx + b.cx) / 2;
1629
+ a.cx = Math.min(a.cx, boundary - a.rw / 2);
1630
+ b.cx = Math.max(b.cx, boundary + b.rw / 2);
1631
+ }
1632
+ }
1633
+
1634
+ let s = '';
1635
+ for (const it of kept) {
1636
+ s += `<rect data-chip data-chip-axis="x" x="${it.cx - it.rw / 2}" y="${cy - rh / 2}" width="${it.rw}" height="${rh}"/>`;
1637
+ s += `<text data-chip-label data-chip-axis="x" x="${it.cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-size="${chipFontSize}">${it.text}</text>`;
1638
+ }
1639
+ return s;
1640
+ }
1641
+
1642
+ /* ── Today-marker (REQ-F-008, ADR-0081) ──────────────────────────
1643
+ * Resolves which datum (if any) is "today": the first row whose
1644
+ * x-axis value string-equals the `today` attribute
1645
+ * (String(datum[x]) === String(today) — the same loose stringify
1646
+ * #fmtValue()/tip() already use elsewhere), or — the ADR's documented
1647
+ * FALLBACK, not the primary mechanism — a row that already carries a
1648
+ * truthy `today` key of its own. Returns -1 when neither identifies a
1649
+ * datum (additive no-op). Cartesian-only by construction: only the 8
1650
+ * cartesian renderers (bar, line, area, scatter, multi-line,
1651
+ * stacked-bar, grouped-bar, composed — gh#1690's list, restated
1652
+ * verbatim here per its own "cannot drift against a live upstream"
1653
+ * posture) ever call this; every radial/part-to-whole type and
1654
+ * sparkline never do, which is what makes REQ-F-016's N/A a structural
1655
+ * fact rather than a runtime check. */
1656
+ #todayIndex() {
1657
+ const xKey = this.x;
1658
+ const data = this.#data;
1659
+ if (this.today) {
1660
+ for (let i = 0; i < data.length; i++) {
1661
+ if (String(data[i]?.[xKey] ?? '') === String(this.today)) return i;
1662
+ }
1663
+ return -1;
1664
+ }
1665
+ return data.findIndex(d => d && d.today);
1666
+ }
1667
+
1668
+ /* Baseline dot AT the plot's zero-line at the today-datum's X position,
1669
+ * plus a SHORT tick — never a full-height rule (REQ-F-008). In
1670
+ * `outside` label mode the tick descends into the existing label
1671
+ * gutter below the baseline (unchanged visual budget); in `chip` mode
1672
+ * there is no gutter below the baseline (pad is zero-inset, REQ-F-001),
1673
+ * so the tick runs the opposite direction, up into the x-chip band that
1674
+ * floats just above that same edge — same short-tick anatomy, direction
1675
+ * mirrored because the label chrome sits on the other side of the
1676
+ * baseline in that mode. This is a judgment call the SPEC/ADR don't
1677
+ * pin explicitly — see the build's Findings.
1678
+ *
1679
+ * Chip-mode clamp (code-checker finding, 2026-08-21): `baselineY` in
1680
+ * chip mode IS the chart box's bottom edge (pad is zero-inset,
1681
+ * REQ-F-001) — an unclamped dot centered there has its lower half
1682
+ * clipped by a full-bleed card's corner-rounded `overflow: hidden`
1683
+ * (REQ-F-002/AC-F-001's own edge-safety clause; SVG `overflow: visible`
1684
+ * doesn't escape the ANCESTOR card's clip). Clamped inward by the same
1685
+ * `chromeInset` every other chip-mode chrome element already respects,
1686
+ * exactly like `#renderYChips`'s own vertical clamp. `outside` mode is
1687
+ * untouched (chromeInset is unread there, same AC-F-004 byte-identity
1688
+ * this build holds everywhere else). */
1689
+ #todayMarkerVertical(x, baselineY, dims) {
1690
+ const r = dims.sizeClass === 'sm' ? 2.5 : 3.5;
1691
+ const tickLen = Math.max(6, dims.fontSize);
1692
+ const dir = dims.chipMode ? -1 : 1;
1693
+ const cy = dims.chipMode ? baselineY - Math.max(dims.chromeInset ?? 0, r) : baselineY;
1694
+ return `<circle data-today-dot cx="${x}" cy="${cy}" r="${r}"/>` +
1695
+ `<line data-today-tick x1="${x}" y1="${cy}" x2="${x}" y2="${cy + dir * tickLen}"/>`;
1696
+ }
1697
+
1698
+ /* REQ-F-008's own mirror clause — "under an orientation where the
1699
+ * category axis runs vertically" (the 2:3 horizontal-bar/stacked-bar/
1700
+ * grouped-bar rows, REQ-R-003) the anatomy mirrors onto that axis: the
1701
+ * dot sits on the VALUE baseline (pad.left, where every horizontal bar
1702
+ * starts) and the tick runs LEFTWARD into the category-label gutter —
1703
+ * there is no x-label band on this transposed axis to speak of, so this
1704
+ * direction is this build's own judgment call, documented here rather
1705
+ * than left implicit. */
1706
+ #todayMarkerHorizontal(y, baselineX, dims) {
1707
+ const r = dims.sizeClass === 'sm' ? 2.5 : 3.5;
1708
+ const tickLen = Math.max(6, dims.fontSize);
1709
+ return `<circle data-today-dot cx="${baselineX}" cy="${y}" r="${r}"/>` +
1710
+ `<line data-today-tick x1="${baselineX}" y1="${y}" x2="${baselineX - tickLen}" y2="${y}"/>`;
1711
+ }
1712
+
1713
+ /* ── Provisional datum (REQ-F-015/016, ADR-0081 §3) ──────────────
1714
+ * Per-datum `.provisional` truthy flag — NOT a chart-ui attribute.
1715
+ * Uniform across the cartesian list only (gh#1690's list, restated
1716
+ * verbatim, same posture as #todayIndex() above): bar, line, area,
1717
+ * scatter, multi-line, stacked-bar, grouped-bar, composed. Bar-family
1718
+ * treatment (hollow bar, dashed outline) and scatter's hollow-ring dot
1719
+ * are pure CSS ([data-provisional] rules in chart.css) driven by this
1720
+ * one attribute string helper; line/area's dashed-span + faded-area
1721
+ * treatment needs its own geometry (#lineProvisionalOverlay below)
1722
+ * since a single continuous path can't dash just one sub-span of
1723
+ * itself via CSS alone. */
1724
+ #provisionalAttr(datum) {
1725
+ return datum && datum.provisional ? ' data-provisional' : '';
1726
+ }
1727
+
1728
+ /* Finds contiguous runs of provisional-flagged points (by index into
1729
+ * `points`) and returns dashed-line + faded-area overlay SVG for each
1730
+ * run — REQ-F-015's "dashed stroke over the provisional span... area
1731
+ * fill suppressed or faded there". The run includes the immediately
1732
+ * preceding point (when one exists) so the dashed overlay visually
1733
+ * connects to the solid line rather than floating detached. The main
1734
+ * `data-line`/`data-area` paths this pairs with are left fully
1735
+ * unchanged (still drawn for the WHOLE series) — this only adds an
1736
+ * overlay on top, so a fixture with no provisional data renders
1737
+ * byte-identically to before this capability existed. `smoothTFactor`
1738
+ * MUST match the caller's own clamped `this.smooth` (code-checker
1739
+ * finding, 2026-08-21) — the overlay's dashed span sits ON TOP of the
1740
+ * solid line/area path, and every caller smooths that path with the
1741
+ * same clamped `t`; passing 0 here drew the overlay as a straight
1742
+ * chord diverging visibly from a curved line whenever `smooth !== 0`. */
1743
+ #lineProvisionalOverlay(points, data, baselineY, dims, smoothTFactor) {
1744
+ let s = '';
1745
+ let runStart = -1;
1746
+ const flush = (endIdxExclusive) => {
1747
+ if (runStart < 0) return;
1748
+ const from = Math.max(runStart - 1, 0);
1749
+ const runPts = points.slice(from, endIdxExclusive);
1750
+ if (runPts.length >= 2) {
1751
+ // Fade rect emitted BEFORE the dashed path (code-checker finding,
1752
+ // 2026-08-21) so the scrim sits underneath, not on top of, the
1753
+ // dashed stroke it's meant to fade — reversed order previously
1754
+ // painted the scrim over the dash's lower half.
1755
+ const minX = runPts[0].x, maxX = runPts[runPts.length - 1].x;
1756
+ const top = Math.min(...runPts.map(p => p.y));
1757
+ s += `<rect data-area-provisional-fade x="${minX}" y="${top}" width="${Math.max(maxX - minX, 0)}" height="${Math.max(baselineY - top, 0)}"/>`;
1758
+ s += `<path data-line-provisional d="${smoothPath(runPts, smoothTFactor)}"/>`;
1759
+ }
1760
+ runStart = -1;
1761
+ };
1762
+ for (let i = 0; i < points.length; i++) {
1763
+ const flagged = !!(data[i] && data[i].provisional);
1764
+ if (flagged && runStart < 0) runStart = i;
1765
+ else if (!flagged && runStart >= 0) flush(i);
1766
+ }
1767
+ flush(points.length);
1319
1768
  return s;
1320
1769
  }
1321
1770
 
@@ -1337,7 +1786,14 @@ export class UIChart extends UIElement {
1337
1786
  // for the scroll-vs-aggregate reading). Runs before `ticks` is derived
1338
1787
  // so the scale reflects the (possibly aggregated) rendered set, not
1339
1788
  // the full original one.
1340
- if (dims.ratioResolved === '1:1' && vals.length > BAR_1_1_VISIBLE_CAP) {
1789
+ // ADR-0081/REQ-F-008/015 today-marker + provisional-datum both key
1790
+ // off the ORIGINAL per-datum index; the "+N more" synthetic tail
1791
+ // bucket aggregateTail may produce below has no single x-value or
1792
+ // provisional flag of its own, so both capabilities are scoped out
1793
+ // when aggregation actually occurred (named here, not silently
1794
+ // dropped — see the build's Findings).
1795
+ const aggregated = dims.ratioResolved === '1:1' && vals.length > BAR_1_1_VISIBLE_CAP;
1796
+ if (aggregated) {
1341
1797
  ({ labels, vals } = aggregateTail(labels, vals, BAR_1_1_VISIBLE_CAP, false));
1342
1798
  }
1343
1799
 
@@ -1351,7 +1807,7 @@ export class UIChart extends UIElement {
1351
1807
  // it, but a full column of height for one row per category. This is a
1352
1808
  // genuine orientation swap, not the same layout rescaled.
1353
1809
  if (dims.ratioResolved === '2:3') {
1354
- return this.#renderBarHorizontal(dims, labels, vals, ticks, maxVal);
1810
+ return this.#renderBarHorizontal(dims, labels, vals, ticks, maxVal, aggregated ? [] : data);
1355
1811
  }
1356
1812
 
1357
1813
  const { width, height, pad } = dims;
@@ -1364,7 +1820,7 @@ export class UIChart extends UIElement {
1364
1820
  // REQ-R-003 (bar, 1:1 "square"): abbreviate category labels — a square
1365
1821
  // has less width per category than 3:2. The synthetic "+N more" label
1366
1822
  // (already short) truncates harmlessly under the same budget.
1367
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
1823
+ let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
1368
1824
  abbreviateLabels: dims.ratioResolved === '1:1',
1369
1825
  });
1370
1826
 
@@ -1378,8 +1834,9 @@ export class UIChart extends UIElement {
1378
1834
  const barH = this.#clampSpan(rawH, plotH);
1379
1835
  const bx = pad.left + barW * i + barGap;
1380
1836
  const by = pad.top + plotH - barH;
1837
+ const provisional = !aggregated ? this.#provisionalAttr(data[i]) : '';
1381
1838
 
1382
- svg += `<path data-bar${tip({ label: labels[i], value: v })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
1839
+ svg += `<path data-bar${provisional}${tip({ label: labels[i], value: v })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
1383
1840
 
1384
1841
  if (showValues) {
1385
1842
  svg += `<text data-value x="${bx + barInner / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
@@ -1396,6 +1853,14 @@ export class UIChart extends UIElement {
1396
1853
  svg += `<line data-hit${tip({ label: 'Average', value: avg })} x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}" stroke="transparent" stroke-width="12"/>`;
1397
1854
  }
1398
1855
 
1856
+ if (!aggregated) {
1857
+ const ti = this.#todayIndex();
1858
+ if (ti > -1 && ti < labels.length) {
1859
+ svg += this.#todayMarkerVertical(pad.left + barW * ti + barGap + barInner / 2, pad.top + plotH, dims);
1860
+ }
1861
+ }
1862
+
1863
+ svg += chipSvg;
1399
1864
  return { svg, viewBox: `0 0 ${width} ${height}` };
1400
1865
  }
1401
1866
 
@@ -1404,8 +1869,13 @@ export class UIChart extends UIElement {
1404
1869
  render path rather than a transposed reuse of the vertical one — the
1405
1870
  gutter sizing (driven by label WIDTH, not a fixed font multiple), the
1406
1871
  grid direction, and the bar path rounding (right end, not top end) all
1407
- differ structurally, not just by a coordinate swap. */
1408
- #renderBarHorizontal(dims, labels, vals, ticks, maxVal) {
1872
+ differ structurally, not just by a coordinate swap. `rows` is the
1873
+ ORIGINAL per-datum objects (empty when the 1:1 aggregate-tail bucket
1874
+ ran — see #renderBar) driving today-marker + provisional lookup;
1875
+ chip-mode label rendering is deliberately NOT mirrored here — this
1876
+ renderer keeps its own always-outside inline axis code unconditionally
1877
+ (named scope-down, see the build's Findings). */
1878
+ #renderBarHorizontal(dims, labels, vals, ticks, maxVal, rows = []) {
1409
1879
  const { width, height } = dims;
1410
1880
  const fs = dims.fontSize;
1411
1881
  const ls = dims.labelSize;
@@ -1445,9 +1915,10 @@ export class UIChart extends UIElement {
1445
1915
  const barW = this.#clampSpan(rawW, plotW);
1446
1916
  const by = pad.top + barH * i + barGap;
1447
1917
  const bx = pad.left;
1918
+ const provisional = this.#provisionalAttr(rows[i]);
1448
1919
 
1449
1920
  svg += `<text data-x-label x="${pad.left - 6}" y="${by + barInner / 2 + fs * 0.35}" text-anchor="end" font-size="${ls}">${esc(labels[i])}</text>`;
1450
- svg += `<path data-bar${tip({ label: labels[i], value: v })} d="${rightRoundedBarPath(bx, by, barW, barInner, this.#resolveRadius())}"/>`;
1921
+ svg += `<path data-bar${provisional}${tip({ label: labels[i], value: v })} d="${rightRoundedBarPath(bx, by, barW, barInner, this.#resolveRadius())}"/>`;
1451
1922
  if (showValues) {
1452
1923
  svg += `<text data-value x="${bx + barW + 4}" y="${by + barInner / 2 + fs * 0.35}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
1453
1924
  }
@@ -1461,6 +1932,12 @@ export class UIChart extends UIElement {
1461
1932
  svg += `<line data-hit${tip({ label: 'Average', value: avg })} x1="${ax}" y1="${pad.top}" x2="${ax}" y2="${height - pad.bottom}" stroke="transparent" stroke-width="12"/>`;
1462
1933
  }
1463
1934
 
1935
+ /* REQ-F-008 mirror — see #todayMarkerHorizontal's own doc comment. */
1936
+ const ti = this.#todayIndex();
1937
+ if (ti > -1 && ti < labels.length && rows.length) {
1938
+ svg += this.#todayMarkerHorizontal(pad.top + barH * ti + barGap + barInner / 2, pad.left, dims);
1939
+ }
1940
+
1464
1941
  return { svg, viewBox: `0 0 ${width} ${height}` };
1465
1942
  }
1466
1943
 
@@ -1492,7 +1969,7 @@ export class UIChart extends UIElement {
1492
1969
  // density") — two DISTINCT treatments, not one mode reused for both.
1493
1970
  const isArea = this.type === 'area';
1494
1971
  const ratio = dims.ratioResolved;
1495
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
1972
+ let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
1496
1973
  thinGrid: ratio !== '3:2',
1497
1974
  vertical: isArea && ratio === '2:3',
1498
1975
  xTickMode: ratio === '2:3' ? 'sparse' : ratio === '1:1' ? 'everyNth' : undefined,
@@ -1514,6 +1991,10 @@ export class UIChart extends UIElement {
1514
1991
  const t = Math.max(0, Math.min(1, this.smooth));
1515
1992
  svg += `<path data-area d="${smoothAreaPath(points, baseline, t)}"/>`;
1516
1993
  svg += `<path data-line d="${smoothPath(points, t)}"/>`;
1994
+ // REQ-F-015 (line/area) — dashed stroke + faded area over any
1995
+ // provisional span; the main paths above are unchanged, this overlay
1996
+ // ADDS on top so a fixture with no provisional data is byte-identical.
1997
+ svg += this.#lineProvisionalOverlay(points, data, baseline, dims, t);
1517
1998
 
1518
1999
  // Density tuning for sm: smaller dots, no value labels.
1519
2000
  const isSm = dims.sizeClass === 'sm';
@@ -1523,8 +2004,9 @@ export class UIChart extends UIElement {
1523
2004
  const showAverage = !this.noAverage && vals.length > 1 && !isSm && !this.noGrid;
1524
2005
 
1525
2006
  points.forEach((p, i) => {
2007
+ const provisional = this.#provisionalAttr(data[i]);
1526
2008
  if (this.#shouldRenderDot(i, points.length)) {
1527
- svg += `<circle data-dot cx="${p.x}" cy="${p.y}" r="${dotR}"/>`;
2009
+ svg += `<circle data-dot${provisional} cx="${p.x}" cy="${p.y}" r="${dotR}"/>`;
1528
2010
  }
1529
2011
  svg += `<circle data-hit${tip({ label: p.label, value: p.v })} cx="${p.x}" cy="${p.y}" r="${hitR}" fill="transparent"/>`;
1530
2012
  if (showValues) {
@@ -1542,6 +2024,12 @@ export class UIChart extends UIElement {
1542
2024
  svg += `<line data-hit${tip({ label: 'Average', value: avg })} x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}" stroke="transparent" stroke-width="12"/>`;
1543
2025
  }
1544
2026
 
2027
+ const ti = this.#todayIndex();
2028
+ if (ti > -1 && ti < points.length) {
2029
+ svg += this.#todayMarkerVertical(points[ti].x, baseline, dims);
2030
+ }
2031
+
2032
+ svg += chipSvg;
1545
2033
  return { svg, viewBox: `0 0 ${width} ${height}` };
1546
2034
  }
1547
2035
 
@@ -1926,7 +2414,7 @@ export class UIChart extends UIElement {
1926
2414
 
1927
2415
  // REQ-R-003 (scatter, 1:1): "axis labels abbreviated" — same
1928
2416
  // truncation treatment as bar@1:1's own square-container cell.
1929
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
2417
+ let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
1930
2418
  abbreviateLabels: ratio === '1:1',
1931
2419
  });
1932
2420
 
@@ -1941,10 +2429,20 @@ export class UIChart extends UIElement {
1941
2429
  const px = pad.left + step * i;
1942
2430
  const rawY = pad.top + plotH - ((maxVal - minVal) ? ((vals[i] - minVal) / (maxVal - minVal)) * plotH : 0);
1943
2431
  const py = this.#clampCoord(rawY, pad.top, pad.top + plotH);
1944
- svg += `<circle data-dot data-scatter cx="${px}" cy="${py}" r="${dotR}"/>`;
2432
+ // REQ-F-015 (scatter) — hollow ring dot for a provisional datum;
2433
+ // CSS ([data-dot][data-provisional]) swaps the fill for a stroke-only
2434
+ // ring rather than touching this render loop's own geometry.
2435
+ const provisional = this.#provisionalAttr(data[i]);
2436
+ svg += `<circle data-dot data-scatter${provisional} cx="${px}" cy="${py}" r="${dotR}"/>`;
1945
2437
  svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
1946
2438
  }
1947
2439
 
2440
+ const ti = this.#todayIndex();
2441
+ if (ti > -1 && ti < vals.length) {
2442
+ svg += this.#todayMarkerVertical(pad.left + step * ti, pad.top + plotH, dims);
2443
+ }
2444
+
2445
+ svg += chipSvg;
1948
2446
  return { svg, viewBox: `0 0 ${width} ${height}` };
1949
2447
  }
1950
2448
 
@@ -2642,7 +3140,7 @@ export class UIChart extends UIElement {
2642
3140
  const barInner = barW * 0.6;
2643
3141
  const barGap = (barW - barInner) / 2;
2644
3142
 
2645
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
3143
+ let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
2646
3144
 
2647
3145
  /* Bar series (slot 0) — "primary series only fully rendered" (2:3) is
2648
3146
  this same unconditional render at every ratio; the primary axis
@@ -2654,7 +3152,11 @@ export class UIChart extends UIElement {
2654
3152
  const barH = this.#clampSpan(rawH, plotH);
2655
3153
  const bx = pad.left + barW * i + barGap;
2656
3154
  const by = pad.top + plotH - barH;
2657
- svg += `<path${this.#seriesFill(0, barKey)}${tip({ label: labels[i], value: v, series: barKey })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
3155
+ // REQ-F-015 (composed) "both treatments on their respective
3156
+ // series": the bar series gets bar-family's hollow/dashed
3157
+ // treatment for a provisional row.
3158
+ const provisional = this.#provisionalAttr(data[i]);
3159
+ svg += `<path${provisional}${this.#seriesFill(0, barKey)}${tip({ label: labels[i], value: v, series: barKey })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
2658
3160
  }
2659
3161
  }
2660
3162
 
@@ -2666,7 +3168,14 @@ export class UIChart extends UIElement {
2666
3168
  the same axis, so the line renders as a small self-scaled
2667
3169
  sparkline (own min/max, no shared axis) tucked into the plot's
2668
3170
  top-right corner instead — the same "inset trend" reading a
2669
- stat-tile's own corner sparkline uses elsewhere in this file. */
3171
+ stat-tile's own corner sparkline uses elsewhere in this file.
3172
+ REQ-F-008/015 — today-marker + the line series' provisional
3173
+ treatment are deliberately NOT mirrored into this inset: it has
3174
+ no shared axis/baseline of its own (self-scaled min/max), the
3175
+ same structural reason REQ-F-016 already treats sparkline as
3176
+ N/A — named scope-down, see the build's Findings. The BAR
3177
+ series above still gets both capabilities unconditionally at
3178
+ every ratio including 2:3. */
2670
3179
  const insetW = plotW * 0.34;
2671
3180
  const insetH = Math.min(plotH * 0.26, insetW * 0.6);
2672
3181
  const insetX = pad.left + plotW - insetW - 2;
@@ -2714,13 +3223,27 @@ export class UIChart extends UIElement {
2714
3223
  ? ` data-slice="1" data-deemphasized-tint style="fill: var(--md-sys-color-neutral-on-surface-variant)"`
2715
3224
  : this.#seriesFill(1, lineKey);
2716
3225
  svg += `<path data-line${lineAttrs} d="${smoothPath(points, t)}"/>`;
2717
- for (const p of points) {
2718
- svg += `<circle data-dot${dotAttrs} cx="${p.x}" cy="${p.y}" r="3"/>`;
3226
+ // REQ-F-015 (composed) "both treatments on their respective
3227
+ // series": the line series gets line/area's own dashed-span
3228
+ // overlay for the same provisional rows the bar series above
3229
+ // renders hollow/dashed for.
3230
+ svg += this.#lineProvisionalOverlay(points, data, pad.top + plotH, dims, t);
3231
+ points.forEach((p, pi) => {
3232
+ const provisional = this.#provisionalAttr(data[pi]);
3233
+ svg += `<circle data-dot${dotAttrs}${provisional} cx="${p.x}" cy="${p.y}" r="3"/>`;
2719
3234
  svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
2720
- }
3235
+ });
2721
3236
  }
2722
3237
  }
2723
3238
 
3239
+ // REQ-F-008 — one today-marker per category (not per series), keyed
3240
+ // off the bar series' own coordinate system (unaffected by the 2:3
3241
+ // inset-sparkline branch above, which reuses the same barW/pad).
3242
+ const ti = this.#todayIndex();
3243
+ if (ti > -1 && ti < data.length) {
3244
+ svg += this.#todayMarkerVertical(pad.left + barW * ti + barW / 2, pad.top + plotH, dims);
3245
+ }
3246
+
2724
3247
  this.#legendData = [
2725
3248
  { label: barKey, key: barKey, slot: 0 },
2726
3249
  // CodeRabbit (PR #1662) — the 1:1 de-emphasis above swaps the
@@ -2732,6 +3255,7 @@ export class UIChart extends UIElement {
2732
3255
  { label: lineKey, key: lineKey, slot: 1, deemphasized: ratio === '1:1' },
2733
3256
  ];
2734
3257
 
3258
+ svg += chipSvg;
2735
3259
  return { svg, viewBox: `0 0 ${width} ${height}` };
2736
3260
  }
2737
3261
 
@@ -2924,7 +3448,7 @@ export class UIChart extends UIElement {
2924
3448
 
2925
3449
  // REQ-R-003 (stacked-bar, 1:1 "square"): abbreviate category labels —
2926
3450
  // same truncation treatment as bar@1:1's own square-container cell.
2927
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
3451
+ let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
2928
3452
  abbreviateLabels: ratio === '1:1',
2929
3453
  });
2930
3454
 
@@ -2955,7 +3479,12 @@ export class UIChart extends UIElement {
2955
3479
  const isTop = k === segCount - 1;
2956
3480
  const r = this.#resolveRadius();
2957
3481
 
2958
- const attrs = `${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
3482
+ // REQ-F-015 (bar family) provisional state is a per-ROW flag:
3483
+ // every segment in that category's stack renders the hollow/
3484
+ // dashed treatment together (the whole period is incomplete, not
3485
+ // one series within it).
3486
+ const provisional = this.#provisionalAttr(data[i]);
3487
+ const attrs = `${provisional}${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
2959
3488
 
2960
3489
  if (isTop) {
2961
3490
  // Top segment (or single segment) — round top corners only.
@@ -2970,6 +3499,12 @@ export class UIChart extends UIElement {
2970
3499
  }
2971
3500
  }
2972
3501
 
3502
+ const ti = this.#todayIndex();
3503
+ if (ti > -1 && ti < data.length) {
3504
+ svg += this.#todayMarkerVertical(pad.left + barW * ti + barW / 2, pad.top + plotH, dims);
3505
+ }
3506
+
3507
+ svg += chipSvg;
2973
3508
  return { svg, viewBox: `0 0 ${width} ${height}` };
2974
3509
  }
2975
3510
 
@@ -3030,7 +3565,8 @@ export class UIChart extends UIElement {
3030
3565
  const segW = clampedEnd - bx;
3031
3566
  stackX = clampedEnd;
3032
3567
  const isEnd = k === segCount - 1;
3033
- const attrs = `${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
3568
+ const provisional = this.#provisionalAttr(data[i]);
3569
+ const attrs = `${provisional}${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
3034
3570
 
3035
3571
  if (isEnd) {
3036
3572
  // End segment (or single segment) — round the value-end corners
@@ -3042,6 +3578,12 @@ export class UIChart extends UIElement {
3042
3578
  }
3043
3579
  }
3044
3580
 
3581
+ /* REQ-F-008 mirror — see #todayMarkerHorizontal's own doc comment. */
3582
+ const ti = this.#todayIndex();
3583
+ if (ti > -1 && ti < labels.length) {
3584
+ svg += this.#todayMarkerHorizontal(pad.top + barH * ti + barH / 2, pad.left, dims);
3585
+ }
3586
+
3045
3587
  return { svg, viewBox: `0 0 ${width} ${height}` };
3046
3588
  }
3047
3589
 
@@ -3085,11 +3627,14 @@ export class UIChart extends UIElement {
3085
3627
  // before category count drops)": category labels abbreviate first
3086
3628
  // (same treatment as bar/stacked-bar's own 1:1 cells), freeing width
3087
3629
  // for the bars themselves rather than ever dropping a category.
3088
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
3630
+ let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
3089
3631
  abbreviateLabels: ratio === '1:1',
3090
3632
  });
3091
3633
 
3092
3634
  for (let i = 0; i < data.length; i++) {
3635
+ // REQ-F-015 (bar family) — per-row provisional flag applies to every
3636
+ // sub-bar in that category's group together.
3637
+ const provisional = this.#provisionalAttr(data[i]);
3093
3638
  for (let k = 0; k < keys.length; k++) {
3094
3639
  if (this.#isSeriesHidden(keys[k])) continue;
3095
3640
  const v = +(data[i][keys[k]] ?? 0);
@@ -3097,7 +3642,7 @@ export class UIChart extends UIElement {
3097
3642
  const barH = this.#clampSpan(rawH, plotH);
3098
3643
  const bx = pad.left + groupW * i + groupPad + (subBarW + barGap) * k;
3099
3644
  const by = pad.top + plotH - barH;
3100
- svg += `<path${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} d="${topRoundedBarPath(bx, by, subBarW, barH, this.#resolveRadius())}"/>`;
3645
+ svg += `<path${provisional}${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} d="${topRoundedBarPath(bx, by, subBarW, barH, this.#resolveRadius())}"/>`;
3101
3646
 
3102
3647
  if (!this.noValues) {
3103
3648
  svg += `<text data-value x="${bx + subBarW / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
@@ -3105,6 +3650,12 @@ export class UIChart extends UIElement {
3105
3650
  }
3106
3651
  }
3107
3652
 
3653
+ const ti = this.#todayIndex();
3654
+ if (ti > -1 && ti < data.length) {
3655
+ svg += this.#todayMarkerVertical(pad.left + groupW * ti + groupW / 2, pad.top + plotH, dims);
3656
+ }
3657
+
3658
+ svg += chipSvg;
3108
3659
  return { svg, viewBox: `0 0 ${width} ${height}` };
3109
3660
  }
3110
3661
 
@@ -3149,6 +3700,7 @@ export class UIChart extends UIElement {
3149
3700
 
3150
3701
  for (let i = 0; i < labels.length; i++) {
3151
3702
  const groupY = pad.top + groupH * i;
3703
+ const provisional = this.#provisionalAttr(data[i]);
3152
3704
  svg += `<text data-x-label x="${pad.left - 6}" y="${groupY + groupH / 2 + fs * 0.35}" text-anchor="end" font-size="${ls}">${esc(labels[i])}</text>`;
3153
3705
 
3154
3706
  for (let k = 0; k < keys.length; k++) {
@@ -3157,7 +3709,7 @@ export class UIChart extends UIElement {
3157
3709
  const rawW = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotW : 0;
3158
3710
  const barW = this.#clampSpan(rawW, plotW);
3159
3711
  const by = groupY + groupPad + (subBarH + barGap) * k;
3160
- svg += `<path${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} d="${rightRoundedBarPath(pad.left, by, barW, subBarH, this.#resolveRadius())}"/>`;
3712
+ svg += `<path${provisional}${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} d="${rightRoundedBarPath(pad.left, by, barW, subBarH, this.#resolveRadius())}"/>`;
3161
3713
 
3162
3714
  if (!this.noValues) {
3163
3715
  svg += `<text data-value x="${pad.left + barW + 4}" y="${by + subBarH / 2 + fs * 0.35}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
@@ -3165,6 +3717,12 @@ export class UIChart extends UIElement {
3165
3717
  }
3166
3718
  }
3167
3719
 
3720
+ /* REQ-F-008 mirror — see #todayMarkerHorizontal's own doc comment. */
3721
+ const ti = this.#todayIndex();
3722
+ if (ti > -1 && ti < labels.length) {
3723
+ svg += this.#todayMarkerHorizontal(pad.top + groupH * ti + groupH / 2, pad.left, dims);
3724
+ }
3725
+
3168
3726
  return { svg, viewBox: `0 0 ${width} ${height}` };
3169
3727
  }
3170
3728
 
@@ -3187,7 +3745,7 @@ export class UIChart extends UIElement {
3187
3745
  const plotW = width - pad.left - pad.right;
3188
3746
  const step = plotW / Math.max(data.length - 1, 1);
3189
3747
 
3190
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, { pointAligned: true });
3748
+ let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, { pointAligned: true });
3191
3749
 
3192
3750
  // REQ-R-003 (multi-line) — a progressive de-emphasis across the three
3193
3751
  // ratios rather than one fixed identification mechanism:
@@ -3235,12 +3793,17 @@ export class UIChart extends UIElement {
3235
3793
 
3236
3794
  /* Line */
3237
3795
  svg += `<path data-line${this.#seriesStroke(k % 10, keys[k])}${deemphAttr} d="${smoothPath(points, t)}"/>`;
3796
+ // REQ-F-015 (line/area, per-series) — the row-level provisional flag
3797
+ // applies to every series at that x-column; each series draws its
3798
+ // own dashed/faded overlay.
3799
+ svg += this.#lineProvisionalOverlay(points, data, baseline, dims, t);
3238
3800
 
3239
3801
  /* Dots + hit targets. Hit circles deliberately omit data-slice so
3240
3802
  they aren't caught by the circle[data-slice] fill rule in CSS. */
3241
3803
  points.forEach((p, i) => {
3804
+ const provisional = this.#provisionalAttr(data[i]);
3242
3805
  if (this.#shouldRenderDot(i, points.length)) {
3243
- svg += `<circle data-dot${this.#seriesFill(k % 10, keys[k])}${deemphAttr} cx="${p.x}" cy="${p.y}" r="3"/>`;
3806
+ svg += `<circle data-dot${this.#seriesFill(k % 10, keys[k])}${deemphAttr}${provisional} cx="${p.x}" cy="${p.y}" r="3"/>`;
3244
3807
  }
3245
3808
  svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: keys[k] })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
3246
3809
  });
@@ -3251,8 +3814,15 @@ export class UIChart extends UIElement {
3251
3814
  }
3252
3815
  }
3253
3816
 
3817
+ // REQ-F-008 — one today-marker per category (not per series).
3818
+ const ti = this.#todayIndex();
3819
+ if (ti > -1 && ti < data.length) {
3820
+ svg += this.#todayMarkerVertical(pad.left + step * ti, pad.top + plotH, dims);
3821
+ }
3822
+
3254
3823
  this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
3255
3824
 
3825
+ svg += chipSvg;
3256
3826
  return { svg, viewBox: `0 0 ${width} ${height}` };
3257
3827
  }
3258
3828