@weasel-js/forge 1.5.0 → 1.5.1

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.
Files changed (37) hide show
  1. package/dist/{FrameController-Ba3LaWfq.d.ts → FrameController-BPfCt4PJ.d.ts} +68 -1
  2. package/dist/{chunk-O5OQZQRZ.js → chunk-2JDYOBWM.js} +5 -5
  3. package/dist/chunk-2JDYOBWM.js.map +1 -0
  4. package/dist/{chunk-KKF7ZX5E.js → chunk-2MADHLRA.js} +124 -23
  5. package/dist/chunk-2MADHLRA.js.map +1 -0
  6. package/dist/{chunk-UZCXH724.js → chunk-6SRQIHJQ.js} +3 -8
  7. package/dist/chunk-6SRQIHJQ.js.map +1 -0
  8. package/dist/{chunk-IOBG7GZQ.js → chunk-FTFR2YPM.js} +2 -2
  9. package/dist/chunk-FTFR2YPM.js.map +1 -0
  10. package/dist/chunk-TLO6UHF2.js +103 -0
  11. package/dist/chunk-TLO6UHF2.js.map +1 -0
  12. package/dist/{config-DdumOfyd.d.ts → config-CrLpfSq9.d.ts} +6 -2
  13. package/dist/csf/preview-api.js +2 -2
  14. package/dist/frame/index.d.ts +5 -4
  15. package/dist/frame/index.js +5 -5
  16. package/dist/index.d.ts +97 -5
  17. package/dist/index.js.map +1 -1
  18. package/dist/shell/index.css +97 -12
  19. package/dist/shell/index.css.map +1 -1
  20. package/dist/shell/index.d.ts +58 -4
  21. package/dist/shell/index.js +363 -173
  22. package/dist/shell/index.js.map +1 -1
  23. package/dist/test/index.d.ts +2 -2
  24. package/dist/test/index.js +7 -7
  25. package/dist/test/index.js.map +1 -1
  26. package/dist/test/play.d.ts +25 -0
  27. package/dist/test/play.js +14 -0
  28. package/dist/test/play.js.map +1 -0
  29. package/dist/vite/index.js +46 -13
  30. package/dist/vite/index.js.map +1 -1
  31. package/package.json +14 -5
  32. package/dist/chunk-IOBG7GZQ.js.map +0 -1
  33. package/dist/chunk-KKF7ZX5E.js.map +0 -1
  34. package/dist/chunk-KKVTFMWV.js +0 -59
  35. package/dist/chunk-KKVTFMWV.js.map +0 -1
  36. package/dist/chunk-O5OQZQRZ.js.map +0 -1
  37. package/dist/chunk-UZCXH724.js.map +0 -1
@@ -1,106 +1,60 @@
1
- import { schemaFromDescription, openChannel } from '../chunk-O5OQZQRZ.js';
2
- import { stableStringify, PORT_HANDOFF, PROTOCOL_VERSION } from '../chunk-IOBG7GZQ.js';
1
+ import { schemaFromDescription, openChannel } from '../chunk-2JDYOBWM.js';
2
+ import { stableStringify, PORT_HANDOFF, PROTOCOL_VERSION } from '../chunk-FTFR2YPM.js';
3
+ import { useTrialId, Button, Lab, useLabContext, TrialIdContext, usePersistedState, Select, inferTokenType, ToggleBar, Input, TokenPanel } from '@weasel-js/labkit';
4
+ import { createContext, useContext, useCallback, useSyncExternalStore, useState, useRef, useEffect, useMemo, useLayoutEffect, useId } from 'react';
5
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
3
6
  import { createRoot } from 'react-dom/client';
4
- import { PropertyList, TextRow, ColorRow, Button, Lab, usePersistedState, useLabContext, TrialIdContext, useTrialId, ToggleBar } from '@weasel-js/labkit';
5
- import { createContext, memo, useMemo, useCallback, useState, useEffect, useRef, useLayoutEffect, useId, useContext, useSyncExternalStore } from 'react';
6
7
  import { TOKEN_MANIFEST } from '@weasel-js/theme';
7
- import { jsxs, jsx } from 'react/jsx-runtime';
8
8
  import { f } from '@weasel-js/labkit/config';
9
9
 
10
- // src/shell/cssVars/color.ts
11
- var HEX = /^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i;
12
- var RGB = /^rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})\b/i;
13
- var COLOR_FUNCTION = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(.+\)$/i;
14
- var KEYWORDS = /* @__PURE__ */ new Set([
15
- "transparent",
16
- "currentcolor",
17
- "black",
18
- "white",
19
- "gray",
20
- "silver",
21
- "red",
22
- "orange",
23
- "yellow",
24
- "gold",
25
- "lime",
26
- "green",
27
- "teal",
28
- "cyan",
29
- "blue",
30
- "navy",
31
- "purple",
32
- "magenta",
33
- "pink",
34
- "brown"
35
- ]);
36
- function browserSupports() {
37
- return typeof CSS !== "undefined" && typeof CSS.supports === "function" ? (value) => CSS.supports("color", value) : null;
38
- }
39
- function parsesAsColor(value, supports = browserSupports()) {
40
- const v = value.trim();
41
- if (!v || v.includes("var(")) return false;
42
- if (supports) return supports(v);
43
- return HEX.test(v) || COLOR_FUNCTION.test(v) || KEYWORDS.has(v.toLowerCase());
44
- }
45
- function hexOf(v) {
46
- if (HEX.test(v)) {
47
- const digits = v.slice(1).toLowerCase();
48
- return `#${digits.length <= 4 ? [...digits.slice(0, 3)].map((d) => d + d).join("") : digits.slice(0, 6)}`;
49
- }
50
- const rgb = RGB.exec(v);
51
- return rgb ? `#${rgb.slice(1, 4).map((n) => Math.min(255, Number(n)).toString(16).padStart(2, "0")).join("")}` : null;
52
- }
53
- var paint;
54
- function serialized(value) {
55
- if (paint === void 0) {
56
- try {
57
- paint = document.createElement("canvas").getContext("2d");
58
- } catch {
59
- paint = null;
60
- }
61
- }
62
- if (!paint) return null;
63
- paint.fillStyle = "#000000";
64
- paint.fillStyle = value;
65
- const onBlack = String(paint.fillStyle);
66
- paint.fillStyle = "#ffffff";
67
- paint.fillStyle = value;
68
- return onBlack === String(paint.fillStyle) ? onBlack : null;
69
- }
70
- function toHex(value) {
71
- const v = value.trim();
72
- const direct = hexOf(v);
73
- if (direct) return direct;
74
- const browser = typeof document === "undefined" ? null : serialized(v);
75
- return browser && browser !== v ? hexOf(browser) : null;
76
- }
77
- var NONE = {};
78
- function useCssOverrides() {
79
- return usePersistedState("fg-css-overrides", NONE, { scope: "trial" });
80
- }
81
- var NO_FRAME = { send: null, vars: [] };
10
+ var NO_FRAME = { send: null, vars: [], audit: null, a11y: null, capture: null, size: null };
82
11
  function createTrialFrames() {
83
12
  const frames = /* @__PURE__ */ new Map();
13
+ const hosts = /* @__PURE__ */ new Map();
84
14
  const listeners = /* @__PURE__ */ new Set();
85
15
  const changed = () => {
86
16
  for (const listener of [...listeners]) listener();
87
17
  };
18
+ const patch = (trialId, next) => {
19
+ const frame = frames.get(trialId);
20
+ if (!frame) return;
21
+ frames.set(trialId, { ...frame, ...next });
22
+ changed();
23
+ };
88
24
  return {
89
- connect(trialId, send) {
90
- frames.set(trialId, { send, vars: [] });
25
+ connect(trialId, calls) {
26
+ const held = frames.get(trialId);
27
+ frames.set(trialId, {
28
+ send: calls.send,
29
+ vars: [],
30
+ audit: calls.audit,
31
+ a11y: null,
32
+ capture: calls.capture,
33
+ size: held?.size ?? null
34
+ });
91
35
  changed();
92
36
  return () => {
93
- if (frames.get(trialId)?.send !== send) return;
37
+ if (frames.get(trialId)?.send !== calls.send) return;
94
38
  frames.delete(trialId);
95
39
  changed();
96
40
  };
97
41
  },
98
- report(trialId, vars) {
99
- const frame = frames.get(trialId);
100
- if (!frame) return;
101
- frames.set(trialId, { ...frame, vars });
42
+ report: (trialId, vars) => patch(trialId, { vars }),
43
+ reportA11y: (trialId, a11y) => patch(trialId, { a11y }),
44
+ reportSize(trialId, size) {
45
+ const frame = frames.get(trialId) ?? NO_FRAME;
46
+ if (frame.size?.width === size.width && frame.size?.height === size.height) return;
47
+ frames.set(trialId, { ...frame, size });
102
48
  changed();
103
49
  },
50
+ hostRef(trialId) {
51
+ let ref = hosts.get(trialId);
52
+ if (!ref) {
53
+ ref = { current: null };
54
+ hosts.set(trialId, ref);
55
+ }
56
+ return ref;
57
+ },
104
58
  get: (trialId) => frames.get(trialId) ?? NO_FRAME,
105
59
  subscribe(listener) {
106
60
  listeners.add(listener);
@@ -116,35 +70,128 @@ function useTrialFrame(trialId) {
116
70
  const read = () => frames?.get(trialId) ?? NO_FRAME;
117
71
  return useSyncExternalStore(subscribe, read, read);
118
72
  }
73
+ function Finding({ finding }) {
74
+ return /* @__PURE__ */ jsxs("li", { className: "fg-a11y__finding", children: [
75
+ /* @__PURE__ */ jsxs("p", { className: "fg-a11y__help", children: [
76
+ /* @__PURE__ */ jsx("a", { href: finding.helpUrl, target: "_blank", rel: "noreferrer", children: finding.help }),
77
+ finding.impact ? /* @__PURE__ */ jsx("span", { className: "fg-a11y__impact", "data-impact": finding.impact, children: finding.impact }) : null
78
+ ] }),
79
+ /* @__PURE__ */ jsx("ul", { className: "fg-a11y__nodes", children: finding.nodes.map((node) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("code", { children: node.target.join(" ") }) }, `${finding.id}:${node.target.join(",")}`)) })
80
+ ] });
81
+ }
82
+ function Findings({ title, findings }) {
83
+ if (findings.length === 0) return null;
84
+ return /* @__PURE__ */ jsxs("section", { className: "fg-a11y__group", children: [
85
+ /* @__PURE__ */ jsxs("h3", { className: "fg-a11y__group-title", children: [
86
+ title,
87
+ " (",
88
+ findings.length,
89
+ ")"
90
+ ] }),
91
+ /* @__PURE__ */ jsx("ul", { className: "fg-a11y__findings", children: findings.map((finding) => /* @__PURE__ */ jsx(Finding, { finding }, finding.id)) })
92
+ ] });
93
+ }
94
+ function A11yPanel() {
95
+ const trialId = useTrialId();
96
+ const frame = useTrialFrame(trialId);
97
+ const [running, setRunning] = useState(false);
98
+ const audit = frame.audit;
99
+ const ran = useRef(null);
100
+ useEffect(() => {
101
+ if (!audit || ran.current === audit) return;
102
+ ran.current = audit;
103
+ setRunning(true);
104
+ void audit().finally(() => setRunning(false));
105
+ }, [audit]);
106
+ const rerun = () => {
107
+ if (!audit) return;
108
+ setRunning(true);
109
+ void audit().finally(() => setRunning(false));
110
+ };
111
+ const outcome = frame.a11y;
112
+ return /* @__PURE__ */ jsxs("div", { className: "fg-a11y", children: [
113
+ /* @__PURE__ */ jsx("div", { className: "fg-a11y__controls", children: /* @__PURE__ */ jsx(Button, { size: "sm", variant: "secondary", onClick: rerun, disabled: !audit || running, children: running ? "Checking\u2026" : "Re-check" }) }),
114
+ !audit ? /* @__PURE__ */ jsx("p", { className: "fg-a11y__note", children: "No frame is connected." }) : null,
115
+ audit && !outcome ? /* @__PURE__ */ jsx("p", { className: "fg-a11y__note", children: running ? "Running axe\u2026" : "Not checked yet." }) : null,
116
+ outcome && !outcome.ok ? /* @__PURE__ */ jsxs("p", { className: "fg-a11y__note", role: "alert", children: [
117
+ "axe could not run: ",
118
+ outcome.message
119
+ ] }) : null,
120
+ outcome?.ok ? /* @__PURE__ */ jsxs(Fragment, { children: [
121
+ /* @__PURE__ */ jsxs("p", { className: "fg-a11y__tally", children: [
122
+ outcome.report.violations.length,
123
+ " violations \xB7 ",
124
+ outcome.report.incomplete.length,
125
+ " to review \xB7",
126
+ " ",
127
+ outcome.report.passes,
128
+ " passes"
129
+ ] }),
130
+ /* @__PURE__ */ jsx(Findings, { title: "Violations", findings: outcome.report.violations }),
131
+ /* @__PURE__ */ jsx(Findings, { title: "Needs review", findings: outcome.report.incomplete })
132
+ ] }) : null
133
+ ] });
134
+ }
135
+ function FocusedA11y() {
136
+ const { focusedTrialId, trials, instruments } = useLabContext();
137
+ const record = trials.find((trial) => trial.id === focusedTrialId);
138
+ if (!record) return null;
139
+ const instrument = instruments.find((i) => i.name === record.instrumentName);
140
+ return /* @__PURE__ */ jsxs("section", { className: "fg-a11y-focus", "aria-label": "Accessibility", children: [
141
+ /* @__PURE__ */ jsx("p", { className: "fg-a11y__trial", children: record.title ?? instrument?.title ?? record.instrumentName }),
142
+ /* @__PURE__ */ jsx(TrialIdContext.Provider, { value: record.id, children: /* @__PURE__ */ jsx(A11yPanel, {}) })
143
+ ] });
144
+ }
145
+ var A11Y_SECTION = {
146
+ id: "fg-a11y",
147
+ region: "aside",
148
+ item: { title: "Accessibility", body: /* @__PURE__ */ jsx(FocusedA11y, {}) }
149
+ };
150
+ var NONE = {};
151
+ function useCssOverrides() {
152
+ return usePersistedState("fg-css-overrides", NONE, { scope: "trial" });
153
+ }
119
154
  var TABS = [
120
155
  { value: "theme", label: "Theme" },
121
156
  { value: "story", label: "Story" }
122
157
  ];
123
- var VarRow = memo(function VarRow2({ name, value, overridden, write }) {
124
- const hex = useMemo(() => parsesAsColor(value) ? toHex(value) : null, [value]);
125
- const onChange = useCallback((next) => write(name, next), [write, name]);
126
- const reset = useCallback(() => write(name, null), [write, name]);
127
- return /* @__PURE__ */ jsxs("div", { className: "fg-css-var", role: "group", "aria-label": name, children: [
128
- /* @__PURE__ */ jsxs(PropertyList, { className: "fg-css-var__rows", pack: "one-up", density: "tight", children: [
129
- /* @__PURE__ */ jsx(TextRow, { label: name, value, onChange }),
130
- hex ? /* @__PURE__ */ jsx(ColorRow, { label: "Color", value: hex, onChange }) : null
131
- ] }),
132
- overridden ? /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", ariaLabel: `Reset ${name}`, onClick: reset, children: "Reset" }) : null
133
- ] });
134
- });
158
+ var MANIFEST = new Map(TOKEN_MANIFEST.map((token) => [token.name, token]));
159
+ function groupOf(name) {
160
+ const segments = name.replace(/^--/, "").split("-");
161
+ return segments.length > 1 ? segments.slice(0, -1).join("-") : name.replace(/^--/, "");
162
+ }
135
163
  function CssVarsPanel() {
136
164
  const trialId = useTrialId();
137
165
  const frame = useTrialFrame(trialId);
138
166
  const [overrides, setOverrides] = useCssOverrides();
167
+ const [folds, setFolds] = usePersistedState(
168
+ "fg-css-vars-folds",
169
+ {},
170
+ { scope: "lab" }
171
+ );
139
172
  const [tab, setTab] = useState("theme");
140
173
  const [filter, setFilter] = useState("");
141
- const rows = useMemo(() => {
142
- if (tab === "story") return frame.vars.map(({ name, value }) => ({ name, value }));
174
+ const tokens = useMemo(() => {
175
+ if (tab === "story") {
176
+ return frame.vars.map(({ name, value }) => {
177
+ const known = MANIFEST.get(name);
178
+ return { name, type: known?.type ?? inferTokenType(value), group: known?.group ?? groupOf(name), value };
179
+ });
180
+ }
143
181
  const reported = new Map(frame.vars.map((v) => [v.name, v.value]));
144
- return TOKEN_MANIFEST.map((token) => ({ name: token.name, value: reported.get(token.name) ?? token.defaultValue }));
182
+ return TOKEN_MANIFEST.map((token) => ({
183
+ name: token.name,
184
+ type: token.type,
185
+ group: token.group,
186
+ value: reported.get(token.name) ?? token.defaultValue,
187
+ description: token.description
188
+ }));
145
189
  }, [tab, frame.vars]);
146
190
  const query = filter.trim().toLowerCase();
147
- const shown = rows.map((row) => ({ ...row, value: overrides[row.name] ?? row.value })).filter((row) => !query || row.name.toLowerCase().includes(query) || row.value.toLowerCase().includes(query));
191
+ const shown = tokens.map((token) => {
192
+ const override = overrides[token.name];
193
+ return override === void 0 ? token : { ...token, value: override, overridden: true };
194
+ }).filter((token) => !query || token.name.toLowerCase().includes(query) || token.value.toLowerCase().includes(query));
148
195
  const send = frame.send;
149
196
  const write = useCallback(
150
197
  (name, value) => {
@@ -156,36 +203,43 @@ function CssVarsPanel() {
156
203
  },
157
204
  [setOverrides, send]
158
205
  );
206
+ const onCollapsedChange = useCallback(
207
+ (category, collapsed) => setFolds((prev) => ({ ...prev, [category]: collapsed })),
208
+ [setFolds]
209
+ );
159
210
  return /* @__PURE__ */ jsxs("div", { className: "fg-css-vars", children: [
160
- /* @__PURE__ */ jsx(
161
- ToggleBar,
162
- {
163
- ariaLabel: "Variables",
164
- size: "sm",
165
- items: TABS,
166
- value: tab,
167
- onChange: (next) => {
168
- if (next) setTab(next);
211
+ /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__controls", children: [
212
+ /* @__PURE__ */ jsx(
213
+ ToggleBar,
214
+ {
215
+ ariaLabel: "Variables",
216
+ variant: "flat",
217
+ items: TABS,
218
+ value: tab,
219
+ onChange: (next) => {
220
+ if (next) setTab(next);
221
+ }
169
222
  }
170
- }
171
- ),
172
- /* @__PURE__ */ jsx(PropertyList, { pack: "one-up", density: "tight", children: /* @__PURE__ */ jsx(TextRow, { label: "Filter", value: filter, placeholder: "Name or value", onChange: setFilter }) }),
173
- shown.length === 0 ? /* @__PURE__ */ jsx("p", { className: "fg-css-vars__empty", children: rows.length === 0 ? "The story\u2019s frame has reported no variables." : "No variables match." }) : /* @__PURE__ */ jsx("div", { className: "fg-css-vars__list", children: shown.map((row) => /* @__PURE__ */ jsx(
174
- VarRow,
175
- {
176
- name: row.name,
177
- value: row.value,
178
- overridden: row.name in overrides,
179
- write
180
- },
181
- row.name
182
- )) })
223
+ ),
224
+ /* @__PURE__ */ jsx(Input, { "aria-label": "Filter", placeholder: "Filter by name or value", value: filter, onChange: setFilter })
225
+ ] }),
226
+ shown.length === 0 ? /* @__PURE__ */ jsx("p", { className: "fg-css-vars__empty", children: tokens.length === 0 ? "The story\u2019s frame has reported no variables." : "No variables match." }) : /* @__PURE__ */ jsx(TokenPanel, { tokens: shown, onChange: write, collapsed: folds, onCollapsedChange })
227
+ ] });
228
+ }
229
+ function FocusedCssVars() {
230
+ const { focusedTrialId, trials, instruments } = useLabContext();
231
+ const record = trials.find((trial) => trial.id === focusedTrialId);
232
+ if (!record) return null;
233
+ const instrument = instruments.find((i) => i.name === record.instrumentName);
234
+ return /* @__PURE__ */ jsxs("section", { className: "fg-css-vars-focus", "aria-label": "CSS Vars", children: [
235
+ /* @__PURE__ */ jsx("p", { className: "fg-css-vars__trial", children: record.title ?? instrument?.title ?? record.instrumentName }),
236
+ /* @__PURE__ */ jsx(TrialIdContext.Provider, { value: record.id, children: /* @__PURE__ */ jsx(CssVarsPanel, {}) })
183
237
  ] });
184
238
  }
185
239
  var CSS_VARS_SECTION = {
186
240
  id: "fg-css-vars",
187
- region: "sidebar",
188
- item: { title: "CSS Vars", body: /* @__PURE__ */ jsx(CssVarsPanel, {}) }
241
+ region: "aside",
242
+ item: { title: "CSS Vars", body: /* @__PURE__ */ jsx(FocusedCssVars, {}) }
189
243
  };
190
244
  var GLOBALS_KEY = "$globals";
191
245
  var FOLLOW_LAB = "lab";
@@ -223,7 +277,7 @@ function withGlobals(schema, declarations) {
223
277
  f.enum(FOLLOW_LAB, [{ value: FOLLOW_LAB, label: "Lab" }, ...declaration.options]).label(declaration.label)
224
278
  ])
225
279
  );
226
- const group = f.group(leaves).label("").section("Globals");
280
+ const group = f.group(leaves).label("").section("Globals", { layout: "inline", pack: "pairs" });
227
281
  return f.schema({ ...schema.nodes, [GLOBALS_KEY]: group });
228
282
  }
229
283
  var RECORD = "fg-globals";
@@ -239,13 +293,13 @@ function useLabGlobals(declarations) {
239
293
  function GlobalsToolbar({ declarations }) {
240
294
  const [values, set] = useLabGlobals(declarations);
241
295
  return /* @__PURE__ */ jsx("div", { className: "fg-globals", role: "toolbar", "aria-label": "Globals", children: Object.entries(declarations).map(([key, declaration]) => /* @__PURE__ */ jsx(
242
- "select",
296
+ Select,
243
297
  {
244
- className: "fg-globals__select",
245
298
  "aria-label": declaration.label,
246
- value: String(values[key]),
247
- onChange: (event) => set(key, event.target.value),
248
- children: declaration.options.map((option) => /* @__PURE__ */ jsx("option", { value: option.value, children: option.label }, option.value))
299
+ width: "fit",
300
+ selectedKey: String(values[key]),
301
+ onSelectionChange: (value) => set(key, value),
302
+ options: declaration.options
249
303
  },
250
304
  key
251
305
  )) });
@@ -383,10 +437,11 @@ function StoryTree({ ctx, index }) {
383
437
  setFocusKey(key);
384
438
  items.current.get(key)?.focus();
385
439
  };
386
- const activate = (entry, fresh) => {
387
- const open = ctx.trials.find((trial) => trial.instrumentName === entry.id);
388
- if (open && !fresh) revealTrial(open.id);
389
- else ctx.addTrial(entry.id);
440
+ const activate = (entry, another) => {
441
+ const focused = ctx.trials.find((trial) => trial.id === ctx.focusedTrialId);
442
+ if (another || !focused) ctx.addTrial(entry.id);
443
+ else if (focused.instrumentName === entry.id) revealTrial(focused.id);
444
+ else ctx.swapTrial(focused.id, entry.id);
390
445
  setRoute(entry.id);
391
446
  };
392
447
  const onKeyDown = (event) => {
@@ -419,7 +474,7 @@ function StoryTree({ ctx, index }) {
419
474
  case "Enter":
420
475
  case " ":
421
476
  if (node.kind === "folder") setOpen(node.path, !isOpen(node.path));
422
- else activate(node.entry, event.metaKey || event.ctrlKey);
477
+ else activate(node.entry, event.shiftKey);
423
478
  break;
424
479
  default:
425
480
  return;
@@ -469,10 +524,10 @@ function StoryTree({ ctx, index }) {
469
524
  "aria-current": openIds.has(entry.id) ? "true" : void 0,
470
525
  className: "fg-tree__item fg-tree__story",
471
526
  onClick: (event) => {
472
- if (event.button !== 0 || event.shiftKey || event.altKey) return;
527
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey) return;
473
528
  event.preventDefault();
474
529
  setFocusKey(keyOf(node));
475
- activate(entry, event.metaKey || event.ctrlKey);
530
+ activate(entry, event.shiftKey);
476
531
  },
477
532
  children: /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: entry.name })
478
533
  },
@@ -504,26 +559,36 @@ var KEPT_CONFIGS = 50;
504
559
  var UNANSWERED = stableStringify({ hidden: [], errors: {} });
505
560
  function createAnswerBook() {
506
561
  const byConfig = /* @__PURE__ */ new Map();
507
- let errors = {};
508
562
  const listeners = /* @__PURE__ */ new Set();
563
+ const held = /* @__PURE__ */ new Map();
564
+ const keyOf2 = (config) => stableStringify(storyConfig(config));
565
+ const answerFor = (config) => byConfig.get(keyOf2(config));
509
566
  return {
510
567
  record(answers) {
511
568
  const key = stableStringify({ hidden: answers.hidden, errors: answers.errors });
512
569
  const changed = key !== (byConfig.get(answers.configKey)?.key ?? UNANSWERED);
513
570
  byConfig.delete(answers.configKey);
514
- byConfig.set(answers.configKey, { hidden: answers.hidden, key });
571
+ byConfig.set(answers.configKey, { hidden: answers.hidden, errors: answers.errors, key });
515
572
  if (byConfig.size > KEPT_CONFIGS) {
516
573
  const oldest = byConfig.keys().next().value;
517
574
  if (oldest !== void 0) byConfig.delete(oldest);
518
575
  }
519
- errors = answers.errors;
520
- if (changed) for (const fn of listeners) fn();
576
+ if (changed && held.has(answers.configKey)) for (const fn of listeners) fn();
577
+ },
578
+ hold(config) {
579
+ const key = keyOf2(config);
580
+ held.set(key, (held.get(key) ?? 0) + 1);
581
+ return () => {
582
+ const count = (held.get(key) ?? 1) - 1;
583
+ if (count > 0) held.set(key, count);
584
+ else held.delete(key);
585
+ };
521
586
  },
522
587
  hidden(path, config) {
523
- const hidden = byConfig.get(stableStringify(storyConfig(config)))?.hidden;
588
+ const hidden = answerFor(config)?.hidden;
524
589
  return hidden?.some((h) => path === h || path.startsWith(`${h}.`)) ?? false;
525
590
  },
526
- errors: (path) => errors[path] ?? [],
591
+ errors: (path, config) => answerFor(config)?.errors[path] ?? [],
527
592
  subscribe(fn) {
528
593
  listeners.add(fn);
529
594
  return () => {
@@ -536,18 +601,43 @@ function createAnswerBook() {
536
601
  // src/shell/readyKey.ts
537
602
  var readyKey = (ready) => stableStringify({ schema: ready.schema, layout: ready.layout, viewport: ready.viewport });
538
603
  var START_TIMEOUT_MS = 1e4;
604
+ var requests = 0;
605
+ var IN_VIEW_MARGIN = "50%";
606
+ function useInView(ref) {
607
+ const [inView, setInView] = useState(true);
608
+ useEffect(() => {
609
+ const el = ref.current;
610
+ if (!el || typeof IntersectionObserver === "undefined") return;
611
+ const observer = new IntersectionObserver(
612
+ (entries) => {
613
+ const last = entries.at(-1);
614
+ if (last) setInView(last.isIntersecting);
615
+ },
616
+ { rootMargin: IN_VIEW_MARGIN }
617
+ );
618
+ observer.observe(el);
619
+ return () => observer.disconnect();
620
+ }, [ref]);
621
+ return inView;
622
+ }
539
623
  function closeLink(link) {
540
624
  if (!link.current) return;
541
625
  clearTimeout(link.current.timer);
626
+ for (const settle2 of link.current.pending.values()) settle2.gone();
627
+ link.current.pending.clear();
542
628
  link.current.disconnect();
543
629
  link.current.channel.close();
544
630
  link.current = null;
545
631
  }
632
+ function settle(link, id, value) {
633
+ link.pending.get(id)?.resolve?.(value);
634
+ link.pending.delete(id);
635
+ }
546
636
  function mismatchMessage(mismatch) {
547
637
  return mismatch.reason === "version" ? `Frame speaks protocol ${String(mismatch.version)}; this workshop speaks ${PROTOCOL_VERSION}` : "Frame sent a message that is not a forge envelope";
548
638
  }
549
639
  function FrameView(props) {
550
- const { entry, frameUrl, descriptionKey, ctx } = props;
640
+ const { entry, frameUrl, answers, descriptionKey, ctx } = props;
551
641
  const labGlobals2 = useContext(StoryGlobalsContext);
552
642
  const pins = ctx.config?.[GLOBALS_KEY];
553
643
  const globals = useMemo(() => effectiveGlobals(labGlobals2, pins), [labGlobals2, pins]);
@@ -556,10 +646,13 @@ function FrameView(props) {
556
646
  const [overrides] = useCssOverrides();
557
647
  const src = `${frameUrl}#${entry.id}`;
558
648
  const iframeRef = useRef(null);
649
+ const hostRef = useRef(null);
650
+ const inView = useInView(hostRef);
559
651
  const link = useRef(null);
560
652
  const latest = useRef({ ...props, globals, overrides });
561
653
  latest.current = { ...props, globals, overrides };
562
654
  const [fault, setFault] = useState(null);
655
+ const [pending, setPending] = useState(true);
563
656
  const init = (current) => {
564
657
  const { ctx: live, globals: liveGlobals } = latest.current;
565
658
  current.awaiting = null;
@@ -568,7 +661,7 @@ function FrameView(props) {
568
661
  current.channel.send({ type: "init", ...current.sent });
569
662
  };
570
663
  const receive = (current, msg) => {
571
- const { ctx: live, answers, onReady } = latest.current;
664
+ const { ctx: live, answers: answers2, onReady } = latest.current;
572
665
  switch (msg.type) {
573
666
  case "ready": {
574
667
  clearTimeout(current.timer);
@@ -582,6 +675,11 @@ function FrameView(props) {
582
675
  if (current.awaiting === key && key === latest.current.descriptionKey) init(current);
583
676
  break;
584
677
  }
678
+ case "rendered":
679
+ setPending(false);
680
+ current.hasRendered = true;
681
+ for (const ask of current.queued.splice(0)) ask();
682
+ break;
585
683
  case "setConfig":
586
684
  if (!isGlobalsPath(msg.path)) live.setConfig(msg.path, msg.value);
587
685
  break;
@@ -590,11 +688,25 @@ function FrameView(props) {
590
688
  live.setState(msg.state);
591
689
  break;
592
690
  case "answers":
593
- answers.record(msg.answers);
691
+ answers2.record(msg.answers);
594
692
  break;
595
693
  case "vars":
596
694
  if (trialId) frames?.report(trialId, msg.vars);
597
695
  break;
696
+ case "size":
697
+ if (trialId) frames?.reportSize(trialId, { width: msg.width, height: msg.height });
698
+ break;
699
+ case "a11y": {
700
+ const outcome = msg.ok ? { ok: true, report: msg.report } : { ok: false, message: msg.message };
701
+ if (trialId) frames?.reportA11y(trialId, outcome);
702
+ settle(current, msg.id, outcome);
703
+ break;
704
+ }
705
+ case "capture":
706
+ if (msg.ok) settle(current, msg.id, msg.picture);
707
+ else current.pending.get(msg.id)?.fail(new Error(msg.message));
708
+ current.pending.delete(msg.id);
709
+ break;
598
710
  case "fault":
599
711
  if (msg.phase === "render" && msg.seq !== void 0 && msg.seq < current.inputs) break;
600
712
  clearTimeout(current.timer);
@@ -604,6 +716,7 @@ function FrameView(props) {
604
716
  };
605
717
  const onLoad = () => {
606
718
  closeLink(link);
719
+ setPending(true);
607
720
  const target = iframeRef.current?.contentWindow;
608
721
  if (!target) return;
609
722
  const { port1, port2 } = new MessageChannel();
@@ -614,14 +727,58 @@ function FrameView(props) {
614
727
  setFault({ phase: "protocol", message: mismatchMessage(mismatch) });
615
728
  }
616
729
  });
617
- const disconnect = frames && trialId ? frames.connect(trialId, channel.send) : () => {
730
+ const waiting = /* @__PURE__ */ new Map();
731
+ const request = (id, msg, gone) => new Promise((resolve, reject) => {
732
+ waiting.set(id, {
733
+ resolve,
734
+ fail: reject,
735
+ gone: () => gone(resolve, reject)
736
+ });
737
+ const ask = () => channel.send(msg);
738
+ if (current.hasRendered) ask();
739
+ else current.queued.push(ask);
740
+ });
741
+ const audit = () => {
742
+ const id = `a11y-${requests += 1}`;
743
+ return request(
744
+ id,
745
+ { type: "a11y.run", id },
746
+ (resolve) => resolve({ ok: false, message: "The frame went away" })
747
+ );
748
+ };
749
+ const capture = () => {
750
+ const id = `capture-${requests += 1}`;
751
+ return request(
752
+ id,
753
+ { type: "capture.run", id },
754
+ (_resolve, fail) => fail(new Error("The frame could not draw itself"))
755
+ );
756
+ };
757
+ const disconnect = frames && trialId ? frames.connect(trialId, { send: channel.send, audit, capture }) : () => {
758
+ };
759
+ const current = {
760
+ channel,
761
+ timer,
762
+ awaiting: null,
763
+ sent: null,
764
+ inputs: 0,
765
+ disconnect,
766
+ pending: waiting,
767
+ hasRendered: false,
768
+ queued: []
618
769
  };
619
- const current = { channel, timer, awaiting: null, sent: null, inputs: 0, disconnect };
620
770
  link.current = current;
621
771
  channel.on((msg) => receive(current, msg));
622
772
  target.postMessage({ type: PORT_HANDOFF }, location.origin, [port2]);
623
773
  };
624
774
  useEffect(() => () => closeLink(link), []);
775
+ useEffect(() => {
776
+ if (inView) return;
777
+ closeLink(link);
778
+ setPending(true);
779
+ setFault(null);
780
+ }, [inView]);
781
+ useEffect(() => answers.hold(ctx.config), [answers, ctx.config]);
625
782
  useEffect(() => {
626
783
  const current = link.current;
627
784
  if (!current) return;
@@ -653,25 +810,54 @@ function FrameView(props) {
653
810
  }
654
811
  if (input) setFault((shown) => shown?.phase === "render" ? null : shown);
655
812
  }, [ctx.config, ctx.state, globals, descriptionKey]);
656
- return /* @__PURE__ */ jsxs("div", { className: "fg-frame-host", children: [
657
- /* @__PURE__ */ jsx(
658
- "iframe",
659
- {
660
- ref: iframeRef,
661
- className: "fg-frame-view",
662
- src,
663
- title: `${entry.title} / ${entry.name}`,
664
- onLoad
665
- }
666
- ),
667
- fault ? /* @__PURE__ */ jsxs("div", { className: "fg-fault", role: "alert", children: [
668
- fault.phase ? /* @__PURE__ */ jsx("span", { className: "fg-fault__phase", children: fault.phase }) : null,
669
- /* @__PURE__ */ jsx("p", { className: "fg-fault__message", children: fault.message })
670
- ] }) : null
671
- ] });
813
+ return /* @__PURE__ */ jsxs(
814
+ "div",
815
+ {
816
+ ref: (el) => {
817
+ hostRef.current = el;
818
+ if (trialId && frames) frames.hostRef(trialId).current = el;
819
+ },
820
+ className: "fg-frame-host",
821
+ children: [
822
+ inView ? /* @__PURE__ */ jsx(
823
+ "iframe",
824
+ {
825
+ ref: iframeRef,
826
+ className: "fg-frame-view",
827
+ src,
828
+ title: `${entry.title} / ${entry.name}`,
829
+ onLoad,
830
+ "data-pending": pending && !fault ? "" : void 0
831
+ }
832
+ ) : null,
833
+ fault ? /* @__PURE__ */ jsxs("div", { className: "fg-fault", role: "alert", children: [
834
+ fault.phase ? /* @__PURE__ */ jsx("span", { className: "fg-fault__phase", children: fault.phase }) : null,
835
+ /* @__PURE__ */ jsx("p", { className: "fg-fault__message", children: fault.message })
836
+ ] }) : null
837
+ ]
838
+ }
839
+ );
840
+ }
841
+ function annotationsOn(frames) {
842
+ return {
843
+ targets: (_state, _config, trial) => {
844
+ const ref = frames.hostRef(trial.id);
845
+ const frame = frames.get(trial.id);
846
+ const box = frame.size ?? { width: ref.current?.clientWidth ?? 0, height: ref.current?.clientHeight ?? 0 };
847
+ const capture = frame.capture;
848
+ return [
849
+ {
850
+ id: "story",
851
+ ref,
852
+ content: { w: Math.max(1, box.width), h: Math.max(1, box.height) },
853
+ ...capture ? { base: () => capture() } : {}
854
+ }
855
+ ];
856
+ }
857
+ };
672
858
  }
673
859
  function storyInstrument(options) {
674
- const { entry, ready, answers, frameUrl, onReady, globals = {} } = options;
860
+ const { entry, ready, answers, frameUrl, onReady, globals = {}, frames } = options;
675
861
  const config = withGlobals(
676
862
  ready ? schemaFromDescription(ready.schema, answers) : f.schema({}),
677
863
  globals
@@ -694,6 +880,7 @@ function storyInstrument(options) {
694
880
  ctx
695
881
  }
696
882
  ),
883
+ ...frames ? { annotations: annotationsOn(frames) } : {},
697
884
  ...ready?.viewport ? { stage: { size: { width: ready.viewport.width, height: ready.viewport.height } } } : {}
698
885
  };
699
886
  }
@@ -702,7 +889,7 @@ function storyInstrument(options) {
702
889
  var NO_GLOBALS = {};
703
890
  var pruned = (map, ids) => [...map.keys()].every((id) => ids.has(id)) ? map : new Map([...map].filter(([id]) => ids.has(id)));
704
891
  function useStoryRegistry(index, options) {
705
- const { frameUrl, globals = NO_GLOBALS } = options;
892
+ const { frameUrl, globals = NO_GLOBALS, frames } = options;
706
893
  const [readies, setReadies] = useState(() => /* @__PURE__ */ new Map());
707
894
  const [revisions, setRevisions] = useState(() => /* @__PURE__ */ new Map());
708
895
  const onReady = useCallback((entry, ready) => {
@@ -729,20 +916,21 @@ function useStoryRegistry(index, options) {
729
916
  const answers = previous.books.get(entry.id) ?? createAnswerBook();
730
917
  books.set(entry.id, answers);
731
918
  const held = previous.built.get(entry.id);
732
- const kept = held && held.entryKey === entryKey && held.ready === ready && held.frameUrl === frameUrl && held.globals === globals && held.revision === revision ? held : {
919
+ const kept = held && held.entryKey === entryKey && held.ready === ready && held.frameUrl === frameUrl && held.globals === globals && held.frames === frames && held.revision === revision ? held : {
733
920
  entryKey,
734
921
  ready,
735
922
  frameUrl,
736
923
  globals,
924
+ frames,
737
925
  revision,
738
- instrument: storyInstrument({ entry, ready, answers, frameUrl, onReady, globals })
926
+ instrument: storyInstrument({ entry, ready, answers, frameUrl, onReady, globals, ...frames ? { frames } : {} })
739
927
  };
740
928
  built.set(entry.id, kept);
741
929
  return kept.instrument;
742
930
  });
743
931
  const unchanged = list.length === previous.list.length && list.every((i, n) => i === previous.list[n]);
744
932
  return { built, books, list: unchanged ? previous.list : list };
745
- }, [index, readies, revisions, frameUrl, globals, onReady]);
933
+ }, [index, readies, revisions, frameUrl, globals, frames, onReady]);
746
934
  useLayoutEffect(() => {
747
935
  committed.current = cache;
748
936
  }, [cache]);
@@ -757,7 +945,6 @@ function useStoryRegistry(index, options) {
757
945
  return { instruments: cache.list, onReady };
758
946
  }
759
947
  var NO_DECLARATIONS = {};
760
- var TRIAL_CHROME = [CSS_VARS_SECTION];
761
948
  function initialStory(index, fallback) {
762
949
  const route = readRoute();
763
950
  return route !== null && index.some((entry) => entry.id === route) ? route : fallback;
@@ -775,8 +962,8 @@ function RouteOpener({ index }) {
775
962
  }
776
963
  function Workshop({ index, frameUrl, config, stories = [], storageKey, storage }) {
777
964
  const declarations = config?.globals ?? NO_DECLARATIONS;
778
- const registry = useStoryRegistry(index, { frameUrl, globals: declarations });
779
965
  const [frames] = useState(createTrialFrames);
966
+ const registry = useStoryRegistry(index, { frameUrl, globals: declarations, frames });
780
967
  const [labValues, setLabValues] = useState(() => labGlobals(declarations, void 0));
781
968
  const reportLabValues = useCallback(
782
969
  (next) => setLabValues((prev) => stableStringify(prev) === stableStringify(next) ? prev : next),
@@ -785,6 +972,8 @@ function Workshop({ index, frameUrl, config, stories = [], storageKey, storage }
785
972
  const labChrome = useMemo(
786
973
  () => [
787
974
  { id: "fg-stories", region: "sidebar", render: (ctx) => /* @__PURE__ */ jsx(StoryTree, { ctx, index }) },
975
+ CSS_VARS_SECTION,
976
+ A11Y_SECTION,
788
977
  ...Object.keys(declarations).length > 0 ? [{ id: "fg-globals", region: "header", render: () => /* @__PURE__ */ jsx(GlobalsToolbar, { declarations }) }] : [],
789
978
  ...config?.labChrome ?? []
790
979
  ],
@@ -806,9 +995,10 @@ function Workshop({ index, frameUrl, config, stories = [], storageKey, storage }
806
995
  storageKey: storageKey ?? "weaselforge",
807
996
  ...storage ? { storage } : {},
808
997
  labChrome,
809
- chrome: TRIAL_CHROME,
810
998
  addTrial: false,
811
999
  ...config?.controls ? { controls: config.controls } : {},
1000
+ ...config?.pages ? { pages: config.pages } : {},
1001
+ ...config?.path !== void 0 ? { path: config.path } : {},
812
1002
  children: [
813
1003
  /* @__PURE__ */ jsx(RouteOpener, { index }),
814
1004
  /* @__PURE__ */ jsx(LabGlobals, { declarations, onChange: reportLabValues })
@@ -831,6 +1021,6 @@ function mountWorkshop({ container, ...props }) {
831
1021
  };
832
1022
  }
833
1023
 
834
- export { StoryGlobalsContext, Workshop, mountWorkshop, useStoryRegistry };
1024
+ export { A11Y_SECTION, A11yPanel, StoryGlobalsContext, TrialFramesContext, Workshop, mountWorkshop, useStoryRegistry, useTrialFrame };
835
1025
  //# sourceMappingURL=index.js.map
836
1026
  //# sourceMappingURL=index.js.map