@fasl-work/caos-app-shell 0.14.3 → 0.16.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
@@ -8,20 +8,24 @@
8
8
  // npx caos-shell-gate --url https://contraste.fasl-work.com --expect-brand "Contraste" the deployed origin
9
9
  // Options:
10
10
  // --base-path /Repo/ the site's base path with --serve (a project page); default /
11
+ // --spa-fallback with --serve, answer as nginx does for a single-page app (try_files $uri $uri/ /index.html): an extensionless
12
+ // path that matches no file gets index.html with 200; a path with an extension stays 404; default off (Pages)
11
13
  // --routes /,/methodology the routes; default: read from the header navigation
12
- // --workbench /app the routes that carry the case workbench; default /app
14
+ // --workbench /app the routes that carry the case workbench; default /app; '' for a product whose App is a SurfacePage
15
+ // --app-route /app the App route measured as a surface when --workbench is '' (G18 walks its views' Focus mode); default /app
13
16
  // --sizes 390x844,...,2560x1440 --themes light,dark --langs en,es
14
17
  // --case-sample id1,id2 cases walked outside the primary mode; default the first and the last
15
18
  // --single-case the workbench has one case and no case control
16
19
  // --idle-ms 3000 --settle-ms 15000 --text-floor 400 --instrument-min 0.5 --stage-fill-min 0.3 --stage-floor 140
17
20
  // --wide-font-sizes 390x844,1280x800 the sizes of the wide-font pass (G10); --no-wide-font skips it
18
- // --require-focus fail an App without a Focus control (G18; ADR-0070 requires one in a product with simulations)
21
+ // --require-focus fail an App without a Focus control (G18; ADR-0070 requires one in a product with simulations); on a surface
22
+ // App, one with no view that offers it
19
23
  // --out gate-output report (gate-report.json), screenshots (shots/) and the capture index (index.html)
20
24
  // Needs `playwright` in the consuming project (optional peer dependency). Exit 1 on any failure, 2 on misuse.
21
25
 
22
26
  import { runGate } from '../gate/run.mjs';
23
27
 
24
- const FLAGS = new Set(['single-case', 'no-wide-font', 'require-focus']);
28
+ const FLAGS = new Set(['single-case', 'no-wide-font', 'require-focus', 'spa-fallback']);
25
29
  const args = {};
26
30
  const argv = process.argv.slice(2);
27
31
  for (let i = 0; i < argv.length; i += 1) {
@@ -40,12 +44,14 @@ const options = {
40
44
  expectBrand: args['expect-brand'],
41
45
  routes: args.routes,
42
46
  workbench: args.workbench,
47
+ appRoute: args['app-route'],
43
48
  sizes: args.sizes,
44
49
  themes: args.themes,
45
50
  langs: args.langs,
46
51
  caseSample: args['case-sample'],
47
52
  singleCase: args['single-case'] === true,
48
53
  requireFocus: args['require-focus'] === true,
54
+ spaFallback: args['spa-fallback'] === true,
49
55
  idleMs: num('idle-ms'),
50
56
  settleMs: num('settle-ms'),
51
57
  textFloor: num('text-floor'),
package/dist/index.d.ts CHANGED
@@ -53,7 +53,8 @@ declare function WorkbenchShell({ brand, routes, navigationLabel, children, side
53
53
 
54
54
  interface FocusShellProps {
55
55
  stage: ReactNode;
56
- rail: ReactNode;
56
+ /** The parameter column on the right. Null or omitted: no column, and the stage is the whole frame (a view of a surface with no rail). */
57
+ rail?: ReactNode;
57
58
  title: string;
58
59
  description: string;
59
60
  hud: Array<{
@@ -275,10 +276,84 @@ interface CaseSelectorProps {
275
276
  */
276
277
  declare function CaseSelector(props: CaseSelectorProps): react.JSX.Element;
277
278
 
279
+ /** The words Focus mode writes (the product writes none of them). */
280
+ declare const FOCUS_TEXT: {
281
+ readonly enter: {
282
+ readonly en: "Focus";
283
+ readonly es: "Enfocar";
284
+ };
285
+ readonly enterLabel: {
286
+ readonly en: "Open this case in focus mode";
287
+ readonly es: "Abrir este caso en modo enfoque";
288
+ };
289
+ readonly enterViewLabel: {
290
+ readonly en: "Open this view in focus mode";
291
+ readonly es: "Abrir esta vista en modo enfoque";
292
+ };
293
+ readonly exit: {
294
+ readonly en: "Back to the App";
295
+ readonly es: "Volver a la App";
296
+ };
297
+ readonly stage: {
298
+ readonly en: "Focus stage";
299
+ readonly es: "Escenario de enfoque";
300
+ };
301
+ };
302
+ /** The query parameter that holds Focus mode (`?focus=1`), beside `?case=` and `?view=`. */
303
+ declare const FOCUS_PARAM = "focus";
304
+ /** What a product may say about the Focus mode of its App (`CaseWorkbench.focus`; `true` means all defaults). */
305
+ interface CaseFocusConfig {
306
+ /** The stage's heading (default: the name of the selected case). */
307
+ title?: BiText;
308
+ /** One line under it (default: the name of the open group). */
309
+ description?: BiText;
310
+ /** Readouts laid over the stage (ADR-0070): at most four on a phone. */
311
+ hud?: Array<{
312
+ label: BiText;
313
+ value: string;
314
+ }>;
315
+ /** Called when Focus opens (`true`) and closes (`false`), for a product that records it. */
316
+ onChange?: (on: boolean) => void;
317
+ /** The query parameter (default `focus`). */
318
+ param?: string;
319
+ }
320
+ /** What a product may say about the Focus mode of a `SurfacePage` App (`SurfacePage.focus`; `true` means all defaults). */
321
+ interface SurfaceFocusConfig {
322
+ /** Called when Focus opens (`true`) and closes (`false`), for a product that records it. */
323
+ onChange?: (on: boolean) => void;
324
+ /** The query parameter (default `focus`). */
325
+ param?: string;
326
+ }
327
+ /**
328
+ * What a product may say about one view of a `SurfacePage` that has a stage (`TabDef.focus`; `true` means all defaults). Only a
329
+ * view with a stage (a simulation, a drawing, a live run) sets it: a list or a form does not, and shows no Focus control.
330
+ */
331
+ interface ViewFocusConfig {
332
+ /** The stage's heading (default: the view's label). */
333
+ title?: BiText;
334
+ /** One line under it (default: the surface's title). */
335
+ description?: BiText;
336
+ /** Readouts laid over the stage (ADR-0070): at most four on a phone. */
337
+ hud?: Array<{
338
+ label: BiText;
339
+ value: string;
340
+ }>;
341
+ /**
342
+ * The parameter column on the right of the stage in Focus (ADR-0070 requirement 2): the view's own controls, moved here by the
343
+ * product (the shell moves no DOM). Without a rail the stage is the whole frame and the view keeps its controls in its body.
344
+ */
345
+ rail?: ReactNode;
346
+ }
347
+
278
348
  interface TabDef {
279
349
  id: string;
280
350
  label: ReactNode;
281
351
  content: ReactNode;
352
+ /**
353
+ * A view with a stage (a simulation, a drawing, a live run) of a `SurfacePage` that has `focus`: it opens in Focus mode (shell
354
+ * 0.16.0, ADR-0070). `true` takes the defaults. A list or a form sets nothing. Ignored outside a `SurfacePage`.
355
+ */
356
+ focus?: boolean | ViewFocusConfig;
282
357
  }
283
358
  /** At most about six peers, then group by the question (ADR-0071 rule 5). */
284
359
  declare const MAX_PEER_TABS = 6;
@@ -291,13 +366,19 @@ interface TabsProps {
291
366
  /** Called on every selection, controlled or not. */
292
367
  onChange?: (id: string) => void;
293
368
  ariaLabel?: string;
369
+ /**
370
+ * A control at the end of the tab row, in a slot beside the tab list (never inside it) on the same row: its space is reserved
371
+ * whatever it shows, so the row does not move. The tabs scroll beside it when they do not fit. Used by `SurfacePage` for its Focus
372
+ * control (shell 0.16.0). Omit it and the markup is what it was.
373
+ */
374
+ trailing?: ReactNode;
294
375
  }
295
376
  /**
296
377
  * Accessible roving-tabindex tab strip on ONE row (ADR-0071 rule 4): arrow keys, Home and End; only the active panel
297
378
  * is rendered, inside an error boundary; a row wider than its box fades the hidden end. Controlled when `value` is
298
379
  * given (known shell defect 6). More than six peers is reported (rule 5).
299
380
  */
300
- declare function Tabs({ tabs, initial, value, onChange, ariaLabel }: TabsProps): react.JSX.Element;
381
+ declare function Tabs({ tabs, initial, value, onChange, ariaLabel, trailing }: TabsProps): react.JSX.Element;
301
382
 
302
383
  interface SubTabDef {
303
384
  id: string;
@@ -812,7 +893,7 @@ interface SurfacePageProps {
812
893
  lede?: ReactNode;
813
894
  /** Controls that act on the whole surface (a source picker, an export button), at the right of the head. */
814
895
  actions?: ReactNode;
815
- /** The surface's views: one row of at most six (ADR-0071 rule 5); the open one is held in the URL. */
896
+ /** The surface's views: one row of at most six (ADR-0071 rule 5); the open one is held in the URL. A view with a stage sets `focus`. */
816
897
  tabs?: TabDef[];
817
898
  /** Controlled selection, when the product holds it itself. */
818
899
  value?: string;
@@ -822,17 +903,28 @@ interface SurfacePageProps {
822
903
  ariaLabel?: BiText;
823
904
  /** Mark the body as the instrument, so the gate measures what is drawn in it (ADR-0071 rule 8). */
824
905
  instrument?: boolean;
906
+ /**
907
+ * Focus mode for the App (ADR-0070, shell 0.16.0): while the open view sets `TabDef.focus`, a Focus control at the end of the tab row
908
+ * opens that view in the full viewport through `FocusShell` (the view is the stage, its `rail` the parameter column), holds
909
+ * `?focus=1` beside `?view=` in the URL, and returns with Escape or the exit button to the same view and sub-view. `true` takes all
910
+ * the defaults. Omit it and the surface is exactly what it was. Needs `tabs`: a surface with one body has no view to focus.
911
+ */
912
+ focus?: boolean | SurfaceFocusConfig;
825
913
  /** The body when there are no tabs: it fills the viewport. */
826
914
  children?: ReactNode;
827
915
  }
828
916
  /**
829
917
  * The third route type, beside the workbench (`WorkbenchLayout`, a rail and an instrument for one case) and the
830
- * document (`DocPage`, a scrolling reading column): a SURFACE fills the viewport with views that are not one case's,
831
- * such as a hub, an explorer or a console. Under `ShellConfig.contain` its head and tab row keep their height and the
918
+ * document (`DocPage`, a scrolling reading column): a SURFACE fills the viewport with views that are not one case's, such
919
+ * as a hub, an explorer or a console. Under `ShellConfig.contain` its head and tab row keep their height and the
832
920
  * open panel scrolls inside; below 900 px the document scrolls. The containment fifteen to nineteen products wrote for
833
921
  * themselves (CAOS_MANAGE audit 2026-10-07, section 3), owned here so no product writes it again.
922
+ *
923
+ * Its views that have a stage (a simulation, a drawing, a live run) open in Focus mode (0.16.0, ADR-0070): the same behaviour as
924
+ * `CaseWorkbench`'s, one piece of code (`useFocusMode`). The control sits at the end of the tab row (a slot beside the tab list) with
925
+ * its space reserved, so it adds no row and moves nothing; it is hidden while the open view has no stage.
834
926
  */
835
- declare function SurfacePage({ title, lede, actions, tabs, value, onChange, deepLinkView, ariaLabel, instrument, children }: SurfacePageProps): react.JSX.Element;
927
+ declare function SurfacePage({ title, lede, actions, tabs, value, onChange, deepLinkView, ariaLabel, instrument, focus, children }: SurfacePageProps): react.JSX.Element;
836
928
 
837
929
  /** Writes one query parameter with replaceState, keeping the router's history state (React Router keeps its entry
838
930
  * key and index there; replacing it with null broke the index the back button relies on). */
@@ -1108,44 +1200,6 @@ interface VariantBarProps {
1108
1200
  * one-line note of what it shows. Selecting a regime must change every view of the case. */
1109
1201
  declare function VariantBar({ variants, activeId, onSelect, title, lane }: VariantBarProps): react.JSX.Element;
1110
1202
 
1111
- /** The words Focus mode writes (the product writes none of them). */
1112
- declare const FOCUS_TEXT: {
1113
- readonly enter: {
1114
- readonly en: "Focus";
1115
- readonly es: "Enfocar";
1116
- };
1117
- readonly enterLabel: {
1118
- readonly en: "Open this case in focus mode";
1119
- readonly es: "Abrir este caso en modo enfoque";
1120
- };
1121
- readonly exit: {
1122
- readonly en: "Back to the App";
1123
- readonly es: "Volver a la App";
1124
- };
1125
- readonly stage: {
1126
- readonly en: "Focus stage";
1127
- readonly es: "Escenario de enfoque";
1128
- };
1129
- };
1130
- /** The query parameter that holds Focus mode (`?focus=1`), beside `?case=` and `?view=`. */
1131
- declare const FOCUS_PARAM = "focus";
1132
- /** What a product may say about the Focus mode of its App (`CaseWorkbench.focus`; `true` means all defaults). */
1133
- interface CaseFocusConfig {
1134
- /** The stage's heading (default: the name of the selected case). */
1135
- title?: BiText;
1136
- /** One line under it (default: the name of the open group). */
1137
- description?: BiText;
1138
- /** Readouts laid over the stage (ADR-0070): at most four on a phone. */
1139
- hud?: Array<{
1140
- label: BiText;
1141
- value: string;
1142
- }>;
1143
- /** Called when Focus opens (`true`) and closes (`false`), for a product that records it. */
1144
- onChange?: (on: boolean) => void;
1145
- /** The query parameter (default `focus`). */
1146
- param?: string;
1147
- }
1148
-
1149
1203
  /** One group of views of the selected case, named for the question the user is asking (ADR-0071 rule 5). */
1150
1204
  interface WorkbenchGroup {
1151
1205
  id: string;
@@ -1219,6 +1273,40 @@ declare const MAX_WORKBENCH_GROUPS = 6;
1219
1273
  */
1220
1274
  declare function CaseWorkbench(props: CaseWorkbenchProps): react.JSX.Element;
1221
1275
 
1276
+ /** What the hook needs of a product's Focus configuration: `CaseFocusConfig` and `SurfaceFocusConfig` both satisfy it. */
1277
+ interface FocusModeConfig {
1278
+ /** The query parameter (default `focus`). */
1279
+ param?: string;
1280
+ /** Called when Focus opens (`true`) and closes (`false`), for a product that records it. */
1281
+ onChange?: (on: boolean) => void;
1282
+ }
1283
+ interface FocusModeOptions {
1284
+ /**
1285
+ * False while what Focus would open is not available (a surface whose open view has no stage): Focus is then never shown, and a
1286
+ * `?focus=1` left in the URL is removed. Default true.
1287
+ */
1288
+ enabled?: boolean;
1289
+ /** Whether a `?focus=1` in the URL at the first render may open Focus (a surface checks the view the link names). Default true. */
1290
+ initialAllowed?: () => boolean;
1291
+ }
1292
+ interface FocusMode {
1293
+ /** True when the frame shows: configured, on, and enabled. */
1294
+ show: boolean;
1295
+ /** Opens or closes Focus: writes the URL, moves keyboard focus on the next commit and calls `onChange`. */
1296
+ setFocus: (on: boolean) => void;
1297
+ /** Attach to the control that opens Focus: keyboard focus returns to it when Focus closes. */
1298
+ enterRef: React.MutableRefObject<HTMLButtonElement | null>;
1299
+ /** What the reader had open in the tab rows of the views; provide it as `ViewMemoryContext` around both frames. */
1300
+ memory: Map<string, string>;
1301
+ }
1302
+ /**
1303
+ * Focus mode (ADR-0070), the one behaviour `CaseWorkbench` and `SurfacePage` share: the state held in the URL (`?focus=1`), Escape
1304
+ * to leave, the page behind the frame inert, keyboard focus moved to the exit control on entry and back to the entering control on
1305
+ * return, and the view memory the frame's remount reads. The component renders the frame (`FocusShell`) when `show` is true.
1306
+ * `cfg` null means the App has no Focus mode: nothing here acts.
1307
+ */
1308
+ declare function useFocusMode(cfg: FocusModeConfig | null, options?: FocusModeOptions): FocusMode;
1309
+
1222
1310
  interface KnobProps {
1223
1311
  /** Stable id, written as `data-control` so the gate can enumerate and move every control. */
1224
1312
  id: string;
@@ -1291,4 +1379,4 @@ interface ViewsRowProps {
1291
1379
  */
1292
1380
  declare function ViewsRow({ children, shares }: ViewsRowProps): react.JSX.Element;
1293
1381
 
1294
- export { BREAKPOINTS, BarChart, type BarChartProps, type BarDatum, BiText, Callout, CardDeck, type CardDeckProps, type CaseDef, type CaseFocusConfig, type CaseGroup, type CaseKind, CaseSelector, type CaseSelectorProps, type CaseSelectorText, CaseWorkbench, type CaseWorkbenchProps, ChipGroup, type ChipGroupProps, type ChipOption, type Citation, CitationsProvider, Cite, DECK_TEXT, DOC_AREAS, type DeckCard, DocPage, type DocPageProps, DocReferences, DocRefsScope, DocSection, type DocSectionProps, EXECUTIVE_SCHEMA_VERSION, Equation, type ExecutiveData, type ExecutiveProblem, ExecutiveView, type ExecutiveViewProps, FLIP_LIMITS, FLIP_TEXT, FOCUS_PARAM, FOCUS_TEXT, FOUR_CS, Figure, type FittedLabel, FlipCard, type FlipCardProps, FocusShell, type FocusShellProps, FormatOptions, Gauge, type GaugeProps, type GaugeZone, Glossary, type GlossaryEntry, InlineMath, Knob, type KnobProps, Landing, type Lane, LaneBadge, Lang, LanguageToggle, MAX_PEER_TABS, MAX_WORKBENCH_GROUPS, PanelBoundary, type PausedVizController, PlotCard, type PlotCardProps, type Provenance, Question, type QuestionProps, type RailSection, Readout, type ReadoutItem, type ReadoutProps, ReferenceList, Refs, Stage, type StageSize, Stat, type StatProps, type SubTabDef, SubTabs, type SubTabsProps, SurfacePage, type SurfacePageProps, type TabDef, type TabGroupDef, TabGroups, type TabGroupsProps, Tabs, type TabsProps, type Theme, ThemeToggle, type Tone, type UsePausedVizOptions, VariantBar, type VariantBarProps, type VariantDef, Verdict, type VerdictProps, ViewsRow, type ViewsRowProps, type VizFrame, type VizLoop, type VizLoopDeps, type VizLoopOptions, type WorkbenchGroup, WorkbenchLayout, type WorkbenchLayoutProps, type WorkbenchRoute, WorkbenchShell, type WorkbenchShellProps, type WorkbenchState, WorkbenchStateContext, applyTheme, caseKind, caseTooltip, casesInSource, createVizLoop, deckPosition, deckWindow, firstAppearance, fitLabel, fontFamily, groupByCategory, isStale, makeStateKey, niceStep, niceTicks, perPageFor, readCaseParam, readTheme, removeQueryParam, replaceQueryParam, sourcesPresent, textWidth, usePausedViz, useStageSize, useThemeStore, useUrlView, useWorkbenchState, validateExecutive, viewParamOf, widestLabel, withCaseParam };
1382
+ export { BREAKPOINTS, BarChart, type BarChartProps, type BarDatum, BiText, Callout, CardDeck, type CardDeckProps, type CaseDef, type CaseFocusConfig, type CaseGroup, type CaseKind, CaseSelector, type CaseSelectorProps, type CaseSelectorText, CaseWorkbench, type CaseWorkbenchProps, ChipGroup, type ChipGroupProps, type ChipOption, type Citation, CitationsProvider, Cite, DECK_TEXT, DOC_AREAS, type DeckCard, DocPage, type DocPageProps, DocReferences, DocRefsScope, DocSection, type DocSectionProps, EXECUTIVE_SCHEMA_VERSION, Equation, type ExecutiveData, type ExecutiveProblem, ExecutiveView, type ExecutiveViewProps, FLIP_LIMITS, FLIP_TEXT, FOCUS_PARAM, FOCUS_TEXT, FOUR_CS, Figure, type FittedLabel, FlipCard, type FlipCardProps, type FocusMode, type FocusModeConfig, type FocusModeOptions, FocusShell, type FocusShellProps, FormatOptions, Gauge, type GaugeProps, type GaugeZone, Glossary, type GlossaryEntry, InlineMath, Knob, type KnobProps, Landing, type Lane, LaneBadge, Lang, LanguageToggle, MAX_PEER_TABS, MAX_WORKBENCH_GROUPS, PanelBoundary, type PausedVizController, PlotCard, type PlotCardProps, type Provenance, Question, type QuestionProps, type RailSection, Readout, type ReadoutItem, type ReadoutProps, ReferenceList, Refs, Stage, type StageSize, Stat, type StatProps, type SubTabDef, SubTabs, type SubTabsProps, type SurfaceFocusConfig, SurfacePage, type SurfacePageProps, type TabDef, type TabGroupDef, TabGroups, type TabGroupsProps, Tabs, type TabsProps, type Theme, ThemeToggle, type Tone, type UsePausedVizOptions, VariantBar, type VariantBarProps, type VariantDef, Verdict, type VerdictProps, type ViewFocusConfig, ViewsRow, type ViewsRowProps, type VizFrame, type VizLoop, type VizLoopDeps, type VizLoopOptions, type WorkbenchGroup, WorkbenchLayout, type WorkbenchLayoutProps, type WorkbenchRoute, WorkbenchShell, type WorkbenchShellProps, type WorkbenchState, WorkbenchStateContext, applyTheme, caseKind, caseTooltip, casesInSource, createVizLoop, deckPosition, deckWindow, firstAppearance, fitLabel, fontFamily, groupByCategory, isStale, makeStateKey, niceStep, niceTicks, perPageFor, readCaseParam, readTheme, removeQueryParam, replaceQueryParam, sourcesPresent, textWidth, useFocusMode, usePausedViz, useStageSize, useThemeStore, useUrlView, useWorkbenchState, validateExecutive, viewParamOf, widestLabel, withCaseParam };
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, useLayoutEffect, useCallback, useContext, useMemo, Children } from 'react';
5
+ import { createContext, Component, useState, useRef, useId, useEffect, useLayoutEffect, useCallback, useContext, useMemo, Children, isValidElement } 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';
@@ -540,7 +540,7 @@ function FocusShell({ stage, rail, title, description, hud, onExit, exitLabel, s
540
540
  ro?.disconnect();
541
541
  };
542
542
  }, []);
543
- return /* @__PURE__ */ jsxs(Root, { className: "caos-focus-shell", "data-focus-shell": "", children: [
543
+ return /* @__PURE__ */ jsxs(Root, { className: rail === null || rail === void 0 ? "caos-focus-shell caos-focus-norail" : "caos-focus-shell", "data-focus-shell": "", children: [
544
544
  /* @__PURE__ */ jsxs("section", { ref: stageRef, className: "caos-focus-stage", "aria-label": stageLabel, children: [
545
545
  /* @__PURE__ */ jsx("div", { className: "caos-focus-instrument", children: stage }),
546
546
  /* @__PURE__ */ jsxs("div", { className: "caos-focus-label", children: [
@@ -557,7 +557,7 @@ function FocusShell({ stage, rail, title, description, hud, onExit, exitLabel, s
557
557
  /* @__PURE__ */ jsx("button", { type: "button", "data-focus-exit": "", onClick: onExit, children: exitLabel })
558
558
  ] })
559
559
  ] }),
560
- /* @__PURE__ */ jsx("aside", { className: "caos-focus-rail", "data-rail": "", children: rail })
560
+ rail !== null && rail !== void 0 && /* @__PURE__ */ jsx("aside", { className: "caos-focus-rail", "data-rail": "", children: rail })
561
561
  ] });
562
562
  }
563
563
 
@@ -867,7 +867,8 @@ function caseKindOf(c) {
867
867
  return c.kind ?? "synthetic";
868
868
  }
869
869
  var ViewMemoryContext = createContext(null);
870
- var memoryKey = (kind, ariaLabel, ids) => `${kind}|${ariaLabel ?? ""}|${ids.join(",")}`;
870
+ var PanelPathContext = createContext("");
871
+ var memoryKey = (kind, path, ids) => `${kind}|${path}|${ids.join(",")}`;
871
872
  var CitationsContext = createContext({ list: [], byId: {} });
872
873
  function CitationsProvider({ items, children }) {
873
874
  const byId = {};
@@ -964,9 +965,9 @@ function DocReferences({ ids }) {
964
965
  const c = byId[id];
965
966
  const h = href(c);
966
967
  return /* @__PURE__ */ jsxs("li", { children: [
967
- /* @__PURE__ */ jsx("span", { children: c.citation }),
968
+ /* @__PURE__ */ jsx("span", { translate: "no", children: c.citation }),
968
969
  " ",
969
- h ? /* @__PURE__ */ jsx("a", { href: h, target: "_blank", rel: "noreferrer noopener", className: "faint", children: c.doi ? `doi:${c.doi}` : "link" }) : null
970
+ h ? /* @__PURE__ */ jsx("a", { href: h, target: "_blank", rel: "noreferrer noopener", className: "faint", children: c.doi ? /* @__PURE__ */ jsx("span", { translate: "no", children: `doi:${c.doi}` }) : "link" }) : null
970
971
  ] }, id);
971
972
  }) })
972
973
  ] });
@@ -990,19 +991,20 @@ function ReferenceList({ ids, heading }) {
990
991
  /* @__PURE__ */ jsx("ol", { className: "reference-list", children: items.map((c) => {
991
992
  const h = href(c);
992
993
  return /* @__PURE__ */ jsxs("li", { children: [
993
- /* @__PURE__ */ jsx("span", { children: c.citation }),
994
+ /* @__PURE__ */ jsx("span", { translate: "no", children: c.citation }),
994
995
  " ",
995
- h ? /* @__PURE__ */ jsx("a", { href: h, target: "_blank", rel: "noreferrer noopener", className: "faint", children: c.doi ? `doi:${c.doi}` : "link" }) : null
996
+ h ? /* @__PURE__ */ jsx("a", { href: h, target: "_blank", rel: "noreferrer noopener", className: "faint", children: c.doi ? /* @__PURE__ */ jsx("span", { translate: "no", children: `doi:${c.doi}` }) : "link" }) : null
996
997
  ] }, c.id);
997
998
  }) })
998
999
  ] });
999
1000
  }
1000
1001
  var MAX_PEER_TABS = 6;
1001
- function Tabs({ tabs, initial, value, onChange, ariaLabel }) {
1002
+ function Tabs({ tabs, initial, value, onChange, ariaLabel, trailing }) {
1002
1003
  const baseId = useId();
1003
1004
  const first = tabs[0]?.id ?? "";
1004
1005
  const memory = useContext(ViewMemoryContext);
1005
- const mkey = memoryKey("tabs", ariaLabel, tabs.map((t2) => t2.id));
1006
+ const path = useContext(PanelPathContext);
1007
+ const mkey = memoryKey("tabs", path, tabs.map((t2) => t2.id));
1006
1008
  const [own, setOwn] = useState(() => {
1007
1009
  const kept = memory?.get(mkey);
1008
1010
  return kept && tabs.some((t2) => t2.id === kept) ? kept : initial ?? first;
@@ -1033,7 +1035,7 @@ function Tabs({ tabs, initial, value, onChange, ariaLabel }) {
1033
1035
  }
1034
1036
  }
1035
1037
  const current = tabs.find((t2) => t2.id === active) ?? tabs[0];
1036
- return /* @__PURE__ */ jsxs("div", { className: "tabs", children: [
1038
+ return /* @__PURE__ */ jsxs("div", { className: trailing === void 0 ? "tabs" : "tabs tabs-has-slot", children: [
1037
1039
  /* @__PURE__ */ jsx("div", { className: "tablist", role: "tablist", "aria-label": ariaLabel, ref: rowRef, children: tabs.map((tab, idx) => {
1038
1040
  const selected = tab.id === current?.id;
1039
1041
  return /* @__PURE__ */ jsx(
@@ -1054,6 +1056,7 @@ function Tabs({ tabs, initial, value, onChange, ariaLabel }) {
1054
1056
  tab.id
1055
1057
  );
1056
1058
  }) }),
1059
+ trailing !== void 0 && /* @__PURE__ */ jsx("div", { className: "tabs-slot", "data-tabs-slot": "", children: trailing }),
1057
1060
  current && /* @__PURE__ */ jsx(
1058
1061
  "div",
1059
1062
  {
@@ -1063,7 +1066,7 @@ function Tabs({ tabs, initial, value, onChange, ariaLabel }) {
1063
1066
  tabIndex: 0,
1064
1067
  className: "tabpanel",
1065
1068
  "data-panel": current.id,
1066
- children: /* @__PURE__ */ jsx(PanelBoundary, { panel: current.id, children: /* @__PURE__ */ jsx(DocRefsScope, { children: current.content }) })
1069
+ children: /* @__PURE__ */ jsx(PanelPathContext.Provider, { value: `${path}/${current.id}`, children: /* @__PURE__ */ jsx(PanelBoundary, { panel: current.id, children: /* @__PURE__ */ jsx(DocRefsScope, { children: current.content }) }) })
1067
1070
  },
1068
1071
  current.id
1069
1072
  )
@@ -1073,7 +1076,8 @@ function SubTabs({ tabs, initial, value, onChange, ariaLabel, orientation = "hor
1073
1076
  const baseId = useId();
1074
1077
  const first = tabs[0]?.id ?? "";
1075
1078
  const memory = useContext(ViewMemoryContext);
1076
- const mkey = memoryKey("subtabs", ariaLabel, tabs.map((t2) => t2.id));
1079
+ const path = useContext(PanelPathContext);
1080
+ const mkey = memoryKey("subtabs", path, tabs.map((t2) => t2.id));
1077
1081
  const [own, setOwn] = useState(() => {
1078
1082
  const kept = memory?.get(mkey);
1079
1083
  return kept && tabs.some((t2) => t2.id === kept) ? kept : initial ?? first;
@@ -1147,7 +1151,7 @@ function SubTabs({ tabs, initial, value, onChange, ariaLabel, orientation = "hor
1147
1151
  tabIndex: 0,
1148
1152
  className: "subtabpanel",
1149
1153
  "data-panel": current.id,
1150
- children: /* @__PURE__ */ jsx(PanelBoundary, { panel: current.id, children: /* @__PURE__ */ jsx(DocRefsScope, { children: current.content }) })
1154
+ children: /* @__PURE__ */ jsx(PanelPathContext.Provider, { value: `${path}/${current.id}`, children: /* @__PURE__ */ jsx(PanelBoundary, { panel: current.id, children: /* @__PURE__ */ jsx(DocRefsScope, { children: current.content }) }) })
1151
1155
  },
1152
1156
  current.id
1153
1157
  ) })
@@ -2920,20 +2924,155 @@ function useUrlView(ids, param, value, onChange) {
2920
2924
  function viewParamOf(option) {
2921
2925
  return option === false ? null : typeof option === "string" ? option : "view";
2922
2926
  }
2923
- function SurfacePage({ title, lede, actions, tabs, value, onChange, deepLinkView = true, ariaLabel, instrument, children }) {
2927
+
2928
+ // src/workbench/focus.ts
2929
+ var FOCUS_TEXT = {
2930
+ enter: { en: "Focus", es: "Enfocar" },
2931
+ enterLabel: { en: "Open this case in focus mode", es: "Abrir este caso en modo enfoque" },
2932
+ enterViewLabel: { en: "Open this view in focus mode", es: "Abrir esta vista en modo enfoque" },
2933
+ exit: { en: "Back to the App", es: "Volver a la App" },
2934
+ stage: { en: "Focus stage", es: "Escenario de enfoque" }
2935
+ };
2936
+ var FOCUS_PARAM = "focus";
2937
+
2938
+ // src/shell/useFocusMode.ts
2939
+ function useFocusMode(cfg, options = {}) {
2940
+ const enabled = options.enabled ?? true;
2941
+ const param = cfg?.param ?? FOCUS_PARAM;
2942
+ const [on, setOn] = useState(
2943
+ () => cfg !== null && typeof window !== "undefined" && readCaseParam(window.location.search, param) === "1" && (options.initialAllowed?.() ?? true)
2944
+ );
2945
+ const enterRef = useRef(null);
2946
+ const memory = useRef(/* @__PURE__ */ new Map());
2947
+ const turned = useRef(null);
2948
+ const onChangeRef = useRef(cfg?.onChange);
2949
+ onChangeRef.current = cfg?.onChange;
2950
+ const setFocus = useCallback(
2951
+ (next) => {
2952
+ turned.current = next ? "in" : "out";
2953
+ setOn(next);
2954
+ if (next) replaceQueryParam(param, "1");
2955
+ else removeQueryParam(param);
2956
+ onChangeRef.current?.(next);
2957
+ },
2958
+ [param]
2959
+ );
2960
+ const show = cfg !== null && on && enabled;
2961
+ useEffect(() => {
2962
+ if (cfg === null || enabled) return;
2963
+ if (on) setOn(false);
2964
+ if (typeof window !== "undefined" && readCaseParam(window.location.search, param) === "1") removeQueryParam(param);
2965
+ }, [cfg === null, enabled, on, param]);
2966
+ useEffect(() => {
2967
+ if (!show) return void 0;
2968
+ const onKey = (e) => {
2969
+ if (e.key !== "Escape" || e.defaultPrevented) return;
2970
+ e.preventDefault();
2971
+ setFocus(false);
2972
+ };
2973
+ document.addEventListener("keydown", onKey);
2974
+ return () => document.removeEventListener("keydown", onKey);
2975
+ }, [show, setFocus]);
2976
+ useEffect(() => {
2977
+ if (!show) return void 0;
2978
+ const frame = document.querySelector("[data-focus-shell]");
2979
+ if (!frame) return void 0;
2980
+ const made = [];
2981
+ for (let node = frame; node && node !== document.body; node = node.parentElement) {
2982
+ for (const sib of Array.from(node.parentElement?.children ?? [])) {
2983
+ if (sib !== node && !sib.hasAttribute("inert") && sib.tagName !== "SCRIPT" && sib.tagName !== "STYLE") {
2984
+ sib.setAttribute("inert", "");
2985
+ made.push(sib);
2986
+ }
2987
+ }
2988
+ }
2989
+ return () => {
2990
+ for (const el of made) el.removeAttribute("inert");
2991
+ };
2992
+ }, [show]);
2993
+ useEffect(() => {
2994
+ if (turned.current === "in" && show) document.querySelector("[data-focus-exit]")?.focus();
2995
+ else if (turned.current === "out" && !show) enterRef.current?.focus();
2996
+ turned.current = null;
2997
+ }, [show]);
2998
+ return { show, setFocus, enterRef, memory: memory.current };
2999
+ }
3000
+ function textOf(node) {
3001
+ if (typeof node === "string" || typeof node === "number") return String(node);
3002
+ if (Array.isArray(node)) return node.map(textOf).join("");
3003
+ if (isValidElement(node)) return textOf(node.props.children);
3004
+ return "";
3005
+ }
3006
+ var viewFocus = (tab) => !tab || !tab.focus ? null : tab.focus === true ? {} : tab.focus;
3007
+ function SurfacePage({ title, lede, actions, tabs, value, onChange, deepLinkView = true, ariaLabel, instrument, focus, children }) {
2924
3008
  const lang = useShellLang();
2925
- const [open, select] = useUrlView((tabs ?? []).map((t2) => t2.id), tabs ? viewParamOf(deepLinkView) : null, value, onChange);
2926
- const head = title !== void 0 || lede !== void 0 || actions !== void 0;
2927
- return /* @__PURE__ */ jsxs("div", { className: "page-body wide caos-surface", "data-surface": "", children: [
3009
+ const placeAbove = useContext(PanelPathContext);
3010
+ const ids = (tabs ?? []).map((t2) => t2.id);
3011
+ const viewParam = tabs ? viewParamOf(deepLinkView) : null;
3012
+ const [open, select] = useUrlView(ids, viewParam, value, onChange);
3013
+ const focusCfg = focus === true ? {} : focus || null;
3014
+ const openTab = tabs?.find((t2) => t2.id === open) ?? tabs?.[0];
3015
+ const openFocus = focusCfg ? viewFocus(openTab) : null;
3016
+ const mode = useFocusMode(focusCfg, {
3017
+ enabled: openFocus !== null,
3018
+ // a link opens Focus only on a view with a stage: the view it names (the first one when it names none), never an unknown one
3019
+ initialAllowed: () => {
3020
+ const named = value ?? (viewParam && typeof window !== "undefined" ? readCaseParam(window.location.search, viewParam) : null);
3021
+ if (named !== null && !ids.includes(named)) return false;
3022
+ return viewFocus(tabs?.find((t2) => t2.id === (named ?? ids[0]))) !== null;
3023
+ }
3024
+ });
3025
+ const reserve = focusCfg !== null && Boolean(tabs?.some((t2) => viewFocus(t2) !== null));
3026
+ const idle = openFocus === null;
3027
+ const enter = reserve && /* @__PURE__ */ jsxs(
3028
+ "button",
3029
+ {
3030
+ ref: mode.enterRef,
3031
+ type: "button",
3032
+ className: "btn caos-focus-enter",
3033
+ "data-focus-enter": "",
3034
+ "aria-label": FOCUS_TEXT.enterViewLabel[lang],
3035
+ title: FOCUS_TEXT.enterViewLabel[lang],
3036
+ "aria-hidden": idle ? true : void 0,
3037
+ tabIndex: idle ? -1 : void 0,
3038
+ style: idle ? { visibility: "hidden" } : void 0,
3039
+ onClick: () => mode.setFocus(true),
3040
+ children: [
3041
+ /* @__PURE__ */ jsx(Maximize2, { size: 14, "aria-hidden": "true" }),
3042
+ /* @__PURE__ */ jsx("span", { children: FOCUS_TEXT.enter[lang] })
3043
+ ]
3044
+ }
3045
+ );
3046
+ if (mode.show && openTab && openFocus) {
3047
+ const label = textOf(openTab.label);
3048
+ const surfaceTitle = title !== void 0 ? pick(title, lang) : "";
3049
+ return /* @__PURE__ */ jsx(ViewMemoryContext.Provider, { value: mode.memory, children: /* @__PURE__ */ jsx(
3050
+ FocusShell,
3051
+ {
3052
+ title: openFocus.title !== void 0 ? pick(openFocus.title, lang) : label,
3053
+ description: openFocus.description !== void 0 ? pick(openFocus.description, lang) : surfaceTitle,
3054
+ hud: (openFocus.hud ?? []).map((h) => ({ label: pick(h.label, lang), value: h.value })),
3055
+ onExit: () => mode.setFocus(false),
3056
+ exitLabel: FOCUS_TEXT.exit[lang],
3057
+ stageLabel: FOCUS_TEXT.stage[lang],
3058
+ landmark: false,
3059
+ stage: /* @__PURE__ */ jsx("div", { className: "caos-surface-focus", "data-surface-focus": "", "data-view-param": viewParam ?? "", "data-focus-param": focusCfg?.param ?? FOCUS_PARAM, "data-focus-view": openTab.id, "data-focus-group": openTab.id, "data-instrument": "", children: /* @__PURE__ */ jsx("div", { className: "tabs", children: /* @__PURE__ */ jsx("div", { className: "tabpanel", role: "tabpanel", tabIndex: 0, "aria-label": label || void 0, "data-panel": openTab.id, children: /* @__PURE__ */ jsx(PanelPathContext.Provider, { value: `${placeAbove}/${openTab.id}`, children: /* @__PURE__ */ jsx(PanelBoundary, { panel: openTab.id, children: /* @__PURE__ */ jsx(DocRefsScope, { children: openTab.content }) }) }) }) }) }),
3060
+ rail: openFocus.rail !== void 0 && openFocus.rail !== null ? /* @__PURE__ */ jsx("div", { className: "caos-focus-rail-body", children: openFocus.rail }) : null
3061
+ }
3062
+ ) });
3063
+ }
3064
+ const head = title !== void 0 || lede !== void 0 || actions !== void 0 && actions !== null;
3065
+ const page = /* @__PURE__ */ jsxs("div", { className: "page-body wide caos-surface", "data-surface": "", "data-view-param": reserve ? viewParam ?? "" : void 0, children: [
2928
3066
  head && /* @__PURE__ */ jsxs("div", { className: "caos-surface-head", children: [
2929
3067
  /* @__PURE__ */ jsxs("div", { className: "caos-surface-heading", children: [
2930
3068
  title !== void 0 && /* @__PURE__ */ jsx("h1", { className: "caos-surface-title", children: pick(title, lang) }),
2931
3069
  lede !== void 0 && /* @__PURE__ */ jsx("p", { className: "caos-surface-lede", children: lede })
2932
3070
  ] }),
2933
- actions !== void 0 && /* @__PURE__ */ jsx("div", { className: "caos-surface-actions", children: actions })
3071
+ actions !== void 0 && actions !== null && /* @__PURE__ */ jsx("div", { className: "caos-surface-actions", children: actions })
2934
3072
  ] }),
2935
- /* @__PURE__ */ jsx("div", { className: "caos-surface-body", "data-instrument": instrument ? "" : void 0, children: tabs ? /* @__PURE__ */ jsx(Tabs, { tabs, value: open, onChange: select, ariaLabel: pick(ariaLabel ?? title ?? { en: "Views", es: "Vistas" }, lang) }) : children })
3073
+ /* @__PURE__ */ jsx("div", { className: "caos-surface-body", "data-instrument": instrument ? "" : void 0, children: tabs ? /* @__PURE__ */ jsx(Tabs, { tabs, value: open, onChange: select, ariaLabel: pick(ariaLabel ?? title ?? { en: "Views", es: "Vistas" }, lang), trailing: enter || void 0 }) : children })
2936
3074
  ] });
3075
+ return focusCfg ? /* @__PURE__ */ jsx(ViewMemoryContext.Provider, { value: mode.memory, children: page }) : page;
2937
3076
  }
2938
3077
 
2939
3078
  // src/lib/breakpoints.ts
@@ -3298,15 +3437,6 @@ function VariantBar({ variants, activeId, onSelect, title, lane }) {
3298
3437
  active?.note && /* @__PURE__ */ jsx("p", { className: "caos-variant-note", children: pick(active.note, lang) })
3299
3438
  ] });
3300
3439
  }
3301
-
3302
- // src/workbench/focus.ts
3303
- var FOCUS_TEXT = {
3304
- enter: { en: "Focus", es: "Enfocar" },
3305
- enterLabel: { en: "Open this case in focus mode", es: "Abrir este caso en modo enfoque" },
3306
- exit: { en: "Back to the App", es: "Volver a la App" },
3307
- stage: { en: "Focus stage", es: "Escenario de enfoque" }
3308
- };
3309
- var FOCUS_PARAM = "focus";
3310
3440
  var MAX_WORKBENCH_GROUPS = MAX_PEER_TABS;
3311
3441
  function panel(id, lane, provenance, content) {
3312
3442
  return /* @__PURE__ */ jsx("div", { className: "caos-cw-panel", "data-group": id, "data-lane": lane, "data-provenance": provenance, children: content });
@@ -3315,56 +3445,8 @@ function CaseWorkbench(props) {
3315
3445
  const { caseId, cases, source, controls, replayOnly, variants, rail, railLabel, groups, compare, context: context2, group, onGroupChange, deepLinkView = true, ariaLabel, focus } = props;
3316
3446
  const lang = useShellLang();
3317
3447
  const focusCfg = focus === true ? {} : focus || null;
3318
- const focusParam = focusCfg?.param ?? FOCUS_PARAM;
3319
- const [focusOn, setFocusOn] = useState(() => focusCfg !== null && typeof window !== "undefined" && readCaseParam(window.location.search, focusParam) === "1");
3320
- const enterRef = useRef(null);
3321
- const memory = useRef(/* @__PURE__ */ new Map());
3322
- const turned = useRef(null);
3323
- const onChangeRef = useRef(focusCfg?.onChange);
3324
- onChangeRef.current = focusCfg?.onChange;
3325
- const setFocus = useCallback(
3326
- (on) => {
3327
- turned.current = on ? "in" : "out";
3328
- setFocusOn(on);
3329
- if (on) replaceQueryParam(focusParam, "1");
3330
- else removeQueryParam(focusParam);
3331
- onChangeRef.current?.(on);
3332
- },
3333
- [focusParam]
3334
- );
3335
- const showFocus = focusCfg !== null && focusOn;
3336
- useEffect(() => {
3337
- if (!showFocus) return void 0;
3338
- const onKey = (e) => {
3339
- if (e.key !== "Escape" || e.defaultPrevented) return;
3340
- e.preventDefault();
3341
- setFocus(false);
3342
- };
3343
- document.addEventListener("keydown", onKey);
3344
- return () => document.removeEventListener("keydown", onKey);
3345
- }, [showFocus, setFocus]);
3346
- useEffect(() => {
3347
- if (!showFocus) return void 0;
3348
- const frame = document.querySelector("[data-focus-shell]");
3349
- if (!frame) return void 0;
3350
- const made = [];
3351
- for (let node = frame; node && node !== document.body; node = node.parentElement) {
3352
- for (const sib of Array.from(node.parentElement?.children ?? [])) {
3353
- if (sib !== node && !sib.hasAttribute("inert") && sib.tagName !== "SCRIPT" && sib.tagName !== "STYLE") {
3354
- sib.setAttribute("inert", "");
3355
- made.push(sib);
3356
- }
3357
- }
3358
- }
3359
- return () => {
3360
- for (const el of made) el.removeAttribute("inert");
3361
- };
3362
- }, [showFocus]);
3363
- useEffect(() => {
3364
- if (turned.current === "in" && showFocus) document.querySelector("[data-focus-exit]")?.focus();
3365
- else if (turned.current === "out" && !showFocus) enterRef.current?.focus();
3366
- turned.current = null;
3367
- }, [showFocus]);
3448
+ const { show: showFocus, setFocus, enterRef, memory } = useFocusMode(focusCfg);
3449
+ const placeAbove = useContext(PanelPathContext);
3368
3450
  const variantId = variants?.activeId;
3369
3451
  const stateKey = useMemo(
3370
3452
  () => makeStateKey({ caseId, variantId: variantId ?? null, source: source ?? null, controls: controls ?? {} }),
@@ -3430,7 +3512,7 @@ function CaseWorkbench(props) {
3430
3512
  if (showFocus && focusCfg) {
3431
3513
  const open = tabs.find((t2) => t2.id === openGroup) ?? tabs[0];
3432
3514
  const caseName = cases?.cases.find((c) => c.id === caseId)?.name ?? caseId;
3433
- return /* @__PURE__ */ jsx(ViewMemoryContext.Provider, { value: memory.current, children: /* @__PURE__ */ jsx(WorkbenchStateContext.Provider, { value: { stateKey, caseId, variantId, source }, children: /* @__PURE__ */ jsx(
3515
+ return /* @__PURE__ */ jsx(ViewMemoryContext.Provider, { value: memory, children: /* @__PURE__ */ jsx(WorkbenchStateContext.Provider, { value: { stateKey, caseId, variantId, source }, children: /* @__PURE__ */ jsx(
3434
3516
  FocusShell,
3435
3517
  {
3436
3518
  title: focusCfg.title !== void 0 ? pick(focusCfg.title, lang) : caseName,
@@ -3440,12 +3522,12 @@ function CaseWorkbench(props) {
3440
3522
  exitLabel: FOCUS_TEXT.exit[lang],
3441
3523
  stageLabel: FOCUS_TEXT.stage[lang],
3442
3524
  landmark: false,
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 }) }) }) }) }),
3525
+ stage: /* @__PURE__ */ jsx("div", { className: "caos-cw caos-cw-focus", ...instrumentData, "data-focus-group": open?.id, "data-focus-param": focusCfg.param ?? FOCUS_PARAM, "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(PanelPathContext.Provider, { value: `${placeAbove}/${open.id}`, children: /* @__PURE__ */ jsx(PanelBoundary, { panel: open.id, children: /* @__PURE__ */ jsx(DocRefsScope, { children: open.content }) }) }) }) }) }),
3444
3526
  rail: /* @__PURE__ */ jsx("div", { className: "caos-focus-rail-body", children: /* @__PURE__ */ jsx(RailBody, { rail: rail ?? null, railHead: focusPickers, railLabel }) })
3445
3527
  }
3446
3528
  ) }) });
3447
3529
  }
3448
- return /* @__PURE__ */ jsx(ViewMemoryContext.Provider, { value: memory.current, children: /* @__PURE__ */ jsx(WorkbenchStateContext.Provider, { value: { stateKey, caseId, variantId, source }, children: /* @__PURE__ */ jsx(WorkbenchLayout, { rail: rail ?? null, railHead, railLabel, children: /* @__PURE__ */ jsx("div", { className: "caos-cw", ...instrumentData, children: /* @__PURE__ */ jsx(
3530
+ return /* @__PURE__ */ jsx(ViewMemoryContext.Provider, { value: memory, children: /* @__PURE__ */ jsx(WorkbenchStateContext.Provider, { value: { stateKey, caseId, variantId, source }, children: /* @__PURE__ */ jsx(WorkbenchLayout, { rail: rail ?? null, railHead, railLabel, children: /* @__PURE__ */ jsx("div", { className: "caos-cw", ...instrumentData, children: /* @__PURE__ */ jsx(
3449
3531
  Tabs,
3450
3532
  {
3451
3533
  tabs,
@@ -3655,4 +3737,4 @@ function ViewsRow({ children, shares }) {
3655
3737
  }) });
3656
3738
  }
3657
3739
 
3658
- export { AppShell, ArchitectureModal, BREAKPOINTS, BarChart, Callout, CardDeck, CaseSelector, CaseWorkbench, ChipGroup, CitationsProvider, Cite, DECK_TEXT, DOC_AREAS, DocPage, DocReferences, DocRefsScope, DocSection, EXECUTIVE_SCHEMA_VERSION, Equation, ExecutiveView, FLIP_LIMITS, FLIP_TEXT, FOCUS_PARAM, FOCUS_TEXT, FOUR_CS, Figure, FlipCard, FocusShell, Gauge, Glossary, InlineMath, Knob, Landing, LaneBadge, LanguageToggle, MAX_PEER_TABS, MAX_WORKBENCH_GROUPS, PanelBoundary, PlotCard, Question, Readout, ReferenceList, Refs, Stat, SubTabs, SurfacePage, TabGroups, Tabs, ThemeToggle, VariantBar, Verdict, ViewsRow, WorkbenchLayout, WorkbenchShell, WorkbenchStateContext, caseKind, caseTooltip, casesInSource, createVizLoop, deckPosition, deckWindow, firstAppearance, fitLabel, fontFamily, groupByCategory, isStale, makeStateKey, niceStep, niceTicks, perPageFor, readCaseParam, removeQueryParam, replaceQueryParam, sourcesPresent, textWidth, usePausedViz, useUrlView, useWorkbenchState, validateArchitectureConfig, validateExecutive, viewParamOf, widestLabel, withCaseParam };
3740
+ export { AppShell, ArchitectureModal, BREAKPOINTS, BarChart, Callout, CardDeck, CaseSelector, CaseWorkbench, ChipGroup, CitationsProvider, Cite, DECK_TEXT, DOC_AREAS, DocPage, DocReferences, DocRefsScope, DocSection, EXECUTIVE_SCHEMA_VERSION, Equation, ExecutiveView, FLIP_LIMITS, FLIP_TEXT, FOCUS_PARAM, FOCUS_TEXT, FOUR_CS, Figure, FlipCard, FocusShell, Gauge, Glossary, InlineMath, Knob, Landing, LaneBadge, LanguageToggle, MAX_PEER_TABS, MAX_WORKBENCH_GROUPS, PanelBoundary, PlotCard, Question, Readout, ReferenceList, Refs, Stat, SubTabs, SurfacePage, TabGroups, Tabs, ThemeToggle, VariantBar, Verdict, ViewsRow, WorkbenchLayout, WorkbenchShell, WorkbenchStateContext, caseKind, caseTooltip, casesInSource, createVizLoop, deckPosition, deckWindow, firstAppearance, fitLabel, fontFamily, groupByCategory, isStale, makeStateKey, niceStep, niceTicks, perPageFor, readCaseParam, removeQueryParam, replaceQueryParam, sourcesPresent, textWidth, useFocusMode, usePausedViz, useUrlView, useWorkbenchState, validateArchitectureConfig, validateExecutive, viewParamOf, widestLabel, withCaseParam };
package/gate/inpage.mjs CHANGED
@@ -1029,7 +1029,9 @@ export function installLib() {
1029
1029
  /** The App or Focus mode as the reader meets it: which one, the case and the group, the URL, the page and the rail. */
1030
1030
  G.focusFacts = () => {
1031
1031
  const shell = document.querySelector('[data-focus-shell]');
1032
- const stage = document.querySelector('[data-case-workbench]');
1032
+ // the instrument of the App: a case workbench, or (a surface App, 0.16.0) the surface or its Focus frame
1033
+ // by explicit order, never the first match in document order: a workbench nested in a surface view is read as the workbench
1034
+ const stage = document.querySelector('[data-case-workbench]') ?? document.querySelector('[data-surface-focus]') ?? document.querySelector('[data-surface]');
1033
1035
  const rail = document.querySelector('.caos-focus-rail');
1034
1036
  const lists = [...document.querySelectorAll('[data-focus-shell] [role="tablist"]')].filter(visible);
1035
1037
  const rows = lists.map((t) => new Set([...t.querySelectorAll('[role="tab"]')].map((b) => Math.round(b.getBoundingClientRect().top))).size);
@@ -1062,11 +1064,17 @@ export function installLib() {
1062
1064
  focus: Boolean(shell),
1063
1065
  covered,
1064
1066
  enterInside: [...document.querySelectorAll('[data-focus-shell] [data-focus-enter]')].filter(visible).length,
1065
- app: Boolean(document.querySelector('[data-workbench]')),
1067
+ app: Boolean(document.querySelector('[data-workbench], [data-surface]')),
1066
1068
  caseId: stage ? stage.getAttribute('data-case') : null,
1067
1069
  group: shell ? (stage ? stage.getAttribute('data-focus-group') : null) : panel ? panel.getAttribute('data-panel') : null,
1068
1070
  search: location.search,
1069
- subview: (() => { const t = document.querySelector('[data-case-workbench] .subtab.active'); return t ? t.getAttribute('data-tab') : null; })(),
1071
+ // the query parameter that holds Focus (`focus`, or `param` of the product's focus configuration), written on the Focus frame
1072
+ focusParam: (() => { const e = document.querySelector('[data-focus-param]'); return e ? e.getAttribute('data-focus-param') : null; })(),
1073
+ // a head with no title, lede or actions (an empty head row) on a surface
1074
+ emptyHead: (() => { const h = document.querySelector('[data-surface] > .caos-surface-head'); return h && !h.querySelector('.caos-surface-title, .caos-surface-lede, .caos-surface-actions') ? Math.round(h.getBoundingClientRect().height) : null; })(),
1075
+ // the query parameter the surface holds its open view in (`deepLinkView`): `view`, or the product's own name
1076
+ viewParam: (() => { const e = document.querySelector('[data-surface-focus]') ?? document.querySelector('[data-surface]'); return e && e.hasAttribute('data-view-param') ? e.getAttribute('data-view-param') : null; })(),
1077
+ subview: (() => { const t = document.querySelector('[data-case-workbench] .subtab.active, [data-surface] .subtab.active, [data-surface-focus] .subtab.active'); return t ? t.getAttribute('data-tab') : null; })(),
1070
1078
  nestedMain: document.querySelectorAll('main').length > 1 || Boolean(document.querySelector('main main')),
1071
1079
  vw: window.innerWidth,
1072
1080
  vh: window.innerHeight,
@@ -1281,6 +1289,75 @@ export function installLib() {
1281
1289
  return [...new Set(out)].slice(0, 12);
1282
1290
  };
1283
1291
 
1292
+ /**
1293
+ * 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
1294
+ * "35%" that broke into "3", "5", "%" in a squeezed flex row; no other check sees it, because the pieces are neither
1295
+ * clipped nor outside their box. A short value is a run of characters without breaking whitespace (U+0020, tab, CR,
1296
+ * 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
1297
+ * sentence is stripped from its ends, with a digit, only digits, signs (the hyphens and dashes included), separators,
1298
+ * 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,
1299
+ * 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;
1300
+ * a break at those places is a normal line break, so the pieces between them are what must stay whole. A leading sign
1301
+ * stays in the first segment (a lone "-" apart from "3,2" is a broken value). The lines of a segment of 2 or more
1302
+ * characters are the groups of its Range's client rects by top (2px); a segment on more than one line fails the value,
1303
+ * also when it is longer than its container: a segment is never cut into pieces. Two values apart by a regular space
1304
+ * ("12 %") are two runs and may wrap between them. Limits: text nodes only (a value split across elements, <b>35</b>%,
1305
+ * is not read); SVG text is G10's
1306
+ * subject and canvas text cannot be read; code, formulas and anything marked translate="no" are skipped.
1307
+ */
1308
+ G.brokenValues = () => {
1309
+ const out = [];
1310
+ const skip = 'code, pre, kbd, samp, .katex, svg, canvas, textarea, [translate="no"], [aria-hidden="true"], script, style';
1311
+ const runRe = /[^ \t\r\n]+/g;
1312
+ const BREAK_AFTER = '-\u2010\u2011\u2013\u2014/';
1313
+ const strip = /^[()[\]"',;:.]+|[()[\]"',;:.]+$/g;
1314
+ const allowed = /^[\p{Nd}+\-\u2010\u2011\u2013\u2014\u2212\u00b1.,:/'\u00a0\u202f$\u20ac\u00a3\u00a5%\u2030\u00d7\u00b0\p{L}]+$/u;
1315
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
1316
+ const range = document.createRange();
1317
+ const seen = new Set();
1318
+ for (let n = walker.nextNode(); n && out.length < 12; n = walker.nextNode()) {
1319
+ const text = n.nodeValue || '';
1320
+ if (!/\d/.test(text)) continue;
1321
+ const el = n.parentElement;
1322
+ if (!el || el.closest(skip) || !shown(el)) continue;
1323
+ for (const m of text.matchAll(runRe)) {
1324
+ const run = m[0];
1325
+ if (!/\d/.test(run)) continue;
1326
+ const lead = run.length - run.replace(/^[()[\]"',;:.]+/, '').length;
1327
+ const value = run.replace(strip, '');
1328
+ if (value.length < 2 || value.length > 16 || !/\d/.test(value) || !allowed.test(value)) continue;
1329
+ if ((value.match(/\p{L}/gu) || []).length > 4) continue;
1330
+ const start = m.index + lead;
1331
+ // a break right after a hyphen, a dash or a slash is a normal line break; the segments between them must stay whole.
1332
+ // A leading sign belongs to the first segment (a "-" apart from "3,2" is a broken value).
1333
+ const cuts = [0];
1334
+ for (let i = 1; i < value.length - 1; i += 1) if (BREAK_AFTER.includes(value[i])) cuts.push(i + 1);
1335
+ cuts.push(value.length);
1336
+ let worst = 1;
1337
+ for (let k = 0; k + 1 < cuts.length; k += 1) {
1338
+ if (cuts[k + 1] - cuts[k] < 2) continue;
1339
+ range.setStart(n, start + cuts[k]);
1340
+ range.setEnd(n, start + cuts[k + 1]);
1341
+ const tops = [];
1342
+ for (const r of range.getClientRects()) {
1343
+ if (r.width <= 0.5) continue;
1344
+ if (!tops.some((t) => Math.abs(t - r.top) <= 2)) tops.push(r.top);
1345
+ }
1346
+ worst = Math.max(worst, tops.length);
1347
+ }
1348
+ if (worst > 1) {
1349
+ const msg = `"${value}" breaks across ${worst} lines (${describe(el)})`;
1350
+ if (!seen.has(msg)) {
1351
+ seen.add(msg);
1352
+ out.push(msg);
1353
+ if (out.length >= 12) break;
1354
+ }
1355
+ }
1356
+ }
1357
+ }
1358
+ return out;
1359
+ };
1360
+
1284
1361
  /** The scroll container of an element: the nearest ancestor that scrolls vertically, or the document. */
1285
1362
  const scroller = (el) => {
1286
1363
  for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
package/gate/run.mjs CHANGED
@@ -29,6 +29,9 @@
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
30
  // group (by the exit control and by Escape); no page scroll, the rail without scroll, one tab row; no stage
31
31
  // control under the Focus chrome (label, HUD, actions); no Focus control inside the Focus frame
32
+ // on a surface App (--workbench '', --app-route; 0.16.0) the same for the views of its tab row: another view or
33
+ // sub-view in Focus or after the return, no ?focus=1 and view in the URL, no view that offers the control
34
+ // G19 values whole a short value (number, percentage, amount, unit) sits on one line in every state
32
35
  // plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
33
36
  // references).
34
37
 
@@ -44,6 +47,7 @@ export const DEFAULTS = {
44
47
  themes: 'light,dark',
45
48
  langs: 'en,es',
46
49
  workbench: '/app',
50
+ appRoute: '/app',
47
51
  idleMs: 3000,
48
52
  settleMs: 15000,
49
53
  textFloor: 400,
@@ -92,6 +96,8 @@ export async function runGate(o) {
92
96
  const themes = String(opt.themes).split(',').map((s) => s.trim());
93
97
  const langs = String(opt.langs).split(',').map((s) => s.trim());
94
98
  const workbenchRoute = String(opt.workbench).split(',')[0].trim() || '/app';
99
+ // the App route of a product whose App is a surface (`--workbench ''`): G18 measures its Focus mode (shell 0.16.0)
100
+ const appRoute = String(opt.appRoute ?? '').trim() || '/app';
95
101
  const workbenchRoutes = new Set(String(opt.workbench).split(',').map((s) => s.trim()).filter(Boolean));
96
102
  const primarySize = sizes.find(([w]) => w >= opt.viewportMinWidth) ?? sizes[0];
97
103
  const shotsDir = join(opt.out, 'shots');
@@ -102,7 +108,7 @@ export async function runGate(o) {
102
108
  let server = null;
103
109
  let base = opt.url;
104
110
  if (opt.serve) {
105
- server = await servePages(opt.serve, opt.basePath ?? '/');
111
+ server = await servePages(opt.serve, opt.basePath ?? '/', 0, { spaFallback: Boolean(opt.spaFallback) });
106
112
  base = server.url;
107
113
  }
108
114
  base = base.endsWith('/') ? base : `${base}/`;
@@ -258,6 +264,11 @@ export async function runGate(o) {
258
264
  await ctx.close();
259
265
  }
260
266
 
267
+ // ---- G18 on a surface App: under --require-focus the App route must be among the measured routes, or nothing would be measured
268
+ if (opt.requireFocus && workbenchRoutes.size === 0 && !routes.includes(appRoute)) {
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
+ }
271
+
261
272
  // ---- 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.
262
273
  {
263
274
  const ctx = await openContext(primarySize, themes[0], langs[0]);
@@ -343,6 +354,7 @@ export async function runGate(o) {
343
354
  if (walkState.doc) docResult(at, lang, walkState.doc, Math.max(1, leaves));
344
355
 
345
356
  if (isWorkbench) await walkCases(page, sink, at, { route, isWorkbench, primary, interact: idleHere, size, w, h, theme, lang, walkState, contrast });
357
+ else if (workbenchRoutes.size === 0 && route === appRoute) await surfaceFocusWalk(page, sink, at, { route, isWorkbench, primary, interact: idleHere, size, w, h, theme, lang, walkState, contrast });
346
358
 
347
359
  if (primary && isWorkbench) {
348
360
  const f = await page.evaluate(() => window.__caosGate.facts());
@@ -465,6 +477,7 @@ export async function runGate(o) {
465
477
  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)`);
466
478
  for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', where, m);
467
479
  if (s.lang === 'es') for (const m of await page.evaluate(() => window.__caosGate.decimals())) fail('G11', where, m);
480
+ for (const m of await page.evaluate(() => window.__caosGate.brokenValues())) fail('G19', where, m);
468
481
  if (s.w > 760) for (const m of await page.evaluate(() => window.__caosGate.sticky())) fail('G12', where, m);
469
482
  if (s.contrast) for (const m of await page.evaluate(() => window.__caosGate.contrast())) fail('G13', where, m);
470
483
  if (f.sectionsWithoutRefs) fail('ADR-0017.4', where, `${f.sectionsWithoutRefs} section(s) end without references or a stated reason`);
@@ -845,16 +858,18 @@ export async function runGate(o) {
845
858
  await subs.nth(sn - 1).click({ timeout: 4000 }).catch(() => undefined);
846
859
  await settle(page);
847
860
  }
848
- await focusVisitOne(page, sink, where, s, caseId, index + k, total, enter);
861
+ await focusVisitOne(page, sink, where, s, { kind: 'case', id: caseId, total }, index + k, enter);
849
862
  }
850
863
  await tabs.nth(0).click({ timeout: 4000 }).catch(() => undefined);
851
864
  await settle(page);
852
865
  }
853
866
 
854
- async function focusVisitOne(page, sink, where, s, caseId, index, total, enter) {
855
- const at = { ...where, trail: `case ${caseId} > Focus` };
867
+ async function focusVisitOne(page, sink, where, s, subject, index, enter) {
868
+ const isView = subject.kind === 'view';
869
+ const caseId = subject.id;
870
+ const at = { ...where, trail: `${subject.kind} ${subject.id} > Focus` };
856
871
  const facts = () => page.evaluate(() => window.__caosGate.focusFacts());
857
- const label = `Focus mode of case ${caseId}`;
872
+ const label = `Focus mode of ${subject.kind} ${subject.id}`;
858
873
  const before = await facts();
859
874
  try {
860
875
  await enter.evaluate((el) => window.__caosGate.bringEl(el)).catch(() => undefined);
@@ -870,11 +885,18 @@ export async function runGate(o) {
870
885
  fail('G18', at, `${label}: the Focus control did not open Focus mode`);
871
886
  return;
872
887
  }
873
- if (f.caseId !== caseId) fail('G18', at, `${label} shows case ${f.caseId}`);
874
- if (f.group !== before.group) fail('G18', at, `${label} shows group ${f.group}, the App had ${before.group}`);
888
+ if (!isView && f.caseId !== caseId) fail('G18', at, `${label} shows case ${f.caseId}`);
889
+ if (f.group !== before.group) fail('G18', at, `${label} shows ${isView ? 'view' : 'group'} ${f.group}, the App had ${before.group}`);
875
890
  if (f.subview !== before.subview) fail('G18', at, `${label} shows sub-view ${f.subview}, the App had ${before.subview}`);
876
891
  const q = new URLSearchParams(f.search);
877
- if (q.get('focus') !== '1' || (total > 1 && q.get('case') !== caseId)) fail('G18', at, `${label}: the URL (${f.search || 'no query'}) does not hold ?focus=1 and the case`);
892
+ // the parameter the App holds Focus in (`focus`, or the product's own `param`), named by the Focus frame
893
+ const fp = f.focusParam || 'focus';
894
+ if (isView) {
895
+ // the view the App held in its URL stays there (a surface that keeps its view out of the URL has none to keep)
896
+ const param = before.viewParam || 'view';
897
+ const held = new URLSearchParams(before.search).get(param);
898
+ if (q.get(fp) !== '1' || (held !== null && q.get(param) !== held)) fail('G18', at, `${label}: the URL (${f.search || 'no query'}) does not hold ?${fp}=1 and the view (?${param}=)`);
899
+ } else if (q.get(fp) !== '1' || (subject.total > 1 && q.get('case') !== caseId)) fail('G18', at, `${label}: the URL (${f.search || 'no query'}) does not hold ?${fp}=1 and the case`);
878
900
  if (f.docW > f.vw + 1 || f.docH > f.vh + 1) fail('G18', at, `${label}: the page scrolls (the document is ${f.docW}x${f.docH}px in a ${f.vw}x${f.vh}px viewport); Focus mode is the viewport`);
879
901
  if (f.nestedMain) fail('G18', at, `${label}: the page holds a main inside a main (the Focus frame must not be a second landmark inside the App's main)`);
880
902
  // the keyboard stays in the Focus frame: the App behind it is out of the tab order
@@ -893,6 +915,7 @@ export async function runGate(o) {
893
915
  for (const c of f.covered) fail('G18', at, `${label}: ${c.chrome} covers the control "${c.control}" (the stage must clear the Focus chrome)`);
894
916
  if (f.enterInside > 0) fail('G18', at, `${label}: the Focus control shows inside Focus mode (it opens the mode the reader is already in)`);
895
917
  for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', at, m);
918
+ for (const m of await page.evaluate(() => window.__caosGate.brokenValues())) fail('G19', at, m);
896
919
  if (s.contrast) for (const m of await page.evaluate(() => window.__caosGate.contrast())) fail('G13', at, m);
897
920
  if (index === 0) await saveShot(page, `focus_${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}.png`);
898
921
  // leave: the exit control for one case, Escape for the next, so both ways are walked
@@ -914,18 +937,103 @@ export async function runGate(o) {
914
937
  const g = await facts();
915
938
  if (g.focus) {
916
939
  fail('G18', at, `${label}: ${byButton && left ? 'the exit control' : 'Escape'} does not return to the App`);
917
- await page.goto(`${urlFor(s.route)}?case=${encodeURIComponent(caseId)}`, { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
940
+ await page.goto(`${urlFor(s.route)}?${isView ? (before.viewParam || 'view') : 'case'}=${encodeURIComponent(caseId)}`, { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
918
941
  await settle(page);
919
942
  } else {
920
943
  if (!g.app) fail('G18', at, `${label}: back from Focus mode there is no App on the page`);
921
- if (g.caseId !== caseId) fail('G18', at, `${label}: back in the App on case ${g.caseId}, it was left on case ${caseId}`);
922
- if (g.group !== before.group) fail('G18', at, `${label}: back in the App on group ${g.group}, it was left on group ${before.group}`);
944
+ if (!isView && g.caseId !== caseId) fail('G18', at, `${label}: back in the App on case ${g.caseId}, it was left on case ${caseId}`);
945
+ if (g.group !== before.group) fail('G18', at, `${label}: back in the App on ${isView ? 'view' : 'group'} ${g.group}, it was left on ${isView ? 'view' : 'group'} ${before.group}`);
923
946
  if (g.subview !== before.subview) fail('G18', at, `${label}: back in the App on sub-view ${g.subview}, it was left on sub-view ${before.subview}`);
924
- if (new URLSearchParams(g.search).has('focus')) fail('G18', at, `${label}: the URL still holds ?focus after the return`);
947
+ if (new URLSearchParams(g.search).has(fp)) fail('G18', at, `${label}: the URL still holds ?${fp} after the return`);
925
948
  }
926
949
  drain(sink, at);
927
950
  }
928
951
 
952
+ /**
953
+ * G18 on an App that is a surface (`--workbench ''`, the App route `--app-route`, shell 0.16.0): every view of the surface's tab row is
954
+ * opened and its tab row measured (the row and the head must not move between views: the Focus control must add no row, ADR-0071);
955
+ * each view that offers the Focus control is entered from its last sub-view (the first view is where the walk leaves the App, so a
956
+ * Focus that fell back to it would go unseen) and, in the primary mode, left both by the exit button and by Escape; outside the primary
957
+ * mode, the last focusable view only. The same failures as for a workbench apply, with the view in place of the case and the group.
958
+ */
959
+ async function surfaceFocusWalk(page, sink, at, s) {
960
+ const rowTabs = page.locator('[data-surface] > .caos-surface-body > .tabs > [role="tablist"] > [role="tab"]');
961
+ const n = await rowTabs.count();
962
+ const focusable = [];
963
+ const rows = [];
964
+ // the tab row and the head, from the surface's own top (the document may have scrolled)
965
+ const geometry = () =>
966
+ page.evaluate(() => {
967
+ const surface = document.querySelector('[data-surface]');
968
+ const list = document.querySelector('[data-surface] > .caos-surface-body > .tabs > [role="tablist"]');
969
+ if (!surface || !list) return null;
970
+ const top = surface.getBoundingClientRect().top;
971
+ const r = list.getBoundingClientRect();
972
+ const head = document.querySelector('[data-surface] > .caos-surface-head');
973
+ const round = (x) => Math.round(x * 10) / 10;
974
+ // the height the tabs themselves need (their lowest edge, the row's bottom padding and rule): anything above it is height something
975
+ // else gave the row, such as a taller Focus control in the slot beside it
976
+ const cs = getComputedStyle(list);
977
+ // the tabs stretch to the row, so they are measured at their natural height (aligned to the start for this one reading)
978
+ const before = list.style.alignItems;
979
+ list.style.alignItems = 'flex-start';
980
+ const lowest = Math.max(0, ...[...list.querySelectorAll('[role="tab"]')].map((b) => b.getBoundingClientRect().bottom));
981
+ list.style.alignItems = before;
982
+ const need = lowest - r.top + parseFloat(cs.paddingBottom) + parseFloat(cs.borderBottomWidth);
983
+ return { top: round(r.top - top), height: round(r.height), head: head ? round(head.getBoundingClientRect().height) : 0, slack: round(r.height - need) };
984
+ });
985
+ for (let i = 0; i < n; i += 1) {
986
+ await rowTabs.nth(i).click({ timeout: 4000 }).catch(() => undefined);
987
+ await settle(page);
988
+ const shown = (await page.locator('[data-focus-enter]').filter({ visible: true }).count()) > 0;
989
+ const facts = await page.evaluate(() => window.__caosGate.focusFacts());
990
+ if (shown) focusable.push({ index: i, id: facts.group });
991
+ rows.push({ id: facts.group, geo: await geometry() });
992
+ }
993
+ if (focusable.length > 0 && rows[0]?.geo) {
994
+ for (const r of rows.slice(1)) {
995
+ if (!r.geo) continue;
996
+ const g0 = rows[0].geo;
997
+ if (r.geo.top !== g0.top || r.geo.height !== g0.height || r.geo.head !== g0.head) {
998
+ fail('G18', { ...at, trail: `view ${r.id}` }, `the surface's tab row or head moves between views (view ${rows[0].id}: row at ${g0.top}px, ${g0.height}px high, head ${g0.head}px; view ${r.id}: row at ${r.geo.top}px, ${r.geo.height}px high, head ${r.geo.head}px): a view must not add a row or shift the tab row (ADR-0071); the Focus control sits on the tab row with its space reserved and adds none, and a per-view head is the product's to keep steady`);
999
+ break;
1000
+ }
1001
+ }
1002
+ }
1003
+ if (focusable.length > 0) {
1004
+ const tall = rows.find((r) => r.geo && r.geo.slack > 3);
1005
+ if (tall) fail('G18', { ...at, trail: `view ${tall.id}` }, `the surface's tab row is ${tall.geo.slack}px taller than its tabs need (view ${tall.id}, row ${tall.geo.height}px): something beside the tabs, such as a taller Focus control in the tab row's slot, adds height to the row on every view (ADR-0071)`);
1006
+ const empty = (await page.evaluate(() => window.__caosGate.focusFacts())).emptyHead;
1007
+ if (empty !== null) fail('G18', { ...at, trail: 'surface head' }, `the surface renders a head with no title, lede or actions (an empty head row ${empty}px high): a surface without them has no head, and the Focus control sits on the tab row, never in a head of its own (ADR-0071)`);
1008
+ }
1009
+ if (focusable.length === 0) {
1010
+ if (opt.requireFocus) {
1011
+ fail('G18', { ...at, trail: 'surface > Focus' }, `the surface App (${at.route}) offers no Focus control ([data-focus-enter]) on any of its ${n} view(s): ADR-0070 requires a visible control on every view with a stage that opens it in focus mode (set TabDef.focus on the view and focus on the SurfacePage)`);
1012
+ }
1013
+ if (n > 0) await rowTabs.nth(0).click({ timeout: 4000 }).catch(() => undefined);
1014
+ await settle(page);
1015
+ return;
1016
+ }
1017
+ const targets = s.primary ? focusable : focusable.slice(-1);
1018
+ for (const [k, v] of targets.entries()) {
1019
+ // the primary mode leaves each focusable view twice: by the exit button (even index), then by Escape (odd index)
1020
+ for (const way of s.primary ? [0, 1] : [0]) {
1021
+ await rowTabs.nth(v.index).click({ timeout: 4000 }).catch(() => undefined);
1022
+ await settle(page);
1023
+ const subs = page.locator('[data-surface] .subtablist [role="tab"]');
1024
+ const sn = await subs.count();
1025
+ if (sn > 1) {
1026
+ await subs.nth(sn - 1).click({ timeout: 4000 }).catch(() => undefined);
1027
+ await settle(page);
1028
+ }
1029
+ const enter = page.locator('[data-focus-enter]').filter({ visible: true }).first();
1030
+ await focusVisitOne(page, sink, at, s, { kind: 'view', id: v.id }, 2 * k + way, enter);
1031
+ }
1032
+ }
1033
+ await rowTabs.nth(0).click({ timeout: 4000 }).catch(() => undefined);
1034
+ await settle(page);
1035
+ }
1036
+
929
1037
  async function workbenchKey(page) {
930
1038
  return page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-state-key') ?? null);
931
1039
  }
package/gate/serve.mjs CHANGED
@@ -3,6 +3,12 @@
3
3
  // index.html; `/name` falls back to `name.html`; anything else is 404 with the site's 404.html. There is no SPA
4
4
  // fallback, which is the point: a route that was not materialised, or an artifact missing from the build, fails here
5
5
  // exactly as it would on Pages (failure classes 2 and 22: deep links that 404, artifacts answered with HTML).
6
+ // With `spaFallback` (`--spa-fallback`, shell 0.16.0) it answers as nginx does for a single-page app on a VPS
7
+ // (`try_files $uri $uri/ /index.html`): an extensionless path that matches no file gets index.html with 200; a path with an
8
+ // extension that matches no file stays 404, so a missing artifact still fails. Edge paths, by design of a gate server and tested:
9
+ // a directory with no index.html answers the app shell (nginx: 301 then 403); a dot in a directory name reads as an extension, so
10
+ // `/route/with.dot/` and `/app/run.v2` stay 404 (nginx would serve the app); a MISSING dotfile such as `/.env` has no extension and
11
+ // answers the app shell (an existing file, a dotfile included, is served as it is); the query string is no part of the path (`/route?x=1.5` answers the app shell, `/missing.js?v=2` is 404).
6
12
 
7
13
  import { createServer } from 'node:http';
8
14
  import { existsSync, readFileSync, statSync } from 'node:fs';
@@ -37,12 +43,14 @@ const MIME = {
37
43
  * @param {string} dir the built site
38
44
  * @param {string} basePath the site's base path ('/' for a custom domain, '/Repo/' for a project page)
39
45
  * @param {number} port 0 for any free port (the gate), or a fixed one (a product's preview)
46
+ * @param {{ spaFallback?: boolean }} options `spaFallback`: answer an extensionless path that matches no file with index.html and 200
40
47
  * @returns {Promise<{ url: string, close: () => Promise<void> }>}
41
48
  */
42
- export function servePages(dir, basePath = '/', port = 0) {
49
+ export function servePages(dir, basePath = '/', port = 0, options = {}) {
43
50
  const root = resolve(dir);
44
51
  const base = basePath.endsWith('/') ? basePath : `${basePath}/`;
45
52
  const notFound = join(root, '404.html');
53
+ const rootIndex = join(root, 'index.html');
46
54
  const server = createServer((req, res) => {
47
55
  const send = (status, file, headers = {}) => {
48
56
  res.writeHead(status, { 'Content-Type': MIME[extname(file).toLowerCase()] ?? 'application/octet-stream', 'Cache-Control': 'no-store', ...headers });
@@ -57,6 +65,8 @@ export function servePages(dir, basePath = '/', port = 0) {
57
65
  };
58
66
  let path;
59
67
  let search = '';
68
+ // nginx `try_files $uri $uri/ /index.html`: a path with no extension that is no file answers the app shell
69
+ const miss = () => (options.spaFallback && !extname(path ?? '') && existsSync(rootIndex) ? send(200, rootIndex) : missing());
60
70
  try {
61
71
  const u = new URL(req.url ?? '/', 'http://x');
62
72
  path = decodeURIComponent(u.pathname);
@@ -77,7 +87,7 @@ export function servePages(dir, basePath = '/', port = 0) {
77
87
  if (st.isFile()) return send(200, file);
78
88
  if (st.isDirectory()) {
79
89
  const index = join(file, 'index.html');
80
- if (!existsSync(index)) return missing();
90
+ if (!existsSync(index)) return miss();
81
91
  if (!path.endsWith('/')) {
82
92
  // the query string survives the redirect, as a Pages host keeps it (an old link /app?case=B2 must reach its case)
83
93
  res.writeHead(301, { Location: `${path}/${search}` });
@@ -87,7 +97,7 @@ export function servePages(dir, basePath = '/', port = 0) {
87
97
  }
88
98
  }
89
99
  if (!path.endsWith('/') && existsSync(`${file}.html`)) return send(200, `${file}.html`);
90
- return missing();
100
+ return miss();
91
101
  });
92
102
  return new Promise((ok) => {
93
103
  server.listen(port, '127.0.0.1', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fasl-work/caos-app-shell",
3
- "version": "0.14.3",
3
+ "version": "0.16.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",
@@ -155,6 +155,7 @@
155
155
  "caos-surface",
156
156
  "caos-surface-actions",
157
157
  "caos-surface-body",
158
+ "caos-surface-focus",
158
159
  "caos-surface-head",
159
160
  "caos-surface-heading",
160
161
  "caos-surface-lede",
@@ -277,6 +278,8 @@
277
278
  "tablist",
278
279
  "tabpanel",
279
280
  "tabs",
281
+ "tabs-has-slot",
282
+ "tabs-slot",
280
283
  "th-refs",
281
284
  "th-refs-label",
282
285
  "tick-list",
@@ -292,6 +295,7 @@
292
295
  "accent",
293
296
  "active",
294
297
  "caos-col-text",
298
+ "caos-focus-norail",
295
299
  "compact",
296
300
  "contain",
297
301
  "error",
@@ -413,6 +417,7 @@
413
417
  "caos-focus-hud",
414
418
  "caos-focus-instrument",
415
419
  "caos-focus-label",
420
+ "caos-focus-norail",
416
421
  "caos-focus-rail",
417
422
  "caos-focus-rail-body",
418
423
  "caos-focus-shell",
@@ -462,6 +467,7 @@
462
467
  "caos-surface",
463
468
  "caos-surface-actions",
464
469
  "caos-surface-body",
470
+ "caos-surface-focus",
465
471
  "caos-surface-head",
466
472
  "caos-surface-heading",
467
473
  "caos-surface-lede",
@@ -596,6 +602,8 @@
596
602
  "tablist",
597
603
  "tabpanel",
598
604
  "tabs",
605
+ "tabs-has-slot",
606
+ "tabs-slot",
599
607
  "th-refs",
600
608
  "th-refs-label",
601
609
  "tick-list",
package/styles.css CHANGED
@@ -837,3 +837,24 @@ svg.lucide { flex: none; }
837
837
  @media (max-width: 900px) {
838
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)); }
839
839
  }
840
+
841
+ /* == Focus mode for a SurfacePage App (shell 0.16.0, ADR-0070) ======================================================== */
842
+
843
+ /* The stage is the open view, laid out as CaseWorkbench's group is (the same clearance under the Focus chrome); a view with no rail
844
+ leaves the frame one column, so the stage is the whole frame. */
845
+ .caos-focus-instrument > .caos-surface-focus { position: absolute; inset: 0; display: flex; flex-direction: column; min-width: 0; min-height: 0; padding: var(--caos-focus-clear-top, 4.6rem) 0.9rem max(0.9rem, var(--caos-focus-clear-bottom, 0px)); }
846
+ .caos-surface-focus > .tabs { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
847
+ .caos-surface-focus > .tabs > .tabpanel:not([hidden]) { flex: 1 1 auto; min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 0.6rem; }
848
+ .caos-focus-shell.caos-focus-norail { grid-template-columns: minmax(0,1fr); }
849
+ @media (max-width: 900px) {
850
+ .caos-focus-instrument > .caos-surface-focus { padding: var(--caos-focus-clear-top, 6.6rem) 0.5rem max(0.5rem, var(--caos-focus-clear-bottom, 0px)); }
851
+ .caos-focus-shell.caos-focus-norail { grid-template-rows: minmax(0,1fr); }
852
+ }
853
+ /* The Focus control of a surface sits at the end of the tab row, in a slot beside the tab list, on the same row (never in the head, never
854
+ inside the tablist). The slot is always there while the surface has Focus, so the row does not move between views; the tabs scroll
855
+ beside it. The slot repeats the tab list's bottom rule, so the line runs under both. */
856
+ .caos-surface-body > .tabs.tabs-has-slot { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr); column-gap: 0.5rem; }
857
+ .tabs-has-slot > .tablist { grid-column: 1; grid-row: 1; }
858
+ .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
+ .tabs-has-slot > .tabs-slot > .caos-focus-enter { align-self: center; padding-block: 0.2rem; }
860
+ .tabs-has-slot > .tabpanel { grid-column: 1 / -1; grid-row: 2; }