@fasl-work/caos-app-shell 0.9.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,7 +70,11 @@ const config: ShellConfig = {
70
70
  Every control writes `data-control`. `PlotCard` and `Readout` take `lane`, `provenance` and a `dataKey`:
71
71
  pass `useWorkbenchState().stateKey` as captured when the computation started (rail and instrument share it), and
72
72
  a view still showing an earlier selection is overlaid as stale. Anything that loads declares
73
- `data-state="loading"` until it is ready; the gate waits for that, never for the network.
73
+ `data-state="loading"` until it is ready; the gate waits for that, never for the network. A view whose data is absent declares
74
+ `data-state="unavailable"` on an element a reader can see (a visible box of 40 by 12 px at least inside the instrument,
75
+ opaque, its text painted inside the box: not pushed out by `text-indent`, cut by `clip-path` or under 8 px) whose rendered text states the reason (12 letters or digits in two words at least) and renders no drawing element;
76
+ the gate then accepts it under G6, while an unreadable note, a declaration without a reason, or one beside a blank,
77
+ collapsed or invisible chart still fails. The gate checks that a reason exists, not that it is true.
74
78
  - **Fill the instrument:** `<PlotCard fill>` takes the height its panel leaves (several `fill` cards share it, a
75
79
  `.caos-views-row` puts them side by side), and a `Stage` or `<UPlotChart height="fill">` inside it gets a real
76
80
  size. A table (`.caos-table`) is sized to its rows: put it above the drawing of the same numbers, never alone.
@@ -99,7 +103,12 @@ const config: ShellConfig = {
99
103
  reader cannot scroll to, see whole or click (G5), blank or underfilled drawings and an instrument under half the
100
104
  viewport (G6), a view that never settles (G7), loops at rest (G8), controls that change nothing (G9), labels cut or
101
105
  overlapping in a drawing, also in a wider fallback font (G10), a decimal point on a Spanish page (G11), a vertical
102
- sub-tab list that scrolls away (G12), text under WCAG AA contrast (G13). The report
106
+ sub-tab list that scrolls away (G12), text under WCAG AA contrast (G13, every language on a documentation page), and a documentation page without the
107
+ required areas of its kind (`ADR-0017.2`: executive and technical Introduction, Methodology, Implementation,
108
+ Experiments, Benchmark; declared with `<DocSection area="...">`, waived only with a reason of 12 letters or digits in two
109
+ words through `floorWaivers`; the executive view opens first, every Introduction section sits in a view, a method cites
110
+ references, a `live` control is registered with `data-control`, and a tab list inside a section belongs to it;
111
+ [docs/04](docs/04_views.md)). The report
103
112
  (`gate-output/gate-report.json`) lists every failure and the smallest margins against each floor; `index.html`
104
113
  beside it shows every capture (G14). Details: [docs/07](docs/07_gate.md).
105
114
  - **Your own CSS:** never restyle a class of `reserved-classes.json` `components`; a `modifiers` word (`on`,
@@ -13,7 +13,7 @@
13
13
  // --sizes 390x844,...,2560x1440 --themes light,dark --langs en,es
14
14
  // --case-sample id1,id2 cases walked outside the primary mode; default the first and the last
15
15
  // --single-case the workbench has one case and no case control
16
- // --idle-ms 3000 --settle-ms 15000 --text-floor 400 --instrument-min 0.5 --stage-fill-min 0.3
16
+ // --idle-ms 3000 --settle-ms 15000 --text-floor 400 --instrument-min 0.5 --stage-fill-min 0.3 --stage-floor 140
17
17
  // --wide-font-sizes 390x844,1280x800 the sizes of the wide-font pass (G10); --no-wide-font skips it
18
18
  // --out gate-output report (gate-report.json), screenshots (shots/) and the capture index (index.html)
19
19
  // Needs `playwright` in the consuming project (optional peer dependency). Exit 1 on any failure, 2 on misuse.
@@ -49,6 +49,7 @@ const options = {
49
49
  textFloor: num('text-floor'),
50
50
  instrumentMin: num('instrument-min'),
51
51
  stageFillMin: num('stage-fill-min'),
52
+ stageFloor: num('stage-floor'),
52
53
  wideFontSizes: args['wide-font-sizes'],
53
54
  wideFont: args['no-wide-font'] === true ? false : undefined,
54
55
  out: args.out,
@@ -65,6 +66,7 @@ try {
65
66
  const shown = report.failures.slice(0, 80);
66
67
  for (const f of shown) console.log(`FAIL [${f.check}] ${f.route} ${f.mode}${f.trail ? ` (${f.trail})` : ''}: ${f.message}`);
67
68
  if (report.failures.length > shown.length) console.log(`... and ${report.failures.length - shown.length} more in ${options.out ?? 'gate-output'}/gate-report.json`);
69
+ for (const w of report.waivers ?? []) console.log(`WAIVER ${w.route} (${w.lang}) ${w.floor}: ${w.reason}${w.valid ? '' : ' [refused]'}`);
68
70
  const counts = Object.entries(report.checks).map(([k, n]) => `${k} ${n}`).join(', ');
69
71
  console.log(report.ok ? `caos-shell-gate: OK, ${report.states} measured states` : `caos-shell-gate: ${report.failures.length} failure(s) in ${report.states} measured states (${counts})`);
70
72
  process.exit(report.ok ? 0 : 1);
package/chart.css CHANGED
@@ -16,7 +16,9 @@
16
16
  .caos-chart-swatch { flex: none; width: 16px; height: 0; border-top: 2px solid var(--swatch); }
17
17
  .caos-chart-key[data-dash] .caos-chart-swatch { border-top-style: dashed; }
18
18
  .caos-chart-key[data-mode="points"] .caos-chart-swatch { width: 7px; height: 7px; border: 0; border-radius: 50%; background: var(--swatch); }
19
- /* A parity plot is square and centred in its box; a chart whose points can be picked shows a pointer. */
19
+ /* A parity plot is square and centred in its box, across and, when it fills a box taller than it is wide, down (it
20
+ sat at the top with the spare height under it until 0.10.0); a chart whose points can be picked shows a pointer. */
20
21
  .caos-chart[data-parity] .uplot { margin-inline: auto; }
22
+ .caos-chart.fill[data-parity] > .caos-chart-plot { display: flex; align-items: center; justify-content: center; }
21
23
  .caos-chart[data-pick] .u-over { cursor: pointer; }
22
24
  .caos-chart .u-cursor-x, .caos-chart .u-cursor-y { border-color: var(--color-fg-faint) !important; }
package/dist/chart.d.ts CHANGED
@@ -14,12 +14,20 @@ interface ChartSeries {
14
14
  dash?: number[];
15
15
  /** `points` draws markers only (a scatter of cases over a curve); `null` values are simply absent. */
16
16
  mode?: 'line' | 'points';
17
+ /** The marker diameter of a `points` series, in CSS pixels (9 by default): a larger one rings the selected case. */
18
+ size?: number;
17
19
  }
18
20
  interface ChartAxis {
19
21
  label: BiText;
20
22
  unit?: BiText;
21
23
  format?: FormatOptions;
22
24
  }
25
+ /** What a reference line says (CAOS_APP_SHELL#94): `warn` an amber threshold (the default), `bad` a red one, `good` a
26
+ * level to reach, `neutral` a level that is no alarm (a nominal size, "no bias"). The line and its label take the tone's
27
+ * colour. */
28
+ type YMarkTone = 'warn' | 'bad' | 'good' | 'neutral';
29
+ /** The shell colour token of each tone of a reference line. */
30
+ declare const YMARK_TONE_TOKENS: Record<YMarkTone, ShellColorToken>;
23
31
  interface UPlotChartProps {
24
32
  /** The x values and their axis. A time axis must be declared (`time: true`); uPlot otherwise reads numbers as
25
33
  * epoch seconds and labels ticks as dates (failure class 7). */
@@ -38,17 +46,32 @@ interface UPlotChartProps {
38
46
  x: number;
39
47
  label: BiText;
40
48
  }[];
49
+ /** Horizontal reference lines at y values, labelled at their right end (a null model's constant prediction, a
50
+ * target, a limit). */
51
+ yMarks?: {
52
+ y: number;
53
+ label: BiText;
54
+ tone?: YMarkTone;
55
+ }[];
41
56
  /** Height in pixels, or `fill` to take the container's height (inside `PlotCard fill`); the width is always the
42
57
  * container's. */
43
58
  height?: number | 'fill';
44
59
  /** Called with the index under the cursor (or null), for linked views; the index is the caller's, before any
45
60
  * sorting by x. */
46
61
  onCursor?: (index: number | null) => void;
47
- /** Called with the caller's index of the point under the cursor when the plot is clicked (select a case, a blast). */
62
+ /** Called with the caller's index of the point under the cursor when the plot is clicked (select a case, a blast).
63
+ * On a chart whose series are all `points` (a scatter, a parity plot) the point under the cursor is the one nearest
64
+ * the pointer in the plane, not the one nearest in x. */
48
65
  onPick?: (index: number) => void;
49
66
  /** A parity plot: predicted (the series) against observed (x) on one shared range, in a square box, with the
50
- * identity line. Unsorted x is allowed (the chart sorts it and maps every index back). */
67
+ * identity line. Unsorted x is allowed (the chart sorts it and maps every index back). The range takes in the
68
+ * reference lines (`yMarks`), so a null model's level is always in view. */
51
69
  parity?: boolean;
70
+ /** The readout under the plot for the point under the cursor (the caller's index), in place of the list of values:
71
+ * a scatter names its case ("Mg1, Murgul: measured 23.0 cm, predicted 25.1 cm"). */
72
+ readout?: (index: number) => string;
73
+ /** The readout at rest; by default "Hover the chart to read the values". */
74
+ hint?: BiText;
52
75
  }
53
76
  /** The colour and dash of a series: its own, or the rotation's; when the rotation comes round (a seventh series
54
77
  * repeats the first colour) the repeat is dashed, so two series never look the same (CAOS_Fragmenta drew this for
@@ -61,6 +84,14 @@ declare function seriesStyle(s: ChartSeries, i: number): {
61
84
  declare function sortOrder(xs: readonly number[]): number[] | null;
62
85
  /** One range for both axes of a parity plot, padded by 5% of the span on each side. */
63
86
  declare function parityRange(...arrays: readonly (readonly (number | null)[])[]): [number, number];
87
+ /**
88
+ * The data's y extent widened to take in the levels of the reference lines, so a line is always drawn (CAOS_APP_SHELL#94;
89
+ * a constant series pulled the range to it, a mark did not). A log axis takes in only the levels above 0. Returns the
90
+ * extent unchanged when every level is inside it; `null` ends (no data) take the levels alone. An end a level pushed out
91
+ * moves a further `pad` of the span (of the span in decades on a log axis), so the line sits inside the frame, not on it;
92
+ * the caller applies uPlot's own padding after this.
93
+ */
94
+ declare function rangeWithLevels(min: number | null, max: number | null, levels: readonly number[], log?: boolean, pad?: number): [number, number] | null;
64
95
  /**
65
96
  * The tick steps an axis may use. With fixed `decimals` (scaled by 100 for a percent) only multiples of the smallest
66
97
  * difference its labels can show, so no two ticks read the same: an integer axis ticks at integers, never at 1.5,
@@ -96,6 +127,26 @@ declare function drawMarks(u: uPlot, marks: {
96
127
  halo: string;
97
128
  family: string;
98
129
  }): void;
130
+ /**
131
+ * The horizontal reference lines of a chart and their labels: a dashed line across the plot, its label at the right
132
+ * end above the line (below it when the line runs along the top), inside the plot, haloed; a label that would overlap
133
+ * the one placed before it moves left of it. A line outside the y range is not drawn. A mark's `color` (the tone's) draws
134
+ * its line and its label; without one `style.color` does.
135
+ */
136
+ declare function drawYMarks(u: uPlot, marks: {
137
+ y: number;
138
+ label: string;
139
+ color?: string;
140
+ }[], style: {
141
+ color: string;
142
+ halo: string;
143
+ family: string;
144
+ }): void;
145
+ /**
146
+ * The index of the point nearest (left, top) in the plane, over every series, or null when no point has a value.
147
+ * `xs` and every column of `ys` are positions in the same pixels as (left, top); a null position is no point.
148
+ */
149
+ declare function nearestPoint(xs: readonly number[], ys: readonly (readonly (number | null)[])[], left: number, top: number): number | null;
99
150
  /**
100
151
  * The house chart (S12 of the 2026-10-04 requirements; uPlot, ADR on interactive charts). It is built only once its
101
152
  * box has a size; it resolves the theme's colours to concrete values and rebuilds when the theme or language changes
@@ -107,6 +158,6 @@ declare function drawMarks(u: uPlot, marks: {
107
158
  * (known shell defect 17). An axis with fixed decimals ticks only where its labels differ (`tickSteps`). The host
108
159
  * declares `data-series`, `data-axis-titles`, `data-ticks-cut`, `data-ticks-repeat` and `data-drawn` for the gate.
109
160
  */
110
- declare function UPlotChart({ x, y, series, marks, height, onCursor, onPick, parity }: UPlotChartProps): react.JSX.Element;
161
+ declare function UPlotChart({ x, y, series, marks, yMarks, height, onCursor, onPick, parity, readout, hint }: UPlotChartProps): react.JSX.Element;
111
162
 
112
- export { type ChartAxis, type ChartSeries, LOG_TABLE_FLOOR, UPlotChart, type UPlotChartProps, drawMarks, logDecades, parityRange, repeatedLabels, seriesStyle, smallestPositive, sortOrder, tickSteps };
163
+ export { type ChartAxis, type ChartSeries, LOG_TABLE_FLOOR, UPlotChart, type UPlotChartProps, YMARK_TONE_TOKENS, type YMarkTone, drawMarks, drawYMarks, logDecades, nearestPoint, parityRange, rangeWithLevels, repeatedLabels, seriesStyle, smallestPositive, sortOrder, tickSteps };
package/dist/chart.js CHANGED
@@ -4,6 +4,12 @@ import { useRef, useCallback, useState, useMemo, useEffect } from 'react';
4
4
  import uPlot from 'uplot';
5
5
  import { jsxs, jsx } from 'react/jsx-runtime';
6
6
 
7
+ var YMARK_TONE_TOKENS = {
8
+ warn: "--color-warn",
9
+ bad: "--color-bad",
10
+ good: "--color-good",
11
+ neutral: "--color-fg-subtle"
12
+ };
7
13
  var ROTATION = ["--color-accent", "--color-magenta", "--color-accent-2", "--color-good", "--color-warn", "--color-bad"];
8
14
  function seriesStyle(s, i) {
9
15
  const color = s.color ?? ROTATION[i % ROTATION.length];
@@ -30,6 +36,30 @@ function parityRange(...arrays) {
30
36
  const pad = (hi - lo || Math.abs(hi) || 1) * 0.05;
31
37
  return [lo - pad, hi + pad];
32
38
  }
39
+ function rangeWithLevels(min, max, levels, log = false, pad = 0) {
40
+ const d0 = min !== null && Number.isFinite(min) ? min : Infinity;
41
+ const d1 = max !== null && Number.isFinite(max) ? max : -Infinity;
42
+ let lo = d0;
43
+ let hi = d1;
44
+ for (const v of levels) {
45
+ if (!Number.isFinite(v) || log && !(v > 0)) continue;
46
+ lo = Math.min(lo, v);
47
+ hi = Math.max(hi, v);
48
+ }
49
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return null;
50
+ if (pad > 0 && (lo < d0 || hi > d1)) {
51
+ if (log && lo > 0) {
52
+ const room = (Math.log10(hi) - Math.log10(lo) || 1) * pad;
53
+ if (lo < d0) lo = lo / 10 ** room;
54
+ if (hi > d1) hi = hi * 10 ** room;
55
+ } else {
56
+ const room = (hi - lo || Math.abs(hi) || 1) * pad;
57
+ if (lo < d0) lo -= room;
58
+ if (hi > d1) hi += room;
59
+ }
60
+ }
61
+ return [lo, hi];
62
+ }
33
63
  var STEPS = [];
34
64
  for (let e = -16; e <= 16; e++) for (const m of [1, 2, 2.5, 5]) STEPS.push(Number((m * 10 ** e).toPrecision(3)));
35
65
  function tickSteps(fmt) {
@@ -57,6 +87,7 @@ var DECADE_AXIS = {
57
87
  splits: (_u, _i, min, max) => logDecades(min, max),
58
88
  filter: (_u, splits) => splits
59
89
  };
90
+ var PARITY_TICK_SPACE = 40;
60
91
  function repeatedLabels(labels) {
61
92
  let n = 0;
62
93
  for (let i = 1; i < labels.length; i++) if (labels[i] && labels[i] === labels[i - 1]) n++;
@@ -102,7 +133,67 @@ function drawMarks(u, marks, style) {
102
133
  }
103
134
  ctx.restore();
104
135
  }
105
- function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parity }) {
136
+ function drawYMarks(u, marks, style) {
137
+ const ctx = u.ctx;
138
+ const pr = uPlot.pxRatio || 1;
139
+ const left = u.bbox.left;
140
+ const right = u.bbox.left + u.bbox.width;
141
+ const top = u.bbox.top;
142
+ const bottom = u.bbox.top + u.bbox.height;
143
+ const gap = 4 * pr;
144
+ const textH = 11 * pr;
145
+ const placed = [];
146
+ ctx.save();
147
+ ctx.font = `${Math.round(11 * pr)}px ${style.family}`;
148
+ ctx.textBaseline = "alphabetic";
149
+ ctx.lineJoin = "round";
150
+ for (const m of [...marks].sort((a, b) => b.y - a.y)) {
151
+ const py = u.valToPos(m.y, "y", true);
152
+ if (!Number.isFinite(py) || py < top - 0.5 || py > bottom + 0.5) continue;
153
+ const color = m.color ?? style.color;
154
+ ctx.strokeStyle = color;
155
+ ctx.lineWidth = Math.max(1, pr);
156
+ ctx.setLineDash([2 * pr, 4 * pr]);
157
+ ctx.beginPath();
158
+ ctx.moveTo(left, py);
159
+ ctx.lineTo(right, py);
160
+ ctx.stroke();
161
+ ctx.setLineDash([]);
162
+ const w = ctx.measureText(m.label).width;
163
+ const base = py - gap - textH < top ? py + gap + textH : py - gap;
164
+ let x1 = right - gap;
165
+ while (placed.some((p) => x1 - w < p.x1 + gap && x1 > p.x0 - gap && base - textH < p.y1 && base > p.y0)) {
166
+ x1 = Math.min(...placed.filter((p) => base - textH < p.y1 && base > p.y0).map((p) => p.x0)) - 2 * gap;
167
+ }
168
+ const x0 = Math.max(left + gap, x1 - w);
169
+ placed.push({ x0, x1: x0 + w, y0: base - textH, y1: base });
170
+ ctx.textAlign = "left";
171
+ ctx.lineWidth = 3 * pr;
172
+ ctx.strokeStyle = style.halo;
173
+ ctx.strokeText(m.label, x0, base);
174
+ ctx.fillStyle = color;
175
+ ctx.fillText(m.label, x0, base);
176
+ }
177
+ ctx.restore();
178
+ }
179
+ function nearestPoint(xs, ys, left, top) {
180
+ let best = null;
181
+ let bestD = Infinity;
182
+ for (let i = 0; i < xs.length; i++) {
183
+ if (!Number.isFinite(xs[i])) continue;
184
+ for (const col of ys) {
185
+ const y = col[i];
186
+ if (y === null || y === void 0 || !Number.isFinite(y)) continue;
187
+ const d = (xs[i] - left) ** 2 + (y - top) ** 2;
188
+ if (d < bestD) {
189
+ bestD = d;
190
+ best = i;
191
+ }
192
+ }
193
+ }
194
+ return best;
195
+ }
196
+ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPick, parity, readout, hint }) {
106
197
  const lang = useShellLang();
107
198
  const theme = useThemeStore((s) => s.theme);
108
199
  const fill = height === "fill";
@@ -124,36 +215,41 @@ function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parit
124
215
  onCursorRef.current = onCursor;
125
216
  const onPickRef = useRef(onPick);
126
217
  onPickRef.current = onPick;
218
+ const pickedRef = useRef(null);
127
219
  const order = useMemo(() => sortOrder(x.values), [x.values]);
128
220
  const orderRef = useRef(order);
129
221
  orderRef.current = order;
130
222
  const xs = useMemo(() => order ? order.map((i) => x.values[i]) : x.values, [order, x.values]);
131
223
  const ys = useMemo(() => series.map((s) => order ? order.map((i) => s.values[i] ?? null) : s.values), [order, series]);
132
- const range = useMemo(() => parity ? parityRange(xs, ...ys) : null, [parity, xs, ys]);
224
+ const levels = useMemo(() => (yMarks ?? []).map((m) => m.y), [yMarks]);
225
+ const range = useMemo(() => parity ? parityRange(xs, ...ys, levels) : null, [parity, xs, ys, levels]);
226
+ const yLevels = !parity && !y.range && levels.length > 0 ? levels : null;
133
227
  const tiny = useMemo(
134
228
  () => ({
135
229
  x: Boolean(x.log) && smallestPositive([xs], range) < LOG_TABLE_FLOOR,
136
- y: Boolean(y.log) && smallestPositive(ys, range ?? y.range) < LOG_TABLE_FLOOR
230
+ y: Boolean(y.log) && smallestPositive(yLevels ? [...ys, yLevels] : ys, range ?? y.range) < LOG_TABLE_FLOOR
137
231
  }),
138
- [x.log, y.log, xs, ys, range, y.range]
232
+ [x.log, y.log, xs, ys, range, y.range, yLevels]
139
233
  );
140
234
  const xLabel = pick(x.label, lang);
141
235
  const yLabel = pick(y.label, lang);
142
236
  const xUnit = x.unit ? pick(x.unit, lang) : "";
143
237
  const yUnit = y.unit ? pick(y.unit, lang) : "";
144
238
  const structure = JSON.stringify({
145
- s: series.map((s, i) => [pick(s.label, lang), seriesStyle(s, i), s.width ?? 2, s.mode ?? "line"]),
239
+ s: series.map((s, i) => [pick(s.label, lang), seriesStyle(s, i), s.width ?? 2, s.mode ?? "line", s.size ?? 9]),
146
240
  x: [xLabel, xUnit, Boolean(x.time), Boolean(x.log), x.format ?? null, tiny.x],
147
241
  p: range,
148
242
  pick: Boolean(onPick),
149
243
  y: [yLabel, yUnit, Boolean(y.log), y.range ?? null, y.format ?? null, tiny.y],
150
244
  m: (marks ?? []).map((m) => [m.x, pick(m.label, lang)]),
245
+ ym: (yMarks ?? []).map((m) => [m.y, pick(m.label, lang), m.tone ?? "warn"]),
151
246
  theme,
152
247
  lang,
153
248
  h,
154
249
  w: width
155
250
  });
156
251
  const data = useMemo(() => [xs, ...ys], [xs, ys]);
252
+ const scatter = series.length > 0 && series.every((s) => s.mode === "points");
157
253
  useEffect(() => {
158
254
  const el = plotRef.current;
159
255
  if (!el || width <= 0 || h <= 0) return;
@@ -167,14 +263,30 @@ function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parit
167
263
  const labels = formatTicks(vals, lang, fmt ?? { digits: 4 });
168
264
  repeats[axis] = repeatedLabels(labels);
169
265
  hostRef.current?.setAttribute("data-ticks-repeat", String(repeats.x + repeats.y));
266
+ hostRef.current?.setAttribute(`data-ticks-${axis}`, String(labels.filter((l) => l).length));
170
267
  return labels;
171
268
  };
269
+ const near = { left: NaN, top: NaN, idx: null };
270
+ const nearest = (u) => {
271
+ const { left, top } = u.cursor;
272
+ if (left === void 0 || top === void 0 || left < 0 || top < 0) return null;
273
+ if (left !== near.left || top !== near.top) {
274
+ const d = u.data;
275
+ const px = d[0].map((v) => v === null ? NaN : u.valToPos(v, "x"));
276
+ const py = d.slice(1).map((col) => col.map((v) => v === null ? null : u.valToPos(v, "y")));
277
+ near.left = left;
278
+ near.top = top;
279
+ near.idx = nearestPoint(px, py, left, top);
280
+ }
281
+ return near.idx;
282
+ };
172
283
  const xSteps = x.time || x.log ? void 0 : tickSteps(x.format);
173
284
  const ySteps = y.log ? void 0 : tickSteps(y.format);
174
285
  const opts = {
175
286
  width,
176
287
  height: h,
177
288
  legend: { show: false },
289
+ ...scatter ? { cursor: { dataIdx: (u, sIdx, closest) => sIdx === 0 ? closest : nearest(u) ?? closest } } : {},
178
290
  // a log axis labels the ticks uPlot keeps and passes null for the rest, which formatTicks leaves blank
179
291
  scales: {
180
292
  // a log x axis spans the data, not the decades around it (uPlot rounds out to the next decade, which left an
@@ -184,7 +296,17 @@ function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parit
184
296
  distr: x.log ? 3 : 1,
185
297
  ...range ? { range } : x.log ? { range: (_u, min, max) => [min, max] } : {}
186
298
  },
187
- y: { distr: y.log ? 3 : 1, ...range ? { range } : y.range ? { range: y.range } : {} }
299
+ y: {
300
+ distr: y.log ? 3 : 1,
301
+ ...range ? { range } : y.range ? { range: y.range } : yLevels ? {
302
+ // uPlot's own range of the data and the levels: its padding, its decade snap on a log axis
303
+ range: (u, dmin, dmax) => {
304
+ const w = rangeWithLevels(dmin, dmax, yLevels, Boolean(y.log), 0.05);
305
+ if (!w) return [dmin, dmax];
306
+ return y.log ? uPlot.rangeLog(w[0], w[1], u.scales.y.log ?? 10, false) : uPlot.rangeNum(w[0], w[1], 0.1, true);
307
+ }
308
+ } : {}
309
+ }
188
310
  },
189
311
  axes: [
190
312
  {
@@ -196,6 +318,7 @@ function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parit
196
318
  labelFont: font,
197
319
  values: x.time ? void 0 : tick(x.format, "x"),
198
320
  ...xSteps ? { incrs: xSteps } : {},
321
+ ...range ? { space: PARITY_TICK_SPACE } : {},
199
322
  ...tiny.x ? DECADE_AXIS : {}
200
323
  },
201
324
  {
@@ -207,6 +330,7 @@ function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parit
207
330
  labelFont: font,
208
331
  values: tick(y.format, "y"),
209
332
  ...ySteps ? { incrs: ySteps } : {},
333
+ ...range ? { space: PARITY_TICK_SPACE } : {},
210
334
  ...tiny.y ? DECADE_AXIS : {},
211
335
  // Sized to the longest tick label. uPlot draws in device pixels with a font scaled by its pixel ratio, so the
212
336
  // label is measured in that font and divided back (measuring the unscaled font and dividing by the ratio
@@ -225,13 +349,14 @@ function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parit
225
349
  ...series.map((s, i) => {
226
350
  const style = seriesStyle(s, i);
227
351
  const stroke = resolveToken(style.color, "#4a8");
228
- return s.mode === "points" ? { label: pick(s.label, lang), stroke, width: 1.5, paths: () => null, points: { show: true, space: 0, size: 9, fill: stroke } } : { label: pick(s.label, lang), stroke, width: s.width ?? 2, dash: style.dash, spanGaps: false, points: { show: false } };
352
+ return s.mode === "points" ? { label: pick(s.label, lang), stroke, width: 1.5, paths: () => null, points: { show: true, space: 0, size: s.size ?? 9, fill: stroke } } : { label: pick(s.label, lang), stroke, width: s.width ?? 2, dash: style.dash, spanGaps: false, points: { show: false } };
229
353
  })
230
354
  ],
231
355
  hooks: {
232
356
  setCursor: [
233
357
  (u) => {
234
- const idx = u.cursor.idx ?? null;
358
+ const idx = scatter ? nearest(u) : u.cursor.idx ?? null;
359
+ pickedRef.current = idx;
235
360
  setCursor(idx);
236
361
  onCursorRef.current?.(idx === null ? null : orderRef.current ? orderRef.current[idx] : idx);
237
362
  }
@@ -239,7 +364,7 @@ function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parit
239
364
  ready: [
240
365
  (u) => {
241
366
  u.over.addEventListener("click", () => {
242
- const idx = u.cursor.idx;
367
+ const idx = pickedRef.current;
243
368
  if (idx === null || idx === void 0 || !onPickRef.current) return;
244
369
  onPickRef.current(orderRef.current ? orderRef.current[idx] : idx);
245
370
  });
@@ -260,12 +385,9 @@ function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parit
260
385
  ctx.stroke();
261
386
  ctx.restore();
262
387
  }
263
- if (!marks?.length) return;
264
- drawMarks(u, marks.map((m) => ({ x: m.x, label: pick(m.label, lang) })), {
265
- color: resolveToken("--color-warn", "#c80"),
266
- halo: surface,
267
- family
268
- });
388
+ const markStyle = { color: resolveToken("--color-warn", "#c80"), halo: surface, family };
389
+ if (yMarks?.length) drawYMarks(u, yMarks.map((m) => ({ y: m.y, label: pick(m.label, lang), color: resolveToken(YMARK_TONE_TOKENS[m.tone ?? "warn"], "#c80") })), markStyle);
390
+ if (marks?.length) drawMarks(u, marks.map((m) => ({ x: m.x, label: pick(m.label, lang) })), markStyle);
269
391
  }
270
392
  ]
271
393
  }
@@ -280,12 +402,10 @@ function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parit
280
402
  useEffect(() => {
281
403
  uRef.current?.setData(data);
282
404
  }, [data]);
283
- const read = cursor !== null ? [
405
+ const read = cursor !== null && readout ? readout(order ? order[cursor] : cursor) : cursor !== null ? [
284
406
  `${xLabel} ${formatNumber(xs[cursor], lang, x.format ?? { digits: 4 })}${xUnit ? `${NBSP}${xUnit}` : ""}`,
285
- ...series.map(
286
- (s, k) => `${pick(s.label, lang)} ${formatNumber(ys[k][cursor] ?? null, lang, y.format ?? { digits: 4 })}${yUnit ? `${NBSP}${yUnit}` : ""}`
287
- )
288
- ].join(" \xB7 ") : lang === "es" ? "Pase el cursor sobre el gr\xE1fico para leer los valores" : "Hover the chart to read the values";
407
+ ...series.map((s, k) => ({ s, v: ys[k][cursor] ?? null })).filter(({ v }) => !scatter || v !== null).map(({ s, v }) => `${pick(s.label, lang)} ${formatNumber(v, lang, y.format ?? { digits: 4 })}${yUnit ? `${NBSP}${yUnit}` : ""}`)
408
+ ].join(" \xB7 ") : hint ? pick(hint, lang) : lang === "es" ? "Pase el cursor sobre el gr\xE1fico para leer los valores" : "Hover the chart to read the values";
289
409
  return /* @__PURE__ */ jsxs(
290
410
  "div",
291
411
  {
@@ -299,6 +419,7 @@ function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parit
299
419
  "data-parity": parity ? "1" : void 0,
300
420
  "data-log-x": x.log ? "1" : void 0,
301
421
  "data-pick": onPick ? "1" : void 0,
422
+ "data-y-marks": yMarks?.length ? String(yMarks.length) : void 0,
302
423
  children: [
303
424
  /* @__PURE__ */ jsx("div", { ref: setPlot, className: "caos-chart-plot" }),
304
425
  series.length > 1 && /* @__PURE__ */ jsx("ul", { className: "caos-chart-legend", "aria-label": lang === "es" ? "Series del gr\xE1fico" : "Series of the chart", children: series.map((s, i) => /* @__PURE__ */ jsxs("li", { className: "caos-chart-key", "data-mode": s.mode ?? "line", "data-dash": seriesStyle(s, i).dash ? "1" : void 0, children: [
@@ -311,4 +432,4 @@ function UPlotChart({ x, y, series, marks, height = 280, onCursor, onPick, parit
311
432
  );
312
433
  }
313
434
 
314
- export { LOG_TABLE_FLOOR, UPlotChart, drawMarks, logDecades, parityRange, repeatedLabels, seriesStyle, smallestPositive, sortOrder, tickSteps };
435
+ export { LOG_TABLE_FLOOR, UPlotChart, YMARK_TONE_TOKENS, drawMarks, drawYMarks, logDecades, nearestPoint, parityRange, rangeWithLevels, repeatedLabels, seriesStyle, smallestPositive, sortOrder, tickSteps };
package/dist/index.d.ts CHANGED
@@ -361,14 +361,30 @@ declare function Cite({ id, paren }: {
361
361
  id: string;
362
362
  paren?: boolean;
363
363
  }): react.JSX.Element;
364
+ /** The citation ids of a scope in the order of first appearance: each id once, sections in the order given. */
365
+ declare function firstAppearance(sections: readonly (readonly string[])[]): string[];
366
+ /**
367
+ * A scope that lists the references of the sections inside it, once, at its own end: the `DocPage`, and each open panel
368
+ * of `Tabs` and `SubTabs` (the innermost scope wins). Sections register in a layout effect; the order is their order in
369
+ * the document (not the order of registration), so a section that mounts later keeps its place. It adds no element of
370
+ * its own, and nothing renders while no section registered an id.
371
+ */
372
+ declare function DocRefsScope({ children }: {
373
+ children: ReactNode;
374
+ }): react.JSX.Element;
375
+ /** The end-of-scope references block (ADR-0017 s4, shell 0.11.0): the full citation of each id, once, as an ordered list. */
376
+ declare function DocReferences({ ids }: {
377
+ ids: string[];
378
+ }): react.JSX.Element | null;
364
379
  /** A short inline "Refs: a · b · c" row (e.g. under a deep sub-tab). */
365
380
  declare function Refs({ ids, label }: {
366
381
  ids: string[];
367
382
  label: string;
368
383
  }): react.JSX.Element;
369
384
  /** Full ordered bibliography. Omit `ids` to render every provided citation.
370
- * @deprecated ADR-0017 s4 bans a bottom-of-page bibliography; end each section with `Refs` (or use `DocSection`).
371
- * Kept so existing products still compile; it reports itself on the console, which the gate fails on. */
385
+ * @deprecated Superseded by the end-of-page references block that `DocPage` and the tab panels render from their
386
+ * `DocSection`s (ADR-0017 s4). Kept so existing products still compile; it reports itself on the console, which the
387
+ * gate fails on. */
372
388
  declare function ReferenceList({ ids, heading }: {
373
389
  ids?: string[];
374
390
  heading?: string;
@@ -377,25 +393,91 @@ declare function ReferenceList({ ids, heading }: {
377
393
  /**
378
394
  * A documentation route (Introduction, Methodology, Implementation, Experiments, Benchmark): the page head with
379
395
  * its title and lede, then sections. `wide` for a route with a sub-tab rail (ADR-0071 rule 2, amended
380
- * 2026-09-04); a single prose column keeps the reading width and is centred (ADR-0017 §1).
396
+ * 2026-09-04); a single prose column keeps the reading width and is centred (ADR-0017 §1). Text takes the full width
397
+ * of the column, and the references of its sections are listed once at the end of the page (ADR-0017 §4).
398
+ */
399
+ /**
400
+ * The areas a section may declare, per documentation route (ADR-0017 section 2.1). The gate's table (`gate/areas.mjs`)
401
+ * holds each area's rule; `test/docFloors.test.ts` keeps these names equal to that table's.
381
402
  */
382
- declare function DocPage({ title, lede, wide, children }: {
403
+ declare const DOC_AREAS: {
404
+ readonly '/introduction': readonly ["initiative", "outcomes", "positioning", "credibility", "problem", "principles", "math", "pipeline", "honesty"];
405
+ readonly '/methodology': readonly ["method"];
406
+ readonly '/implementation': readonly ["architecture", "reproducibility", "engine", "lanes", "contract", "deployment"];
407
+ readonly '/experiments': readonly ["question", "metrics", "protocol", "datasets"];
408
+ readonly '/benchmark': readonly ["results", "comparison", "robustness", "live", "provenance", "caveat"];
409
+ };
410
+ interface DocPageProps {
383
411
  title: BiText;
384
412
  lede: ReactNode;
385
413
  wide?: boolean;
414
+ /**
415
+ * The required areas this page cannot carry, each with the reason (ADR-0017 section 2.1): rendered as
416
+ * `data-floor-waiver="<floor>: <reason>"`, separated by `; ` (a reason holds no semicolon). The gate skips a waived
417
+ * floor, lists the waiver, and fails one whose reason has fewer than 12 characters or whose floor is unknown.
418
+ */
419
+ floorWaivers?: Array<{
420
+ floor: string;
421
+ reason: BiText;
422
+ }>;
386
423
  children: ReactNode;
387
- }): react.JSX.Element;
424
+ }
425
+ declare function DocPage({ title, lede, wide, floorWaivers, children }: DocPageProps): react.JSX.Element;
388
426
  interface DocSectionProps {
389
427
  title: BiText;
390
428
  id?: string;
391
- /** Citation ids for this section only, rendered as one inline `Refs` row at its end (ADR-0017 §4). */
429
+ /** The required area this section covers (ADR-0017 section 2.1), rendered as `data-area`; the vocabulary is fixed per page (`gate/areas.mjs`). */
430
+ area?: string;
431
+ /** Methodology only: the kind of the method this section describes, rendered as `data-method-kind`. */
432
+ kind?: 'classical' | 'learned';
433
+ /** Citation ids this section cites; they are listed in the references block at the end of the page or open tab (ADR-0017 §4). */
392
434
  refs?: string[];
393
435
  /** Required when a section cites nothing, so the absence is a stated choice the gate can read. */
394
436
  noRefsReason?: BiText;
395
437
  children: ReactNode;
396
438
  }
397
- /** One section of a documentation route, ending in its own references. */
398
- declare function DocSection({ title, id, refs, noRefsReason, children }: DocSectionProps): react.JSX.Element;
439
+ /** One section of a documentation route; its references are registered with the nearest scope, not rendered here. */
440
+ declare function DocSection({ title, id, area, kind, refs, noRefsReason, children }: DocSectionProps): react.JSX.Element;
441
+
442
+ interface GlossaryEntry {
443
+ /** The symbol or term: a plain string (a symbol such as `S(t)`) or both languages. */
444
+ term: BiText;
445
+ definition: BiText;
446
+ }
447
+ /**
448
+ * The symbol glossary of the governing mathematics (ADR-0017 section 2.1, area `math`): one `<dt>` and `<dd>` per entry,
449
+ * in a `<dl>` that declares `data-glossary`. The gate counts its items and requires at least 10.
450
+ */
451
+ declare function Glossary({ entries, ariaLabel }: {
452
+ entries: GlossaryEntry[];
453
+ ariaLabel?: BiText;
454
+ }): react.JSX.Element;
455
+
456
+ interface StatProps {
457
+ /** The number as written, for example `42` or `3.5`. */
458
+ value: BiText;
459
+ /** Its unit, for example `% of downtime` (a statement without a unit is not quantified). */
460
+ unit: BiText;
461
+ /** Where the number comes from (a document, a measurement, a dataset): shown beside it and declared as `data-stat-source`. */
462
+ source: BiText;
463
+ }
464
+ /**
465
+ * A quantified statement (ADR-0017 section 2.1, the executive view): a value, its unit and its source, rendered as
466
+ * `data-stat` so the gate can count the claims of value that carry a number. A claim without all three is not a stat.
467
+ */
468
+ declare function Stat({ value, unit, source }: StatProps): react.JSX.Element;
469
+
470
+ interface QuestionProps {
471
+ /** The business question the executive would ask. */
472
+ question: ReactNode;
473
+ /** The plain answer, with the evidence and the honest limits. */
474
+ answer: ReactNode;
475
+ }
476
+ /**
477
+ * One question and its answer (ADR-0017 section 2.1, executive view, area `credibility`), rendered as `data-qa` with
478
+ * `data-question` and `data-answer` so the gate can count the pairs that hold both.
479
+ */
480
+ declare function Question({ question, answer }: QuestionProps): react.JSX.Element;
399
481
 
400
482
  interface SurfacePageProps {
401
483
  /** The route's heading; omit it when the header's active route already names the surface. */
@@ -836,4 +918,4 @@ interface ViewsRowProps {
836
918
  */
837
919
  declare function ViewsRow({ children, shares }: ViewsRowProps): react.JSX.Element;
838
920
 
839
- export { BREAKPOINTS, BarChart, type BarChartProps, type BarDatum, BiText, Callout, type CaseDef, type CaseGroup, type CaseKind, CaseSelector, type CaseSelectorProps, type CaseSelectorText, CaseWorkbench, type CaseWorkbenchProps, ChipGroup, type ChipGroupProps, type ChipOption, type Citation, CitationsProvider, Cite, DocPage, DocSection, type DocSectionProps, Equation, Figure, type FittedLabel, FocusShell, type FocusShellProps, FormatOptions, Gauge, type GaugeProps, type GaugeZone, InlineMath, Knob, type KnobProps, type Lane, LaneBadge, Lang, LanguageToggle, MAX_PEER_TABS, MAX_WORKBENCH_GROUPS, PanelBoundary, type PausedVizController, PlotCard, type PlotCardProps, type Provenance, type RailSection, Readout, type ReadoutItem, type ReadoutProps, ReferenceList, Refs, Stage, type StageSize, type SubTabDef, SubTabs, type SubTabsProps, SurfacePage, type SurfacePageProps, type TabDef, type TabGroupDef, TabGroups, type TabGroupsProps, Tabs, type TabsProps, type Theme, ThemeToggle, type Tone, type UsePausedVizOptions, VariantBar, type VariantBarProps, type VariantDef, Verdict, type VerdictProps, ViewsRow, type ViewsRowProps, type VizFrame, type VizLoop, type VizLoopDeps, type VizLoopOptions, type WorkbenchGroup, WorkbenchLayout, type WorkbenchLayoutProps, type WorkbenchRoute, WorkbenchShell, type WorkbenchShellProps, type WorkbenchState, WorkbenchStateContext, applyTheme, caseKind, caseTooltip, casesInSource, createVizLoop, fitLabel, fontFamily, groupByCategory, isStale, makeStateKey, niceStep, niceTicks, readCaseParam, readTheme, replaceQueryParam, sourcesPresent, textWidth, usePausedViz, useStageSize, useThemeStore, useUrlView, useWorkbenchState, viewParamOf, widestLabel, withCaseParam };
921
+ export { BREAKPOINTS, BarChart, type BarChartProps, type BarDatum, BiText, Callout, type CaseDef, type CaseGroup, type CaseKind, CaseSelector, type CaseSelectorProps, type CaseSelectorText, CaseWorkbench, type CaseWorkbenchProps, ChipGroup, type ChipGroupProps, type ChipOption, type Citation, CitationsProvider, Cite, DOC_AREAS, DocPage, type DocPageProps, DocReferences, DocRefsScope, DocSection, type DocSectionProps, Equation, Figure, type FittedLabel, FocusShell, type FocusShellProps, FormatOptions, Gauge, type GaugeProps, type GaugeZone, Glossary, type GlossaryEntry, InlineMath, Knob, type KnobProps, type Lane, LaneBadge, Lang, LanguageToggle, MAX_PEER_TABS, MAX_WORKBENCH_GROUPS, PanelBoundary, type PausedVizController, PlotCard, type PlotCardProps, type Provenance, Question, type QuestionProps, type RailSection, Readout, type ReadoutItem, type ReadoutProps, ReferenceList, Refs, Stage, type StageSize, Stat, type StatProps, type SubTabDef, SubTabs, type SubTabsProps, SurfacePage, type SurfacePageProps, type TabDef, type TabGroupDef, TabGroups, type TabGroupsProps, Tabs, type TabsProps, type Theme, ThemeToggle, type Tone, type UsePausedVizOptions, VariantBar, type VariantBarProps, type VariantDef, Verdict, type VerdictProps, ViewsRow, type ViewsRowProps, type VizFrame, type VizLoop, type VizLoopDeps, type VizLoopOptions, type WorkbenchGroup, WorkbenchLayout, type WorkbenchLayoutProps, type WorkbenchRoute, WorkbenchShell, type WorkbenchShellProps, type WorkbenchState, WorkbenchStateContext, applyTheme, caseKind, caseTooltip, casesInSource, createVizLoop, firstAppearance, fitLabel, fontFamily, groupByCategory, isStale, makeStateKey, niceStep, niceTicks, readCaseParam, readTheme, replaceQueryParam, sourcesPresent, textWidth, usePausedViz, useStageSize, useThemeStore, useUrlView, useWorkbenchState, viewParamOf, widestLabel, withCaseParam };