@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.
- package/bin/caos-shell-gate.mjs +9 -3
- package/dist/index.d.ts +133 -45
- package/dist/index.js +165 -83
- package/gate/inpage.mjs +11 -3
- package/gate/run.mjs +117 -12
- package/gate/serve.mjs +13 -3
- package/package.json +1 -1
- package/reserved-classes.json +8 -0
- package/styles.css +21 -0
package/bin/caos-shell-gate.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
2926
|
-
const
|
|
2927
|
-
|
|
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
|
|
3319
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
857
|
-
const
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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",
|
package/reserved-classes.json
CHANGED
|
@@ -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; }
|