@cssints/apg-specs 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @cssints/apg-specs — APG widget specs
2
2
 
3
- The Quint specs behind [`@cssints/headless`](../headless/README.md): one spec per WAI-ARIA APG pattern (plus the canvas camera/pointer contract), the generated models and typed scaffolding, the `SpecBridge` for each pattern, the ported upstream scenario sets, and the spec-only verification. It is the test oracle of the widgets and of `native/headless`, and it is published so that widget libraries outside this repository can replay the same specs and scenarios against their own code.
3
+ The Quint specs behind [`@cssints/headless`](../headless/README.md): one spec per WAI-ARIA APG pattern, one per non-APG pattern composed of them (resizable panels), plus the canvas camera/pointer contract; the generated models and typed scaffolding, the `SpecBridge` for each pattern, the scenario sets (ported upstream ones and our own), and the spec-only verification. It is the test oracle of the widgets and of `native/headless`, and it is published so that widget libraries outside this repository can replay the same specs and scenarios against their own code.
4
4
 
5
5
  The generated files (`*.model.generated.json`, `*.scaffolding.generated.ts`) are written by the code generation of [`@quint-machine/core`](https://www.npmjs.com/package/@quint-machine/core) and are never edited by hand. Ticket ids like `effects-of-atmosphere-dx00` in the comments of the specs and tests are tickets of the repository these files came from.
6
6
 
@@ -9,12 +9,15 @@ The generated files (`*.model.generated.json`, `*.scaffolding.generated.ts`) are
9
9
  ```
10
10
  specs/apg/<pattern>.qnt # APG pattern specs (one behavior-machine each)
11
11
  specs/canvas/<machine>.qnt # canvas camera + pointer specs
12
+ specs/patterns/<machine>.qnt # non-APG patterns composed of APG widgets (resizable-panels)
12
13
  shared/<module>.qnt # vocabulary + pure defs: aria, focusgroup, popover, invokers, details-group
13
14
  src/<service>/<machine>.model.generated.json # generated model document
14
15
  src/<service>/<machine>.scaffolding.generated.ts # generated typed scaffolding
15
16
  src/apg/<pattern>.bridge.ts # scenario vocabulary <-> spec events/state
17
+ src/patterns/<machine>.bridge.ts # the same, for a non-APG pattern
16
18
  src/scenario.ts # scenario decoder + replay/differential-walk runtime
17
19
  scenarios/apg/*.json, scenarios/canvas/*.json # ported upstream scenario sets
20
+ scenarios/patterns/*.json # scenarios written from our own pattern specs
18
21
  scenarios/parts.json # part names per pattern
19
22
  test/<service>/<machine>.test.ts # spec-only verification
20
23
  scripts/generate-all.ts # regenerates every spec under specs/
@@ -31,7 +34,6 @@ Run in `packages/apg-specs`:
31
34
  - `bun run test` — spec-only verification (`vp test run`). The exhaustive explorations (player, camera) take a minute each and can time out on a loaded machine; run them alone before calling a failure real.
32
35
  - `bun run typecheck` — `tsc-rs` over `src`, `test` and `scripts`.
33
36
  - `bun run check` — all of the above, in order.
34
-
35
37
  - `bun run build` — `vp pack`: `dist/` (the runner and the bridges as ESM with `.d.mts`, the generated models as `.json`).
36
38
 
37
39
  ## Install and use
@@ -47,9 +49,12 @@ npm install @cssints/apg-specs effect @quint-machine/core
47
49
  | `@cssints/apg-specs/scenario` | the scenario decoder, `replayScenario`, `differentialWalk`, `specDriver`, `Unsupported`, driver and bridge types |
48
50
  | `@cssints/apg-specs/apg/<pattern>.bridge` | the `SpecBridge`, `<pattern>Config`, `<pattern>Steps` and `<pattern>Probe` of a pattern |
49
51
  | `@cssints/apg-specs/apg/<pattern>.model.json` | the generated model document (`src/apg/<pattern>.model.generated.json` in the repository) |
52
+ | `@cssints/apg-specs/patterns/<machine>.bridge` | the same for a non-APG pattern composed of APG widgets (`resizable-panels`) |
53
+ | `@cssints/apg-specs/patterns/<machine>.model.json` | its generated model document (`src/patterns/<machine>.model.generated.json`) |
50
54
  | `@cssints/apg-specs/canvas/{canvas.bridge,camera.model.json,pointer.model.json}` | the canvas bridge and models |
51
55
  | `@cssints/apg-specs/scenarios/apg/<pattern>.<set>.json` | a ported scenario set (also `scenarios/canvas/*.json`, `scenarios/parts.json`) |
52
- | `@cssints/apg-specs/specs/apg/<pattern>.qnt` | the raw Quint spec (also `specs/canvas/*.qnt`, `shared/*.qnt`, which the specs import) |
56
+ | `@cssints/apg-specs/scenarios/patterns/<machine>.spec.json` | a scenario set written from our own pattern spec |
57
+ | `@cssints/apg-specs/specs/apg/<pattern>.qnt` | the raw Quint spec (also `specs/canvas/*.qnt`, `specs/patterns/*.qnt`, `shared/*.qnt`, which the specs import) |
53
58
 
54
59
  ```ts
55
60
  import {
@@ -63,4 +68,4 @@ import buttonModel from "@cssints/apg-specs/apg/button.model.json" with { type:
63
68
  import baseUi from "@cssints/apg-specs/scenarios/apg/button.base-ui.json" with { type: "json" };
64
69
  ```
65
70
 
66
- In this repository the `development` (vite, tsc) and `bun` export conditions point at `src/`. The scenario format is specified in `scenarios/apg/README.md`.
71
+ In this repository the `development` (vite, tsc) and `bun` export conditions point at `src/`. The scenario format is specified in `scenarios/apg/README.md`; `scenarios/patterns/` holds the scenarios of our own non-APG patterns (same format, no upstream).
@@ -0,0 +1,44 @@
1
+ import { ActionStep, Probe, ScenarioConfig, SpecBridge } from "../scenario.mjs";
2
+ //#region src/patterns/resizable-panels.bridge.d.ts
3
+ export type PanelsOrientation = "vertical" | "horizontal";
4
+ /** The layouts the spec reaches by a host write (hostEqual / hostWide / ...). */
5
+ export declare const PANELS_LAYOUTS: {
6
+ readonly initial: readonly [20, 40, 40];
7
+ readonly equal: readonly [30, 30, 40];
8
+ readonly wide: readonly [50, 30, 20];
9
+ readonly narrow: readonly [10, 40, 50];
10
+ readonly collapsed: readonly [0, 50, 50];
11
+ };
12
+ export type PanelsLayout = keyof typeof PANELS_LAYOUTS;
13
+ export declare const PANEL_COUNT = 3;
14
+ export declare const PANEL_MIN = 10;
15
+ export declare const PANEL_MAX = 80;
16
+ /**
17
+ * Scenario `config` -> what both targets are mounted with. The spec is the
18
+ * three-pane instance (MIN 10, MAX 80, 10% steps, pane 0 collapsible at 0), so
19
+ * any other geometry is not modelled. `layout` only leaves the initial layout
20
+ * through a host write, which needs `controlled`; an uncontrolled scenario that
21
+ * starts elsewhere is `Unsupported` (it would have to be reached by keys, which
22
+ * no ported scenario needs).
23
+ */
24
+ export interface PanelsConfig {
25
+ readonly orientation: PanelsOrientation;
26
+ readonly layout: PanelsLayout;
27
+ readonly sizes: ReadonlyArray<number>;
28
+ readonly controlled: boolean;
29
+ readonly accepts: boolean;
30
+ }
31
+ export declare const resizablePanelsConfig: (config: ScenarioConfig) => PanelsConfig;
32
+ /**
33
+ * Resizable panels vocabulary <-> `specs/patterns/resizable-panels.qnt`. Parts:
34
+ * `separator:0` / `separator:1` and `pane:0..2`; keys only act on the focused
35
+ * separator (focus it first, as a user would). The drag is its own vocabulary
36
+ * (`dragStart` on a separator, `dragBy` with a ±10 delta, `dragEnd`), so the
37
+ * spec's snapshot + delta model and the DOM's real pointer events see the same
38
+ * steps. `hostLayout` is the host writing a whole layout (controlled only).
39
+ */
40
+ export declare const resizablePanelsBridge: SpecBridge;
41
+ /** The user-level steps a differential walk draws from. */
42
+ export declare const resizablePanelsSteps: ReadonlyArray<ActionStep>;
43
+ export declare const resizablePanelsProbe: Probe;
44
+ //#endregion
@@ -0,0 +1,287 @@
1
+ import { Unsupported } from "../scenario.mjs";
2
+ //#region src/patterns/resizable-panels.bridge.ts
3
+ const specEvent = (tag, params = {}) => ({
4
+ _tag: tag,
5
+ ...params
6
+ });
7
+ const num = (value) => value === void 0 ? 0 : Number(value);
8
+ /** The layouts the spec reaches by a host write (hostEqual / hostWide / ...). */
9
+ const PANELS_LAYOUTS = {
10
+ initial: [
11
+ 20,
12
+ 40,
13
+ 40
14
+ ],
15
+ equal: [
16
+ 30,
17
+ 30,
18
+ 40
19
+ ],
20
+ wide: [
21
+ 50,
22
+ 30,
23
+ 20
24
+ ],
25
+ narrow: [
26
+ 10,
27
+ 40,
28
+ 50
29
+ ],
30
+ collapsed: [
31
+ 0,
32
+ 50,
33
+ 50
34
+ ]
35
+ };
36
+ const PANEL_COUNT = 3;
37
+ const PANEL_MIN = 10;
38
+ const PANEL_MAX = 80;
39
+ const isLayout = (value) => typeof value === "string" && Object.hasOwn(PANELS_LAYOUTS, value);
40
+ const resizablePanelsConfig = (config) => {
41
+ let orientation = "vertical";
42
+ let layout = "initial";
43
+ let controlled = false;
44
+ let accepts = true;
45
+ for (const [key, raw] of Object.entries(config)) if (key === "orientation" && (raw === "vertical" || raw === "horizontal")) orientation = raw;
46
+ else if (key === "layout" && isLayout(raw)) layout = raw;
47
+ else if (key === "controlled" && typeof raw === "boolean") controlled = raw;
48
+ else if (key === "accepts" && typeof raw === "boolean") accepts = raw;
49
+ else if (key === "collapsible" && raw === true) continue;
50
+ else if (key === "panes" && raw === 3) continue;
51
+ else if (key === "min" && raw === 10) continue;
52
+ else if (key === "max" && raw === 80) continue;
53
+ else throw new Unsupported(`config ${key}=${JSON.stringify(raw)}`);
54
+ if (!controlled && layout !== "initial") throw new Unsupported(`layout ${layout} without a controlled host`);
55
+ return {
56
+ orientation,
57
+ layout,
58
+ sizes: PANELS_LAYOUTS[layout],
59
+ controlled,
60
+ accepts
61
+ };
62
+ };
63
+ const separatorIndex = (target) => {
64
+ const match = /^separator:(\d+)$/.exec(target ?? "separator:0");
65
+ if (match === null) throw new Unsupported(`part ${target ?? ""}`);
66
+ const index = Number(match[1]);
67
+ if (index < 0 || index >= 2) throw new Unsupported(`separator ${index}`);
68
+ return index;
69
+ };
70
+ const HOST_EVENTS = {
71
+ initial: "hostEqual",
72
+ equal: "hostEqual",
73
+ wide: "hostWide",
74
+ narrow: "hostNarrow",
75
+ collapsed: "hostCollapsed"
76
+ };
77
+ const KEY_EVENTS = {
78
+ ArrowLeft: "left",
79
+ ArrowRight: "right",
80
+ ArrowUp: "up",
81
+ ArrowDown: "down",
82
+ Home: "home",
83
+ End: "end",
84
+ Enter: "enter"
85
+ };
86
+ const isKeyEvent = (key) => key in KEY_EVENTS;
87
+ /**
88
+ * Resizable panels vocabulary <-> `specs/patterns/resizable-panels.qnt`. Parts:
89
+ * `separator:0` / `separator:1` and `pane:0..2`; keys only act on the focused
90
+ * separator (focus it first, as a user would). The drag is its own vocabulary
91
+ * (`dragStart` on a separator, `dragBy` with a ±10 delta, `dragEnd`), so the
92
+ * spec's snapshot + delta model and the DOM's real pointer events see the same
93
+ * steps. `hostLayout` is the host writing a whole layout (controlled only).
94
+ */
95
+ const resizablePanelsBridge = {
96
+ setup: (config) => {
97
+ const c = resizablePanelsConfig(config);
98
+ const events = [];
99
+ if (c.orientation === "horizontal") events.push(specEvent("orientHorizontal"));
100
+ if (c.controlled) {
101
+ events.push(specEvent("control"));
102
+ if (c.accepts) events.push(specEvent("acceptRequests"));
103
+ if (c.layout !== "initial") events.push(specEvent(HOST_EVENTS[c.layout]));
104
+ }
105
+ return events;
106
+ },
107
+ event: (step) => {
108
+ switch (step.event) {
109
+ case "focus": return [specEvent(`focusSep${separatorIndex(step.target)}`)];
110
+ case "blur": return [specEvent("blur")];
111
+ case "key": {
112
+ const key = step.key ?? "";
113
+ if (!isKeyEvent(key)) throw new Unsupported(`key ${key}`);
114
+ return [specEvent(KEY_EVENTS[key])];
115
+ }
116
+ case "dragStart": return [specEvent(`dragDown${separatorIndex(step.target)}`)];
117
+ case "dragBy": {
118
+ const delta = step.value;
119
+ if (delta === 10) return [specEvent("dragNext")];
120
+ if (delta === -10) return [specEvent("dragPrev")];
121
+ throw new Unsupported(`drag delta ${JSON.stringify(delta)}`);
122
+ }
123
+ case "dragEnd":
124
+ case "dragCancel": return [specEvent("dragUp")];
125
+ case "hover":
126
+ case "unhover": return [];
127
+ case "prop": switch (step.prop) {
128
+ case "orientation":
129
+ if (step.value === "horizontal") return [specEvent("orientHorizontal")];
130
+ if (step.value === "vertical") return [specEvent("orientVertical")];
131
+ throw new Unsupported(`orientation ${JSON.stringify(step.value)}`);
132
+ case "hostLayout":
133
+ if (!isLayout(step.value)) throw new Unsupported(`layout ${JSON.stringify(step.value)}`);
134
+ if (step.value === "initial") throw new Unsupported("layout initial is not a host write");
135
+ return [specEvent(HOST_EVENTS[step.value])];
136
+ default: throw new Unsupported(`prop ${step.prop ?? ""}`);
137
+ }
138
+ default: throw new Unsupported(`event ${step.event}`);
139
+ }
140
+ },
141
+ value: (state) => [
142
+ num(state["s0"]),
143
+ num(state["s1"]),
144
+ num(state["s2"])
145
+ ],
146
+ focus: (state) => {
147
+ switch (state["focus"]) {
148
+ case "sep0": return "separator:0";
149
+ case "sep1": return "separator:1";
150
+ default: return null;
151
+ }
152
+ },
153
+ attr: (state, target, name) => {
154
+ const index = separatorIndex(target);
155
+ switch (name) {
156
+ case "role": return "separator";
157
+ case "aria-orientation": return state["o"] === "horizontal" ? "horizontal" : "vertical";
158
+ case "aria-valuenow": return String(num(state[`s${index}`]));
159
+ case "aria-valuemin": return String(10);
160
+ case "aria-valuemax": return String(80);
161
+ case "tabindex": return "0";
162
+ default: throw new Unsupported(`attribute ${name} on ${target}`);
163
+ }
164
+ },
165
+ visible: () => [],
166
+ events: (before, after) => before["requests"] === after["requests"] ? [] : [["onSizesChange", [
167
+ num(after["rq0"]),
168
+ num(after["rq1"]),
169
+ num(after["rq2"])
170
+ ]]]
171
+ };
172
+ /** The user-level steps a differential walk draws from. */
173
+ const resizablePanelsSteps = [
174
+ {
175
+ event: "focus",
176
+ target: "separator:0"
177
+ },
178
+ {
179
+ event: "focus",
180
+ target: "separator:1"
181
+ },
182
+ {
183
+ event: "blur",
184
+ target: "separator:0"
185
+ },
186
+ {
187
+ event: "key",
188
+ key: "ArrowLeft",
189
+ target: "separator:0"
190
+ },
191
+ {
192
+ event: "key",
193
+ key: "ArrowRight",
194
+ target: "separator:1"
195
+ },
196
+ {
197
+ event: "key",
198
+ key: "ArrowUp",
199
+ target: "separator:0"
200
+ },
201
+ {
202
+ event: "key",
203
+ key: "ArrowDown",
204
+ target: "separator:1"
205
+ },
206
+ {
207
+ event: "key",
208
+ key: "Home",
209
+ target: "separator:0"
210
+ },
211
+ {
212
+ event: "key",
213
+ key: "End",
214
+ target: "separator:1"
215
+ },
216
+ {
217
+ event: "key",
218
+ key: "Enter",
219
+ target: "separator:0"
220
+ },
221
+ {
222
+ event: "key",
223
+ key: "Enter",
224
+ target: "separator:1"
225
+ },
226
+ {
227
+ event: "prop",
228
+ prop: "orientation",
229
+ value: "horizontal"
230
+ },
231
+ {
232
+ event: "prop",
233
+ prop: "orientation",
234
+ value: "vertical"
235
+ },
236
+ {
237
+ event: "prop",
238
+ prop: "hostLayout",
239
+ value: "wide"
240
+ },
241
+ {
242
+ event: "prop",
243
+ prop: "hostLayout",
244
+ value: "narrow"
245
+ },
246
+ {
247
+ event: "prop",
248
+ prop: "hostLayout",
249
+ value: "collapsed"
250
+ },
251
+ {
252
+ event: "prop",
253
+ prop: "hostLayout",
254
+ value: "equal"
255
+ },
256
+ {
257
+ event: "dragStart",
258
+ target: "separator:0"
259
+ },
260
+ {
261
+ event: "dragStart",
262
+ target: "separator:1"
263
+ },
264
+ {
265
+ event: "dragBy",
266
+ value: 10
267
+ },
268
+ {
269
+ event: "dragBy",
270
+ value: -10
271
+ },
272
+ { event: "dragEnd" }
273
+ ];
274
+ const resizablePanelsProbe = {
275
+ attrs: [
276
+ ["separator:0", "role"],
277
+ ["separator:0", "aria-orientation"],
278
+ ["separator:0", "aria-valuenow"],
279
+ ["separator:0", "aria-valuemin"],
280
+ ["separator:0", "aria-valuemax"],
281
+ ["separator:0", "tabindex"],
282
+ ["separator:1", "aria-valuenow"]
283
+ ],
284
+ events: ["onSizesChange"]
285
+ };
286
+ //#endregion
287
+ export { PANELS_LAYOUTS, PANEL_COUNT, PANEL_MAX, PANEL_MIN, resizablePanelsBridge, resizablePanelsConfig, resizablePanelsProbe, resizablePanelsSteps };