@fasl-work/caos-app-shell 0.16.0 → 0.17.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/dist/chart.d.ts CHANGED
@@ -16,6 +16,17 @@ interface ChartSeries {
16
16
  mode?: 'line' | 'points';
17
17
  /** The marker diameter of a `points` series, in CSS pixels (9 by default): a larger one rings the selected case. */
18
18
  size?: number;
19
+ /** A `points` series on x values of its own (measured cases over a modelled curve that has other sizes): one x per
20
+ * value. It is drawn over the plot on its own x, outside the curve's data, so it takes no part in the cursor, the
21
+ * pick or the readout, and the axes take its extent in. */
22
+ x?: number[];
23
+ }
24
+ /** What a reference line may be dragged over: the range it stays inside, and the step it snaps to (a hundredth of the
25
+ * range when not given; on a log axis the step is a factor). */
26
+ interface YMarkDrag {
27
+ min: number;
28
+ max: number;
29
+ step?: number;
19
30
  }
20
31
  interface ChartAxis {
21
32
  label: BiText;
@@ -52,7 +63,12 @@ interface UPlotChartProps {
52
63
  y: number;
53
64
  label: BiText;
54
65
  tone?: YMarkTone;
66
+ drag?: YMarkDrag;
55
67
  }[];
68
+ /** Called with the index in `yMarks` and the new y of a draggable reference line (`drag`) every time the reader moves
69
+ * it, by the pointer or the keyboard (a slider on the line: arrows, Page keys, Home and End); the caller sets the new
70
+ * y in `yMarks`. */
71
+ onYMarkChange?: (index: number, y: number) => void;
56
72
  /** Height in pixels, or `fill` to take the container's height (inside `PlotCard fill`); the width is always the
57
73
  * container's. */
58
74
  height?: number | 'fill';
@@ -110,6 +126,29 @@ declare const LOG_TABLE_FLOOR = 1e-22;
110
126
  declare function logDecades(min: number, max: number, most?: number): number[];
111
127
  /** The smallest positive value among columns of values and the lower end of a fixed range; Infinity if none. */
112
128
  declare function smallestPositive(columns: readonly (readonly (number | null)[])[], range?: readonly [number, number] | null): number;
129
+ /**
130
+ * The ticks of a log x axis that get a label: the powers of ten only (null for every other tick, which formatTicks leaves
131
+ * blank). A range with no power of ten in it keeps its ticks, so the axis is never left without a label.
132
+ */
133
+ declare function decadeOnly(ticks: readonly (number | null)[]): (number | null)[];
134
+ /** The side of the square box of a parity plot: the smaller of the box's width and the height asked for (the box's
135
+ * height when the chart fills it). */
136
+ declare function parityBox(boxWidth: number, boxHeight: number, height: number, fill: boolean): number;
137
+ /** A dragged value kept inside the range of its line and snapped to its step; a value that is not a number falls to the
138
+ * lower end. */
139
+ declare function clampMark(y: number, drag: YMarkDrag): number;
140
+ /**
141
+ * The value a key sets on a draggable line, or null for a key it does not handle: the arrows one step, Page Up and Down
142
+ * ten, Home and End the ends of the range. A step is a hundredth of the range, or `drag.step`; on a log axis it is a factor
143
+ * (a hundredth of the range in decades) and `drag.step` is not used.
144
+ */
145
+ declare function nudgeMark(y: number, key: string, drag: YMarkDrag, log?: boolean): number | null;
146
+ /** The x and y extent of the series that bring an x of their own, finite points only (on a log x axis, `positiveX`,
147
+ * only the points with x above 0); null when there are none. */
148
+ declare function overlayExtent(series: readonly ChartSeries[], positiveX?: boolean): {
149
+ x: [number, number];
150
+ y: [number, number];
151
+ } | null;
113
152
  /** How many tick labels repeat the label before them (empty labels apart); the gate fails a chart with any (G6). */
114
153
  declare function repeatedLabels(labels: readonly (string | null | undefined)[]): number;
115
154
  /**
@@ -158,6 +197,6 @@ declare function nearestPoint(xs: readonly number[], ys: readonly (readonly (num
158
197
  * (known shell defect 17). An axis with fixed decimals ticks only where its labels differ (`tickSteps`). The host
159
198
  * declares `data-series`, `data-axis-titles`, `data-ticks-cut`, `data-ticks-repeat` and `data-drawn` for the gate.
160
199
  */
161
- declare function UPlotChart({ x, y, series, marks, yMarks, height, onCursor, onPick, parity, readout, hint }: UPlotChartProps): react.JSX.Element;
200
+ declare function UPlotChart({ x, y, series, marks, yMarks, onYMarkChange, height, onCursor, onPick, parity, readout, hint }: UPlotChartProps): react.JSX.Element;
162
201
 
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 };
202
+ export { type ChartAxis, type ChartSeries, LOG_TABLE_FLOOR, UPlotChart, type UPlotChartProps, YMARK_TONE_TOKENS, type YMarkDrag, type YMarkTone, clampMark, decadeOnly, drawMarks, drawYMarks, logDecades, nearestPoint, nudgeMark, overlayExtent, parityBox, parityRange, rangeWithLevels, repeatedLabels, seriesStyle, smallestPositive, sortOrder, tickSteps };
package/dist/chart.js CHANGED
@@ -83,11 +83,73 @@ function smallestPositive(columns, range) {
83
83
  for (const c of columns) for (const v of c) if (v !== null && Number.isFinite(v) && v > 0 && v < lo) lo = v;
84
84
  return lo;
85
85
  }
86
+ function isDecade(t) {
87
+ if (t === null || t === void 0 || !Number.isFinite(t) || t <= 0) return false;
88
+ const d = Math.log10(t);
89
+ return Math.abs(d - Math.round(d)) < 1e-6;
90
+ }
91
+ function decadeOnly(ticks) {
92
+ if (!ticks.some(isDecade)) return [...ticks];
93
+ return ticks.map((t) => isDecade(t) ? t : null);
94
+ }
95
+ function parityBox(boxWidth, boxHeight, height, fill) {
96
+ return Math.min(boxWidth, fill ? boxHeight : height);
97
+ }
98
+ function clampMark(y, drag) {
99
+ if (!Number.isFinite(y)) return drag.min;
100
+ const v = drag.step && drag.step > 0 ? drag.min + Math.round((y - drag.min) / drag.step) * drag.step : y;
101
+ const clamped = Math.min(drag.max, Math.max(drag.min, v));
102
+ return Number(clamped.toPrecision(12));
103
+ }
104
+ function nudgeMark(y, key, drag, log = false) {
105
+ const dir = key === "ArrowUp" || key === "ArrowRight" || key === "PageUp" ? 1 : key === "ArrowDown" || key === "ArrowLeft" || key === "PageDown" ? -1 : 0;
106
+ if (key === "Home") return drag.min;
107
+ if (key === "End") return drag.max;
108
+ if (dir === 0) return null;
109
+ const n = key === "PageUp" || key === "PageDown" ? 10 : 1;
110
+ if (log && drag.min > 0 && drag.max > drag.min) {
111
+ const decades = (Math.log10(drag.max) - Math.log10(drag.min)) / 100;
112
+ return Math.min(drag.max, Math.max(drag.min, y * 10 ** (dir * n * decades)));
113
+ }
114
+ const step = drag.step && drag.step > 0 ? drag.step : (drag.max - drag.min) / 100;
115
+ return clampMark(y + dir * n * step, { ...drag, step: void 0 });
116
+ }
117
+ function isOverlay(s) {
118
+ return Boolean(s.x) && s.mode === "points";
119
+ }
120
+ function dragWithin(drag, range) {
121
+ if (!range) return drag;
122
+ const lo = Math.min(range[0], range[1]);
123
+ const hi = Math.max(range[0], range[1]);
124
+ const min = Math.min(hi, Math.max(lo, drag.min));
125
+ const max = Math.max(lo, Math.min(hi, drag.max));
126
+ return { ...drag, min, max: Math.max(min, max) };
127
+ }
128
+ function overlayExtent(series, positiveX = false) {
129
+ let x0 = Infinity;
130
+ let x1 = -Infinity;
131
+ let y0 = Infinity;
132
+ let y1 = -Infinity;
133
+ for (const s of series) {
134
+ if (!isOverlay(s) || !s.x) continue;
135
+ for (let i = 0; i < Math.min(s.x.length, s.values.length); i++) {
136
+ const px = s.x[i];
137
+ const py = s.values[i];
138
+ if (py === null || py === void 0 || !Number.isFinite(px) || !Number.isFinite(py) || positiveX && !(px > 0)) continue;
139
+ x0 = Math.min(x0, px);
140
+ x1 = Math.max(x1, px);
141
+ y0 = Math.min(y0, py);
142
+ y1 = Math.max(y1, py);
143
+ }
144
+ }
145
+ return Number.isFinite(x0) ? { x: [x0, x1], y: [y0, y1] } : null;
146
+ }
86
147
  var DECADE_AXIS = {
87
148
  splits: (_u, _i, min, max) => logDecades(min, max),
88
149
  filter: (_u, splits) => splits
89
150
  };
90
151
  var PARITY_TICK_SPACE = 40;
152
+ var HANDLE_HEIGHT = 14;
91
153
  function repeatedLabels(labels) {
92
154
  let n = 0;
93
155
  for (let i = 1; i < labels.length; i++) if (labels[i] && labels[i] === labels[i - 1]) n++;
@@ -193,7 +255,7 @@ function nearestPoint(xs, ys, left, top) {
193
255
  }
194
256
  return best;
195
257
  }
196
- function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPick, parity, readout, hint }) {
258
+ function UPlotChart({ x, y, series, marks, yMarks, onYMarkChange, height = 280, onCursor, onPick, parity, readout, hint }) {
197
259
  const lang = useShellLang();
198
260
  const theme = useThemeStore((s) => s.theme);
199
261
  const fill = height === "fill";
@@ -206,7 +268,7 @@ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPic
206
268
  },
207
269
  [measureRef]
208
270
  );
209
- const width = parity ? Math.min(box.width, fill ? box.height : height) : box.width;
271
+ const width = parity ? parityBox(box.width, box.height, typeof height === "number" ? height : 0, fill) : box.width;
210
272
  const h = parity ? width : fill ? box.height : height;
211
273
  const uRef = useRef(null);
212
274
  const hostRef = useRef(null);
@@ -220,36 +282,57 @@ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPic
220
282
  const orderRef = useRef(order);
221
283
  orderRef.current = order;
222
284
  const xs = useMemo(() => order ? order.map((i) => x.values[i]) : x.values, [order, x.values]);
223
- const ys = useMemo(() => series.map((s) => order ? order.map((i) => s.values[i] ?? null) : s.values), [order, series]);
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;
285
+ const entries = useMemo(() => series.map((s, i) => ({ s, i })), [series]);
286
+ const baseEntries = useMemo(() => entries.filter(({ s }) => !isOverlay(s)), [entries]);
287
+ const overlays = useMemo(() => entries.filter(({ s }) => isOverlay(s)), [entries]);
288
+ const ext = useMemo(() => overlayExtent(series, Boolean(x.log)), [series, x.log]);
289
+ const ys = useMemo(() => baseEntries.map(({ s }) => order ? order.map((i) => s.values[i] ?? null) : s.values), [order, baseEntries]);
290
+ const levels = useMemo(() => (yMarks ?? []).flatMap((m) => m.drag ? [m.drag.min, m.drag.max] : [m.y]), [yMarks]);
291
+ const range = useMemo(() => parity ? parityRange(xs, ...ys, levels, ext ? [...ext.x, ...ext.y] : []) : null, [parity, xs, ys, levels, ext]);
292
+ const yLevels = !parity && !y.range && (levels.length > 0 || ext) ? [...levels, ...ext ? ext.y : []] : null;
293
+ const xExt = useMemo(() => {
294
+ if (!ext || range || xs.length === 0) return null;
295
+ const lo = Math.min(xs[0], ext.x[0]);
296
+ const hi = Math.max(xs[xs.length - 1], ext.x[1]);
297
+ if (x.log && !(lo > 0)) return null;
298
+ return hi > lo ? [lo, hi] : x.log ? [lo / 10, hi * 10] : [lo - 1, hi + 1];
299
+ }, [ext, range, xs, x.log]);
227
300
  const tiny = useMemo(
228
301
  () => ({
229
- x: Boolean(x.log) && smallestPositive([xs], range) < LOG_TABLE_FLOOR,
302
+ x: Boolean(x.log) && smallestPositive(ext ? [xs, ext.x] : [xs], range) < LOG_TABLE_FLOOR,
230
303
  y: Boolean(y.log) && smallestPositive(yLevels ? [...ys, yLevels] : ys, range ?? y.range) < LOG_TABLE_FLOOR
231
304
  }),
232
- [x.log, y.log, xs, ys, range, y.range, yLevels]
305
+ [x.log, y.log, xs, ys, range, y.range, yLevels, ext]
233
306
  );
234
307
  const xLabel = pick(x.label, lang);
235
308
  const yLabel = pick(y.label, lang);
309
+ const yMarksRef = useRef(yMarks);
310
+ yMarksRef.current = yMarks;
311
+ const liveRef = useRef([]);
312
+ liveRef.current = (yMarks ?? []).map((m) => m.y);
313
+ const overlaysRef = useRef(overlays);
314
+ overlaysRef.current = overlays;
315
+ const onYMarkChangeRef = useRef(onYMarkChange);
316
+ onYMarkChangeRef.current = onYMarkChange;
317
+ const dragCount = (yMarks ?? []).filter((m) => m.drag).length;
236
318
  const xUnit = x.unit ? pick(x.unit, lang) : "";
237
319
  const yUnit = y.unit ? pick(y.unit, lang) : "";
238
320
  const structure = JSON.stringify({
239
- s: series.map((s, i) => [pick(s.label, lang), seriesStyle(s, i), s.width ?? 2, s.mode ?? "line", s.size ?? 9]),
240
- x: [xLabel, xUnit, Boolean(x.time), Boolean(x.log), x.format ?? null, tiny.x],
321
+ s: series.map((s, i) => [pick(s.label, lang), seriesStyle(s, i), s.width ?? 2, s.mode ?? "line", s.size ?? 9, isOverlay(s)]),
322
+ x: [xLabel, xUnit, Boolean(x.time), Boolean(x.log), x.format ?? null, tiny.x, xExt],
323
+ oe: ext,
241
324
  p: range,
242
325
  pick: Boolean(onPick),
243
326
  y: [yLabel, yUnit, Boolean(y.log), y.range ?? null, y.format ?? null, tiny.y],
244
327
  m: (marks ?? []).map((m) => [m.x, pick(m.label, lang)]),
245
- ym: (yMarks ?? []).map((m) => [m.y, pick(m.label, lang), m.tone ?? "warn"]),
328
+ ym: (yMarks ?? []).map((m) => m.drag ? ["drag", m.drag] : [m.y, pick(m.label, lang), m.tone ?? "warn"]),
246
329
  theme,
247
330
  lang,
248
331
  h,
249
332
  w: width
250
333
  });
251
334
  const data = useMemo(() => [xs, ...ys], [xs, ys]);
252
- const scatter = series.length > 0 && series.every((s) => s.mode === "points");
335
+ const scatter = baseEntries.length > 0 && baseEntries.every(({ s }) => s.mode === "points");
253
336
  useEffect(() => {
254
337
  const el = plotRef.current;
255
338
  if (!el || width <= 0 || h <= 0) return;
@@ -259,8 +342,9 @@ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPic
259
342
  const font = `11px ${family}`;
260
343
  const surface = resolveToken("--color-surface", "#fff");
261
344
  const repeats = { x: 0, y: 0 };
262
- const tick = (fmt, axis) => (_u, vals) => {
263
- const labels = formatTicks(vals, lang, fmt ?? { digits: 4 });
345
+ const tick = (fmt, axis, pre) => (_u, vals) => {
346
+ const labels = formatTicks(pre ? pre(vals) : vals, lang, fmt ?? { digits: 4 });
347
+ if (axis === "x" && pre) hostRef.current?.setAttribute("data-log-labels", labels.filter((l) => l).join("|"));
264
348
  repeats[axis] = repeatedLabels(labels);
265
349
  hostRef.current?.setAttribute("data-ticks-repeat", String(repeats.x + repeats.y));
266
350
  hostRef.current?.setAttribute(`data-ticks-${axis}`, String(labels.filter((l) => l).length));
@@ -294,7 +378,7 @@ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPic
294
378
  x: {
295
379
  time: Boolean(x.time),
296
380
  distr: x.log ? 3 : 1,
297
- ...range ? { range } : x.log ? { range: (_u, min, max) => [min, max] } : {}
381
+ ...range ? { range } : xExt ? { range: xExt } : x.log ? { range: (_u, min, max) => [min, max] } : {}
298
382
  },
299
383
  y: {
300
384
  distr: y.log ? 3 : 1,
@@ -316,7 +400,8 @@ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPic
316
400
  font,
317
401
  label: xUnit ? `${xLabel} (${xUnit})` : xLabel,
318
402
  labelFont: font,
319
- values: x.time ? void 0 : tick(x.format, "x"),
403
+ // a log x axis labels the decades only; the ticks between stay drawn, unlabelled
404
+ values: x.time ? void 0 : tick(x.format, "x", x.log && !tiny.x ? decadeOnly : void 0),
320
405
  ...xSteps ? { incrs: xSteps } : {},
321
406
  ...range ? { space: PARITY_TICK_SPACE } : {},
322
407
  ...tiny.x ? DECADE_AXIS : {}
@@ -346,7 +431,7 @@ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPic
346
431
  ],
347
432
  series: [
348
433
  {},
349
- ...series.map((s, i) => {
434
+ ...baseEntries.map(({ s, i }) => {
350
435
  const style = seriesStyle(s, i);
351
436
  const stroke = resolveToken(style.color, "#4a8");
352
437
  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 } };
@@ -368,6 +453,55 @@ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPic
368
453
  if (idx === null || idx === void 0 || !onPickRef.current) return;
369
454
  onPickRef.current(orderRef.current ? orderRef.current[idx] : idx);
370
455
  });
456
+ const accent = resolveToken("--color-accent", "#06c");
457
+ (yMarks ?? []).forEach((m, i) => {
458
+ if (!m.drag) return;
459
+ const drag = dragWithin(m.drag, range ? void 0 : y.range);
460
+ const el2 = document.createElement("div");
461
+ el2.setAttribute("role", "slider");
462
+ el2.setAttribute("tabindex", "0");
463
+ el2.setAttribute("aria-orientation", "vertical");
464
+ el2.setAttribute("aria-valuemin", String(drag.min));
465
+ el2.setAttribute("aria-valuemax", String(drag.max));
466
+ el2.setAttribute("data-y-handle", String(i));
467
+ Object.assign(el2.style, { position: "absolute", left: "0", width: "100%", height: `${HANDLE_HEIGHT}px`, cursor: "ns-resize", touchAction: "none", outlineOffset: "-2px", zIndex: "3" });
468
+ el2.addEventListener("focus", () => el2.style.outline = `2px solid ${accent}`);
469
+ el2.addEventListener("blur", () => el2.style.outline = "none");
470
+ el2.addEventListener("click", (e) => e.stopPropagation());
471
+ const set = (raw) => {
472
+ const v = clampMark(raw, y.log ? { ...drag, step: void 0 } : drag);
473
+ if (v === liveRef.current[i]) return;
474
+ liveRef.current[i] = v;
475
+ onYMarkChangeRef.current?.(i, v);
476
+ u.redraw(false);
477
+ };
478
+ let dragging = false;
479
+ el2.addEventListener("pointerdown", (e) => {
480
+ if (e.button !== 0) return;
481
+ dragging = true;
482
+ el2.setPointerCapture(e.pointerId);
483
+ el2.focus();
484
+ e.preventDefault();
485
+ });
486
+ el2.addEventListener("pointermove", (e) => {
487
+ if (!dragging) return;
488
+ set(u.posToVal(e.clientY - u.over.getBoundingClientRect().top, "y"));
489
+ });
490
+ const end = (e) => {
491
+ dragging = false;
492
+ if (el2.hasPointerCapture(e.pointerId)) el2.releasePointerCapture(e.pointerId);
493
+ };
494
+ el2.addEventListener("pointerup", end);
495
+ el2.addEventListener("pointercancel", end);
496
+ el2.addEventListener("keydown", (e) => {
497
+ const v = nudgeMark(liveRef.current[i], e.key, drag, Boolean(y.log));
498
+ if (v === null) return;
499
+ e.preventDefault();
500
+ set(v);
501
+ });
502
+ u.over.appendChild(el2);
503
+ });
504
+ if (dragCount) queueMicrotask(() => uRef.current === u && u.redraw(false));
371
505
  }
372
506
  ],
373
507
  draw: [
@@ -385,8 +519,53 @@ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPic
385
519
  ctx.stroke();
386
520
  ctx.restore();
387
521
  }
522
+ const dots = overlaysRef.current;
523
+ if (dots.length) {
524
+ const ctx = u.ctx;
525
+ const pr = uPlot.pxRatio || 1;
526
+ let drawn = 0;
527
+ ctx.save();
528
+ ctx.beginPath();
529
+ ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
530
+ ctx.clip();
531
+ for (const { s, i } of dots) {
532
+ ctx.fillStyle = resolveToken(seriesStyle(s, i).color, "#4a8");
533
+ ctx.strokeStyle = surface;
534
+ ctx.lineWidth = Math.max(1, pr);
535
+ const r = (s.size ?? 9) / 2 * pr;
536
+ const ox = s.x ?? [];
537
+ for (let k = 0; k < Math.min(ox.length, s.values.length); k++) {
538
+ const v = s.values[k];
539
+ if (v === null || v === void 0 || !Number.isFinite(v) || !Number.isFinite(ox[k])) continue;
540
+ const px = u.valToPos(ox[k], "x", true);
541
+ const py = u.valToPos(v, "y", true);
542
+ if (!Number.isFinite(px) || !Number.isFinite(py) || px < u.bbox.left - 0.5 || px > u.bbox.left + u.bbox.width + 0.5 || py < u.bbox.top - 0.5 || py > u.bbox.top + u.bbox.height + 0.5) continue;
543
+ ctx.beginPath();
544
+ ctx.arc(px, py, r, 0, Math.PI * 2);
545
+ ctx.fill();
546
+ ctx.stroke();
547
+ drawn++;
548
+ }
549
+ }
550
+ ctx.restore();
551
+ hostRef.current?.setAttribute("data-overlay-drawn", String(drawn));
552
+ }
388
553
  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);
554
+ const lines = yMarksRef.current ?? [];
555
+ if (lines.length) drawYMarks(u, lines.map((m, i) => ({ y: liveRef.current[i] ?? m.y, label: pick(m.label, lang), color: resolveToken(YMARK_TONE_TOKENS[m.tone ?? "warn"], "#c80") })), markStyle);
556
+ u.over.querySelectorAll("[data-y-handle]").forEach((el2) => {
557
+ const i = Number(el2.getAttribute("data-y-handle"));
558
+ const m = lines[i];
559
+ const v = liveRef.current[i];
560
+ if (!m || v === void 0) return;
561
+ const top = u.valToPos(v, "y");
562
+ el2.style.display = Number.isFinite(top) && top >= 0 && top <= u.bbox.height / (uPlot.pxRatio || 1) ? "" : "none";
563
+ el2.style.top = `${top - HANDLE_HEIGHT / 2}px`;
564
+ const label = pick(m.label, lang);
565
+ el2.setAttribute("aria-label", label);
566
+ el2.setAttribute("aria-valuenow", String(Number(v.toPrecision(12))));
567
+ el2.setAttribute("aria-valuetext", `${label}: ${formatNumber(v, lang, y.format ?? { digits: 4 })}${yUnit ? `${NBSP}${yUnit}` : ""}`);
568
+ });
390
569
  if (marks?.length) drawMarks(u, marks.map((m) => ({ x: m.x, label: pick(m.label, lang) })), markStyle);
391
570
  }
392
571
  ]
@@ -402,9 +581,14 @@ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPic
402
581
  useEffect(() => {
403
582
  uRef.current?.setData(data);
404
583
  }, [data]);
584
+ const overlaysKey = JSON.stringify(overlays.map(({ s, i }) => [i, s.x, s.values, s.size ?? 9, seriesStyle(s, i).color]));
585
+ const linesKey = JSON.stringify((yMarks ?? []).map((m) => [m.y, pick(m.label, lang), m.tone ?? "warn"]));
586
+ useEffect(() => {
587
+ uRef.current?.redraw(false);
588
+ }, [linesKey, overlaysKey]);
405
589
  const read = cursor !== null && readout ? readout(order ? order[cursor] : cursor) : cursor !== null ? [
406
590
  `${xLabel} ${formatNumber(xs[cursor], lang, x.format ?? { digits: 4 })}${xUnit ? `${NBSP}${xUnit}` : ""}`,
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}` : ""}`)
591
+ ...baseEntries.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
592
  ].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";
409
593
  return /* @__PURE__ */ jsxs(
410
594
  "div",
@@ -420,6 +604,8 @@ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPic
420
604
  "data-log-x": x.log ? "1" : void 0,
421
605
  "data-pick": onPick ? "1" : void 0,
422
606
  "data-y-marks": yMarks?.length ? String(yMarks.length) : void 0,
607
+ "data-y-drag": dragCount ? String(dragCount) : void 0,
608
+ "data-overlay": overlays.length ? String(overlays.length) : void 0,
423
609
  children: [
424
610
  /* @__PURE__ */ jsx("div", { ref: setPlot, className: "caos-chart-plot" }),
425
611
  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: [
@@ -432,4 +618,4 @@ function UPlotChart({ x, y, series, marks, yMarks, height = 280, onCursor, onPic
432
618
  );
433
619
  }
434
620
 
435
- export { LOG_TABLE_FLOOR, UPlotChart, YMARK_TONE_TOKENS, drawMarks, drawYMarks, logDecades, nearestPoint, parityRange, rangeWithLevels, repeatedLabels, seriesStyle, smallestPositive, sortOrder, tickSteps };
621
+ export { LOG_TABLE_FLOOR, UPlotChart, YMARK_TONE_TOKENS, clampMark, decadeOnly, drawMarks, drawYMarks, logDecades, nearestPoint, nudgeMark, overlayExtent, parityBox, parityRange, rangeWithLevels, repeatedLabels, seriesStyle, smallestPositive, sortOrder, tickSteps };
package/dist/index.d.ts CHANGED
@@ -260,8 +260,9 @@ interface CaseSelectorProps {
260
260
  * category structure is preserved either way; only the height changes.
261
261
  */
262
262
  layout?: 'chips' | 'select';
263
- /** A control that sits on the select's row, after it (the workbench's Focus button, shell 0.14.1): the select shrinks to make
264
- * room, so the selector is no taller with it. With `layout: 'chips'` it gets a row of its own, after the chips. */
263
+ /** A control of the product that sits on the select's row, after it: the select shrinks to make room, so the selector is no taller with
264
+ * it (a long case name may then be cut: keep the control narrow). The workbench's own Focus control is not an accessory since 0.17.0:
265
+ * it sits in the group tab row. With `layout: 'chips'` it gets a row of its own, after the chips. */
265
266
  accessory?: ReactNode;
266
267
  }
267
268
  /**
@@ -1251,7 +1252,7 @@ interface CaseWorkbenchProps {
1251
1252
  deepLinkView?: boolean | string;
1252
1253
  ariaLabel?: BiText;
1253
1254
  /**
1254
- * Focus mode for the App (ADR-0070, shell 0.14.0): a Focus button in the rail head opens the selected case's open group in
1255
+ * Focus mode for the App (ADR-0070, shell 0.14.0): a Focus control at the end of the group tab row opens the selected case's open group in
1255
1256
  * the full viewport through `FocusShell` (the stage is the view, the rail stays), holds `?focus=1` beside `?case=` and
1256
1257
  * `?view=` in the URL, and returns with Escape or the exit button to the same case, group and sub-view. `true` takes all
1257
1258
  * the defaults. Omit it and the App is exactly what it was.
package/dist/index.js CHANGED
@@ -795,17 +795,64 @@ function CaseSelector(props) {
795
795
  const visible = casesInSource(cases, source);
796
796
  const groups = groupByCategory(visible);
797
797
  const modified = cases.find((c) => c.id === modifiedFromId) ?? null;
798
+ const wrapRef = useRef(null);
799
+ const [minHeight, setMinHeight] = useState(null);
800
+ const nameKey = visible.map((c) => c.name).join("");
801
+ useLayoutEffect(() => {
802
+ const wrap = wrapRef.current;
803
+ const display = wrap?.querySelector(".cs-select-display");
804
+ if (!wrap || !display) return void 0;
805
+ const names = nameKey ? nameKey.split("") : [];
806
+ const measure = () => {
807
+ const st = getComputedStyle(display);
808
+ const room = display.clientWidth - parseFloat(st.paddingLeft) - parseFloat(st.paddingRight);
809
+ if (!(room > 0)) return;
810
+ const copy = document.createElement("span");
811
+ copy.className = "cs-select-text";
812
+ copy.setAttribute("aria-hidden", "true");
813
+ copy.setAttribute("style", `position:absolute;visibility:hidden;left:0;top:0;width:${room}px;display:block;overflow:visible;max-height:none;-webkit-line-clamp:unset;line-clamp:unset`);
814
+ display.append(copy);
815
+ let tallest = 0;
816
+ for (const n of names) {
817
+ copy.textContent = n;
818
+ tallest = Math.max(tallest, copy.getBoundingClientRect().height);
819
+ }
820
+ const line = parseFloat(getComputedStyle(copy).lineHeight) || parseFloat(st.fontSize) * 1.25;
821
+ copy.remove();
822
+ const frame = parseFloat(st.paddingTop) + parseFloat(st.paddingBottom) + parseFloat(st.borderTopWidth) + parseFloat(st.borderBottomWidth);
823
+ const next = tallest > line * 1.5 ? Math.ceil((tallest + frame) * 100) / 100 : null;
824
+ setMinHeight((prev) => prev === next ? prev : next);
825
+ };
826
+ measure();
827
+ let width = wrap.getBoundingClientRect().width;
828
+ const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
829
+ const w = wrap.getBoundingClientRect().width;
830
+ if (w !== width) {
831
+ width = w;
832
+ measure();
833
+ }
834
+ });
835
+ ro?.observe(wrap);
836
+ void document.fonts?.ready.then(measure);
837
+ return () => ro?.disconnect();
838
+ }, [layout, nameKey]);
839
+ const options = groups.flatMap((g) => g.cases);
840
+ const selectedName = (options.find((c) => c.id === selectedId) ?? options.find((c) => !c.disabled))?.name ?? "";
798
841
  const select = /* @__PURE__ */ jsx(
799
842
  "select",
800
843
  {
801
844
  "data-control": "case",
802
- className: "select cs-select",
845
+ className: "select cs-select cs-select-native",
803
846
  "aria-label": ariaLabel ?? "Case selector",
804
847
  value: selectedId,
805
848
  onChange: (e) => onSelect(e.target.value),
806
849
  children: groups.map((g) => /* @__PURE__ */ jsx("optgroup", { label: g.category || " ", children: g.cases.map((c) => /* @__PURE__ */ jsx("option", { value: c.id, disabled: c.disabled, title: caseTooltip(c) || void 0, children: c.name }, c.id)) }, g.category || "_"))
807
850
  }
808
851
  );
852
+ const selectBox = /* @__PURE__ */ jsxs("div", { className: "cs-select-wrap", ref: wrapRef, children: [
853
+ /* @__PURE__ */ jsx("div", { className: "cs-select-display", style: minHeight === null ? void 0 : { minHeight }, "aria-hidden": "true", children: /* @__PURE__ */ jsx("span", { className: "cs-select-text", children: selectedName }) }),
854
+ select
855
+ ] });
809
856
  return /* @__PURE__ */ jsxs("div", { className: `cs${className ? ` ${className}` : ""}`, role: "group", "aria-label": ariaLabel ?? "Case selector", children: [
810
857
  source && lanes.length > 1 && /* @__PURE__ */ jsxs("div", { className: "cs-source", children: [
811
858
  /* @__PURE__ */ jsx("span", { className: "cs-source-label", children: t2.source }),
@@ -824,9 +871,9 @@ function CaseSelector(props) {
824
871
  ] }),
825
872
  source && source !== "synthetic" && /* @__PURE__ */ jsx("p", { className: "cs-locked banner", children: lockedNote ?? t2.lockedNote }),
826
873
  layout === "select" && (props.accessory ? /* @__PURE__ */ jsxs("div", { className: "cs-select-row", children: [
827
- select,
874
+ selectBox,
828
875
  props.accessory
829
- ] }) : select),
876
+ ] }) : selectBox),
830
877
  layout === "chips" && groups.map((g) => /* @__PURE__ */ jsxs("div", { className: "cs-group", role: "group", "aria-label": g.category || void 0, "data-control": "case", children: [
831
878
  g.category && /* @__PURE__ */ jsx("span", { className: "cs-group-label", children: g.category }),
832
879
  /* @__PURE__ */ jsx("div", { className: "cs-chips", children: g.cases.map((c) => {
@@ -3487,19 +3534,12 @@ function CaseWorkbench(props) {
3487
3534
  ]
3488
3535
  }
3489
3536
  );
3490
- const besideSelect = Boolean(cases && (cases.layout ?? "chips") === "select");
3491
3537
  const pickers = cases || variants ? /* @__PURE__ */ jsxs(Fragment, { children: [
3492
- cases && /* @__PURE__ */ jsx(CaseSelector, { lang, ...cases, accessory: besideSelect ? enter : cases.accessory }),
3538
+ cases && /* @__PURE__ */ jsx(CaseSelector, { lang, ...cases }),
3493
3539
  variants && /* @__PURE__ */ jsx(VariantBar, { ...variants })
3494
3540
  ] }) : null;
3495
- const focusPickers = cases || variants ? /* @__PURE__ */ jsxs(Fragment, { children: [
3496
- cases && /* @__PURE__ */ jsx(CaseSelector, { lang, ...cases, accessory: cases.accessory }),
3497
- variants && /* @__PURE__ */ jsx(VariantBar, { ...variants })
3498
- ] }) : null;
3499
- const railHead = pickers || enter ? /* @__PURE__ */ jsxs(Fragment, { children: [
3500
- pickers,
3501
- besideSelect ? null : enter
3502
- ] }) : null;
3541
+ const railHead = pickers;
3542
+ const focusPickers = pickers;
3503
3543
  const instrumentData = {
3504
3544
  "data-case-workbench": "",
3505
3545
  "data-group-count": tabs.length,
@@ -3533,7 +3573,8 @@ function CaseWorkbench(props) {
3533
3573
  tabs,
3534
3574
  value: openGroup,
3535
3575
  onChange: selectGroup,
3536
- ariaLabel: pick(ariaLabel ?? { en: "Case views", es: "Vistas del caso" }, lang)
3576
+ ariaLabel: pick(ariaLabel ?? { en: "Case views", es: "Vistas del caso" }, lang),
3577
+ trailing: enter || void 0
3537
3578
  }
3538
3579
  ) }) }) }) });
3539
3580
  }
package/gate/inpage.mjs CHANGED
@@ -181,6 +181,67 @@ export function installLib() {
181
181
  };
182
182
  };
183
183
 
184
+ /**
185
+ * Selects whose selected option does not fit the room it leaves, or a case select that shows another name than the one it holds (BL-074, shell
186
+ * 0.17.0; the reviews of 0.17.0). What is measured is what the reader sees:
187
+ * - a select with the shell's display (`.cs-select-wrap`, the case select since the trial of 0.17.0) shows the name whole at any length in the
188
+ * display's real `.cs-select-text` element: the name is cut when that element overflows its box (`scrollHeight` or `scrollWidth` past its
189
+ * `clientHeight` or `clientWidth` by more than 1px, e.g. a product stylesheet that clamps it), measured on the element itself, never on a probe
190
+ * copy, and when the display's own box is smaller than its content (a product stylesheet that fixes its height); the display is stale when the name it shows, whitespace normalized, differs from the text of the option the select holds;
191
+ * - any other select shows its text on one line: the room is its width less its own CSS padding and less the native arrow, which is
192
+ * measured, not assumed (a probe select of the same class holding one character, sized to its content; 0 with `appearance: none`).
193
+ * Plain text is measured with the select's own font on one canvas. Runs in the App and inside Focus (its rail holds a case select too).
194
+ */
195
+ const selectCuts = () => {
196
+ const cuts = [];
197
+ const mctx = document.createElement('canvas').getContext('2d');
198
+ const norm = (t) => (t ?? '').trim().replace(/\s+/g, ' ');
199
+ for (const sel of document.querySelectorAll('select')) {
200
+ if (!mctx || !visible(sel)) continue;
201
+ const ss = getComputedStyle(sel);
202
+ const held = norm(sel.options[sel.selectedIndex]?.textContent);
203
+ const display = sel.closest('.cs-select-wrap')?.querySelector('.cs-select-display');
204
+ if (display) {
205
+ const t = display.querySelector('.cs-select-text') ?? display;
206
+ const shown = norm(t.textContent);
207
+ if (shown !== held) {
208
+ cuts.push(`${describe(sel)} shows "${shown}" while the select holds "${held}" (a stale display)`);
209
+ continue;
210
+ }
211
+ const overV = t.scrollHeight > t.clientHeight + 1;
212
+ const overH = t.scrollWidth > t.clientWidth + 1;
213
+ if (overV || overH) {
214
+ const line = Number.parseFloat(getComputedStyle(t).lineHeight) || 1;
215
+ cuts.push(`${describe(sel)} cuts the case name "${shown}" (the name needs ${Math.round(t.scrollHeight / line)} lines, ${Math.round(t.scrollHeight)}px high and ${Math.round(t.scrollWidth)}px wide, and its box holds ${Math.round(t.clientHeight)}px by ${Math.round(t.clientWidth)}px; held option "${held}")`);
216
+ } else if (display.scrollHeight > display.clientHeight + 1 || display.scrollWidth > display.clientWidth + 1) {
217
+ // the text fits its own box but the display's box is smaller (a product stylesheet that fixes the display's height)
218
+ cuts.push(`${describe(sel)} cuts the case name "${shown}" in its display (the name needs ${Math.round(display.scrollHeight)}px by ${Math.round(display.scrollWidth)}px, the display holds ${Math.round(display.clientHeight)}px by ${Math.round(display.clientWidth)}px; held option "${held}")`);
219
+ }
220
+ continue;
221
+ }
222
+ const text = held;
223
+ if (!text) continue;
224
+ mctx.font = `${ss.fontStyle} ${ss.fontWeight} ${ss.fontSize} ${ss.fontFamily}`;
225
+ const pad = Number.parseFloat(ss.paddingLeft) + Number.parseFloat(ss.paddingRight);
226
+ let arrow = 0;
227
+ const host = sel.parentElement;
228
+ if (host) {
229
+ const probe = sel.cloneNode(false);
230
+ for (const a of [...probe.attributes]) if (a.name !== 'class') probe.removeAttribute(a.name);
231
+ probe.append(new Option('x'));
232
+ probe.setAttribute('style', 'position:absolute;visibility:hidden;width:auto;min-width:0;max-width:none;flex:none;left:0;top:0');
233
+ host.append(probe);
234
+ const pv = getComputedStyle(probe);
235
+ arrow = Math.max(0, probe.clientWidth - Number.parseFloat(pv.paddingLeft) - Number.parseFloat(pv.paddingRight) - mctx.measureText('x').width);
236
+ probe.remove();
237
+ }
238
+ const need = mctx.measureText(text).width;
239
+ const has = sel.clientWidth - pad - arrow;
240
+ if (need > has + 1) cuts.push(`${describe(sel)} cuts "${text}" (${Math.round(need)}px of text in ${Math.round(has)}px of room, the ${Math.round(arrow)}px arrow taken out)`);
241
+ }
242
+ return cuts;
243
+ };
244
+
184
245
  /** The static layout of the current view (G5, G6 inputs, ADR-0071 and ADR-0017 measures). */
185
246
  G.facts = () => {
186
247
  const vw = window.innerWidth;
@@ -272,6 +333,8 @@ export function installLib() {
272
333
  if (!named) truncated.push(describe(el));
273
334
  }
274
335
 
336
+ const cutSelects = selectCuts();
337
+
275
338
  // Containers that cut their content: overflow hidden or clip with more content than box (S1, G5).
276
339
  const clipped = [];
277
340
  const containers = new Set([de, body, document.getElementById('root'), ...document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel, .caos-cw, [data-plot]')]);
@@ -415,6 +478,8 @@ export function installLib() {
415
478
  rail,
416
479
  truncated: truncated.slice(0, 5),
417
480
  truncatedCount: truncated.length,
481
+ cutSelects: cutSelects.slice(0, 5),
482
+ cutSelectCount: cutSelects.length,
418
483
  clipped,
419
484
  instrument,
420
485
  surfaces,
@@ -1063,6 +1128,7 @@ export function installLib() {
1063
1128
  return {
1064
1129
  focus: Boolean(shell),
1065
1130
  covered,
1131
+ cutSelects: (() => { const c = selectCuts(); return { count: c.length, list: c.slice(0, 5) }; })(),
1066
1132
  enterInside: [...document.querySelectorAll('[data-focus-shell] [data-focus-enter]')].filter(visible).length,
1067
1133
  app: Boolean(document.querySelector('[data-workbench], [data-surface]')),
1068
1134
  caseId: stage ? stage.getAttribute('data-case') : null,
package/gate/run.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // G4 deep links every route loaded directly (and with a trailing slash), 200 and rendered; artifacts
9
9
  // are JSON, not the HTML fallback; a missing asset answers 404; doc text clears a floor
10
10
  // G5 scroll and reach boxes inside the viewport, the rail and their scrollers; tall views scroll; nothing is
11
- // cut; truncated text carries its full text; every control reachable by the pointer
11
+ // cut; truncated text carries its full text; a select shows its whole selected text (0.17.0); every control reachable by the pointer
12
12
  // G6 painted area drawing surfaces have painted pixels, fill their stage, and cover at least half the
13
13
  // viewport on the App route, measured on the screenshot, never on a host box
14
14
  // G7 walk everything every route, tab, sub-tab and case, at five sizes, both themes, both languages,
@@ -269,6 +269,15 @@ export async function runGate(o) {
269
269
  fail('G18', { route: appRoute, mode: 'routes' }, `the App route ${appRoute} is not among the measured routes (${routes.join(', ') || 'none'}): with --workbench '' and --require-focus the gate measures the App as a surface and must walk it; add it to --routes or name it with --app-route`);
270
270
  }
271
271
 
272
+ // ---- G18 on a workbench App (shell 0.17.0): under --require-focus every workbench route must be among the measured routes, or no Focus would be measured
273
+ if (opt.requireFocus) {
274
+ for (const wr of workbenchRoutes) {
275
+ if (!routes.includes(wr)) {
276
+ fail('G18', { route: wr, mode: 'routes' }, `the workbench route ${wr} is not among the measured routes (${routes.join(', ') || 'none'}): with --require-focus the gate must walk the App to measure its Focus mode; add it to --routes or drop it from --workbench`);
277
+ }
278
+ }
279
+ }
280
+
272
281
  // ---- The landing (shell 0.13.0): the root opens the Introduction on its executive view; an old App link with a query string reaches the App.
273
282
  {
274
283
  const ctx = await openContext(primarySize, themes[0], langs[0]);
@@ -460,6 +469,7 @@ export async function runGate(o) {
460
469
  if (f.rail.outsideCount) fail('G5', where, `${f.rail.outsideCount} rail element(s) outside the rail: ${f.rail.outside.join('; ')}`);
461
470
  }
462
471
  if (f.truncatedCount) fail('G5', where, `${f.truncatedCount} truncated text(s) without the full text in a title: ${f.truncated.join('; ')}`);
472
+ if (f.cutSelectCount) fail('G5', where, `${f.cutSelectCount} select(s) cut the selected text (a case name cut in its box, or a display that names another case than the one the select holds, or a plain select with a text wider than its room): ${f.cutSelects.join('; ')}`);
463
473
  for (const c of f.clipped) fail('G5', where, c);
464
474
  if (f.offCentre !== null && f.offCentre > 2) fail('ADR-0017.1', where, `the prose page is off-centre by ${f.offCentre.toFixed(1)}px`);
465
475
  if (f.referenceLists) fail('ADR-0017.4', where, `${f.referenceLists} bibliography outside the end-of-page references block (a .reference-list); give each DocSection its refs and let the page list them once`);
@@ -913,6 +923,7 @@ export async function runGate(o) {
913
923
  if (big && f.share < opt.instrumentMin) fail('G18', at, `${label}: the stage covers ${(100 * f.share).toFixed(1)}% of the viewport (floor ${(100 * opt.instrumentMin).toFixed(0)}%)`);
914
924
  if (f.exitInside === false) fail('G18', at, `${label}: the exit control is outside the viewport`);
915
925
  for (const c of f.covered) fail('G18', at, `${label}: ${c.chrome} covers the control "${c.control}" (the stage must clear the Focus chrome)`);
926
+ if (f.cutSelects.count) fail('G5', at, `${label}: ${f.cutSelects.count} select(s) cut the selected text (a case name cut in its box, or a display that names another case than the one the select holds, or a plain select with a text wider than its room): ${f.cutSelects.list.join('; ')}`);
916
927
  if (f.enterInside > 0) fail('G18', at, `${label}: the Focus control shows inside Focus mode (it opens the mode the reader is already in)`);
917
928
  for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', at, m);
918
929
  for (const m of await page.evaluate(() => window.__caosGate.brokenValues())) fail('G19', at, m);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fasl-work/caos-app-shell",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",
@@ -56,7 +56,7 @@
56
56
  "check:sdd": "python scripts/check_sdd.py ."
57
57
  },
58
58
  "peerDependencies": {
59
- "katex": "^0.16.0",
59
+ "katex": "^0.16.0 || ^0.17.0 || ^0.18.0 || ^0.19.0",
60
60
  "lucide-react": ">=0.400.0",
61
61
  "react": "^18 || ^19",
62
62
  "react-dom": "^18 || ^19",
@@ -67,11 +67,10 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@playwright/test": "1.63.0",
70
- "@types/katex": "^0.16.7",
71
70
  "@types/react": "^19.0.0",
72
71
  "@types/react-dom": "^19.0.0",
73
72
  "esbuild": "0.28.1",
74
- "katex": "^0.16.11",
73
+ "katex": "0.19.0",
75
74
  "lucide-react": "1.43.0",
76
75
  "react": "^19.0.0",
77
76
  "react-dom": "^19.0.0",
@@ -198,8 +198,11 @@
198
198
  "cs-locked",
199
199
  "cs-modified",
200
200
  "cs-reset",
201
- "cs-select",
201
+ "cs-select-display",
202
+ "cs-select-native",
202
203
  "cs-select-row",
204
+ "cs-select-text",
205
+ "cs-select-wrap",
203
206
  "cs-source",
204
207
  "cs-source-label",
205
208
  "cs-source-tabs",
@@ -512,8 +515,11 @@
512
515
  "cs-locked",
513
516
  "cs-modified",
514
517
  "cs-reset",
515
- "cs-select",
518
+ "cs-select-display",
519
+ "cs-select-native",
516
520
  "cs-select-row",
521
+ "cs-select-text",
522
+ "cs-select-wrap",
517
523
  "cs-source",
518
524
  "cs-source-label",
519
525
  "cs-source-tabs",
package/styles.css CHANGED
@@ -507,11 +507,28 @@ svg.lucide { flex: none; }
507
507
  .cs-reset { border: none; background: transparent; color: var(--color-accent); font-size: 0.8rem; font-weight: 600; padding: 0.1rem 0.2rem; text-decoration: underline; text-underline-offset: 2px; }
508
508
  /* The compact one-of-N case picker (ADR-0071 rule 7): ten cases in four categories cost a 308px chip block, 21% of a
509
509
  2560x1440 viewport, and one row as a dropdown. */
510
- .cs-select { width: 100%; max-width: 460px; }
511
- /* the select and a control beside it (the workbench's Focus button): one row, the select shrinks, the control keeps its size */
510
+ /* The case select shows the selected name whole, at any length, in a display (as many lines as the longest name of the list needs, measured in the DOM by CaseSelector; no clamp, no ellipsis), the native select on top of it, transparent by colour (shell 0.17.0; BL-074). */
511
+ .cs-select-wrap { position: relative; width: 100%; max-width: 460px; min-width: 0; }
512
+ .cs-select-display { box-sizing: border-box; display: flex; align-items: center; position: relative; min-width: 0; background: var(--color-surface-2); color: var(--color-fg); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.35rem 1.7rem 0.35rem 0.5rem; font-size: 0.85rem; line-height: 1.25; }
513
+ .cs-select-text { display: block; max-width: 100%; min-width: 0; overflow-wrap: anywhere; }
514
+ .cs-select-display::after { content: ''; position: absolute; right: 0.65rem; top: 50%; width: 0.4rem; height: 0.4rem; margin-top: -0.3rem; border-right: 2px solid var(--color-fg-subtle); border-bottom: 2px solid var(--color-fg-subtle); transform: rotate(45deg); }
515
+ .cs-select-wrap > .cs-select-native { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; margin: 0; padding: 0 0.5rem; box-sizing: border-box; background: transparent; color: transparent; border-color: transparent; cursor: pointer; appearance: none; -webkit-appearance: none; }
516
+ .cs-select-native option, .cs-select-native optgroup { color: var(--color-fg); background: var(--color-surface-2); }
517
+ .cs-select-wrap > .cs-select-native:focus { outline: none; }
518
+ .cs-select-wrap:focus-within > .cs-select-display { outline: 2px solid var(--color-accent); outline-offset: 2px; }
519
+ @supports selector(:has(*)) {
520
+ .cs-select-wrap:focus-within > .cs-select-display { outline: none; }
521
+ .cs-select-wrap:has(> .cs-select-native:focus-visible) > .cs-select-display { outline: 2px solid var(--color-accent); outline-offset: 2px; }
522
+ }
523
+ /* Windows High Contrast forces a select's text back to CanvasText, which would paint its one cut line over the display's lines (the re-review of 0.17.0, B1) */
524
+ @media (forced-colors: active) {
525
+ .cs-select-wrap > .cs-select-native { forced-color-adjust: none; }
526
+ .cs-select-native option, .cs-select-native optgroup { forced-color-adjust: auto; }
527
+ }
528
+ /* the select and a control beside it (a product's accessory): one row, the select shrinks, the control keeps its size */
512
529
  .cs-select-row { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
513
- .cs-select-row > .cs-select { flex: 1 1 auto; min-width: 0; }
514
- .cs-select-row > :not(.cs-select) { flex: none; }
530
+ .cs-select-row > .cs-select-wrap { flex: 1 1 auto; min-width: 0; }
531
+ .cs-select-row > :not(.cs-select-wrap) { flex: none; }
515
532
 
516
533
  /* == 9 workbench ================================================================================================ */
517
534
 
@@ -858,3 +875,7 @@ svg.lucide { flex: none; }
858
875
  .tabs-has-slot > .tabs-slot { grid-column: 2; grid-row: 1; align-self: stretch; display: flex; align-items: center; padding-bottom: 0.4rem; margin-bottom: 0.9rem; border-bottom: 1px solid var(--color-border); }
859
876
  .tabs-has-slot > .tabs-slot > .caos-focus-enter { align-self: center; padding-block: 0.2rem; }
860
877
  .tabs-has-slot > .tabpanel { grid-column: 1 / -1; grid-row: 2; }
878
+ /* The same slot in the workbench's group tab row (shell 0.17.0, BL-074): the Focus control of the App ends the row, so the case select keeps
879
+ the rail head's whole width. The tab row's own spacing (a tighter rule than the surface's) is repeated under the slot. */
880
+ .caos-cw > .tabs.tabs-has-slot { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr); column-gap: 0.5rem; }
881
+ .caos-cw > .tabs-has-slot > .tabs-slot { margin-bottom: 0.5rem; padding-bottom: var(--space-1); }