@fasl-work/caos-app-shell 0.6.13 → 0.7.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.
@@ -0,0 +1,135 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { B as BiText } from './text-BlY47nIt.js';
4
+
5
+ interface ArchTab {
6
+ id: string;
7
+ /** tab label. */
8
+ en: string;
9
+ es: string;
10
+ /** the explanation body (paragraphs separated by a blank line). */
11
+ body_en: string;
12
+ body_es: string;
13
+ /**
14
+ * an inline '<svg…>…</svg>' string (import it with `?raw`); a URL is refused, because an `<img>` or a fetched
15
+ * file cannot read the page's theme tokens and a path goes stale (S11).
16
+ *
17
+ * ONE file carries BOTH languages (ADR-0058). Tag each translatable `<text>` twice at the same
18
+ * coordinates, `class="… l-en"` and `class="… l-es"`; the modal wrapper sets `data-arch-lang`
19
+ * and the shell stylesheet shows exactly one. Two files would be two things to keep in step, and
20
+ * the one that is not on screen is the one that goes stale. Anything language-neutral (a number,
21
+ * a file name, an identifier) needs no pair.
22
+ */
23
+ svg: string;
24
+ }
25
+ interface ArchitectureConfig {
26
+ /** modal title override (defaults to "Architecture / How it works"). */
27
+ title_en?: string;
28
+ title_es?: string;
29
+ tabs: ArchTab[];
30
+ }
31
+ /**
32
+ * Problems with an architecture configuration, one string each (ADR-0058, ADR-0078). The diagram must be an INLINE
33
+ * SVG string (a fetched or `<img>` file cannot be checked against the theme tokens before it is shown, failure class
34
+ * 14); every `var(--x)` it names must be a shell token (an undefined token renders as nothing, silently, failure
35
+ * class 25); a hex colour does not follow the theme; both languages are required; at least five tabs.
36
+ */
37
+ declare function validateArchitectureConfig(config: ArchitectureConfig): string[];
38
+ declare function ArchitectureModal({ config, onClose }: {
39
+ config: ArchitectureConfig;
40
+ onClose: () => void;
41
+ }): react.JSX.Element | null;
42
+
43
+ interface ShellRoute {
44
+ path: string;
45
+ en: string;
46
+ es: string;
47
+ }
48
+ interface ShellConfig {
49
+ /** Brand: product name + optional lucide icon element (defaults to a generic mark). */
50
+ product: {
51
+ name: string;
52
+ mark?: ReactNode;
53
+ };
54
+ /** Top-level routes for the header nav (use `STANDARD_ROUTES`). 0 or 1 entries hide the nav (a hub). */
55
+ routes?: ShellRoute[];
56
+ /** External links. personal/portfolio default to Felipe's canonical URLs (kept identical app-to-app). */
57
+ links: {
58
+ github: string;
59
+ personal?: string;
60
+ portfolio?: string;
61
+ };
62
+ /** Display version `X.XX.XXX`, read from the repo's VERSION (ADR-0068); a semver form is reported. */
63
+ version: string;
64
+ /** Short commit SHA of the build, printed after the version so the deployed build is identifiable. */
65
+ build?: string;
66
+ /** The product's licence as the footer shows it. Required: the shell's own licence is not inherited, and a
67
+ * default "MIT · open source" once appeared on private products (failure class 16). */
68
+ license: BiText;
69
+ /** Repository visibility. A private product shows no source link in the header or the footer. */
70
+ visibility: 'public' | 'private';
71
+ /** In-app Architecture / "How it works" modal (ADR-0058). When present, an info button appears in the header. */
72
+ architecture?: ArchitectureConfig;
73
+ /** Footer provenance + honesty (ADR-0016 s2): the real data/engine source with citation + licence, and the
74
+ * one-line honest disclaimer of how the app runs. */
75
+ footer?: {
76
+ /** Explicit product attribution, or false when the product excludes personal attribution. */
77
+ attribution?: {
78
+ en: string;
79
+ es: string;
80
+ } | false;
81
+ /** @deprecated use `ShellConfig.license`. */
82
+ license?: {
83
+ en: string;
84
+ es: string;
85
+ };
86
+ provenance?: {
87
+ en: string;
88
+ es: string;
89
+ };
90
+ disclaimer?: {
91
+ en: string;
92
+ es: string;
93
+ };
94
+ };
95
+ /** Routes whose surface IS the viewport (ADR-0071 rule 1), matched exactly or by prefix for a non-root path. */
96
+ fixedRoutes?: string[];
97
+ /** Every route fixed: a single-surface app (a hub, a one-page tool). */
98
+ fixed?: boolean;
99
+ /**
100
+ * Every route is the viewport (ADR-0071 rule 1, as amended 2026-10-04): the workbench (`WorkbenchLayout`) fills
101
+ * it, and a documentation route scrolls INSIDE the main container, so the header and footer stay in view and the
102
+ * document itself never scrolls. The configuration the product template uses.
103
+ */
104
+ contain?: boolean;
105
+ }
106
+ /** The shared CAOS/Faena app shell: sticky header (brand + nav + icon-links + lang/theme) + footer.
107
+ * Wrap your <Routes> (or single landing) in it, inside a Router. */
108
+ declare function AppShell({ config, children }: {
109
+ config: ShellConfig;
110
+ children: ReactNode;
111
+ }): react.JSX.Element;
112
+
113
+ /**
114
+ * The six routes every CAOS product carries, in this order (ADR-0016 s3, ADR-0069 s7, the product quality bar):
115
+ * the App (the tool, landing) and the five documentation routes. One definition, so the header, the router and the
116
+ * gate read the same list and no product retypes it.
117
+ */
118
+ declare const STANDARD_ROUTES: ShellRoute[];
119
+
120
+ /**
121
+ * The storage keys the shell reads and writes. Exported (and built as their own entry, `dist/keys.js`) so the
122
+ * measured gate sets the theme and the language exactly the way the shell reads them, instead of guessing the
123
+ * key names (failure class 10 of the 2026-10-04 history: gates that measured the wrong mode).
124
+ */
125
+ declare const THEME_STORAGE_KEY = "caos.theme";
126
+ declare const LANG_STORAGE_KEY = "caos.lang";
127
+ /**
128
+ * The pre-paint script for index.html (inline, before the bundle): applies the stored or preferred theme and the
129
+ * stored language before the first frame, so a light-theme user never sees a dark flash and the document language
130
+ * is right from the start (ADR-0011, ADR-0012). Built from the keys above, so the two cannot drift; exported from
131
+ * this React-free entry so a build config can inline it.
132
+ */
133
+ declare const THEME_BOOT_SCRIPT: string;
134
+
135
+ export { AppShell as A, LANG_STORAGE_KEY as L, STANDARD_ROUTES as S, THEME_BOOT_SCRIPT as T, type ArchTab as a, type ArchitectureConfig as b, ArchitectureModal as c, type ShellConfig as d, type ShellRoute as e, THEME_STORAGE_KEY as f, validateArchitectureConfig as v };
package/dist/keys.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { L as LANG_STORAGE_KEY, S as STANDARD_ROUTES, T as THEME_BOOT_SCRIPT, f as THEME_STORAGE_KEY } from './keys-CnvqkckF.js';
2
+ import 'react';
3
+ import './text-BlY47nIt.js';
4
+ import 'zustand';
package/dist/keys.js ADDED
@@ -0,0 +1 @@
1
+ export { LANG_STORAGE_KEY, STANDARD_ROUTES, THEME_BOOT_SCRIPT, THEME_STORAGE_KEY } from './chunk-X6Y5CDUD.js';
@@ -0,0 +1,21 @@
1
+ import * as zustand from 'zustand';
2
+
3
+ type Lang = 'en' | 'es';
4
+ interface LangState {
5
+ lang: Lang;
6
+ setLang: (l: Lang) => void;
7
+ toggle: () => void;
8
+ }
9
+ declare const useLangStore: zustand.UseBoundStore<zustand.StoreApi<LangState>>;
10
+ /** Current language, for app pages that render language-branched long-form content. */
11
+ declare const useShellLang: () => Lang;
12
+
13
+ /** A string shown to users: one language-neutral string (a number, a code, a name) or both languages. */
14
+ type BiText = string | {
15
+ en: string;
16
+ es: string;
17
+ };
18
+ /** The text for the current language. English is the canonical fallback (ADR-0011). */
19
+ declare function pick(text: BiText | undefined, lang: Lang): string;
20
+
21
+ export { type BiText as B, type Lang as L, useShellLang as a, pick as p, useLangStore as u };
@@ -0,0 +1,40 @@
1
+ import { L as Lang } from './text-BlY47nIt.js';
2
+
3
+ interface FormatOptions {
4
+ /** Significant digits for a value below 10^(digits-1) (default 4); a larger value shows every integer digit, so a
5
+ * count is never rounded (27345 people, not 27,350). Ignored when `decimals` is set. */
6
+ digits?: number;
7
+ /** Fixed number of decimals. */
8
+ decimals?: number;
9
+ /** Render a fraction as a percentage (0.123 -> 12.3 %). */
10
+ percent?: boolean;
11
+ }
12
+ /** "Not available" in the interface language: what an absent or non-finite value renders as. */
13
+ declare const NOT_AVAILABLE: Record<Lang, string>;
14
+ /**
15
+ * A number in the interface language: a decimal point in English and a decimal comma in Spanish
16
+ * (`conventions/languages.md`), significant digits by default. `null`, `undefined` and non-finite values render as
17
+ * "not available", never as `NaN` (failure classes 5 and 23 of the 2026-10-04 history).
18
+ */
19
+ declare function formatNumber(value: number | null | undefined, lang: Lang, opts?: FormatOptions): string;
20
+ /** `formatNumber` bound to the current shell language. */
21
+ declare function useFormat(): (value: number | null | undefined, opts?: FormatOptions) => string;
22
+
23
+ /**
24
+ * The colour and font tokens `styles.css` defines for both themes. The architecture-modal validation and the
25
+ * template's web-baseline guard check every `var(--x)` a product names against this list; a token that is not
26
+ * defined renders as nothing, silently (failure class 25 of the 2026-10-04 history). A unit test keeps this list
27
+ * equal to the stylesheet.
28
+ */
29
+ declare const SHELL_TOKENS: readonly ["--color-bg", "--color-surface", "--color-surface-2", "--color-border", "--color-fg", "--color-fg-subtle", "--color-fg-faint", "--color-accent", "--color-accent-fg", "--color-accent-soft", "--color-accent-2", "--color-magenta", "--color-good", "--color-warn", "--color-bad", "--color-shadow", "--font-sans", "--font-mono", "--maxw", "--maxw-wide"];
30
+ type ShellToken = (typeof SHELL_TOKENS)[number];
31
+ /** The computed value of a token on the document, or `fallback` outside a browser. */
32
+ declare function resolveToken(token: string, fallback?: string): string;
33
+ /**
34
+ * The colour tokens resolved to concrete values for the active theme, recomputed when the theme changes. Canvas
35
+ * and WebGL renderers cannot read CSS variables, so they take these (failure classes 7 and 18: charts drawn in
36
+ * dark-theme colours on a light page).
37
+ */
38
+ declare function useThemeTokens(): Record<ShellToken, string>;
39
+
40
+ export { type FormatOptions as F, NOT_AVAILABLE as N, type ShellToken as S, SHELL_TOKENS as a, useThemeTokens as b, formatNumber as f, resolveToken as r, useFormat as u };
@@ -0,0 +1,454 @@
1
+ // The gate's in-page library. `installLib` is serialised by Playwright into an init script, so it must be
2
+ // self-contained: everything it uses is defined inside it. It installs `window.__caosGate` before the product's own
3
+ // scripts run, wraps requestAnimationFrame to count frames during an idle window (G8), and exposes the measurements
4
+ // the runner decides on. Each measurement answers one question about what a reader would see, on the element a
5
+ // reader would see it on (never `#root`, never a host box standing in for a drawing).
6
+
7
+ export function installLib() {
8
+ const G = (window.__caosGate = window.__caosGate || {});
9
+ G.raf = 0;
10
+ G.counting = false;
11
+ const nativeRaf = window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : null;
12
+ if (nativeRaf) {
13
+ window.requestAnimationFrame = (cb) =>
14
+ nativeRaf((t) => {
15
+ if (G.counting) G.raf += 1;
16
+ cb(t);
17
+ });
18
+ }
19
+
20
+ const visible = (el) => {
21
+ if (!el || el.nodeType !== 1) return false;
22
+ const r = el.getBoundingClientRect();
23
+ if (r.width <= 0 || r.height <= 0) return false;
24
+ if (el.checkVisibility) return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true });
25
+ const s = getComputedStyle(el);
26
+ return s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) !== 0;
27
+ };
28
+ const shown = (el) => el.getClientRects().length > 0 && (el.checkVisibility ? el.checkVisibility({ checkVisibilityCSS: true }) : true);
29
+ const path = (el) => {
30
+ const parts = [];
31
+ let e = el;
32
+ while (e && e.nodeType === 1 && e !== document.body && e !== document.documentElement) {
33
+ const p = e.parentElement;
34
+ if (!p) break;
35
+ parts.unshift(`${e.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(p.children, e) + 1})`);
36
+ e = p;
37
+ }
38
+ return parts.length ? `body > ${parts.join(' > ')}` : 'body';
39
+ };
40
+ const describe = (el) => {
41
+ if (!el || el.nodeType !== 1) return String(el);
42
+ const t = el.tagName.toLowerCase();
43
+ const cls = typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 3).join('.')}` : '';
44
+ const attrs = ['data-control', 'data-tab', 'data-panel', 'data-plot', 'data-stage', 'data-case', 'data-readout', 'aria-label']
45
+ .filter((a) => el.hasAttribute(a))
46
+ .map((a) => `[${a}="${String(el.getAttribute(a)).slice(0, 30)}"]`)
47
+ .join('');
48
+ const text = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 32);
49
+ return `${t}${el.id ? `#${el.id}` : ''}${cls}${attrs}${text ? ` "${text}"` : ''}`;
50
+ };
51
+ G.describe = describe;
52
+
53
+ /** '' when the page has settled on its declared state; otherwise why not (G7: never networkidle, never a sleep). */
54
+ G.ready = () => {
55
+ if (!document.querySelector('[data-brand]')) return 'no [data-brand]: the shell has not rendered';
56
+ const loading = [...document.querySelectorAll('[data-state="loading"]')].filter(shown);
57
+ if (loading.length) return `${loading.length} element(s) still declare data-state="loading": ${loading.slice(0, 3).map(describe).join('; ')}`;
58
+ const stale = [...document.querySelectorAll('[data-stale="1"]')].filter(shown);
59
+ if (stale.length) return `${stale.length} view(s) still show an earlier selection (data-stale="1"): ${stale.slice(0, 3).map(describe).join('; ')}`;
60
+ const undrawn = [...document.querySelectorAll('[data-drawn="0"]')].filter(shown);
61
+ if (undrawn.length) return `${undrawn.length} stage(s) never got a size (data-drawn="0"): ${undrawn.slice(0, 3).map(describe).join('; ')}`;
62
+ return '';
63
+ };
64
+
65
+ /** What the page says it is and which mode it is in (G1, G2). */
66
+ G.identity = () => {
67
+ const b = document.querySelector('[data-brand]');
68
+ const active = document.querySelector('header nav a.active, header nav a[aria-current="page"]');
69
+ return {
70
+ brand: b ? b.getAttribute('data-brand') : null,
71
+ title: document.title,
72
+ theme: document.documentElement.dataset.theme ?? null,
73
+ lang: document.documentElement.lang || null,
74
+ activeHref: active ? active.href : null,
75
+ };
76
+ };
77
+
78
+ /** The static layout of the current view (G5, G6 inputs, ADR-0071 and ADR-0017 measures). */
79
+ G.facts = () => {
80
+ const vw = window.innerWidth;
81
+ const vh = window.innerHeight;
82
+ const de = document.documentElement;
83
+ const body = document.body;
84
+ const docH = Math.max(de.scrollHeight, body.scrollHeight);
85
+ const docW = Math.max(de.scrollWidth, body.scrollWidth);
86
+
87
+ // Element boxes beyond the viewport, not clipped by an ancestor (scrollWidth alone misses clipped documents).
88
+ const beyond = [];
89
+ const walk = (el, clipped) => {
90
+ for (const c of el.children) {
91
+ const s = getComputedStyle(c);
92
+ if (s.display === 'none') continue;
93
+ const r = c.getBoundingClientRect();
94
+ if (!clipped && r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && (r.right > vw + 1 || r.left < -1)) {
95
+ beyond.push(`${describe(c)} spans ${Math.round(r.left)}..${Math.round(r.right)} of a ${vw}px viewport`);
96
+ continue;
97
+ }
98
+ if (c.tagName.toLowerCase() === 'svg') continue;
99
+ walk(c, clipped || s.overflowX !== 'visible');
100
+ }
101
+ };
102
+ walk(body, false);
103
+
104
+ // Tab rows: one row each (ADR-0071 rule 4); tops clustered so a 1px active-tab offset is not a second row.
105
+ const rows = [...document.querySelectorAll('[role="tablist"]')]
106
+ .filter((tl) => visible(tl) && tl.getAttribute('aria-orientation') !== 'vertical')
107
+ .map((tl) => {
108
+ const tops = [...tl.querySelectorAll('[role="tab"]')]
109
+ .filter((t) => t.closest('[role="tablist"]') === tl && visible(t))
110
+ .map((t) => t.getBoundingClientRect().top)
111
+ .sort((a, b) => a - b);
112
+ let n = tops.length ? 1 : 0;
113
+ for (let i = 1; i < tops.length; i += 1) if (tops[i] - tops[i - 1] > 8) n += 1;
114
+ return { name: tl.getAttribute('aria-label') || describe(tl), rows: n };
115
+ });
116
+
117
+ // The rail: no scroll (rule 6) and every descendant inside its box (G5).
118
+ const railEl = document.querySelector('[data-rail]');
119
+ let rail = null;
120
+ if (railEl && visible(railEl)) {
121
+ const rr = railEl.getBoundingClientRect();
122
+ const outside = [];
123
+ for (const d of railEl.querySelectorAll('*')) {
124
+ if (d.closest('svg') && d.tagName.toLowerCase() !== 'svg') continue;
125
+ if (!visible(d)) continue;
126
+ const r = d.getBoundingClientRect();
127
+ if (r.width <= 1 || r.height <= 1) continue;
128
+ let p = d.parentElement;
129
+ let scroller = false;
130
+ while (p && p !== railEl) {
131
+ if (getComputedStyle(p).overflowX !== 'visible') {
132
+ scroller = true;
133
+ break;
134
+ }
135
+ p = p.parentElement;
136
+ }
137
+ if (scroller) continue;
138
+ if (r.right > rr.right + 1 || r.left < rr.left - 1) outside.push(`${describe(d)} spans ${Math.round(r.left)}..${Math.round(r.right)}, rail ${Math.round(rr.left)}..${Math.round(rr.right)}`);
139
+ }
140
+ rail = { scroll: railEl.scrollHeight, client: railEl.clientHeight, outside: outside.slice(0, 5), outsideCount: outside.length };
141
+ }
142
+
143
+ // Truncated text must carry its full text (G5).
144
+ const truncated = [];
145
+ for (const el of document.querySelectorAll('body *')) {
146
+ if (el.namespaceURI === 'http://www.w3.org/2000/svg') continue;
147
+ const s = getComputedStyle(el);
148
+ if (s.textOverflow !== 'ellipsis' || el.scrollWidth <= el.clientWidth + 1 || !visible(el)) continue;
149
+ // The full text must be readable somewhere: a title (or accessible name) on the element or its two nearest
150
+ // ancestors that CONTAINS it. Any label nearby is not enough: the rail's own aria-label would excuse everything.
151
+ const full = (el.textContent || '').trim().replace(/s+/g, ' ');
152
+ let named = false;
153
+ for (let i = 0, t = el; i < 3 && t; i += 1, t = t.parentElement) {
154
+ const label = `${t.getAttribute('title') || ''} ${t.getAttribute('aria-label') || ''}`.replace(/s+/g, ' ');
155
+ if (full && label.includes(full)) {
156
+ named = true;
157
+ break;
158
+ }
159
+ }
160
+ if (!named) truncated.push(describe(el));
161
+ }
162
+
163
+ // Containers that cut their content: overflow hidden or clip with more content than box (S1, G5).
164
+ const clipped = [];
165
+ const containers = new Set([de, body, document.getElementById('root'), ...document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel, .caos-cw, [data-plot]')]);
166
+ for (const el of containers) {
167
+ if (!el || !visible(el)) continue;
168
+ const s = getComputedStyle(el);
169
+ if ((s.overflowY === 'hidden' || s.overflowY === 'clip') && el.scrollHeight > el.clientHeight + 4) {
170
+ clipped.push(`${describe(el)} cuts ${el.scrollHeight - el.clientHeight}px of content (overflow-y ${s.overflowY})`);
171
+ }
172
+ if ((s.overflowX === 'hidden' || s.overflowX === 'clip') && el.scrollWidth > el.clientWidth + 4) {
173
+ clipped.push(`${describe(el)} cuts ${el.scrollWidth - el.clientWidth}px of content horizontally (overflow-x ${s.overflowX})`);
174
+ }
175
+ }
176
+
177
+ // Layout containers that scroll sideways: a wide child pushed the page, the instrument or a panel past its width.
178
+ // Rows built to scroll (the nav, tab rows, a plot body holding a wide table) are not layout containers.
179
+ for (const el of new Set([...document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel, .subtabpanel, .caos-cw')])) {
180
+ if (!visible(el)) continue;
181
+ const s = getComputedStyle(el);
182
+ if ((s.overflowX === 'auto' || s.overflowX === 'scroll') && el.scrollWidth > el.clientWidth + 4) {
183
+ clipped.push(`${describe(el)} scrolls sideways by ${el.scrollWidth - el.clientWidth}px (a child is wider than the layout)`);
184
+ }
185
+ }
186
+
187
+ // The instrument and what is drawn in it (G6): canvases, svg drawings, images, tables; icons excluded.
188
+ const inst = document.querySelector('[data-instrument]');
189
+ let instrument = null;
190
+ const surfaces = [];
191
+ let prose = false;
192
+ if (inst && visible(inst)) {
193
+ const ir = inst.getBoundingClientRect();
194
+ const cx0 = Math.max(0, ir.left);
195
+ const cy0 = Math.max(0, ir.top);
196
+ const cx1 = Math.min(vw, ir.right);
197
+ const cy1 = Math.min(vh, ir.bottom);
198
+ instrument = { x: cx0, y: cy0, w: Math.max(0, cx1 - cx0), h: Math.max(0, cy1 - cy0) };
199
+ const ctx = inst.querySelector('.caos-cw-context');
200
+ prose = Boolean(ctx && visible(ctx));
201
+ for (const el of inst.querySelectorAll('canvas, svg, img, table, video')) {
202
+ const tag = el.tagName.toLowerCase();
203
+ if (tag === 'svg' && el.parentElement && el.parentElement.closest('svg')) continue;
204
+ if (el.parentElement && el.parentElement.closest('table') && tag === 'table') continue;
205
+ if (!visible(el)) continue;
206
+ const r = el.getBoundingClientRect();
207
+ const min = tag === 'canvas' || tag === 'table' ? 16 : 48;
208
+ if (r.width < min || r.height < min) continue;
209
+ const x = Math.max(cx0, r.left);
210
+ const y = Math.max(cy0, r.top);
211
+ const x2 = Math.min(cx1, r.right);
212
+ const y2 = Math.min(cy1, r.bottom);
213
+ if (x2 - x < 2 || y2 - y < 2) continue;
214
+ // The stage a drawing is judged against: the view's body, else the sized stage, else the surface itself; the
215
+ // view is the card that holds it. A drawing that fills a small corner of a large view underfills its stage.
216
+ const stageEl = el.closest('.caos-plot-body') || el.closest('[data-drawn]') || el;
217
+ const viewEl = el.closest('[data-plot]') || stageEl;
218
+ const clipRect = (b) => {
219
+ const sx = Math.max(cx0, b.left);
220
+ const sy = Math.max(cy0, b.top);
221
+ return { x: sx, y: sy, w: Math.max(0, Math.min(cx1, b.right) - sx), h: Math.max(0, Math.min(cy1, b.bottom) - sy) };
222
+ };
223
+ surfaces.push({
224
+ kind: tag,
225
+ label: describe(viewEl),
226
+ x,
227
+ y,
228
+ w: x2 - x,
229
+ h: y2 - y,
230
+ right: r.right,
231
+ stageKey: path(stageEl),
232
+ stage: clipRect(stageEl.getBoundingClientRect()),
233
+ viewKey: path(viewEl),
234
+ view: clipRect(viewEl.getBoundingClientRect()),
235
+ });
236
+ }
237
+ }
238
+
239
+ const wb = document.querySelector('[data-case-workbench]');
240
+ const workbench = wb
241
+ ? { case: wb.getAttribute('data-case'), key: wb.getAttribute('data-state-key'), replayOnly: wb.getAttribute('data-replay-only') === '1' }
242
+ : null;
243
+
244
+ let offCentre = null;
245
+ const proseBody = document.querySelector('.page-body:not(.wide)');
246
+ if (proseBody && visible(proseBody)) {
247
+ const parent = proseBody.parentElement;
248
+ const pb = proseBody.getBoundingClientRect();
249
+ const cb = parent.getBoundingClientRect();
250
+ offCentre = Math.abs(pb.left - cb.left - (parent.clientWidth - pb.width) / 2);
251
+ }
252
+ const pageBody = document.querySelector('.page-body');
253
+ return {
254
+ vw,
255
+ vh,
256
+ docH,
257
+ docW,
258
+ beyond: beyond.slice(0, 5),
259
+ beyondCount: beyond.length,
260
+ rows,
261
+ rail,
262
+ truncated: truncated.slice(0, 5),
263
+ truncatedCount: truncated.length,
264
+ clipped,
265
+ instrument,
266
+ surfaces,
267
+ prose,
268
+ workbench,
269
+ offCentre,
270
+ pageText: pageBody ? (pageBody.innerText || '').trim().length : 0,
271
+ referenceLists: [...document.querySelectorAll('.reference-list')].filter(visible).length,
272
+ uncaptioned: [...document.querySelectorAll('.equation')].filter((e) => visible(e) && !e.querySelector('.equation-caption')).length,
273
+ sectionsWithoutRefs: [...document.querySelectorAll('[data-no-refs-reason="missing"]')].filter(visible).length,
274
+ };
275
+ };
276
+
277
+ /** A tall view must move when scrolled (S1, G5). Restores the positions it changed. */
278
+ G.scrolls = () => {
279
+ const out = [];
280
+ const de = document.documentElement;
281
+ const docH = Math.max(de.scrollHeight, document.body.scrollHeight);
282
+ if (docH > window.innerHeight + 1) {
283
+ const y = window.scrollY;
284
+ winScroll(0, 0);
285
+ winScroll(0, docH);
286
+ const moved = window.scrollY > 0;
287
+ winScroll(0, y);
288
+ if (!moved) out.push(`the document is ${docH}px tall in a ${window.innerHeight}px viewport and does not scroll`);
289
+ }
290
+ for (const el of document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel')) {
291
+ if (!visible(el)) continue;
292
+ const s = getComputedStyle(el);
293
+ if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 4) {
294
+ const t = el.scrollTop;
295
+ setScroll(el, el.scrollLeft, 0);
296
+ setScroll(el, el.scrollLeft, el.scrollHeight);
297
+ const moved = el.scrollTop > 0;
298
+ setScroll(el, el.scrollLeft, t);
299
+ if (!moved) out.push(`${describe(el)} holds ${el.scrollHeight - el.clientHeight}px more content and does not scroll`);
300
+ }
301
+ }
302
+ return out;
303
+ };
304
+
305
+ const setScroll = (el, left, top) => el.scrollTo({ left, top, behavior: 'instant' });
306
+ const winScroll = (x, y) => window.scrollTo({ left: x, top: y, behavior: 'instant' });
307
+
308
+ /** Brings an element into view the way a reader can: by scrolling the containers a reader can scroll (overflow auto
309
+ * or scroll) and the document, never an overflow-hidden ancestor (scrollIntoView would, and hide a real cut). */
310
+ const bring = (el) => {
311
+ for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
312
+ const s = getComputedStyle(a);
313
+ const ar = a.getBoundingClientRect();
314
+ let r = el.getBoundingClientRect();
315
+ if ((s.overflowX === 'auto' || s.overflowX === 'scroll') && a.scrollWidth > a.clientWidth) {
316
+ if (r.left < ar.left) setScroll(a, a.scrollLeft - (ar.left - r.left + 8), a.scrollTop);
317
+ else if (r.right > ar.right) setScroll(a, a.scrollLeft + (r.right - ar.right + 8), a.scrollTop);
318
+ }
319
+ r = el.getBoundingClientRect();
320
+ if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && a.scrollHeight > a.clientHeight) {
321
+ if (r.top < ar.top) setScroll(a, a.scrollLeft, a.scrollTop - (ar.top - r.top + 8));
322
+ else if (r.bottom > ar.bottom) setScroll(a, a.scrollLeft, a.scrollTop + (r.bottom - ar.bottom + 8));
323
+ }
324
+ }
325
+ const r = el.getBoundingClientRect();
326
+ if (r.top < 0 || r.bottom > window.innerHeight) winScroll(window.scrollX, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2);
327
+ const r2 = el.getBoundingClientRect();
328
+ if (r2.left < 0 || r2.right > window.innerWidth) winScroll(window.scrollX + r2.left + r2.width / 2 - window.innerWidth / 2, window.scrollY);
329
+ };
330
+ G.bringEl = (el) => bring(el);
331
+ G.bring = (selector) => {
332
+ const el = document.querySelector(selector);
333
+ if (el) bring(el);
334
+ return Boolean(el);
335
+ };
336
+
337
+ /** Every visible control can be brought into the viewport and receives the pointer at its centre (G5). */
338
+ G.reach = () => {
339
+ const sel = 'a[href], button, select, input:not([type="hidden"]), textarea, [role="tab"], [role="button"], [tabindex]:not([tabindex="-1"])';
340
+ const saved = new Map();
341
+ for (const e of document.querySelectorAll('*')) if (e.scrollLeft || e.scrollTop) saved.set(e, [e.scrollLeft, e.scrollTop]);
342
+ const sx = window.scrollX;
343
+ const sy = window.scrollY;
344
+ const out = [];
345
+ let checked = 0;
346
+ const els = [...document.querySelectorAll(sel)].filter((el) => !el.disabled && !el.closest('[aria-hidden="true"]') && visible(el));
347
+ for (const el of els) {
348
+ bring(el);
349
+ // A wrapped inline element (a citation link over two lines) has its box centre between its line fragments; the
350
+ // pointer lands on a fragment, so the largest one is tested.
351
+ const frags = [...el.getClientRects()].filter((q) => q.width >= 1 && q.height >= 1);
352
+ const r = frags.length ? frags.reduce((a, b) => (b.width * b.height > a.width * a.height ? b : a)) : el.getBoundingClientRect();
353
+ if (r.width < 1 || r.height < 1) continue;
354
+ checked += 1;
355
+ const fits = r.width <= window.innerWidth && r.height <= window.innerHeight;
356
+ if (fits && (r.left < -1 || r.right > window.innerWidth + 1 || r.top < -1 || r.bottom > window.innerHeight + 1)) {
357
+ out.push(`${describe(el)} cannot be brought into the viewport (at ${Math.round(r.left)},${Math.round(r.top)})`);
358
+ continue;
359
+ }
360
+ const px = Math.min(Math.max(r.left + Math.min(r.width, window.innerWidth) / 2, 0), window.innerWidth - 1);
361
+ const py = Math.min(Math.max(r.top + Math.min(r.height, window.innerHeight) / 2, 0), window.innerHeight - 1);
362
+ const hit = document.elementFromPoint(px, py);
363
+ const ok =
364
+ hit && (hit === el || el.contains(hit) || (el.labels && [...el.labels].some((l) => l.contains(hit))) || (hit.tagName === 'LABEL' && hit.control === el));
365
+ if (!ok) out.push(`${describe(el)} is covered by ${hit ? describe(hit) : 'nothing (outside the page)'}`);
366
+ }
367
+ for (const e of document.querySelectorAll('*')) {
368
+ const s = saved.get(e);
369
+ if (s) setScroll(e, s[0], s[1]);
370
+ else if (e.scrollLeft || e.scrollTop) setScroll(e, 0, 0);
371
+ }
372
+ winScroll(sx, sy);
373
+ return { checked, unreachable: out.slice(0, 8), unreachableCount: out.length };
374
+ };
375
+
376
+ /** Animation frames and DOM mutations over a window at rest (G8). */
377
+ G.idle = (ms) =>
378
+ new Promise((done) => {
379
+ let mutations = 0;
380
+ const sample = [];
381
+ const mo = new MutationObserver((recs) => {
382
+ mutations += recs.length;
383
+ for (const r of recs) {
384
+ if (sample.length >= 3) break;
385
+ const t = r.target.nodeType === 1 ? r.target : r.target.parentElement;
386
+ sample.push(`${r.type}${r.attributeName ? ` ${r.attributeName}` : ''} on ${describe(t)}`);
387
+ }
388
+ });
389
+ mo.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
390
+ G.raf = 0;
391
+ G.counting = true;
392
+ setTimeout(() => {
393
+ G.counting = false;
394
+ mo.disconnect();
395
+ done({ raf: G.raf, mutations, sample, ms });
396
+ }, ms);
397
+ });
398
+
399
+ /** The visible tab lists outside the header, footer and dialogs, each with the list whose panel holds it (G7). */
400
+ G.tablists = () =>
401
+ [...document.querySelectorAll('[role="tablist"]')]
402
+ .filter((tl) => visible(tl) && !tl.closest('header, footer, [role="dialog"]'))
403
+ .map((tl) => {
404
+ const panel = tl.parentElement ? tl.parentElement.closest('[role="tabpanel"]') : null;
405
+ let owner = null;
406
+ if (panel) {
407
+ const id = panel.getAttribute('aria-labelledby');
408
+ const tab = id ? document.getElementById(id) : null;
409
+ const otl = tab ? tab.closest('[role="tablist"]') : null;
410
+ owner = otl ? path(otl) : 'unknown';
411
+ }
412
+ const tabs = [...tl.querySelectorAll('[role="tab"]')].filter((t) => t.closest('[role="tablist"]') === tl && visible(t));
413
+ return {
414
+ key: path(tl),
415
+ owner,
416
+ name: tl.getAttribute('aria-label') || '',
417
+ source: Boolean(tl.closest('.cs-source-tabs') || tl.classList.contains('cs-source-tabs')),
418
+ tabs: tabs.map((t) => (t.getAttribute('data-tab') || (t.textContent || '').trim()).slice(0, 40)),
419
+ active: tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true'),
420
+ };
421
+ });
422
+
423
+ /** The cases the case control offers right now (G9). */
424
+ G.cases = () => {
425
+ const ctl = [...document.querySelectorAll('[data-control="case"]')].filter(visible);
426
+ const ids = [];
427
+ for (const c of ctl) {
428
+ if (c.tagName === 'SELECT') {
429
+ for (const o of c.options) if (!o.disabled && o.value) ids.push(o.value);
430
+ } else for (const b of c.querySelectorAll('[data-case]')) if (!b.disabled) ids.push(b.getAttribute('data-case'));
431
+ }
432
+ return { kind: ctl.length === 0 ? 'none' : ctl[0].tagName === 'SELECT' ? 'select' : 'chips', ids: [...new Set(ids)] };
433
+ };
434
+
435
+ /** The registered controls of the instrument, except the case control (G9). */
436
+ G.controls = () =>
437
+ [...document.querySelectorAll('[data-control]')]
438
+ .filter((c) => visible(c) && c.getAttribute('data-control') !== 'case')
439
+ .map((c) => {
440
+ let kind = 'unknown';
441
+ if (c.tagName === 'SELECT' || c.querySelector('select')) kind = 'select';
442
+ else if ((c.tagName === 'INPUT' && c.type === 'range') || c.querySelector('input[type="range"]')) kind = 'range';
443
+ else if (c.querySelector('input[type="checkbox"], input[type="radio"]') || (c.tagName === 'INPUT' && (c.type === 'checkbox' || c.type === 'radio'))) kind = 'check';
444
+ else if (c.querySelector('input[type="number"]') || (c.tagName === 'INPUT' && c.type === 'number')) kind = 'number';
445
+ else if (c.querySelector('button') || c.tagName === 'BUTTON') kind = 'buttons';
446
+ return { id: c.getAttribute('data-control'), path: path(c), kind };
447
+ });
448
+
449
+ /** The visible keyed views: every plot and readout with the selection key it shows (G9). */
450
+ G.views = () =>
451
+ [...document.querySelectorAll('[data-plot], [data-readout]')]
452
+ .filter(visible)
453
+ .map((v) => ({ label: describe(v), key: v.getAttribute('data-state-key') || '', stale: v.getAttribute('data-stale') === '1' }));
454
+ }