@fasl-work/caos-app-shell 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,7 +110,7 @@ const config: ShellConfig = {
110
110
  (G1) or mode (G2), console errors and 4xx/5xx (G3), broken deep links or HTML for an artifact (G4), anything a
111
111
  reader cannot scroll to, see whole or click (G5), blank or underfilled drawings and an instrument under half the
112
112
  viewport (G6), a view that never settles (G7), loops at rest (G8), controls that change nothing (G9), labels cut or
113
- overlapping in a drawing, also in a wider fallback font (G10), a decimal point on a Spanish page (G11), a vertical
113
+ overlapping in a drawing, also in a wider fallback font (G10), a decimal point on a Spanish page (G11), a number or percentage broken across lines (G19), a vertical
114
114
  sub-tab list that scrolls away (G12), text under WCAG AA contrast (G13, every language on a documentation page), and a documentation page without the
115
115
  required areas of its kind (`ADR-0017.2`: executive and technical Introduction, Methodology, Implementation,
116
116
  Experiments, Benchmark; declared with `<DocSection area="...">`, waived only with a reason of 12 letters or digits in two
package/dist/index.d.ts CHANGED
@@ -69,7 +69,16 @@ interface FocusShellProps {
69
69
  */
70
70
  landmark?: boolean;
71
71
  }
72
- /** Full-viewport, scenario-scoped analysis frame. The product supplies only its instrument and controls. */
72
+ /**
73
+ * Full-viewport, scenario-scoped analysis frame. The product supplies only its instrument and controls.
74
+ *
75
+ * The frame measures its overlay chrome and sets three lengths on `.caos-focus-stage` (custom properties, so the instrument
76
+ * inherits them): `--caos-focus-clear-top` (the stage's top edge to the lowest chrome box in its upper half, plus 8px),
77
+ * `--caos-focus-clear-bottom` (likewise from the bottom edge, 0 or unset when nothing sits in the lower half) and
78
+ * `--caos-focus-label-bottom` (the label's bottom edge from the stage's top). A product that renders its own instrument keeps
79
+ * its controls clear of the chrome with `padding-top: var(--caos-focus-clear-top, 4.6rem)`. Before the first measurement and in
80
+ * a non-browser render the properties are unset and the CSS fallbacks apply.
81
+ */
73
82
  declare function FocusShell({ stage, rail, title, description, hud, onExit, exitLabel, stageLabel, landmark }: FocusShellProps): react.JSX.Element;
74
83
 
75
84
  /** Light/dark icon toggle. Shows the icon of the theme you would switch TO. */
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { useThemeStore, useShellLang, useLangStore, SHELL_TOKENS, pick, useStage
2
2
  export { NBSP, NOT_AVAILABLE, SCIENTIFIC_BELOW, SHELL_TOKENS, Stage, applyTheme, formatNumber, formatTicks, pick, readTheme, resolveToken, useFormat, useLangStore, useShellLang, useStageSize, useThemeStore, useThemeTokens } from './chunk-YECZXGVG.js';
3
3
  import { APP_ROUTE, LANDING_ROUTE } from './chunk-33WTUQ7Q.js';
4
4
  export { APP_ROUTE, LANDING_ROUTE, LANG_STORAGE_KEY, STANDARD_ROUTES, THEME_BOOT_SCRIPT, THEME_STORAGE_KEY } from './chunk-33WTUQ7Q.js';
5
- import { createContext, Component, useState, useRef, useId, useEffect, useCallback, useContext, useMemo, useLayoutEffect, Children } from 'react';
5
+ import { createContext, Component, useState, useRef, useId, useEffect, useLayoutEffect, useCallback, useContext, useMemo, Children } from 'react';
6
6
  import { useInRouterContext, NavLink, useLocation, Navigate, Link } from 'react-router';
7
7
  import { Sun, Moon, Languages, Boxes, CodeXml, Globe, Briefcase, Info, ChevronLeft, ChevronRight, Maximize2, RotateCw } from 'lucide-react';
8
8
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
@@ -483,10 +483,65 @@ function WorkbenchShell({
483
483
  overlays
484
484
  ] });
485
485
  }
486
+ var GAP = 8;
487
+ function measureChrome(stage) {
488
+ const box = stage.getBoundingClientRect();
489
+ if (box.height <= 0 || box.width <= 0) return;
490
+ const label = stage.querySelector(":scope > .caos-focus-label");
491
+ const chrome2 = [
492
+ label,
493
+ ...Array.from(stage.querySelectorAll(":scope > .caos-focus-hud > div")),
494
+ stage.querySelector(":scope > .caos-focus-actions")
495
+ ].filter((e) => e !== null);
496
+ const write = (name, px) => {
497
+ const next = px === null ? "" : `${px}px`;
498
+ if (stage.style.getPropertyValue(name) === next) return false;
499
+ if (px === null) stage.style.removeProperty(name);
500
+ else stage.style.setProperty(name, next);
501
+ return true;
502
+ };
503
+ const labelBottom = label ? Math.ceil(label.getBoundingClientRect().bottom - box.top) : null;
504
+ const moved = write("--caos-focus-label-bottom", labelBottom !== null && labelBottom > 0 ? labelBottom : null);
505
+ if (moved) {
506
+ measureChrome(stage);
507
+ return;
508
+ }
509
+ const mid = box.height / 2;
510
+ let top = 0;
511
+ let bottom = 0;
512
+ for (const el of chrome2) {
513
+ const r = el.getBoundingClientRect();
514
+ if (r.width <= 0 || r.height <= 0) continue;
515
+ if (r.top - box.top < mid) top = Math.max(top, r.bottom - box.top + GAP);
516
+ else bottom = Math.max(bottom, box.bottom - r.top + GAP);
517
+ }
518
+ write("--caos-focus-clear-top", top > 0 ? Math.ceil(top) : null);
519
+ write("--caos-focus-clear-bottom", bottom > 0 ? Math.ceil(bottom) : null);
520
+ }
486
521
  function FocusShell({ stage, rail, title, description, hud, onExit, exitLabel, stageLabel, landmark = true }) {
487
522
  const Root = landmark ? "main" : "div";
523
+ const stageRef = useRef(null);
524
+ useLayoutEffect(() => {
525
+ const el = stageRef.current;
526
+ if (el) measureChrome(el);
527
+ });
528
+ useLayoutEffect(() => {
529
+ const el = stageRef.current;
530
+ if (!el) return void 0;
531
+ const run = () => measureChrome(el);
532
+ window.addEventListener("resize", run);
533
+ let ro;
534
+ if (typeof ResizeObserver !== "undefined") {
535
+ ro = new ResizeObserver(run);
536
+ for (const c of Array.from(el.querySelectorAll(":scope > .caos-focus-label, :scope > .caos-focus-hud, :scope > .caos-focus-actions"))) ro.observe(c);
537
+ }
538
+ return () => {
539
+ window.removeEventListener("resize", run);
540
+ ro?.disconnect();
541
+ };
542
+ }, []);
488
543
  return /* @__PURE__ */ jsxs(Root, { className: "caos-focus-shell", "data-focus-shell": "", children: [
489
- /* @__PURE__ */ jsxs("section", { className: "caos-focus-stage", "aria-label": stageLabel, children: [
544
+ /* @__PURE__ */ jsxs("section", { ref: stageRef, className: "caos-focus-stage", "aria-label": stageLabel, children: [
490
545
  /* @__PURE__ */ jsx("div", { className: "caos-focus-instrument", children: stage }),
491
546
  /* @__PURE__ */ jsxs("div", { className: "caos-focus-label", children: [
492
547
  /* @__PURE__ */ jsx("strong", { children: title }),
@@ -3355,6 +3410,10 @@ function CaseWorkbench(props) {
3355
3410
  cases && /* @__PURE__ */ jsx(CaseSelector, { lang, ...cases, accessory: besideSelect ? enter : cases.accessory }),
3356
3411
  variants && /* @__PURE__ */ jsx(VariantBar, { ...variants })
3357
3412
  ] }) : null;
3413
+ const focusPickers = cases || variants ? /* @__PURE__ */ jsxs(Fragment, { children: [
3414
+ cases && /* @__PURE__ */ jsx(CaseSelector, { lang, ...cases, accessory: cases.accessory }),
3415
+ variants && /* @__PURE__ */ jsx(VariantBar, { ...variants })
3416
+ ] }) : null;
3358
3417
  const railHead = pickers || enter ? /* @__PURE__ */ jsxs(Fragment, { children: [
3359
3418
  pickers,
3360
3419
  besideSelect ? null : enter
@@ -3382,7 +3441,7 @@ function CaseWorkbench(props) {
3382
3441
  stageLabel: FOCUS_TEXT.stage[lang],
3383
3442
  landmark: false,
3384
3443
  stage: /* @__PURE__ */ jsx("div", { className: "caos-cw caos-cw-focus", ...instrumentData, "data-focus-group": open?.id, "data-instrument": "", children: /* @__PURE__ */ jsx("div", { className: "tabs", children: open && /* @__PURE__ */ jsx("div", { className: "tabpanel", role: "tabpanel", tabIndex: 0, "aria-label": open.label, "data-panel": open.id, children: /* @__PURE__ */ jsx(PanelBoundary, { panel: open.id, children: /* @__PURE__ */ jsx(DocRefsScope, { children: open.content }) }) }) }) }),
3385
- rail: /* @__PURE__ */ jsx("div", { className: "caos-focus-rail-body", children: /* @__PURE__ */ jsx(RailBody, { rail: rail ?? null, railHead: pickers, railLabel }) })
3444
+ rail: /* @__PURE__ */ jsx("div", { className: "caos-focus-rail-body", children: /* @__PURE__ */ jsx(RailBody, { rail: rail ?? null, railHead: focusPickers, railLabel }) })
3386
3445
  }
3387
3446
  ) }) });
3388
3447
  }
package/gate/inpage.mjs CHANGED
@@ -1036,8 +1036,32 @@ export function installLib() {
1036
1036
  const panel = stage ? stage.querySelector('[role="tabpanel"]') : null;
1037
1037
  const st = document.querySelector('.caos-focus-stage');
1038
1038
  const box = st ? st.getBoundingClientRect() : null;
1039
+ // a stage control the Focus chrome (the label, the HUD tiles, the actions) sits on: more than 1px in both directions
1040
+ const chrome = st
1041
+ ? [
1042
+ ...[...st.querySelectorAll(':scope > .caos-focus-label')].map((e) => [e, 'the stage label']),
1043
+ ...[...st.querySelectorAll(':scope > .caos-focus-hud > div')].map((e) => [e, 'the HUD']),
1044
+ ...[...st.querySelectorAll(':scope > .caos-focus-actions')].map((e) => [e, 'the Focus actions']),
1045
+ ].filter(([e]) => visible(e))
1046
+ : [];
1047
+ const covered = [];
1048
+ const controlSel = '[role="tab"], button, a[href], select, input, textarea, summary, [role="button"], [role="radio"], [role="checkbox"], [role="switch"], [role="slider"]';
1049
+ for (const c of document.querySelectorAll('.caos-focus-instrument')) {
1050
+ for (const el of c.querySelectorAll(controlSel)) {
1051
+ if (covered.length >= 4) break;
1052
+ if (!visible(el)) continue;
1053
+ const r = el.getBoundingClientRect();
1054
+ const hit = chrome.find(([e]) => {
1055
+ const b = e.getBoundingClientRect();
1056
+ return r.left < b.right - 1 && r.right > b.left + 1 && r.top < b.bottom - 1 && r.bottom > b.top + 1;
1057
+ });
1058
+ if (hit) covered.push({ control: (el.getAttribute('aria-label') || el.textContent || el.tagName).trim().replace(/\s+/g, ' ').slice(0, 40), chrome: hit[1] });
1059
+ }
1060
+ }
1039
1061
  return {
1040
1062
  focus: Boolean(shell),
1063
+ covered,
1064
+ enterInside: [...document.querySelectorAll('[data-focus-shell] [data-focus-enter]')].filter(visible).length,
1041
1065
  app: Boolean(document.querySelector('[data-workbench]')),
1042
1066
  caseId: stage ? stage.getAttribute('data-case') : null,
1043
1067
  group: shell ? (stage ? stage.getAttribute('data-focus-group') : null) : panel ? panel.getAttribute('data-panel') : null,
@@ -1257,6 +1281,75 @@ export function installLib() {
1257
1281
  return [...new Set(out)].slice(0, 12);
1258
1282
  };
1259
1283
 
1284
+ /**
1285
+ * G19, values whole (BL-065): a short value written in the page's text sits on one line. Shell 0.14.2 fixed a share
1286
+ * "35%" that broke into "3", "5", "%" in a squeezed flex row; no other check sees it, because the pieces are neither
1287
+ * clipped nor outside their box. A short value is a run of characters without breaking whitespace (U+0020, tab, CR,
1288
+ * LF; U+00A0 and U+202F do not end a run) inside one text node, 2 to 16 characters long once the punctuation of the
1289
+ * sentence is stripped from its ends, with a digit, only digits, signs (the hyphens and dashes included), separators,
1290
+ * currency signs, % per mille x and degree signs and at most 4 letters (35%, 0,96, US$1.2bn, 2026-10-15, -3,2pp, 1e-6,
1291
+ * ADR-0071). A value is cut into segments right after a hyphen (U+002D, U+2010, U+2011), an en or em dash and a slash;
1292
+ * a break at those places is a normal line break, so the pieces between them are what must stay whole. A leading sign
1293
+ * stays in the first segment (a lone "-" apart from "3,2" is a broken value). The lines of a segment of 2 or more
1294
+ * characters are the groups of its Range's client rects by top (2px); a segment on more than one line fails the value,
1295
+ * also when it is longer than its container: a segment is never cut into pieces. Two values apart by a regular space
1296
+ * ("12 %") are two runs and may wrap between them. Limits: text nodes only (a value split across elements, <b>35</b>%,
1297
+ * is not read); SVG text is G10's
1298
+ * subject and canvas text cannot be read; code, formulas and anything marked translate="no" are skipped.
1299
+ */
1300
+ G.brokenValues = () => {
1301
+ const out = [];
1302
+ const skip = 'code, pre, kbd, samp, .katex, svg, canvas, textarea, [translate="no"], [aria-hidden="true"], script, style';
1303
+ const runRe = /[^ \t\r\n]+/g;
1304
+ const BREAK_AFTER = '-\u2010\u2011\u2013\u2014/';
1305
+ const strip = /^[()[\]"',;:.]+|[()[\]"',;:.]+$/g;
1306
+ const allowed = /^[\p{Nd}+\-\u2010\u2011\u2013\u2014\u2212\u00b1.,:/'\u00a0\u202f$\u20ac\u00a3\u00a5%\u2030\u00d7\u00b0\p{L}]+$/u;
1307
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
1308
+ const range = document.createRange();
1309
+ const seen = new Set();
1310
+ for (let n = walker.nextNode(); n && out.length < 12; n = walker.nextNode()) {
1311
+ const text = n.nodeValue || '';
1312
+ if (!/\d/.test(text)) continue;
1313
+ const el = n.parentElement;
1314
+ if (!el || el.closest(skip) || !shown(el)) continue;
1315
+ for (const m of text.matchAll(runRe)) {
1316
+ const run = m[0];
1317
+ if (!/\d/.test(run)) continue;
1318
+ const lead = run.length - run.replace(/^[()[\]"',;:.]+/, '').length;
1319
+ const value = run.replace(strip, '');
1320
+ if (value.length < 2 || value.length > 16 || !/\d/.test(value) || !allowed.test(value)) continue;
1321
+ if ((value.match(/\p{L}/gu) || []).length > 4) continue;
1322
+ const start = m.index + lead;
1323
+ // a break right after a hyphen, a dash or a slash is a normal line break; the segments between them must stay whole.
1324
+ // A leading sign belongs to the first segment (a "-" apart from "3,2" is a broken value).
1325
+ const cuts = [0];
1326
+ for (let i = 1; i < value.length - 1; i += 1) if (BREAK_AFTER.includes(value[i])) cuts.push(i + 1);
1327
+ cuts.push(value.length);
1328
+ let worst = 1;
1329
+ for (let k = 0; k + 1 < cuts.length; k += 1) {
1330
+ if (cuts[k + 1] - cuts[k] < 2) continue;
1331
+ range.setStart(n, start + cuts[k]);
1332
+ range.setEnd(n, start + cuts[k + 1]);
1333
+ const tops = [];
1334
+ for (const r of range.getClientRects()) {
1335
+ if (r.width <= 0.5) continue;
1336
+ if (!tops.some((t) => Math.abs(t - r.top) <= 2)) tops.push(r.top);
1337
+ }
1338
+ worst = Math.max(worst, tops.length);
1339
+ }
1340
+ if (worst > 1) {
1341
+ const msg = `"${value}" breaks across ${worst} lines (${describe(el)})`;
1342
+ if (!seen.has(msg)) {
1343
+ seen.add(msg);
1344
+ out.push(msg);
1345
+ if (out.length >= 12) break;
1346
+ }
1347
+ }
1348
+ }
1349
+ }
1350
+ return out;
1351
+ };
1352
+
1260
1353
  /** The scroll container of an element: the nearest ancestor that scrolls vertically, or the document. */
1261
1354
  const scroller = (el) => {
1262
1355
  for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
package/gate/run.mjs CHANGED
@@ -27,7 +27,9 @@
27
27
  // Space, its hidden face out of the accessibility tree; a carousel reaches every card, never scrolls sideways
28
28
  // G17 animated drawings an animated SVG has its reduced-motion block, plays for 10 s at most and is still under reduced motion
29
29
  // G18 Focus mode the App's Focus control opens the case's open group in the full viewport and returns to the same case and
30
- // group (by the exit control and by Escape); no page scroll, the rail without scroll, one tab row
30
+ // group (by the exit control and by Escape); no page scroll, the rail without scroll, one tab row; no stage
31
+ // control under the Focus chrome (label, HUD, actions); no Focus control inside the Focus frame
32
+ // G19 values whole a short value (number, percentage, amount, unit) sits on one line in every state
31
33
  // plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
32
34
  // references).
33
35
 
@@ -464,6 +466,7 @@ export async function runGate(o) {
464
466
  if (big) for (const c of f.fillStages ?? []) if (c.h < opt.stageFloor) fail('G6', where, `the drawing of ${c.label} keeps ${Math.round(c.h)} px under its note (floor ${opt.stageFloor} px)`);
465
467
  for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', where, m);
466
468
  if (s.lang === 'es') for (const m of await page.evaluate(() => window.__caosGate.decimals())) fail('G11', where, m);
469
+ for (const m of await page.evaluate(() => window.__caosGate.brokenValues())) fail('G19', where, m);
467
470
  if (s.w > 760) for (const m of await page.evaluate(() => window.__caosGate.sticky())) fail('G12', where, m);
468
471
  if (s.contrast) for (const m of await page.evaluate(() => window.__caosGate.contrast())) fail('G13', where, m);
469
472
  if (f.sectionsWithoutRefs) fail('ADR-0017.4', where, `${f.sectionsWithoutRefs} section(s) end without references or a stated reason`);
@@ -889,7 +892,10 @@ export async function runGate(o) {
889
892
  if (f.tabRows.some((r) => r > 1)) fail('G18', at, `${label}: a tab row wraps onto ${Math.max(...f.tabRows)} rows`);
890
893
  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)}%)`);
891
894
  if (f.exitInside === false) fail('G18', at, `${label}: the exit control is outside the viewport`);
895
+ for (const c of f.covered) fail('G18', at, `${label}: ${c.chrome} covers the control "${c.control}" (the stage must clear the Focus chrome)`);
896
+ if (f.enterInside > 0) fail('G18', at, `${label}: the Focus control shows inside Focus mode (it opens the mode the reader is already in)`);
892
897
  for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', at, m);
898
+ for (const m of await page.evaluate(() => window.__caosGate.brokenValues())) fail('G19', at, m);
893
899
  if (s.contrast) for (const m of await page.evaluate(() => window.__caosGate.contrast())) fail('G13', at, m);
894
900
  if (index === 0) await saveShot(page, `focus_${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}.png`);
895
901
  // leave: the exit control for one case, Escape for the next, so both ways are walked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fasl-work/caos-app-shell",
3
- "version": "0.14.2",
3
+ "version": "0.15.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",
package/styles.css CHANGED
@@ -672,7 +672,7 @@ svg.lucide { flex: none; }
672
672
  .caos-focus-label { position: absolute; top: 14px; left: 14px; max-width: min(46ch,calc(100% - 230px)); display: grid; gap: 4px; padding: 10px 12px; pointer-events: none; }
673
673
  .caos-focus-label strong { font-size: 0.96rem; line-height: 1.2; }
674
674
  .caos-focus-label span { font-size: 0.78rem; line-height: 1.4; color: var(--color-fg-subtle); }
675
- .caos-focus-hud { position: absolute; left: 14px; top: 104px; display: grid; gap: 6px; pointer-events: none; }
675
+ .caos-focus-hud { position: absolute; left: 14px; top: calc(var(--caos-focus-label-bottom, 96px) + 8px); display: grid; gap: 6px; pointer-events: none; }
676
676
  .caos-focus-hud > div { display: grid; gap: 1px; padding: 7px 10px; min-width: 120px; }
677
677
  .caos-focus-hud strong { font: 700 1rem var(--font-mono,monospace); }
678
678
  .caos-focus-hud span { color: var(--color-fg-subtle); font-size: 0.61rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
@@ -825,11 +825,15 @@ svg.lucide { flex: none; }
825
825
 
826
826
  /* == Focus mode for the App (shell 0.14.0, ADR-0070) ================================================================== */
827
827
 
828
- /* The stage is the open group; the label overlay (heading and line) sits above it, so the group starts below the label. */
829
- .caos-focus-instrument > .caos-cw-focus { position: absolute; inset: 0; padding: 4.6rem 0.9rem 0.9rem; }
828
+ /* The stage is the open group; the label overlay (heading and line) sits above it, so the group starts below the label.
829
+ FocusShell measures the overlay chrome and sets, on .caos-focus-stage, three lengths the instrument inherits (shell 0.14.3):
830
+ --caos-focus-clear-top (stage top to the lowest chrome box in the upper half, plus 8px), --caos-focus-clear-bottom (stage
831
+ bottom to the highest chrome box in the lower half, plus 8px; unset when none) and --caos-focus-label-bottom (the label's
832
+ bottom edge). The fallbacks below apply before the first measurement and in a non-browser render. */
833
+ .caos-focus-instrument > .caos-cw-focus { position: absolute; inset: 0; padding: var(--caos-focus-clear-top, 4.6rem) 0.9rem max(0.9rem, var(--caos-focus-clear-bottom, 0px)); }
830
834
  .caos-focus-instrument > .caos-cw-focus > .tabs { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
831
835
  .caos-focus-rail-body { display: flex; flex-direction: column; gap: var(--space-3); padding: 0.85rem; min-width: 0; }
832
836
  .caos-focus-enter { align-self: flex-start; }
833
837
  @media (max-width: 900px) {
834
- .caos-focus-instrument > .caos-cw-focus { padding: 6.6rem 0.5rem 0.5rem; }
838
+ .caos-focus-instrument > .caos-cw-focus { padding: var(--caos-focus-clear-top, 6.6rem) 0.5rem max(0.5rem, var(--caos-focus-clear-bottom, 0px)); }
835
839
  }