@fasl-work/caos-app-shell 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## [0.25.000] - 2026-11-08
2
+
3
+ Two gaps that CEFOP_DinHot's full gate found on its release build (shell 0.23.2; CAOS_APP_SHELL#207, #208; known defects 61 and 62).
4
+
5
+ ### Added
6
+
7
+ - `VariantBar` `layout?: 'chips' | 'select'` (default `'chips'`, markup unchanged; known defect 61, SHW-063, issue 207). `'select'` draws the variants as one native select with the shell's display, the active variant's full label shown whole and wrapping inside the rail, and keeps the title with its count, the lane badge, the active variant's note, `data-variant-bar`, `data-variant-count`, `data-control="variant"`, the keyboard of a native select and the disabled options. A list of 14 long-named variants fits a 232 px rail at 1280 x 800 in en and es and in the wide-font pass (as chips it scrolled the rail: G5 441, ADR-0071.6 266 and G18 14 findings on DinHot).
8
+ - `SelectDisplay` (`src/lib/SelectDisplay.tsx`, internal): the display of the case select, extracted so `CaseSelector` and `VariantBar` share one component; the case select's markup and behaviour are unchanged.
9
+ - Gate plants: `variants-long-chips` (the long list as chips fails ADR-0071.6), `legit-variants-select` (the same list in `layout: 'select'` passes), `legit-chart-in-document`, `legit-chart-pointer-redispatch` and `chart-readout-scroll-move` (the document walk on a long page with a chart).
10
+
11
+ ### Fixed
12
+
13
+ - A chart's readout no longer changes when the page scrolls under a resting pointer (known defect 62, SHC-103, issue 208, ADR-0017.2). The browser re-dispatches the pointer position (a mouseover, mouseenter and mousemove at the same client coordinates) to the chart that came under it, and sends a mouseleave at the same coordinates when the scroll takes the plot away; `UPlotChart` moved its cursor and readout on both, so the gate's document walk reported "document text changed during traversal" (reproduced on the shell's own fixture, three tabs of /experiments). `UPlotChart` now changes its cursor and readout only on a real pointer move, an enter or a leave by moving, or the keyboard; the held leave is applied at the next real move of the pointer, and a touch or a pen is never judged.
14
+
15
+ ### Adoption
16
+
17
+ - A product with a long or long-named variant list sets `layout: 'select'` on its `variants` prop (`CaseWorkbench` forwards it to `VariantBar`); nothing else changes. A product with a short list keeps the chips.
18
+ - Known defect 62: no product change. A product that worked around it (a pointer moved off the document before the walk, a readout excluded from the walk) removes the workaround. A chart scrolled away from a resting pointer keeps its last reading until the pointer next moves, leaves by moving, or the keyboard acts.
19
+
1
20
  ## [0.24.000] - 2026-11-08
2
21
 
3
22
  Four defects that an ADR-0082 review of CAOS_ChancaDEM on 0.23.2 found (CAOS_MANAGE `wip/chancadem/viz-review-2026-11-07.md`, findings M11, M1, M3, m4).
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.24.000
1
+ 0.25.000
package/dist/chart.js CHANGED
@@ -5,6 +5,46 @@ import { useRef, useCallback, useState, useId, useEffect, useMemo } from 'react'
5
5
  import uPlot from 'uplot';
6
6
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
7
7
 
8
+ // src/chart/pointer.ts
9
+ var SCROLL_WINDOW_MS = 300;
10
+ var newTrack = () => ({ x: Number.NaN, y: Number.NaN, scrolledAt: -Infinity, type: "mouse" });
11
+ function scrollSynthesised(t, e, now) {
12
+ if (t.type !== "mouse") return false;
13
+ if (!Number.isNaN(t.x)) return e.clientX === t.x && e.clientY === t.y;
14
+ return now - t.scrolledAt < SCROLL_WINDOW_MS && e.movementX === 0 && e.movementY === 0;
15
+ }
16
+ var track = null;
17
+ var realMoveListeners = /* @__PURE__ */ new Set();
18
+ function onRealMove(fn) {
19
+ realMoveListeners.add(fn);
20
+ return () => void realMoveListeners.delete(fn);
21
+ }
22
+ function pointerTrack() {
23
+ if (track) return track;
24
+ const t = newTrack();
25
+ track = t;
26
+ if (typeof window === "undefined") return t;
27
+ window.addEventListener("scroll", () => {
28
+ t.scrolledAt = performance.now();
29
+ }, { capture: true, passive: true });
30
+ const type = (e) => {
31
+ t.type = e.pointerType || "mouse";
32
+ };
33
+ window.addEventListener("pointerdown", type, { capture: true, passive: true });
34
+ window.addEventListener("pointermove", type, { capture: true, passive: true });
35
+ window.addEventListener(
36
+ "mousemove",
37
+ (e) => {
38
+ if (e.clientX === t.x && e.clientY === t.y) return;
39
+ t.x = e.clientX;
40
+ t.y = e.clientY;
41
+ for (const fn of [...realMoveListeners]) fn();
42
+ },
43
+ { passive: true }
44
+ );
45
+ return t;
46
+ }
47
+
8
48
  // src/chart/uplotZoom.ts
9
49
  var rangeOf = (u, key) => [u.scales[key].min ?? 0, u.scales[key].max ?? 1];
10
50
  function createZoom(u, ctx) {
@@ -506,6 +546,7 @@ function nearestPoint(xs, ys, left, top) {
506
546
  }
507
547
  function UPlotChart({ x, y, series, marks, yMarks, onYMarkChange, height = 280, onCursor, onPick, parity, readout, hint, solo, title, zoomY, zoomOff }) {
508
548
  const lang = useShellLang();
549
+ pointerTrack();
509
550
  const theme = useThemeStore((s) => s.theme);
510
551
  const fill = height === "fill";
511
552
  const [measureRef, box] = useStageSize();
@@ -767,6 +808,27 @@ function UPlotChart({ x, y, series, marks, yMarks, onYMarkChange, height = 280,
767
808
  ],
768
809
  ready: [
769
810
  (u2) => {
811
+ const track2 = pointerTrack();
812
+ let heldLeave = false;
813
+ let replay = false;
814
+ const real = (e) => {
815
+ if (replay) return;
816
+ if (scrollSynthesised(track2, e, performance.now())) {
817
+ if (e.type === "mouseleave") heldLeave = true;
818
+ e.stopImmediatePropagation();
819
+ } else if (e.type !== "mouseleave") heldLeave = false;
820
+ };
821
+ u2.root.addEventListener("mousemove", real, true);
822
+ u2.root.addEventListener("mouseenter", real, true);
823
+ u2.root.addEventListener("mouseleave", real, true);
824
+ const stopHeld = onRealMove(() => {
825
+ if (!heldLeave) return;
826
+ heldLeave = false;
827
+ replay = true;
828
+ u2.over.dispatchEvent(new MouseEvent("mouseleave", { clientX: track2.x, clientY: track2.y }));
829
+ replay = false;
830
+ });
831
+ u2.hooks.destroy = [...u2.hooks.destroy ?? [], stopHeld];
770
832
  u2.over.addEventListener("click", () => {
771
833
  const idx = pickedRef.current;
772
834
  if (idx === null || idx === void 0 || !onPickRef.current) return;
package/dist/index.d.ts CHANGED
@@ -1200,10 +1200,15 @@ interface VariantBarProps {
1200
1200
  onSelect: (id: string) => void;
1201
1201
  title?: BiText;
1202
1202
  lane?: Lane;
1203
+ /** How the one-of-N choice is drawn. 'chips' (the default) is one scrollable row of chips; 'select' is one native select with the shell's display
1204
+ * (the case select's: the active variant's full label shown whole, wrapping inside the rail), for a long or long-named list (ADR-0071 rule 7: 14
1205
+ * methods named by their names drew a 605 px column of chips in a 713 px rail). The title with its count, the lane badge, the active variant's
1206
+ * note, `data-variant-bar`, `data-variant-count` and `data-control="variant"` are the same in both. */
1207
+ layout?: 'chips' | 'select';
1203
1208
  }
1204
- /** The regimes of the selected case on ONE scrollable row (ADR-0071 rule 4), the active regime's lane, and the
1205
- * one-line note of what it shows. Selecting a regime must change every view of the case. */
1206
- declare function VariantBar({ variants, activeId, onSelect, title, lane }: VariantBarProps): react.JSX.Element;
1209
+ /** The regimes of the selected case on ONE scrollable row (ADR-0071 rule 4) or, with `layout: 'select'`, in one select (rule 7), the active
1210
+ * regime's lane, and the one-line note of what it shows. Selecting a regime must change every view of the case. */
1211
+ declare function VariantBar({ variants, activeId, onSelect, title, lane, layout }: VariantBarProps): react.JSX.Element;
1207
1212
 
1208
1213
  /** One group of views of the selected case, named for the question the user is asking (ADR-0071 rule 5). */
1209
1214
  interface WorkbenchGroup {
package/dist/index.js CHANGED
@@ -702,6 +702,53 @@ function usePausedViz(frame, options = {}) {
702
702
  const restart = useCallback(() => loopRef.current?.restart(), []);
703
703
  return { playing, play, pause, toggle, restart };
704
704
  }
705
+ function SelectDisplay({ names, selectedName, children }) {
706
+ const wrapRef = useRef(null);
707
+ const [minHeight, setMinHeight] = useState(null);
708
+ const nameKey = names.join("");
709
+ useLayoutEffect(() => {
710
+ const wrap = wrapRef.current;
711
+ const display = wrap?.querySelector(".cs-select-display");
712
+ if (!wrap || !display) return void 0;
713
+ const all = nameKey ? nameKey.split("") : [];
714
+ const measure = () => {
715
+ const st = getComputedStyle(display);
716
+ const room = display.clientWidth - parseFloat(st.paddingLeft) - parseFloat(st.paddingRight);
717
+ if (!(room > 0)) return;
718
+ const copy = document.createElement("span");
719
+ copy.className = "cs-select-text";
720
+ copy.setAttribute("aria-hidden", "true");
721
+ 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`);
722
+ display.append(copy);
723
+ let tallest = 0;
724
+ for (const n of all) {
725
+ copy.textContent = n;
726
+ tallest = Math.max(tallest, copy.getBoundingClientRect().height);
727
+ }
728
+ const line = parseFloat(getComputedStyle(copy).lineHeight) || parseFloat(st.fontSize) * 1.25;
729
+ copy.remove();
730
+ const frame = parseFloat(st.paddingTop) + parseFloat(st.paddingBottom) + parseFloat(st.borderTopWidth) + parseFloat(st.borderBottomWidth);
731
+ const next = tallest > line * 1.5 ? Math.ceil((tallest + frame) * 100) / 100 : null;
732
+ setMinHeight((prev) => prev === next ? prev : next);
733
+ };
734
+ measure();
735
+ let width = wrap.getBoundingClientRect().width;
736
+ const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
737
+ const w = wrap.getBoundingClientRect().width;
738
+ if (w !== width) {
739
+ width = w;
740
+ measure();
741
+ }
742
+ });
743
+ ro?.observe(wrap);
744
+ void document.fonts?.ready.then(measure);
745
+ return () => ro?.disconnect();
746
+ }, [nameKey]);
747
+ return /* @__PURE__ */ jsxs("div", { className: "cs-select-wrap", ref: wrapRef, children: [
748
+ /* @__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 }) }),
749
+ children
750
+ ] });
751
+ }
705
752
 
706
753
  // src/case/caseModel.ts
707
754
  var caseKind = (c) => c.kind ?? "synthetic";
@@ -798,47 +845,6 @@ function CaseSelector(props) {
798
845
  const visible = casesInSource(cases, source);
799
846
  const groups = groupByCategory(visible);
800
847
  const modified = cases.find((c) => c.id === modifiedFromId) ?? null;
801
- const wrapRef = useRef(null);
802
- const [minHeight, setMinHeight] = useState(null);
803
- const nameKey = visible.map((c) => c.name).join("");
804
- useLayoutEffect(() => {
805
- const wrap = wrapRef.current;
806
- const display = wrap?.querySelector(".cs-select-display");
807
- if (!wrap || !display) return void 0;
808
- const names = nameKey ? nameKey.split("") : [];
809
- const measure = () => {
810
- const st = getComputedStyle(display);
811
- const room = display.clientWidth - parseFloat(st.paddingLeft) - parseFloat(st.paddingRight);
812
- if (!(room > 0)) return;
813
- const copy = document.createElement("span");
814
- copy.className = "cs-select-text";
815
- copy.setAttribute("aria-hidden", "true");
816
- 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`);
817
- display.append(copy);
818
- let tallest = 0;
819
- for (const n of names) {
820
- copy.textContent = n;
821
- tallest = Math.max(tallest, copy.getBoundingClientRect().height);
822
- }
823
- const line = parseFloat(getComputedStyle(copy).lineHeight) || parseFloat(st.fontSize) * 1.25;
824
- copy.remove();
825
- const frame = parseFloat(st.paddingTop) + parseFloat(st.paddingBottom) + parseFloat(st.borderTopWidth) + parseFloat(st.borderBottomWidth);
826
- const next = tallest > line * 1.5 ? Math.ceil((tallest + frame) * 100) / 100 : null;
827
- setMinHeight((prev) => prev === next ? prev : next);
828
- };
829
- measure();
830
- let width = wrap.getBoundingClientRect().width;
831
- const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
832
- const w = wrap.getBoundingClientRect().width;
833
- if (w !== width) {
834
- width = w;
835
- measure();
836
- }
837
- });
838
- ro?.observe(wrap);
839
- void document.fonts?.ready.then(measure);
840
- return () => ro?.disconnect();
841
- }, [layout, nameKey]);
842
848
  const options = groups.flatMap((g) => g.cases);
843
849
  const selectedName = (options.find((c) => c.id === selectedId) ?? options.find((c) => !c.disabled))?.name ?? "";
844
850
  const select = /* @__PURE__ */ jsx(
@@ -852,10 +858,7 @@ function CaseSelector(props) {
852
858
  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 || "_"))
853
859
  }
854
860
  );
855
- const selectBox = /* @__PURE__ */ jsxs("div", { className: "cs-select-wrap", ref: wrapRef, children: [
856
- /* @__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 }) }),
857
- select
858
- ] });
861
+ const selectBox = /* @__PURE__ */ jsx(SelectDisplay, { names: visible.map((c) => c.name), selectedName, children: select });
859
862
  return /* @__PURE__ */ jsxs("div", { className: `cs${className ? ` ${className}` : ""}`, role: "group", "aria-label": ariaLabel ?? "Case selector", children: [
860
863
  source && lanes.length > 1 && /* @__PURE__ */ jsxs("div", { className: "cs-source", children: [
861
864
  /* @__PURE__ */ jsx("span", { className: "cs-source-label", children: t2.source }),
@@ -3824,23 +3827,24 @@ function LaneBadge({ lane, provenance, label }) {
3824
3827
  provenance && /* @__PURE__ */ jsx("span", { className: `badge caos-prov caos-prov-${provenance}`, "data-provenance": provenance, children: pick(PROVENANCE_LABELS[provenance], lang) })
3825
3828
  ] });
3826
3829
  }
3827
- function VariantBar({ variants, activeId, onSelect, title, lane }) {
3830
+ function VariantBar({ variants, activeId, onSelect, title, lane, layout = "chips" }) {
3828
3831
  const lang = useShellLang();
3829
3832
  const active = variants.find((v) => v.id === activeId) ?? variants[0];
3830
3833
  const shownLane = active?.lane ?? lane;
3831
3834
  const rowRef = useRef(null);
3832
3835
  useOverflowFade(rowRef, ".chip.on", [active?.id, variants.length, lang]);
3836
+ const name = pick(title ?? { en: "Variants", es: "Variantes" }, lang);
3833
3837
  return /* @__PURE__ */ jsxs("div", { className: "caos-variant-bar", "data-variant-bar": "", "data-variant-count": variants.length, children: [
3834
3838
  /* @__PURE__ */ jsxs("div", { className: "caos-variant-head", children: [
3835
3839
  /* @__PURE__ */ jsxs("span", { className: "caos-variant-title", children: [
3836
- pick(title ?? { en: "Variants", es: "Variantes" }, lang),
3840
+ name,
3837
3841
  " (",
3838
3842
  variants.length,
3839
3843
  ")"
3840
3844
  ] }),
3841
3845
  shownLane && /* @__PURE__ */ jsx(LaneBadge, { lane: shownLane })
3842
3846
  ] }),
3843
- /* @__PURE__ */ jsx("div", { className: "caos-chip-row", role: "radiogroup", "aria-label": pick(title ?? { en: "Variants", es: "Variantes" }, lang), "data-control": "variant", ref: rowRef, children: variants.map((v) => /* @__PURE__ */ jsx(
3847
+ layout === "select" ? /* @__PURE__ */ jsx(SelectDisplay, { names: variants.map((v) => pick(v.label, lang)), selectedName: active ? pick(active.label, lang) : "", children: /* @__PURE__ */ jsx("select", { "data-control": "variant", className: "select cs-select cs-select-native", "aria-label": name, value: active?.id ?? "", onChange: (e) => onSelect(e.target.value), children: variants.map((v) => /* @__PURE__ */ jsx("option", { value: v.id, disabled: v.disabled, children: pick(v.label, lang) }, v.id)) }) }) : /* @__PURE__ */ jsx("div", { className: "caos-chip-row", role: "radiogroup", "aria-label": name, "data-control": "variant", ref: rowRef, children: variants.map((v) => /* @__PURE__ */ jsx(
3844
3848
  "button",
3845
3849
  {
3846
3850
  type: "button",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fasl-work/caos-app-shell",
3
- "version": "0.24.0",
3
+ "version": "0.25.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",