@fasl-work/caos-app-shell 0.15.0 → 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.
@@ -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,
package/gate/run.mjs CHANGED
@@ -29,6 +29,8 @@
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
32
34
  // G19 values whole a short value (number, percentage, amount, unit) sits on one line in every state
33
35
  // plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
34
36
  // references).
@@ -45,6 +47,7 @@ export const DEFAULTS = {
45
47
  themes: 'light,dark',
46
48
  langs: 'en,es',
47
49
  workbench: '/app',
50
+ appRoute: '/app',
48
51
  idleMs: 3000,
49
52
  settleMs: 15000,
50
53
  textFloor: 400,
@@ -93,6 +96,8 @@ export async function runGate(o) {
93
96
  const themes = String(opt.themes).split(',').map((s) => s.trim());
94
97
  const langs = String(opt.langs).split(',').map((s) => s.trim());
95
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';
96
101
  const workbenchRoutes = new Set(String(opt.workbench).split(',').map((s) => s.trim()).filter(Boolean));
97
102
  const primarySize = sizes.find(([w]) => w >= opt.viewportMinWidth) ?? sizes[0];
98
103
  const shotsDir = join(opt.out, 'shots');
@@ -103,7 +108,7 @@ export async function runGate(o) {
103
108
  let server = null;
104
109
  let base = opt.url;
105
110
  if (opt.serve) {
106
- server = await servePages(opt.serve, opt.basePath ?? '/');
111
+ server = await servePages(opt.serve, opt.basePath ?? '/', 0, { spaFallback: Boolean(opt.spaFallback) });
107
112
  base = server.url;
108
113
  }
109
114
  base = base.endsWith('/') ? base : `${base}/`;
@@ -259,6 +264,11 @@ export async function runGate(o) {
259
264
  await ctx.close();
260
265
  }
261
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
+
262
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.
263
273
  {
264
274
  const ctx = await openContext(primarySize, themes[0], langs[0]);
@@ -344,6 +354,7 @@ export async function runGate(o) {
344
354
  if (walkState.doc) docResult(at, lang, walkState.doc, Math.max(1, leaves));
345
355
 
346
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 });
347
358
 
348
359
  if (primary && isWorkbench) {
349
360
  const f = await page.evaluate(() => window.__caosGate.facts());
@@ -847,16 +858,18 @@ export async function runGate(o) {
847
858
  await subs.nth(sn - 1).click({ timeout: 4000 }).catch(() => undefined);
848
859
  await settle(page);
849
860
  }
850
- 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);
851
862
  }
852
863
  await tabs.nth(0).click({ timeout: 4000 }).catch(() => undefined);
853
864
  await settle(page);
854
865
  }
855
866
 
856
- async function focusVisitOne(page, sink, where, s, caseId, index, total, enter) {
857
- 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` };
858
871
  const facts = () => page.evaluate(() => window.__caosGate.focusFacts());
859
- const label = `Focus mode of case ${caseId}`;
872
+ const label = `Focus mode of ${subject.kind} ${subject.id}`;
860
873
  const before = await facts();
861
874
  try {
862
875
  await enter.evaluate((el) => window.__caosGate.bringEl(el)).catch(() => undefined);
@@ -872,11 +885,18 @@ export async function runGate(o) {
872
885
  fail('G18', at, `${label}: the Focus control did not open Focus mode`);
873
886
  return;
874
887
  }
875
- if (f.caseId !== caseId) fail('G18', at, `${label} shows case ${f.caseId}`);
876
- 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}`);
877
890
  if (f.subview !== before.subview) fail('G18', at, `${label} shows sub-view ${f.subview}, the App had ${before.subview}`);
878
891
  const q = new URLSearchParams(f.search);
879
- 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`);
880
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`);
881
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)`);
882
902
  // the keyboard stays in the Focus frame: the App behind it is out of the tab order
@@ -917,18 +937,103 @@ export async function runGate(o) {
917
937
  const g = await facts();
918
938
  if (g.focus) {
919
939
  fail('G18', at, `${label}: ${byButton && left ? 'the exit control' : 'Escape'} does not return to the App`);
920
- 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);
921
941
  await settle(page);
922
942
  } else {
923
943
  if (!g.app) fail('G18', at, `${label}: back from Focus mode there is no App on the page`);
924
- if (g.caseId !== caseId) fail('G18', at, `${label}: back in the App on case ${g.caseId}, it was left on case ${caseId}`);
925
- 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}`);
926
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}`);
927
- 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`);
928
948
  }
929
949
  drain(sink, at);
930
950
  }
931
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
+
932
1037
  async function workbenchKey(page) {
933
1038
  return page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-state-key') ?? null);
934
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.15.0",
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; }