@fasl-work/caos-app-shell 0.18.0 → 0.19.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.
@@ -39,25 +39,6 @@ declare function formatTicks(values: readonly (number | null | undefined)[], lan
39
39
  /** `formatNumber` bound to the current shell language. */
40
40
  declare function useFormat(): (value: number | null | undefined, opts?: FormatOptions) => string;
41
41
 
42
- /**
43
- * The colour and font tokens `styles.css` defines for both themes. The architecture-modal validation and the
44
- * template's web-baseline guard check every `var(--x)` a product names against this list; a token that is not
45
- * defined renders as nothing, silently (failure class 25 of the 2026-10-04 history). A unit test keeps this list
46
- * equal to the stylesheet.
47
- */
48
- declare const SHELL_TOKENS: readonly ["--color-bg", "--color-surface", "--color-surface-2", "--color-border", "--color-fg", "--color-fg-subtle", "--color-fg-faint", "--color-accent", "--color-accent-fg", "--color-accent-soft", "--color-accent-2", "--color-magenta", "--color-good", "--color-warn", "--color-bad", "--color-shadow", "--font-sans", "--font-mono", "--text-xs", "--text-sm", "--text-md", "--text-base", "--text-lg", "--text-xl", "--text-2xl", "--space-1", "--space-2", "--space-3", "--space-4", "--space-5", "--space-6", "--radius-sm", "--radius-md", "--radius-lg", "--radius-pill", "--maxw", "--maxw-wide", "--measure", "--measure-text", "--header-h", "--rail-w", "--fade", "--icon-sm", "--icon-md", "--icon-lg", "--z-header", "--z-focus", "--z-modal"];
49
- type ShellToken = (typeof SHELL_TOKENS)[number];
50
- /** A colour token: what a chart series, a tone or a canvas colour may name. */
51
- type ShellColorToken = Extract<ShellToken, `--color-${string}`>;
52
- /** The computed value of a token on the document, or `fallback` outside a browser. */
53
- declare function resolveToken(token: string, fallback?: string): string;
54
- /**
55
- * The colour tokens resolved to concrete values for the active theme, recomputed when the theme changes. Canvas
56
- * and WebGL renderers cannot read CSS variables, so they take these (failure classes 7 and 18: charts drawn in
57
- * dark-theme colours on a light page).
58
- */
59
- declare function useThemeTokens(): Record<ShellToken, string>;
60
-
61
42
  /**
62
43
  * Text that fits the drawing it labels, measured in the font the page renders.
63
44
  *
@@ -92,8 +73,30 @@ declare function niceStep(x: number): number;
92
73
  /**
93
74
  * Round ticks covering [lo, hi], as many as leave room for labels about `labelSize` pixels along an axis of `room`
94
75
  * pixels (at least two, at most `most`). The first tick is at or below `lo` and the last at or above `hi`, so a bar
95
- * or a point never runs past the last labelled value.
76
+ * or a point never runs past the last labelled value. With a `resolution` (1 for an axis of counts, 10^-d for d fixed
77
+ * decimals) every step is a multiple of it, so no two labels read the same and none is a rounded fraction (0.19.0).
96
78
  */
97
- declare function niceTicks(lo: number, hi: number, room: number, labelSize: number, most?: number): number[];
79
+ declare function niceTicks(lo: number, hi: number, room: number, labelSize: number, most?: number, resolution?: number): number[];
80
+ /** How many tick labels repeat the label before them (empty labels apart); the gate fails a chart with any (G6). */
81
+ declare function repeatedLabels(labels: readonly (string | null | undefined)[]): number;
82
+
83
+ /**
84
+ * The colour and font tokens `styles.css` defines for both themes. The architecture-modal validation and the
85
+ * template's web-baseline guard check every `var(--x)` a product names against this list; a token that is not
86
+ * defined renders as nothing, silently (failure class 25 of the 2026-10-04 history). A unit test keeps this list
87
+ * equal to the stylesheet.
88
+ */
89
+ declare const SHELL_TOKENS: readonly ["--color-bg", "--color-surface", "--color-surface-2", "--color-border", "--color-fg", "--color-fg-subtle", "--color-fg-faint", "--color-accent", "--color-accent-fg", "--color-accent-soft", "--color-accent-2", "--color-magenta", "--color-good", "--color-warn", "--color-bad", "--color-shadow", "--font-sans", "--font-mono", "--text-xs", "--text-sm", "--text-md", "--text-base", "--text-lg", "--text-xl", "--text-2xl", "--space-1", "--space-2", "--space-3", "--space-4", "--space-5", "--space-6", "--radius-sm", "--radius-md", "--radius-lg", "--radius-pill", "--maxw", "--maxw-wide", "--measure", "--measure-text", "--header-h", "--rail-w", "--fade", "--icon-sm", "--icon-md", "--icon-lg", "--z-header", "--z-focus", "--z-modal"];
90
+ type ShellToken = (typeof SHELL_TOKENS)[number];
91
+ /** A colour token: what a chart series, a tone or a canvas colour may name. */
92
+ type ShellColorToken = Extract<ShellToken, `--color-${string}`>;
93
+ /** The computed value of a token on the document, or `fallback` outside a browser. */
94
+ declare function resolveToken(token: string, fallback?: string): string;
95
+ /**
96
+ * The colour tokens resolved to concrete values for the active theme, recomputed when the theme changes. Canvas
97
+ * and WebGL renderers cannot read CSS variables, so they take these (failure classes 7 and 18: charts drawn in
98
+ * dark-theme colours on a light page).
99
+ */
100
+ declare function useThemeTokens(): Record<ShellToken, string>;
98
101
 
99
- export { type FormatOptions as F, NBSP as N, type ShellColorToken as S, type FittedLabel as a, NOT_AVAILABLE as b, SCIENTIFIC_BELOW as c, SHELL_TOKENS as d, type ShellToken as e, fitLabel as f, fontFamily as g, formatNumber as h, formatTicks as i, niceTicks as j, resolveToken as k, useThemeTokens as l, niceStep as n, referenceLineName as r, textWidth as t, useFormat as u, widestLabel as w };
102
+ export { type FormatOptions as F, NBSP as N, type ShellColorToken as S, repeatedLabels as a, type FittedLabel as b, NOT_AVAILABLE as c, SCIENTIFIC_BELOW as d, SHELL_TOKENS as e, type ShellToken as f, fitLabel as g, fontFamily as h, formatNumber as i, formatTicks as j, niceTicks as k, resolveToken as l, useThemeTokens as m, niceStep as n, referenceLineName as r, textWidth as t, useFormat as u, widestLabel as w };
package/gate/inpage.mjs CHANGED
@@ -1580,4 +1580,173 @@ export function installLib() {
1580
1580
  }
1581
1581
  return out.slice(0, 10);
1582
1582
  };
1583
+
1584
+ // ---- G20 (0.19.0): every chart host carries Tier A: one tab stop with a name, the table of the values, a summary, a reset --------------
1585
+ const reasonWords = (t) => (String(t).match(/[\p{L}\p{Nd}]/gu) || []).length;
1586
+ G.chartsTierA = async (drive) => {
1587
+ const out = [];
1588
+ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
1589
+ for (const host of document.querySelectorAll('[data-chart-host]')) {
1590
+ if (!visible(host)) continue;
1591
+ const kind = host.getAttribute('data-chart-host');
1592
+ const label = describe(host.closest('[data-plot]') || host);
1593
+ const problems = [];
1594
+ const stop = host.querySelector('[data-chart-focus]');
1595
+ // a chart that turns zoom off declares data-zoom-off, an empty reason included (the attribute is there, its text is the reason)
1596
+ const entry = { label, kind, problems, zoomOffSet: host.hasAttribute('data-zoom-off'), zoomOff: host.getAttribute('data-zoom-off') || '', zoomOffValid: true };
1597
+ if (entry.zoomOffSet) entry.zoomOffValid = reasonWords(entry.zoomOff) >= 12;
1598
+ out.push(entry);
1599
+ // the labels of a chart's marks (x and y) share one set of boxes, and none lies over another (0.19.0): the chart says how many do
1600
+ const overlaps = host.getAttribute('data-mark-overlaps');
1601
+ if (overlaps !== null && Number(overlaps) > 0) {
1602
+ // the pairs that overlap, each label named with its kind (an x mark or a y mark), from the boxes the chart declares (re-review of 0.19.0, m1)
1603
+ let pairs = [];
1604
+ try {
1605
+ const boxes = JSON.parse(host.getAttribute('data-mark-boxes') || '[]');
1606
+ for (let i = 0; i < boxes.length; i++)
1607
+ for (let j = i + 1; j < boxes.length; j++) {
1608
+ const a = boxes[i];
1609
+ const b = boxes[j];
1610
+ if (a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1) pairs.push(`"${a.t}" (${a.k}) lies over "${b.t}" (${b.k})`);
1611
+ }
1612
+ } catch {
1613
+ pairs = [];
1614
+ }
1615
+ problems.push(`${overlaps} pair(s) of mark label(s) overlap (data-mark-overlaps="${overlaps}")${pairs.length ? `: ${pairs.slice(0, 4).join('; ')}` : ': the chart declares no boxes to name them'}`);
1616
+ }
1617
+ if (!stop) {
1618
+ problems.push('the chart is not a tab stop (no element with data-chart-focus): the keyboard cannot reach its crosshair, zoom or reset');
1619
+ continue;
1620
+ }
1621
+ if (stop.tabIndex < 0) problems.push('the plot is not a tab stop (tabindex is below 0)');
1622
+ const labelledBy = (stop.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean).map((id) => document.getElementById(id)?.textContent || '').join(' ');
1623
+ const name = (stop.getAttribute('aria-label') || labelledBy || '').trim();
1624
+ if (name.length < 2) problems.push('the plot has no accessible name (aria-label or aria-labelledby)');
1625
+ // the summary describes the plot (aria-describedby); the table of the values is linked by aria-details (or, as in 0.19.0, described by)
1626
+ const ids = `${stop.getAttribute('aria-describedby') || ''} ${stop.getAttribute('aria-details') || ''}`.split(/\s+/).filter(Boolean);
1627
+ const described = ids.map((id) => document.getElementById(id)).filter(Boolean);
1628
+ const table = described.find((el) => el.tagName === 'TABLE');
1629
+ const points = Number(host.getAttribute('data-points') || '0');
1630
+ if (!table) problems.push('the plot is not linked to a data table (aria-details must name a table of the plotted values)');
1631
+ else {
1632
+ const rows = table.querySelectorAll('tbody tr').length;
1633
+ if (points > 0 && rows === 0) problems.push('the data table has no rows');
1634
+ if (table.querySelectorAll('thead th').length < 2) problems.push('the data table has no header for the values');
1635
+ }
1636
+ const summary = described.find((el) => el.tagName !== 'TABLE');
1637
+ if (!summary || !(summary.textContent || '').trim()) problems.push('the plot has no one-line text summary (aria-describedby must name it)');
1638
+ // the bar forms of 0.19.0: a floating bar adds the two ends to the table (From, To), grouped bars add the group; a legend is drawn when one is declared
1639
+ const spans = Number(host.getAttribute('data-spans') || '0');
1640
+ const groups = Number(host.getAttribute('data-groups') || '0');
1641
+ const legend = Number(host.getAttribute('data-legend') || '0');
1642
+ if (kind === 'bars' && table) {
1643
+ const want = 3 + (spans > 0 ? 2 : 0) + (groups > 0 ? 1 : 0);
1644
+ const have = table.querySelectorAll('thead th').length;
1645
+ if (have !== want) problems.push(`the data table of ${spans > 0 ? 'floating' : 'grouped'} bars has ${have} columns, it must have ${want} (${[groups > 0 ? 'group' : '', 'category', 'value', spans > 0 ? 'from, to' : '', 'note'].filter(Boolean).join(', ')})`);
1646
+ }
1647
+ if (kind === 'bars' && legend > 0) {
1648
+ const keys = [...host.querySelectorAll('.caos-bars-legend li')].filter(visible);
1649
+ if (keys.length !== legend) problems.push(`the legend is not shown: the chart declares ${legend} entries and ${keys.length} are visible`);
1650
+ }
1651
+ // driven (the `interact` state): the arrow keys move the crosshair and change the readout, a scripted brush zooms, `+` zooms, the one
1652
+ // visible control and `0` reset. A chart that is zoomed already, or cannot be zoomed, is not driven for the zoom
1653
+ if (drive) {
1654
+ const key = (k) => stop.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
1655
+ let zeroDead = false;
1656
+ const readout = host.querySelector('.caos-chart-readout, .caos-bars-readout');
1657
+ if (readout && points > 0) {
1658
+ const rest = (readout.textContent || '').trim();
1659
+ key('ArrowRight');
1660
+ await pause(80);
1661
+ const moved = (readout.textContent || '').trim();
1662
+ if (!moved || moved === rest) problems.push('the arrow keys do not move the crosshair (the readout does not change on ArrowRight)');
1663
+ key('End');
1664
+ key('Home');
1665
+ await pause(40);
1666
+ // floating and grouped bars: the keyboard walks every bar, each readout reads the bar's two ends (a floating bar) and its group (a grouped one)
1667
+ if (kind === 'bars' && (spans > 0 || groups > 0) && points > 1 && points <= 60) {
1668
+ const cells = table ? [...table.querySelectorAll('tbody tr')].map((tr) => [...tr.querySelectorAll('th, td')].map((c) => (c.textContent || '').trim())) : [];
1669
+ const seen = new Set();
1670
+ let unread = 0;
1671
+ let ungrouped = 0;
1672
+ for (let k = 0; k < points; k++) {
1673
+ if (k > 0) key('ArrowRight');
1674
+ await pause(30);
1675
+ const text = (readout.textContent || '').trim();
1676
+ seen.add(text);
1677
+ const row = cells[k] || [];
1678
+ const offset = groups > 0 ? 1 : 0;
1679
+ if (spans > 0 && row[offset + 2] && row[offset + 3] && !(text.includes(row[offset + 2]) && text.includes(row[offset + 3]))) unread += 1;
1680
+ if (groups > 0 && row[0] && !text.includes(row[0])) ungrouped += 1;
1681
+ }
1682
+ if (seen.size !== points) problems.push(`the keyboard walk reached ${seen.size} of ${points} bars (the arrow keys skip bars, or two bars read the same)`);
1683
+ if (unread > 0) problems.push(`the readout of ${unread} floating bar(s) does not read its two ends (the table has them: From and To)`);
1684
+ if (ungrouped > 0) problems.push(`the readout of ${ungrouped} grouped bar(s) does not read its group`);
1685
+ key('Home');
1686
+ await pause(40);
1687
+ }
1688
+ }
1689
+ if (host.getAttribute('data-zoomable') === '1' && !host.hasAttribute('data-zoomed')) {
1690
+ key('+');
1691
+ await pause(80);
1692
+ if (!host.hasAttribute('data-zoomed')) problems.push('the + key does not zoom the chart');
1693
+ else {
1694
+ const control = [...host.querySelectorAll('[data-chart-reset]')].filter(visible);
1695
+ if (control.length !== 1) problems.push(`a zoomed chart shows ${control.length} reset controls, it must show exactly one`);
1696
+ key('0');
1697
+ await pause(80);
1698
+ if (host.hasAttribute('data-zoomed')) {
1699
+ zeroDead = true;
1700
+ key('Escape');
1701
+ await pause(80);
1702
+ }
1703
+ }
1704
+ // a scripted brush across the plot: the mouse events uPlot listens for, the pointer events the bar chart listens for (it brushes along its
1705
+ // category axis: down the rows of a horizontal chart, across the columns of a vertical one)
1706
+ const target = kind === 'bars' ? host.querySelector('[data-chart-focus]') : host.querySelector('.u-over');
1707
+ if (target && visible(target) && !host.hasAttribute('data-zoomed')) {
1708
+ const r = target.getBoundingClientRect();
1709
+ const bars = kind === 'bars';
1710
+ const down = bars && host.getAttribute('data-orientation') !== 'vertical';
1711
+ // a bar chart is brushed from the middle of its first bar to the middle of a bar a third of the way along (the Contraste adoption, F8-3):
1712
+ // a fixed share of the drawing reached across every row of a short horizontal chart, whose axis and title take most of its height, and
1713
+ // a brush over every category is no zoom. Without bars to aim at, a fixed share of the drawing is dragged
1714
+ const drawnBars = bars ? [...host.querySelectorAll('svg [data-bar]')].map((g) => g.getBoundingClientRect()).filter((q) => q.width > 0 && q.height > 0) : [];
1715
+ const aim = drawnBars.length >= 2 ? [drawnBars[0], drawnBars[Math.max(1, Math.floor(drawnBars.length / 3))]] : null;
1716
+ const mid = (q) => ({ x: q.left + q.width / 2, y: q.top + q.height / 2 });
1717
+ const at = (f) => {
1718
+ if (aim) {
1719
+ const [a, b] = [mid(aim[0]), mid(aim[1])];
1720
+ return down ? { x: r.left + r.width * 0.5, y: a.y + (b.y - a.y) * f } : { x: a.x + (b.x - a.x) * f, y: r.top + r.height * 0.5 };
1721
+ }
1722
+ return down ? { x: r.left + r.width * 0.5, y: r.top + r.height * (0.15 + 0.4 * f) } : { x: r.left + r.width * (0.3 + 0.3 * f), y: r.top + r.height * 0.5 };
1723
+ };
1724
+ const fire = (type, f) => {
1725
+ const p = at(f);
1726
+ // uPlot ignores a mouse move with no movement while it drags (a Chrome-on-Windows stray event), so the scripted ones carry some
1727
+ const init = { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y, button: 0, buttons: type.endsWith('up') ? 0 : 1, view: window, movementX: down ? 0 : 3, movementY: down ? 3 : 0 };
1728
+ target.dispatchEvent(bars ? new PointerEvent(type.replace('mouse', 'pointer'), { ...init, pointerId: 41, pointerType: 'mouse', isPrimary: true }) : new MouseEvent(type, init));
1729
+ };
1730
+ if (!bars) target.dispatchEvent(new MouseEvent('mouseenter', { bubbles: false, clientX: at(0).x, clientY: at(0).y }));
1731
+ fire('mousedown', 0);
1732
+ for (let k = 1; k <= 6; k++) fire('mousemove', k / 6);
1733
+ fire('mouseup', 1);
1734
+ await pause(120);
1735
+ if (!host.hasAttribute('data-zoomed')) problems.push('a drag across the plot does not zoom the chart (the brush is dead)');
1736
+ else {
1737
+ key('0');
1738
+ await pause(80);
1739
+ if (host.hasAttribute('data-zoomed')) {
1740
+ zeroDead = true;
1741
+ key('Escape');
1742
+ await pause(80);
1743
+ }
1744
+ }
1745
+ }
1746
+ }
1747
+ if (zeroDead) problems.push('the 0 key does not reset the zoom');
1748
+ }
1749
+ }
1750
+ return out;
1751
+ };
1583
1752
  }
package/gate/run.mjs CHANGED
@@ -32,6 +32,9 @@
32
32
  // on a surface App (--workbench '', --app-route; 0.16.0) the same for the views of its tab row: another view or
33
33
  // sub-view in Focus or after the return, no ?focus=1 and view in the URL, no view that offers the control
34
34
  // G19 values whole a short value (number, percentage, amount, unit) sits on one line in every state
35
+ // G20 chart interactions every chart is one tab stop with an accessible name, described by a table of its values and a one-line summary,
36
+ // and a zoomed chart resets (the + key zooms, the visible control shows, the 0 key resets); a chart that turns zoom
37
+ // off says why (12 letters and digits at least) and is listed among the waivers (0.19.0)
35
38
  // plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
36
39
  // references).
37
40
 
@@ -485,6 +488,15 @@ export async function runGate(o) {
485
488
  if (r.names) fail('G6', where, `${r.label}: ${r.n} named x tick label(s) repeat another tick's label (${r.names.split('|').map((n) => `"${n}"`).join(', ')}); give every named tick a label of its own`);
486
489
  else fail('G6', where, `${r.label}: ${r.n} axis tick label(s) repeat the label before them (give the axis fixed decimals)`);
487
490
  }
491
+ // G20 (0.19.0): every chart is a tab stop with a name, a table of its values, a summary and a reset; one that turns zoom off is listed with its reason
492
+ for (const c of await page.evaluate((drive) => window.__caosGate.chartsTierA(drive), Boolean(s.interact))) {
493
+ for (const m of c.problems) fail('G20', where, `${c.label} (${c.kind} chart): ${m}`);
494
+ if (c.zoomOffSet) {
495
+ // listed once per route and language (the first state), failed in every state while the reason is too short
496
+ if (s.interact) waivers.push({ route: where.route, lang: s.lang, floor: 'chart zoom off', reason: c.zoomOff, valid: c.zoomOffValid });
497
+ if (!c.zoomOffValid) fail('G20', where, `${c.label}: zoom is turned off with a reason of fewer than 12 letters and digits ("${c.zoomOff}"); say why the chart cannot be zoomed`);
498
+ }
499
+ }
488
500
  // a filling card whose note leaves its drawing under the floor (CAOS_APP_SHELL#95); below 1280 px the document scrolls
489
501
  if (big) for (const c of f.fillStages ?? []) if (c.h < opt.stageFloor) fail('G6', where, `the drawing of ${c.label} keeps ${Math.round(c.h)} px under its note (floor ${opt.stageFloor} px)`);
490
502
  for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', where, m);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fasl-work/caos-app-shell",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Shared web-app shell, workbench primitives, content primitives, design system and measured gate for the CAOS / Faena public apps (ADR-0016, ADR-0017, ADR-0071).",
5
5
  "license": "MIT",
6
6
  "author": "Felipe Santibáñez-Leal",
@@ -22,13 +22,24 @@
22
22
  "caos-architecture-diagram",
23
23
  "caos-badges",
24
24
  "caos-bars",
25
+ "caos-bars-brush",
26
+ "caos-bars-cursor",
27
+ "caos-bars-foot",
28
+ "caos-bars-host",
29
+ "caos-bars-key",
30
+ "caos-bars-legend",
31
+ "caos-bars-readout",
32
+ "caos-bars-swatch",
25
33
  "caos-card",
26
34
  "caos-chart",
35
+ "caos-chart-axis-title",
36
+ "caos-chart-foot",
27
37
  "caos-chart-key",
28
38
  "caos-chart-key-button",
29
39
  "caos-chart-legend",
30
40
  "caos-chart-plot",
31
41
  "caos-chart-readout",
42
+ "caos-chart-reset",
32
43
  "caos-chart-swatch",
33
44
  "caos-chip-row",
34
45
  "caos-chipgroup",
@@ -336,13 +347,24 @@
336
347
  "caos-architecture-diagram",
337
348
  "caos-badges",
338
349
  "caos-bars",
350
+ "caos-bars-brush",
351
+ "caos-bars-cursor",
352
+ "caos-bars-foot",
353
+ "caos-bars-host",
354
+ "caos-bars-key",
355
+ "caos-bars-legend",
356
+ "caos-bars-readout",
357
+ "caos-bars-swatch",
339
358
  "caos-card",
340
359
  "caos-chart",
360
+ "caos-chart-axis-title",
361
+ "caos-chart-foot",
341
362
  "caos-chart-key",
342
363
  "caos-chart-key-button",
343
364
  "caos-chart-legend",
344
365
  "caos-chart-plot",
345
366
  "caos-chart-readout",
367
+ "caos-chart-reset",
346
368
  "caos-chart-swatch",
347
369
  "caos-chip-row",
348
370
  "caos-chipgroup",
package/styles.css CHANGED
@@ -356,6 +356,9 @@ code, kbd { font-family: var(--font-mono); }
356
356
  .prose table { display: block; max-width: 100%; overflow-x: auto; }
357
357
  .prose pre { max-width: 100%; overflow-x: auto; }
358
358
  .prose img, .prose svg, .prose video { max-width: 100%; height: auto; }
359
+ /* KaTeX draws a square root, a wide accent and a stretchy arrow as an SVG sized by its own rule (height: inherit, width 100% of
360
+ a span the glyph sizes); the media rule above made it height: auto, which collapsed it to under 2 px (shell 0.19.0, Contraste b11 and b13). */
361
+ .prose .katex svg { height: inherit; max-width: none; }
359
362
 
360
363
  .fig-row { display: grid; grid-template-columns: 1.1fr 1fr; gap: 1.5rem; align-items: center; margin-block: 1.5rem; }
361
364
  .fig-row.rev { grid-template-columns: 1fr 1.1fr; }
@@ -652,6 +655,35 @@ svg.lucide { flex: none; }
652
655
  .caos-bars { position: relative; width: 100%; min-width: 0; }
653
656
  .caos-bars.fill { height: 100%; min-height: 0; overflow-y: auto; }
654
657
  .caos-bars > svg { display: block; }
658
+ /* The bar chart's host holds the drawing, the hidden table, the readout and the reset control (shell 0.19.0, Tier A); a filling
659
+ chart gives the drawing the height the foot leaves. */
660
+ .caos-bars-host { position: relative; width: 100%; min-width: 0; display: flex; flex-direction: column; }
661
+ .caos-bars-host.fill { height: 100%; min-height: 0; }
662
+ .caos-bars-host.fill > .caos-bars { flex: 1 1 0; height: auto; min-height: 0; }
663
+ .caos-bars-host > .caos-bars { flex: none; }
664
+ .caos-bars-host.fill > .caos-bars-foot, .caos-bars-host.fill > .caos-chart-axis-title { flex: none; }
665
+ .caos-bars-foot { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
666
+ .caos-bars-readout { flex: 1 1 0; min-width: 0; margin: 0.15rem 0 0; font-family: var(--font-mono); font-size: 0.74rem; line-height: 1.3; color: var(--color-fg-subtle); min-height: 1.3em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
667
+ .caos-bars[data-chart-focus] { outline-offset: -2px; border-radius: var(--radius-sm); touch-action: pan-y; }
668
+ .caos-bars[data-chart-focus][data-zoomed] { touch-action: none; }
669
+ .caos-bars-brush { fill: var(--color-accent); fill-opacity: 0.16; stroke: var(--color-accent); stroke-width: 1; pointer-events: none; }
670
+ .caos-bars-cursor { fill: var(--color-accent); fill-opacity: 0.1; pointer-events: none; }
671
+ /* grouped bars (0.19.0): the legend of what each tone means, under the bars; a square swatch in the tone's colour */
672
+ .caos-bars-legend { list-style: none; margin: 0.25rem 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.1rem 0.8rem; font-size: 0.74rem; line-height: 1.35; color: var(--color-fg-subtle); }
673
+ .caos-bars-key { display: inline-flex; align-items: center; gap: 0.35rem; min-width: 0; }
674
+ .caos-bars-swatch { flex: none; width: 10px; height: 10px; border-radius: 2px; background: var(--swatch); }
675
+ /* Tier A of the shell charts (0.19.0): the tab stop's ring, an axis title that moved off the axis, the foot with the reset control. */
676
+ .caos-chart-plot[data-chart-focus]:focus-visible, .caos-bars[data-chart-focus]:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }
677
+ /* A chart that holds the focus takes the wheel (a click gives it the focus, however it was given): the ring says so, in both themes. */
678
+ .caos-chart[data-zoomable="1"] > .caos-chart-plot[data-chart-focus]:focus, .caos-bars-host[data-zoomable="1"] > .caos-bars[data-chart-focus]:focus { outline: 2px solid var(--color-accent); outline-offset: -2px; }
679
+ .caos-chart-axis-title { margin: 0 0 0.15rem; font-size: 0.73rem; line-height: 1.3; color: var(--color-fg-subtle); overflow-wrap: anywhere; }
680
+ .caos-chart-axis-title[data-axis-title="x"] { margin: 0.1rem 0 0; text-align: center; }
681
+ .caos-chart.fill > .caos-chart-axis-title, .caos-chart.fill > .caos-chart-foot { flex: none; }
682
+ .caos-chart-foot { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
683
+ .caos-chart-foot > .caos-chart-readout { flex: 1 1 0; min-width: 0; }
684
+ .caos-chart-reset { flex: none; box-sizing: border-box; min-width: 24px; min-height: 24px; margin: 0.15rem 0 0; padding: 0.15rem 0.5rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface-2); color: var(--color-fg); font: inherit; font-size: 0.72rem; line-height: 1.15; cursor: pointer; }
685
+ .caos-chart-reset:hover { border-color: var(--color-fg-subtle); }
686
+ .caos-chart-reset:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 1px; }
655
687
  /* A view whose data is not there yet says so (and declares data-state="loading" for the gate). */
656
688
  .caos-pending { margin: auto; padding: var(--space-5); color: var(--color-fg-faint); font-size: 0.86rem; text-align: center; }
657
689
  /* A failed view (PanelBoundary): an inline message in its own frame, never a blank page. */
@@ -1,289 +0,0 @@
1
- import { LANG_STORAGE_KEY, THEME_STORAGE_KEY } from './chunk-33WTUQ7Q.js';
2
- import { create } from 'zustand';
3
- import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
4
- import { jsx } from 'react/jsx-runtime';
5
-
6
- var KEY = LANG_STORAGE_KEY;
7
- function readLang() {
8
- try {
9
- const s = localStorage.getItem(KEY);
10
- if (s === "en" || s === "es") return s;
11
- } catch {
12
- }
13
- return "en";
14
- }
15
- var useLangStore = create((set, get) => ({
16
- lang: readLang(),
17
- setLang: (l) => {
18
- try {
19
- localStorage.setItem(KEY, l);
20
- } catch {
21
- }
22
- set({ lang: l });
23
- },
24
- toggle: () => {
25
- const next = get().lang === "en" ? "es" : "en";
26
- try {
27
- localStorage.setItem(KEY, next);
28
- } catch {
29
- }
30
- set({ lang: next });
31
- }
32
- }));
33
- var useShellLang = () => useLangStore((s) => s.lang);
34
-
35
- // src/lib/text.ts
36
- function pick(text, lang) {
37
- if (text === void 0) return "";
38
- if (typeof text === "string") return text;
39
- return text[lang] ?? text.en;
40
- }
41
- var KEY2 = THEME_STORAGE_KEY;
42
- function readTheme() {
43
- try {
44
- const s = localStorage.getItem(KEY2);
45
- if (s === "light" || s === "dark") return s;
46
- return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
47
- } catch {
48
- return "dark";
49
- }
50
- }
51
- function applyTheme(t) {
52
- try {
53
- document.documentElement.dataset.theme = t;
54
- } catch {
55
- }
56
- }
57
- var useThemeStore = create((set, get) => ({
58
- theme: readTheme(),
59
- toggleTheme: () => {
60
- const next = get().theme === "dark" ? "light" : "dark";
61
- applyTheme(next);
62
- try {
63
- localStorage.setItem(KEY2, next);
64
- } catch {
65
- }
66
- set({ theme: next });
67
- },
68
- setTheme: (t) => {
69
- applyTheme(t);
70
- try {
71
- localStorage.setItem(KEY2, t);
72
- } catch {
73
- }
74
- set({ theme: t });
75
- }
76
- }));
77
- var SHELL_TOKENS = [
78
- "--color-bg",
79
- "--color-surface",
80
- "--color-surface-2",
81
- "--color-border",
82
- "--color-fg",
83
- "--color-fg-subtle",
84
- "--color-fg-faint",
85
- "--color-accent",
86
- "--color-accent-fg",
87
- "--color-accent-soft",
88
- "--color-accent-2",
89
- "--color-magenta",
90
- "--color-good",
91
- "--color-warn",
92
- "--color-bad",
93
- "--color-shadow",
94
- "--font-sans",
95
- "--font-mono",
96
- "--text-xs",
97
- "--text-sm",
98
- "--text-md",
99
- "--text-base",
100
- "--text-lg",
101
- "--text-xl",
102
- "--text-2xl",
103
- "--space-1",
104
- "--space-2",
105
- "--space-3",
106
- "--space-4",
107
- "--space-5",
108
- "--space-6",
109
- "--radius-sm",
110
- "--radius-md",
111
- "--radius-lg",
112
- "--radius-pill",
113
- "--maxw",
114
- "--maxw-wide",
115
- "--measure",
116
- "--measure-text",
117
- "--header-h",
118
- "--rail-w",
119
- "--fade",
120
- "--icon-sm",
121
- "--icon-md",
122
- "--icon-lg",
123
- "--z-header",
124
- "--z-focus",
125
- "--z-modal"
126
- ];
127
- function resolveToken(token, fallback = "") {
128
- if (typeof document === "undefined") return fallback;
129
- const v = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
130
- return v || fallback;
131
- }
132
- function useThemeTokens() {
133
- const theme = useThemeStore((s) => s.theme);
134
- return useMemo(() => {
135
- const out = {};
136
- for (const t of SHELL_TOKENS) out[t] = resolveToken(t);
137
- return out;
138
- }, [theme]);
139
- }
140
-
141
- // src/lib/format.ts
142
- var LOCALE = { en: "en-US", es: "es-CL" };
143
- var NBSP = "\xA0";
144
- var SCIENTIFIC_BELOW = 1e-4;
145
- function belowScientific(magnitude) {
146
- return magnitude !== 0 && Math.abs(magnitude) < SCIENTIFIC_BELOW * (1 - 1e-12);
147
- }
148
- var NOT_AVAILABLE = { en: "not available", es: "no disponible" };
149
- function formatNumber(value, lang, opts = {}) {
150
- if (value === null || value === void 0 || !Number.isFinite(value)) return NOT_AVAILABLE[lang];
151
- const v = opts.percent ? value * 100 : value;
152
- const digits = opts.digits ?? 4;
153
- const scientific = v !== 0 && (opts.notation === "scientific" || belowScientific(v));
154
- const grouping = opts.grouping === false ? { useGrouping: false } : {};
155
- const nf = opts.decimals !== void 0 ? new Intl.NumberFormat(LOCALE[lang], { minimumFractionDigits: opts.decimals, maximumFractionDigits: opts.decimals, ...grouping }) : scientific ? new Intl.NumberFormat(LOCALE[lang], { notation: "scientific", maximumSignificantDigits: digits }) : Math.abs(v) >= 10 ** (digits - 1) ? new Intl.NumberFormat(LOCALE[lang], { maximumFractionDigits: 0, ...grouping }) : new Intl.NumberFormat(LOCALE[lang], { maximumSignificantDigits: digits, ...grouping });
156
- return opts.percent ? `${nf.format(v)}${NBSP}%` : nf.format(v);
157
- }
158
- function formatTicks(values, lang, opts = {}) {
159
- let o = opts;
160
- if (opts.decimals === void 0 && opts.notation === void 0) {
161
- const scale = opts.percent ? 100 : 1;
162
- const small = values.some((v) => v !== null && v !== void 0 && Number.isFinite(v) && belowScientific(v * scale));
163
- if (small) o = { ...opts, notation: "scientific" };
164
- }
165
- return values.map((v) => v === null || v === void 0 ? "" : formatNumber(v, lang, o));
166
- }
167
- function useFormat() {
168
- const lang = useShellLang();
169
- return (value, opts) => formatNumber(value, lang, opts);
170
- }
171
- function useStageSize() {
172
- const [size, setSize] = useState({ width: 0, height: 0 });
173
- const roRef = useRef(null);
174
- const ref = useCallback((el) => {
175
- roRef.current?.disconnect();
176
- roRef.current = null;
177
- if (!el) return;
178
- const measure = () => {
179
- const r = el.getBoundingClientRect();
180
- setSize((prev) => prev.width === Math.round(r.width) && prev.height === Math.round(r.height) ? prev : { width: Math.round(r.width), height: Math.round(r.height) });
181
- };
182
- measure();
183
- if (typeof ResizeObserver !== "undefined") {
184
- roRef.current = new ResizeObserver(measure);
185
- roRef.current.observe(el);
186
- }
187
- }, []);
188
- useEffect(() => () => roRef.current?.disconnect(), []);
189
- return [ref, size];
190
- }
191
- function Stage({ label, children, className }) {
192
- const lang = useShellLang();
193
- const [ref, size] = useStageSize();
194
- const ready = size.width > 0 && size.height > 0;
195
- const name = pick(label, lang);
196
- useEffect(() => {
197
- if (ready) return;
198
- const t = setTimeout(() => {
199
- console.error(`[caos-app-shell] Stage "${name}" has no size; give its container a height (ADR-0071 rule 8)`);
200
- }, 1500);
201
- return () => clearTimeout(t);
202
- }, [ready, name]);
203
- return /* @__PURE__ */ jsx(
204
- "div",
205
- {
206
- ref,
207
- className: ["caos-stage", className].filter(Boolean).join(" "),
208
- "data-stage": name,
209
- "data-drawn": ready ? "1" : "0",
210
- "data-width": size.width,
211
- "data-height": size.height,
212
- children: ready ? children(size) : null
213
- }
214
- );
215
- }
216
-
217
- // src/chart/text.ts
218
- function referenceLineName(lang, n, of) {
219
- const base = lang === "es" ? "L\xEDnea de referencia" : "Reference line";
220
- return of > 1 ? `${base} ${n}` : base;
221
- }
222
- var context;
223
- function fontFamily() {
224
- if (typeof document === "undefined") return "sans-serif";
225
- return getComputedStyle(document.documentElement).getPropertyValue("--font-sans").trim() || "sans-serif";
226
- }
227
- function textWidth(text, px, weight = 400) {
228
- if (context === void 0) {
229
- try {
230
- context = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d");
231
- } catch {
232
- context = null;
233
- }
234
- }
235
- if (!context) return text.length * px * 0.62;
236
- context.font = `${weight} ${px}px ${fontFamily()}`;
237
- return context.measureText(text).width;
238
- }
239
- function widestLabel(labels, px, weight = 400) {
240
- return Math.max(0, ...labels.map((label) => textWidth(label, px, weight)));
241
- }
242
- function shorten(line, width, px) {
243
- if (textWidth(line, px) <= width) return line;
244
- let cut = line;
245
- while (cut.length > 1 && textWidth(`${cut}\u2026`, px) > width) cut = cut.slice(0, -1);
246
- return `${cut.trimEnd()}\u2026`;
247
- }
248
- function fitLabel(label, width, px, maxLines = 2) {
249
- if (textWidth(label, px) <= width) return { lines: [label], shortened: false };
250
- const words = label.split(" ");
251
- if (maxLines === 2 && words.length > 1) {
252
- let best = null;
253
- let bestWidth = Infinity;
254
- for (let k = 1; k < words.length; k += 1) {
255
- const first = words.slice(0, k).join(" ");
256
- const second = words.slice(k).join(" ");
257
- const widest = Math.max(textWidth(first, px), textWidth(second, px));
258
- if (widest < bestWidth) {
259
- bestWidth = widest;
260
- best = [first, second];
261
- }
262
- }
263
- if (best && bestWidth <= width) return { lines: best, shortened: false };
264
- if (best) return { lines: [shorten(best[0], width, px), shorten(best[1], width, px)], shortened: true };
265
- }
266
- return { lines: [shorten(label, width, px)], shortened: true };
267
- }
268
- function niceStep(x) {
269
- if (!(x > 0) || !Number.isFinite(x)) return 1;
270
- const magnitude = 10 ** Math.floor(Math.log10(x));
271
- return ([1, 2, 2.5, 5, 10].find((m) => m * magnitude >= x * (1 - 1e-12)) ?? 10) * magnitude;
272
- }
273
- function niceTicks(lo, hi, room, labelSize, most = 6) {
274
- if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [0];
275
- if (hi < lo) [lo, hi] = [hi, lo];
276
- if (hi === lo) hi = lo === 0 ? 1 : lo + Math.abs(lo);
277
- const count = Math.max(2, Math.min(most, Math.floor(room / (labelSize + 16)) + 1));
278
- const step = niceStep((hi - lo) / (count - 1));
279
- const first = Math.floor(lo / step + 1e-9) * step;
280
- const ticks = [];
281
- for (let k = 0; first + k * step <= hi + step * 1e-9 || ticks.length < 2; k += 1) {
282
- ticks.push(Number((first + k * step).toPrecision(12)));
283
- if (ticks.length > 50) break;
284
- }
285
- if (ticks[ticks.length - 1] < hi - step * 1e-9) ticks.push(Number((ticks[ticks.length - 1] + step).toPrecision(12)));
286
- return ticks;
287
- }
288
-
289
- export { NBSP, NOT_AVAILABLE, SCIENTIFIC_BELOW, SHELL_TOKENS, Stage, applyTheme, fitLabel, fontFamily, formatNumber, formatTicks, niceStep, niceTicks, pick, readTheme, referenceLineName, resolveToken, textWidth, useFormat, useLangStore, useShellLang, useStageSize, useThemeStore, useThemeTokens, widestLabel };