@weasel-js/forge 1.5.0 → 1.5.2

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-eZM3hlcE.d.ts} +74 -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-5YHEO26U.js} +133 -23
  5. package/dist/chunk-5YHEO26U.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-CSVP6duU.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 +209 -12
  19. package/dist/shell/index.css.map +1 -1
  20. package/dist/shell/index.d.ts +62 -4
  21. package/dist/shell/index.js +712 -182
  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 +72 -20
  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,62 @@
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 as ToggleBar$1, Input, TokenPanel, refitScale } 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
- import { TOKEN_MANIFEST } from '@weasel-js/theme';
7
- import { jsxs, jsx } from 'react/jsx-runtime';
8
- import { f } from '@weasel-js/labkit/config';
7
+ import { InfoIcon, ToggleBar, Checkbox, Dialog } from '@weasel-js/ui';
8
+ import { TOKEN_MANIFEST, THEME_SOURCES } from '@weasel-js/theme';
9
+ import { scale } from '@weasel-js/theme/engine';
10
+ import { f, valueAtPath, isConfigBranch } from '@weasel-js/labkit/config';
9
11
 
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: [] };
12
+ var NO_FRAME = { send: null, vars: [], audit: null, a11y: null, capture: null, size: null };
82
13
  function createTrialFrames() {
83
14
  const frames = /* @__PURE__ */ new Map();
15
+ const hosts = /* @__PURE__ */ new Map();
84
16
  const listeners = /* @__PURE__ */ new Set();
85
17
  const changed = () => {
86
18
  for (const listener of [...listeners]) listener();
87
19
  };
20
+ const patch = (trialId, next) => {
21
+ const frame = frames.get(trialId);
22
+ if (!frame) return;
23
+ frames.set(trialId, { ...frame, ...next });
24
+ changed();
25
+ };
88
26
  return {
89
- connect(trialId, send) {
90
- frames.set(trialId, { send, vars: [] });
27
+ connect(trialId, calls) {
28
+ const held = frames.get(trialId);
29
+ frames.set(trialId, {
30
+ send: calls.send,
31
+ vars: [],
32
+ audit: calls.audit,
33
+ a11y: null,
34
+ capture: calls.capture,
35
+ size: held?.size ?? null
36
+ });
91
37
  changed();
92
38
  return () => {
93
- if (frames.get(trialId)?.send !== send) return;
39
+ if (frames.get(trialId)?.send !== calls.send) return;
94
40
  frames.delete(trialId);
95
41
  changed();
96
42
  };
97
43
  },
98
- report(trialId, vars) {
99
- const frame = frames.get(trialId);
100
- if (!frame) return;
101
- frames.set(trialId, { ...frame, vars });
44
+ report: (trialId, vars) => patch(trialId, { vars }),
45
+ reportA11y: (trialId, a11y) => patch(trialId, { a11y }),
46
+ reportSize(trialId, size) {
47
+ const frame = frames.get(trialId) ?? NO_FRAME;
48
+ if (frame.size?.width === size.width && frame.size?.height === size.height) return;
49
+ frames.set(trialId, { ...frame, size });
102
50
  changed();
103
51
  },
52
+ hostRef(trialId) {
53
+ let ref = hosts.get(trialId);
54
+ if (!ref) {
55
+ ref = { current: null };
56
+ hosts.set(trialId, ref);
57
+ }
58
+ return ref;
59
+ },
104
60
  get: (trialId) => frames.get(trialId) ?? NO_FRAME,
105
61
  subscribe(listener) {
106
62
  listeners.add(listener);
@@ -116,35 +72,164 @@ function useTrialFrame(trialId) {
116
72
  const read = () => frames?.get(trialId) ?? NO_FRAME;
117
73
  return useSyncExternalStore(subscribe, read, read);
118
74
  }
75
+ function Finding({ finding }) {
76
+ return /* @__PURE__ */ jsxs("li", { className: "fg-a11y__finding", children: [
77
+ /* @__PURE__ */ jsxs("p", { className: "fg-a11y__help", children: [
78
+ /* @__PURE__ */ jsx("a", { href: finding.helpUrl, target: "_blank", rel: "noreferrer", children: finding.help }),
79
+ finding.impact ? /* @__PURE__ */ jsx("span", { className: "fg-a11y__impact", "data-impact": finding.impact, children: finding.impact }) : null
80
+ ] }),
81
+ /* @__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(",")}`)) })
82
+ ] });
83
+ }
84
+ function Findings({ title, findings }) {
85
+ if (findings.length === 0) return null;
86
+ return /* @__PURE__ */ jsxs("section", { className: "fg-a11y__group", children: [
87
+ /* @__PURE__ */ jsxs("h3", { className: "fg-a11y__group-title", children: [
88
+ title,
89
+ " (",
90
+ findings.length,
91
+ ")"
92
+ ] }),
93
+ /* @__PURE__ */ jsx("ul", { className: "fg-a11y__findings", children: findings.map((finding) => /* @__PURE__ */ jsx(Finding, { finding }, finding.id)) })
94
+ ] });
95
+ }
96
+ function A11yPanel() {
97
+ const trialId = useTrialId();
98
+ const frame = useTrialFrame(trialId);
99
+ const [running, setRunning] = useState(false);
100
+ const audit = frame.audit;
101
+ const ran = useRef(null);
102
+ useEffect(() => {
103
+ if (!audit || ran.current === audit) return;
104
+ ran.current = audit;
105
+ setRunning(true);
106
+ void audit().finally(() => setRunning(false));
107
+ }, [audit]);
108
+ const rerun = () => {
109
+ if (!audit) return;
110
+ setRunning(true);
111
+ void audit().finally(() => setRunning(false));
112
+ };
113
+ const outcome = frame.a11y;
114
+ return /* @__PURE__ */ jsxs("div", { className: "fg-a11y", children: [
115
+ /* @__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" }) }),
116
+ !audit ? /* @__PURE__ */ jsx("p", { className: "fg-a11y__note", children: "No frame is connected." }) : null,
117
+ audit && !outcome ? /* @__PURE__ */ jsx("p", { className: "fg-a11y__note", children: running ? "Running axe\u2026" : "Not checked yet." }) : null,
118
+ outcome && !outcome.ok ? /* @__PURE__ */ jsxs("p", { className: "fg-a11y__note", role: "alert", children: [
119
+ "axe could not run: ",
120
+ outcome.message
121
+ ] }) : null,
122
+ outcome?.ok ? /* @__PURE__ */ jsxs(Fragment, { children: [
123
+ /* @__PURE__ */ jsxs("p", { className: "fg-a11y__tally", children: [
124
+ outcome.report.violations.length,
125
+ " violations \xB7 ",
126
+ outcome.report.incomplete.length,
127
+ " to review \xB7",
128
+ " ",
129
+ outcome.report.passes,
130
+ " passes"
131
+ ] }),
132
+ /* @__PURE__ */ jsx(Findings, { title: "Violations", findings: outcome.report.violations }),
133
+ /* @__PURE__ */ jsx(Findings, { title: "Needs review", findings: outcome.report.incomplete })
134
+ ] }) : null
135
+ ] });
136
+ }
137
+ function FocusedA11y() {
138
+ const { focusedTrialId, trials, instruments } = useLabContext();
139
+ const record = trials.find((trial) => trial.id === focusedTrialId);
140
+ if (!record) return null;
141
+ const instrument = instruments.find((i) => i.name === record.instrumentName);
142
+ return /* @__PURE__ */ jsxs("section", { className: "fg-a11y-focus", "aria-label": "Accessibility", children: [
143
+ /* @__PURE__ */ jsx("p", { className: "fg-a11y__trial", children: record.title ?? instrument?.title ?? record.instrumentName }),
144
+ /* @__PURE__ */ jsx(TrialIdContext.Provider, { value: record.id, children: /* @__PURE__ */ jsx(A11yPanel, {}) })
145
+ ] });
146
+ }
147
+ var A11Y_SECTION = {
148
+ id: "fg-a11y",
149
+ region: "aside",
150
+ item: { title: "Accessibility", body: /* @__PURE__ */ jsx(FocusedA11y, {}) }
151
+ };
152
+ var NONE = {};
153
+ function useCssOverrides() {
154
+ return usePersistedState("fg-css-overrides", NONE, { scope: "trial" });
155
+ }
119
156
  var TABS = [
120
157
  { value: "theme", label: "Theme" },
121
158
  { value: "story", label: "Story" }
122
159
  ];
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
- ] });
160
+ var MANIFEST = new Map(TOKEN_MANIFEST.map((token) => [token.name, token]));
161
+ var literal = (v) => typeof v === "number";
162
+ var SCALE_SOURCES = Object.entries(THEME_SOURCES.weasel?.scales ?? {}).flatMap(([name, def]) => {
163
+ if (!Array.isArray(def.steps)) return [];
164
+ const steps = def.steps;
165
+ const tokens = steps.map((step) => `--wzl-${name}-${step}`);
166
+ const group = MANIFEST.get(tokens[0] ?? "")?.group;
167
+ if (!group) return [];
168
+ const factors = Array.isArray(def.factors) && def.factors.every(literal) ? def.factors : null;
169
+ const rule = def.factors !== void 0 ? factors ? { kind: "factors", factors } : "factors" : def.ratio !== void 0 ? literal(def.ratio) ? { kind: "ratio", ratio: def.ratio } : "ratio" : literal(def.step) ? { kind: "step", step: def.step } : "step";
170
+ return [{ name, group, steps, tokens, rule }];
134
171
  });
172
+ function standingScale(source, amounts) {
173
+ const seed = { tokens: source.tokens, base: amounts[0] ?? 0, rule: { kind: "step", step: 0 } };
174
+ if (typeof source.rule === "string") return refitScale(seed, source.rule, amounts);
175
+ const { rule } = source;
176
+ if (rule.kind !== "factors") return { ...seed, rule };
177
+ let at = 0;
178
+ rule.factors.forEach((f3, i) => {
179
+ if (Math.abs(f3 - 1) < Math.abs((rule.factors[at] ?? 1) - 1)) at = i;
180
+ });
181
+ const base = (amounts[at] ?? 0) / (rule.factors[at] ?? 1);
182
+ return { ...seed, base: Math.round(base * 100) / 100, rule };
183
+ }
184
+ function groupOf(name) {
185
+ const segments = name.replace(/^--/, "").split("-");
186
+ return segments.length > 1 ? segments.slice(0, -1).join("-") : name.replace(/^--/, "");
187
+ }
135
188
  function CssVarsPanel() {
136
189
  const trialId = useTrialId();
137
190
  const frame = useTrialFrame(trialId);
138
191
  const [overrides, setOverrides] = useCssOverrides();
192
+ const [folds, setFolds] = usePersistedState(
193
+ "fg-css-vars-folds",
194
+ {},
195
+ { scope: "lab" }
196
+ );
197
+ const [edited, setEdited] = usePersistedState("fg-css-vars-scales", {}, { scope: "lab" });
139
198
  const [tab, setTab] = useState("theme");
140
199
  const [filter, setFilter] = useState("");
141
- const rows = useMemo(() => {
142
- if (tab === "story") return frame.vars.map(({ name, value }) => ({ name, value }));
200
+ const tokens = useMemo(() => {
201
+ if (tab === "story") {
202
+ return frame.vars.map(({ name, value }) => {
203
+ const known = MANIFEST.get(name);
204
+ return { name, type: known?.type ?? inferTokenType(value), group: known?.group ?? groupOf(name), value };
205
+ });
206
+ }
143
207
  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 }));
208
+ return TOKEN_MANIFEST.map((token) => ({
209
+ name: token.name,
210
+ type: token.type,
211
+ group: token.group,
212
+ value: reported.get(token.name) ?? token.defaultValue,
213
+ description: token.description
214
+ }));
145
215
  }, [tab, frame.vars]);
146
216
  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));
217
+ const shown = tokens.map((token) => {
218
+ const override = overrides[token.name];
219
+ return override === void 0 ? token : { ...token, value: override, overridden: true };
220
+ }).filter((token) => !query || token.name.toLowerCase().includes(query) || token.value.toLowerCase().includes(query));
221
+ const scales = useMemo(() => {
222
+ if (tab !== "theme") return void 0;
223
+ const values = new Map(shown.map((t) => [t.name, t.value]));
224
+ const out = {};
225
+ for (const source of SCALE_SOURCES) {
226
+ const amounts = source.tokens.map((name) => Number.parseFloat(values.get(name) ?? ""));
227
+ if (amounts.some(Number.isNaN)) continue;
228
+ const own = source.tokens.some((name) => overrides[name] !== void 0) ? edited[source.group] : void 0;
229
+ out[source.group] = own ?? standingScale(source, amounts);
230
+ }
231
+ return out;
232
+ }, [tab, shown, overrides, edited]);
148
233
  const send = frame.send;
149
234
  const write = useCallback(
150
235
  (name, value) => {
@@ -156,37 +241,272 @@ function CssVarsPanel() {
156
241
  },
157
242
  [setOverrides, send]
158
243
  );
244
+ const onScaleChange = useCallback(
245
+ (group, next) => {
246
+ const source = SCALE_SOURCES.find((sc) => sc.group === group);
247
+ if (!source) return;
248
+ setEdited((prev) => ({ ...prev, [group]: next }));
249
+ const { rule } = next;
250
+ const values = scale(source.steps, {
251
+ base: next.base,
252
+ ...rule.kind === "factors" ? { factors: rule.factors } : rule.kind === "ratio" ? { ratio: rule.ratio } : { step: rule.step }
253
+ });
254
+ source.steps.forEach((step, i) => {
255
+ const value = values[step];
256
+ const name = source.tokens[i];
257
+ if (value !== void 0 && name !== void 0) write(name, value);
258
+ });
259
+ },
260
+ [setEdited, write]
261
+ );
262
+ const onCollapsedChange = useCallback(
263
+ (category, collapsed) => setFolds((prev) => ({ ...prev, [category]: collapsed })),
264
+ [setFolds]
265
+ );
159
266
  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);
267
+ /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__controls", children: [
268
+ /* @__PURE__ */ jsx(
269
+ ToggleBar$1,
270
+ {
271
+ ariaLabel: "Variables",
272
+ variant: "flat",
273
+ items: TABS,
274
+ value: tab,
275
+ onChange: (next) => {
276
+ if (next) setTab(next);
277
+ }
169
278
  }
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,
279
+ ),
280
+ /* @__PURE__ */ jsx(Input, { "aria-label": "Filter", placeholder: "Filter by name or value", value: filter, onChange: setFilter })
281
+ ] }),
282
+ 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(
283
+ TokenPanel,
175
284
  {
176
- name: row.name,
177
- value: row.value,
178
- overridden: row.name in overrides,
179
- write
180
- },
181
- row.name
182
- )) })
285
+ tokens: shown,
286
+ onChange: write,
287
+ scales,
288
+ onScaleChange,
289
+ collapsed: folds,
290
+ onCollapsedChange
291
+ }
292
+ )
293
+ ] });
294
+ }
295
+ function FocusedCssVars() {
296
+ const { focusedTrialId, trials, instruments } = useLabContext();
297
+ const record = trials.find((trial) => trial.id === focusedTrialId);
298
+ if (!record) return null;
299
+ const instrument = instruments.find((i) => i.name === record.instrumentName);
300
+ return /* @__PURE__ */ jsxs("section", { className: "fg-css-vars-focus", "aria-label": "CSS Vars", children: [
301
+ /* @__PURE__ */ jsx("p", { className: "fg-css-vars__trial", children: record.title ?? instrument?.title ?? record.instrumentName }),
302
+ /* @__PURE__ */ jsx(TrialIdContext.Provider, { value: record.id, children: /* @__PURE__ */ jsx(CssVarsPanel, {}) })
183
303
  ] });
184
304
  }
185
305
  var CSS_VARS_SECTION = {
186
306
  id: "fg-css-vars",
187
- region: "sidebar",
188
- item: { title: "CSS Vars", body: /* @__PURE__ */ jsx(CssVarsPanel, {}) }
307
+ region: "aside",
308
+ item: { title: "CSS Vars", body: /* @__PURE__ */ jsx(FocusedCssVars, {}) }
189
309
  };
310
+
311
+ // src/shell/tree/buildComponents.ts
312
+ var LIBRARIES = [
313
+ ["/packages/ui/", "weasel-ui"],
314
+ ["/packages/labkit/", "labkit"],
315
+ ["/packages/forge/", "forge"],
316
+ ["/apps/draw/", "draw"],
317
+ ["/apps/forge/", "forge"]
318
+ ];
319
+ function libraryOf(entry) {
320
+ const file = entry.file.replaceAll("\\", "/");
321
+ for (const [marker, label] of LIBRARIES) {
322
+ if (file.includes(marker)) return label;
323
+ }
324
+ return entry.title.split("/")[0];
325
+ }
326
+ function buildComponents(index) {
327
+ const byTitle = /* @__PURE__ */ new Map();
328
+ for (const entry of index) {
329
+ let row = byTitle.get(entry.title);
330
+ if (!row) {
331
+ const segments = entry.title.split("/");
332
+ row = {
333
+ path: entry.title,
334
+ label: segments[segments.length - 1],
335
+ library: libraryOf(entry),
336
+ entries: []
337
+ };
338
+ byTitle.set(entry.title, row);
339
+ }
340
+ row.entries.push(entry);
341
+ }
342
+ const rows = [...byTitle.values()];
343
+ disambiguate(rows);
344
+ return rows.sort(
345
+ (a, b) => a.label.localeCompare(b.label) || a.library.localeCompare(b.library) || a.path.localeCompare(b.path)
346
+ );
347
+ }
348
+ function disambiguate(rows) {
349
+ const byLabel = /* @__PURE__ */ new Map();
350
+ for (const row of rows) {
351
+ const group = byLabel.get(row.label);
352
+ if (group) group.push(row);
353
+ else byLabel.set(row.label, [row]);
354
+ }
355
+ for (const group of byLabel.values()) {
356
+ if (group.length < 2) continue;
357
+ for (const row of group) {
358
+ const segments = row.path.split("/");
359
+ for (let take = 2; take <= segments.length; take++) {
360
+ row.label = segments.slice(-take).join("/");
361
+ if (group.every((other) => other === row || !other.path.endsWith(`/${row.label}`))) break;
362
+ }
363
+ }
364
+ }
365
+ }
366
+ function filterComponents(rows, query) {
367
+ const needle = query.trim().toLowerCase();
368
+ if (!needle) return [...rows];
369
+ const out = [];
370
+ for (const row of rows) {
371
+ if (`${row.path}/${row.library}`.toLowerCase().includes(needle)) {
372
+ out.push(row);
373
+ continue;
374
+ }
375
+ const entries = row.entries.filter((entry) => entry.name.toLowerCase().includes(needle));
376
+ if (entries.length > 0) out.push({ ...row, entries });
377
+ }
378
+ return out;
379
+ }
380
+ function componentNodes(rows) {
381
+ return rows.map(
382
+ (row) => row.entries.length === 1 ? { kind: "story", entry: row.entries[0], tag: row.library, label: row.label } : {
383
+ kind: "folder",
384
+ label: row.label,
385
+ path: row.path,
386
+ tag: row.library,
387
+ children: row.entries.map((entry) => ({ kind: "story", entry }))
388
+ }
389
+ );
390
+ }
391
+ function librariesIn(index) {
392
+ return [...new Set(index.map(libraryOf))].sort((a, b) => a.localeCompare(b));
393
+ }
394
+
395
+ // src/shell/info/dossier.ts
396
+ var GLOBALS = "$globals";
397
+ function leaves(shape, prefix = "") {
398
+ const out = [];
399
+ for (const [key, entry] of Object.entries(shape)) {
400
+ if (prefix === "" && key === GLOBALS) continue;
401
+ const path = prefix ? `${prefix}.${key}` : key;
402
+ if (isConfigBranch(entry)) out.push(...leaves(entry.children, path));
403
+ else if (!entry.annotations.hidden) out.push({ path, node: entry });
404
+ }
405
+ return out;
406
+ }
407
+ function storyDossier(input) {
408
+ const { entry, instrument, config, ready = true } = input;
409
+ const schema = ready ? instrument?.config : void 0;
410
+ const args = schema ? leaves(schema.nodes).map(({ path, node }) => {
411
+ const value = valueAtPath(config, path);
412
+ return {
413
+ path,
414
+ label: node.annotations.name ?? path.split(".").pop() ?? path,
415
+ kind: node.kind,
416
+ default: node.default,
417
+ value: value === void 0 ? node.default : value,
418
+ ...node.annotations.description === void 0 ? {} : { description: node.annotations.description }
419
+ };
420
+ }) : [];
421
+ return {
422
+ id: entry.id,
423
+ title: entry.title,
424
+ name: entry.name,
425
+ exportName: entry.exportName,
426
+ file: entry.file,
427
+ library: libraryOf(entry),
428
+ ...entry.componentName === void 0 ? {} : { componentName: entry.componentName },
429
+ ...entry.description === void 0 ? {} : { description: entry.description },
430
+ ...entry.componentDescription === void 0 ? {} : { componentDescription: entry.componentDescription },
431
+ args,
432
+ argsPending: !schema
433
+ };
434
+ }
435
+ function printed(value) {
436
+ if (value === void 0 || value === null) return "\u2014";
437
+ if (typeof value === "string") return value === "" ? '""' : value;
438
+ if (typeof value === "function") return "\u0192";
439
+ if (typeof value === "object") return JSON.stringify(value);
440
+ return String(value);
441
+ }
442
+ function Row({ label, children }) {
443
+ return /* @__PURE__ */ jsxs("div", { className: "fg-info__row", children: [
444
+ /* @__PURE__ */ jsx("dt", { className: "fg-info__key", children: label }),
445
+ /* @__PURE__ */ jsx("dd", { className: "fg-info__value", children })
446
+ ] });
447
+ }
448
+ function ArgRows({ args }) {
449
+ return /* @__PURE__ */ jsxs("table", { className: "fg-info__args", children: [
450
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
451
+ /* @__PURE__ */ jsx("th", { scope: "col", children: "Name" }),
452
+ /* @__PURE__ */ jsx("th", { scope: "col", children: "Kind" }),
453
+ /* @__PURE__ */ jsx("th", { scope: "col", children: "Value" }),
454
+ /* @__PURE__ */ jsx("th", { scope: "col", children: "Default" })
455
+ ] }) }),
456
+ /* @__PURE__ */ jsx("tbody", { children: args.map((arg) => /* @__PURE__ */ jsxs("tr", { children: [
457
+ /* @__PURE__ */ jsxs("th", { scope: "row", children: [
458
+ /* @__PURE__ */ jsx("span", { className: "fg-info__arg-name", children: arg.label }),
459
+ arg.description ? /* @__PURE__ */ jsx("span", { className: "fg-info__arg-doc", children: arg.description }) : null
460
+ ] }),
461
+ /* @__PURE__ */ jsx("td", { className: "fg-info__arg-kind", children: arg.kind ?? "\u2014" }),
462
+ /* @__PURE__ */ jsx("td", { className: "fg-info__value-cell", children: printed(arg.value) }),
463
+ /* @__PURE__ */ jsx("td", { className: "fg-info__value-cell", children: printed(arg.default) })
464
+ ] }, arg.path)) })
465
+ ] });
466
+ }
467
+ function StoryDossier({ dossier }) {
468
+ return /* @__PURE__ */ jsxs("div", { className: "fg-info", children: [
469
+ dossier.description || dossier.componentDescription ? /* @__PURE__ */ jsxs("section", { className: "fg-info__section", "aria-label": "Docs", children: [
470
+ dossier.description ? /* @__PURE__ */ jsx("p", { className: "fg-info__doc", children: dossier.description }) : null,
471
+ dossier.componentDescription ? /* @__PURE__ */ jsx("p", { className: "fg-info__doc fg-info__doc--component", children: dossier.componentDescription }) : null
472
+ ] }) : null,
473
+ /* @__PURE__ */ jsx("section", { className: "fg-info__section", "aria-label": "Identity", children: /* @__PURE__ */ jsxs("dl", { className: "fg-info__rows", children: [
474
+ /* @__PURE__ */ jsx(Row, { label: "Story", children: dossier.name }),
475
+ /* @__PURE__ */ jsx(Row, { label: "Component", children: dossier.componentName ?? dossier.title.split("/").pop() }),
476
+ /* @__PURE__ */ jsx(Row, { label: "Library", children: dossier.library }),
477
+ /* @__PURE__ */ jsx(Row, { label: "Title", children: dossier.title }),
478
+ /* @__PURE__ */ jsx(Row, { label: "Export", children: /* @__PURE__ */ jsx("code", { children: dossier.exportName }) }),
479
+ /* @__PURE__ */ jsx(Row, { label: "Id", children: /* @__PURE__ */ jsx("code", { children: dossier.id }) }),
480
+ /* @__PURE__ */ jsx(Row, { label: "File", children: /* @__PURE__ */ jsx("code", { className: "fg-info__path", children: dossier.file }) })
481
+ ] }) }),
482
+ /* @__PURE__ */ jsxs("section", { className: "fg-info__section", "aria-label": "Args", children: [
483
+ /* @__PURE__ */ jsx("h3", { className: "fg-info__heading", children: "Args" }),
484
+ dossier.argsPending ? /* @__PURE__ */ jsx("p", { className: "fg-info__empty", children: "Open this story to read its args." }) : dossier.args.length === 0 ? /* @__PURE__ */ jsx("p", { className: "fg-info__empty", children: "This story takes no args." }) : /* @__PURE__ */ jsx(ArgRows, { args: dossier.args })
485
+ ] })
486
+ ] });
487
+ }
488
+ function StoryInfoDialog({ index, isReady, isOpen, onOpenChange }) {
489
+ const { focusedTrialId, trials, instruments } = useLabContext();
490
+ const trial = trials.find((record) => record.id === focusedTrialId);
491
+ const entry = trial ? index.find((candidate) => candidate.id === trial.instrumentName) : void 0;
492
+ if (!entry) return null;
493
+ const dossier = storyDossier({
494
+ entry,
495
+ instrument: instruments.find((candidate) => candidate.name === entry.id),
496
+ config: trial?.config,
497
+ ready: isReady(entry.id)
498
+ });
499
+ return /* @__PURE__ */ jsx(
500
+ Dialog,
501
+ {
502
+ isOpen,
503
+ onOpenChange,
504
+ title: `${dossier.title} / ${dossier.name}`,
505
+ className: "fg-info-dialog",
506
+ children: /* @__PURE__ */ jsx(StoryDossier, { dossier })
507
+ }
508
+ );
509
+ }
190
510
  var GLOBALS_KEY = "$globals";
191
511
  var FOLLOW_LAB = "lab";
192
512
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -217,13 +537,13 @@ function storyConfig(config, previous) {
217
537
  function withGlobals(schema, declarations) {
218
538
  const entries = Object.entries(declarations);
219
539
  if (entries.length === 0) return schema;
220
- const leaves = Object.fromEntries(
540
+ const leaves2 = Object.fromEntries(
221
541
  entries.map(([key, declaration]) => [
222
542
  key,
223
543
  f.enum(FOLLOW_LAB, [{ value: FOLLOW_LAB, label: "Lab" }, ...declaration.options]).label(declaration.label)
224
544
  ])
225
545
  );
226
- const group = f.group(leaves).label("").section("Globals");
546
+ const group = f.group(leaves2).label("").section("Globals", { layout: "inline", pack: "pairs" });
227
547
  return f.schema({ ...schema.nodes, [GLOBALS_KEY]: group });
228
548
  }
229
549
  var RECORD = "fg-globals";
@@ -239,13 +559,13 @@ function useLabGlobals(declarations) {
239
559
  function GlobalsToolbar({ declarations }) {
240
560
  const [values, set] = useLabGlobals(declarations);
241
561
  return /* @__PURE__ */ jsx("div", { className: "fg-globals", role: "toolbar", "aria-label": "Globals", children: Object.entries(declarations).map(([key, declaration]) => /* @__PURE__ */ jsx(
242
- "select",
562
+ Select,
243
563
  {
244
- className: "fg-globals__select",
245
564
  "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))
565
+ width: "fit",
566
+ selectedKey: String(values[key]),
567
+ onSelectionChange: (value) => set(key, value),
568
+ options: declaration.options
249
569
  },
250
570
  key
251
571
  )) });
@@ -341,6 +661,10 @@ function filterTree(nodes, query) {
341
661
  });
342
662
  return walk(nodes);
343
663
  }
664
+ var VIEWS = [
665
+ { value: "tree", label: "Tree" },
666
+ { value: "components", label: "Components" }
667
+ ];
344
668
  var keyOf = (node) => node.kind === "folder" ? `folder:${node.path}` : `story:${node.entry.id}`;
345
669
  function ancestorsOf(entry) {
346
670
  const paths = /* @__PURE__ */ new Set();
@@ -357,14 +681,22 @@ function StoryTree({ ctx, index }) {
357
681
  const [route, setRoute] = useRoute();
358
682
  const [query, setQuery] = useState("");
359
683
  const [folds, setFolds] = usePersistedState("fg-tree-open", {}, { scope: "lab" });
684
+ const [view, setView] = usePersistedState("fg-tree-view", "tree", { scope: "lab" });
685
+ const [hidden, setHidden] = usePersistedState("fg-tree-libraries", {}, { scope: "lab" });
360
686
  const [focusKey, setFocusKey] = useState(null);
361
687
  const items = useRef(/* @__PURE__ */ new Map());
362
- const tree = useMemo(() => buildTree(index), [index]);
363
- const shown = useMemo(() => filterTree(tree, query), [tree, query]);
364
- const routed = index.find((entry) => entry.id === route);
688
+ const libraries = useMemo(() => librariesIn(index), [index]);
689
+ const kept = useMemo(() => index.filter((entry) => !hidden[libraryOf(entry)]), [index, hidden]);
690
+ const tree = useMemo(() => buildTree(kept), [kept]);
691
+ const components = useMemo(() => buildComponents(kept), [kept]);
692
+ const shown = useMemo(
693
+ () => view === "components" ? componentNodes(filterComponents(components, query)) : filterTree(tree, query),
694
+ [view, components, tree, query]
695
+ );
696
+ const routed = kept.find((entry) => entry.id === route);
365
697
  const routeAncestors = useMemo(() => ancestorsOf(routed), [routed]);
366
698
  const filtering = query.trim() !== "";
367
- const isOpen = (path) => filtering || (folds[path] ?? routeAncestors.has(path));
699
+ const isOpen = (path) => filtering || (folds[path] ?? (view === "tree" && routeAncestors.has(path)));
368
700
  const rows = [];
369
701
  const collect = (nodes, parent) => {
370
702
  for (const node of nodes) {
@@ -383,10 +715,11 @@ function StoryTree({ ctx, index }) {
383
715
  setFocusKey(key);
384
716
  items.current.get(key)?.focus();
385
717
  };
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);
718
+ const activate = (entry, another) => {
719
+ const focused = ctx.trials.find((trial) => trial.id === ctx.focusedTrialId);
720
+ if (another || !focused) ctx.addTrial(entry.id);
721
+ else if (focused.instrumentName === entry.id) revealTrial(focused.id);
722
+ else ctx.swapTrial(focused.id, entry.id);
390
723
  setRoute(entry.id);
391
724
  };
392
725
  const onKeyDown = (event) => {
@@ -419,7 +752,7 @@ function StoryTree({ ctx, index }) {
419
752
  case "Enter":
420
753
  case " ":
421
754
  if (node.kind === "folder") setOpen(node.path, !isOpen(node.path));
422
- else activate(node.entry, event.metaKey || event.ctrlKey);
755
+ else activate(node.entry, event.shiftKey);
423
756
  break;
424
757
  default:
425
758
  return;
@@ -453,7 +786,10 @@ function StoryTree({ ctx, index }) {
453
786
  "aria-label": node.label,
454
787
  className: "fg-tree__node",
455
788
  children: [
456
- /* @__PURE__ */ jsx("div", { className: "fg-tree__item fg-tree__folder", onClick: () => setOpen(node.path, !open), children: /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: node.label }) }),
789
+ /* @__PURE__ */ jsxs("div", { className: "fg-tree__item fg-tree__folder", onClick: () => setOpen(node.path, !open), children: [
790
+ /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: node.label }),
791
+ node.tag ? /* @__PURE__ */ jsx("span", { className: "fg-tree__tag", children: node.tag }) : null
792
+ ] }),
457
793
  open ? /* @__PURE__ */ jsx("div", { role: "group", className: "fg-tree__group", children: renderNodes(node.children, level + 1) }) : null
458
794
  ]
459
795
  },
@@ -461,7 +797,7 @@ function StoryTree({ ctx, index }) {
461
797
  );
462
798
  }
463
799
  const { entry } = node;
464
- return /* @__PURE__ */ jsx(
800
+ return /* @__PURE__ */ jsxs(
465
801
  "a",
466
802
  {
467
803
  ...itemProps(node, level),
@@ -469,12 +805,15 @@ function StoryTree({ ctx, index }) {
469
805
  "aria-current": openIds.has(entry.id) ? "true" : void 0,
470
806
  className: "fg-tree__item fg-tree__story",
471
807
  onClick: (event) => {
472
- if (event.button !== 0 || event.shiftKey || event.altKey) return;
808
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey) return;
473
809
  event.preventDefault();
474
810
  setFocusKey(keyOf(node));
475
- activate(entry, event.metaKey || event.ctrlKey);
811
+ activate(entry, event.shiftKey);
476
812
  },
477
- children: /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: entry.name })
813
+ children: [
814
+ /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: node.label ?? entry.name }),
815
+ node.tag ? /* @__PURE__ */ jsx("span", { className: "fg-tree__tag", children: node.tag }) : null
816
+ ]
478
817
  },
479
818
  keyOf(node)
480
819
  );
@@ -485,6 +824,30 @@ function StoryTree({ ctx, index }) {
485
824
  "No story ",
486
825
  /* @__PURE__ */ jsx("code", { children: route })
487
826
  ] }) : null,
827
+ /* @__PURE__ */ jsx(
828
+ ToggleBar,
829
+ {
830
+ ariaLabel: "Sidebar view",
831
+ variant: "flat",
832
+ items: VIEWS,
833
+ value: view,
834
+ onChange: (next) => {
835
+ if (next) setView(next);
836
+ }
837
+ }
838
+ ),
839
+ /* @__PURE__ */ jsxs("fieldset", { className: "fg-tree__libraries", children: [
840
+ /* @__PURE__ */ jsx("legend", { className: "fg-tree__libraries-legend", children: "Packages" }),
841
+ libraries.map((library) => /* @__PURE__ */ jsx(
842
+ Checkbox,
843
+ {
844
+ isSelected: !hidden[library],
845
+ onChange: (on) => setHidden((prev) => ({ ...prev, [library]: !on })),
846
+ children: library
847
+ },
848
+ library
849
+ ))
850
+ ] }),
488
851
  /* @__PURE__ */ jsx(
489
852
  "input",
490
853
  {
@@ -504,26 +867,36 @@ var KEPT_CONFIGS = 50;
504
867
  var UNANSWERED = stableStringify({ hidden: [], errors: {} });
505
868
  function createAnswerBook() {
506
869
  const byConfig = /* @__PURE__ */ new Map();
507
- let errors = {};
508
870
  const listeners = /* @__PURE__ */ new Set();
871
+ const held = /* @__PURE__ */ new Map();
872
+ const keyOf2 = (config) => stableStringify(storyConfig(config));
873
+ const answerFor = (config) => byConfig.get(keyOf2(config));
509
874
  return {
510
875
  record(answers) {
511
876
  const key = stableStringify({ hidden: answers.hidden, errors: answers.errors });
512
877
  const changed = key !== (byConfig.get(answers.configKey)?.key ?? UNANSWERED);
513
878
  byConfig.delete(answers.configKey);
514
- byConfig.set(answers.configKey, { hidden: answers.hidden, key });
879
+ byConfig.set(answers.configKey, { hidden: answers.hidden, errors: answers.errors, key });
515
880
  if (byConfig.size > KEPT_CONFIGS) {
516
881
  const oldest = byConfig.keys().next().value;
517
882
  if (oldest !== void 0) byConfig.delete(oldest);
518
883
  }
519
- errors = answers.errors;
520
- if (changed) for (const fn of listeners) fn();
884
+ if (changed && held.has(answers.configKey)) for (const fn of listeners) fn();
885
+ },
886
+ hold(config) {
887
+ const key = keyOf2(config);
888
+ held.set(key, (held.get(key) ?? 0) + 1);
889
+ return () => {
890
+ const count = (held.get(key) ?? 1) - 1;
891
+ if (count > 0) held.set(key, count);
892
+ else held.delete(key);
893
+ };
521
894
  },
522
895
  hidden(path, config) {
523
- const hidden = byConfig.get(stableStringify(storyConfig(config)))?.hidden;
896
+ const hidden = answerFor(config)?.hidden;
524
897
  return hidden?.some((h) => path === h || path.startsWith(`${h}.`)) ?? false;
525
898
  },
526
- errors: (path) => errors[path] ?? [],
899
+ errors: (path, config) => answerFor(config)?.errors[path] ?? [],
527
900
  subscribe(fn) {
528
901
  listeners.add(fn);
529
902
  return () => {
@@ -536,18 +909,43 @@ function createAnswerBook() {
536
909
  // src/shell/readyKey.ts
537
910
  var readyKey = (ready) => stableStringify({ schema: ready.schema, layout: ready.layout, viewport: ready.viewport });
538
911
  var START_TIMEOUT_MS = 1e4;
912
+ var requests = 0;
913
+ var IN_VIEW_MARGIN = "50%";
914
+ function useInView(ref) {
915
+ const [inView, setInView] = useState(true);
916
+ useEffect(() => {
917
+ const el = ref.current;
918
+ if (!el || typeof IntersectionObserver === "undefined") return;
919
+ const observer = new IntersectionObserver(
920
+ (entries) => {
921
+ const last = entries.at(-1);
922
+ if (last) setInView(last.isIntersecting);
923
+ },
924
+ { rootMargin: IN_VIEW_MARGIN }
925
+ );
926
+ observer.observe(el);
927
+ return () => observer.disconnect();
928
+ }, [ref]);
929
+ return inView;
930
+ }
539
931
  function closeLink(link) {
540
932
  if (!link.current) return;
541
933
  clearTimeout(link.current.timer);
934
+ for (const settle2 of link.current.pending.values()) settle2.gone();
935
+ link.current.pending.clear();
542
936
  link.current.disconnect();
543
937
  link.current.channel.close();
544
938
  link.current = null;
545
939
  }
940
+ function settle(link, id, value) {
941
+ link.pending.get(id)?.resolve?.(value);
942
+ link.pending.delete(id);
943
+ }
546
944
  function mismatchMessage(mismatch) {
547
945
  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
946
  }
549
947
  function FrameView(props) {
550
- const { entry, frameUrl, descriptionKey, ctx } = props;
948
+ const { entry, frameUrl, answers, descriptionKey, ctx } = props;
551
949
  const labGlobals2 = useContext(StoryGlobalsContext);
552
950
  const pins = ctx.config?.[GLOBALS_KEY];
553
951
  const globals = useMemo(() => effectiveGlobals(labGlobals2, pins), [labGlobals2, pins]);
@@ -556,10 +954,13 @@ function FrameView(props) {
556
954
  const [overrides] = useCssOverrides();
557
955
  const src = `${frameUrl}#${entry.id}`;
558
956
  const iframeRef = useRef(null);
957
+ const hostRef = useRef(null);
958
+ const inView = useInView(hostRef);
559
959
  const link = useRef(null);
560
960
  const latest = useRef({ ...props, globals, overrides });
561
961
  latest.current = { ...props, globals, overrides };
562
962
  const [fault, setFault] = useState(null);
963
+ const [pending, setPending] = useState(true);
563
964
  const init = (current) => {
564
965
  const { ctx: live, globals: liveGlobals } = latest.current;
565
966
  current.awaiting = null;
@@ -568,7 +969,7 @@ function FrameView(props) {
568
969
  current.channel.send({ type: "init", ...current.sent });
569
970
  };
570
971
  const receive = (current, msg) => {
571
- const { ctx: live, answers, onReady } = latest.current;
972
+ const { ctx: live, answers: answers2, onReady } = latest.current;
572
973
  switch (msg.type) {
573
974
  case "ready": {
574
975
  clearTimeout(current.timer);
@@ -582,6 +983,11 @@ function FrameView(props) {
582
983
  if (current.awaiting === key && key === latest.current.descriptionKey) init(current);
583
984
  break;
584
985
  }
986
+ case "rendered":
987
+ setPending(false);
988
+ current.hasRendered = true;
989
+ for (const ask of current.queued.splice(0)) ask();
990
+ break;
585
991
  case "setConfig":
586
992
  if (!isGlobalsPath(msg.path)) live.setConfig(msg.path, msg.value);
587
993
  break;
@@ -590,11 +996,25 @@ function FrameView(props) {
590
996
  live.setState(msg.state);
591
997
  break;
592
998
  case "answers":
593
- answers.record(msg.answers);
999
+ answers2.record(msg.answers);
594
1000
  break;
595
1001
  case "vars":
596
1002
  if (trialId) frames?.report(trialId, msg.vars);
597
1003
  break;
1004
+ case "size":
1005
+ if (trialId) frames?.reportSize(trialId, { width: msg.width, height: msg.height });
1006
+ break;
1007
+ case "a11y": {
1008
+ const outcome = msg.ok ? { ok: true, report: msg.report } : { ok: false, message: msg.message };
1009
+ if (trialId) frames?.reportA11y(trialId, outcome);
1010
+ settle(current, msg.id, outcome);
1011
+ break;
1012
+ }
1013
+ case "capture":
1014
+ if (msg.ok) settle(current, msg.id, msg.picture);
1015
+ else current.pending.get(msg.id)?.fail(new Error(msg.message));
1016
+ current.pending.delete(msg.id);
1017
+ break;
598
1018
  case "fault":
599
1019
  if (msg.phase === "render" && msg.seq !== void 0 && msg.seq < current.inputs) break;
600
1020
  clearTimeout(current.timer);
@@ -604,6 +1024,7 @@ function FrameView(props) {
604
1024
  };
605
1025
  const onLoad = () => {
606
1026
  closeLink(link);
1027
+ setPending(true);
607
1028
  const target = iframeRef.current?.contentWindow;
608
1029
  if (!target) return;
609
1030
  const { port1, port2 } = new MessageChannel();
@@ -614,14 +1035,58 @@ function FrameView(props) {
614
1035
  setFault({ phase: "protocol", message: mismatchMessage(mismatch) });
615
1036
  }
616
1037
  });
617
- const disconnect = frames && trialId ? frames.connect(trialId, channel.send) : () => {
1038
+ const waiting = /* @__PURE__ */ new Map();
1039
+ const request = (id, msg, gone) => new Promise((resolve, reject) => {
1040
+ waiting.set(id, {
1041
+ resolve,
1042
+ fail: reject,
1043
+ gone: () => gone(resolve, reject)
1044
+ });
1045
+ const ask = () => channel.send(msg);
1046
+ if (current.hasRendered) ask();
1047
+ else current.queued.push(ask);
1048
+ });
1049
+ const audit = () => {
1050
+ const id = `a11y-${requests += 1}`;
1051
+ return request(
1052
+ id,
1053
+ { type: "a11y.run", id },
1054
+ (resolve) => resolve({ ok: false, message: "The frame went away" })
1055
+ );
1056
+ };
1057
+ const capture = () => {
1058
+ const id = `capture-${requests += 1}`;
1059
+ return request(
1060
+ id,
1061
+ { type: "capture.run", id },
1062
+ (_resolve, fail) => fail(new Error("The frame could not draw itself"))
1063
+ );
1064
+ };
1065
+ const disconnect = frames && trialId ? frames.connect(trialId, { send: channel.send, audit, capture }) : () => {
1066
+ };
1067
+ const current = {
1068
+ channel,
1069
+ timer,
1070
+ awaiting: null,
1071
+ sent: null,
1072
+ inputs: 0,
1073
+ disconnect,
1074
+ pending: waiting,
1075
+ hasRendered: false,
1076
+ queued: []
618
1077
  };
619
- const current = { channel, timer, awaiting: null, sent: null, inputs: 0, disconnect };
620
1078
  link.current = current;
621
1079
  channel.on((msg) => receive(current, msg));
622
1080
  target.postMessage({ type: PORT_HANDOFF }, location.origin, [port2]);
623
1081
  };
624
1082
  useEffect(() => () => closeLink(link), []);
1083
+ useEffect(() => {
1084
+ if (inView) return;
1085
+ closeLink(link);
1086
+ setPending(true);
1087
+ setFault(null);
1088
+ }, [inView]);
1089
+ useEffect(() => answers.hold(ctx.config), [answers, ctx.config]);
625
1090
  useEffect(() => {
626
1091
  const current = link.current;
627
1092
  if (!current) return;
@@ -653,25 +1118,54 @@ function FrameView(props) {
653
1118
  }
654
1119
  if (input) setFault((shown) => shown?.phase === "render" ? null : shown);
655
1120
  }, [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
- ] });
1121
+ return /* @__PURE__ */ jsxs(
1122
+ "div",
1123
+ {
1124
+ ref: (el) => {
1125
+ hostRef.current = el;
1126
+ if (trialId && frames) frames.hostRef(trialId).current = el;
1127
+ },
1128
+ className: "fg-frame-host",
1129
+ children: [
1130
+ inView ? /* @__PURE__ */ jsx(
1131
+ "iframe",
1132
+ {
1133
+ ref: iframeRef,
1134
+ className: "fg-frame-view",
1135
+ src,
1136
+ title: `${entry.title} / ${entry.name}`,
1137
+ onLoad,
1138
+ "data-pending": pending && !fault ? "" : void 0
1139
+ }
1140
+ ) : null,
1141
+ fault ? /* @__PURE__ */ jsxs("div", { className: "fg-fault", role: "alert", children: [
1142
+ fault.phase ? /* @__PURE__ */ jsx("span", { className: "fg-fault__phase", children: fault.phase }) : null,
1143
+ /* @__PURE__ */ jsx("p", { className: "fg-fault__message", children: fault.message })
1144
+ ] }) : null
1145
+ ]
1146
+ }
1147
+ );
1148
+ }
1149
+ function annotationsOn(frames) {
1150
+ return {
1151
+ targets: (_state, _config, trial) => {
1152
+ const ref = frames.hostRef(trial.id);
1153
+ const frame = frames.get(trial.id);
1154
+ const box = frame.size ?? { width: ref.current?.clientWidth ?? 0, height: ref.current?.clientHeight ?? 0 };
1155
+ const capture = frame.capture;
1156
+ return [
1157
+ {
1158
+ id: "story",
1159
+ ref,
1160
+ content: { w: Math.max(1, box.width), h: Math.max(1, box.height) },
1161
+ ...capture ? { base: () => capture() } : {}
1162
+ }
1163
+ ];
1164
+ }
1165
+ };
672
1166
  }
673
1167
  function storyInstrument(options) {
674
- const { entry, ready, answers, frameUrl, onReady, globals = {} } = options;
1168
+ const { entry, ready, answers, frameUrl, onReady, globals = {}, frames } = options;
675
1169
  const config = withGlobals(
676
1170
  ready ? schemaFromDescription(ready.schema, answers) : f.schema({}),
677
1171
  globals
@@ -694,6 +1188,7 @@ function storyInstrument(options) {
694
1188
  ctx
695
1189
  }
696
1190
  ),
1191
+ ...frames ? { annotations: annotationsOn(frames) } : {},
697
1192
  ...ready?.viewport ? { stage: { size: { width: ready.viewport.width, height: ready.viewport.height } } } : {}
698
1193
  };
699
1194
  }
@@ -702,7 +1197,7 @@ function storyInstrument(options) {
702
1197
  var NO_GLOBALS = {};
703
1198
  var pruned = (map, ids) => [...map.keys()].every((id) => ids.has(id)) ? map : new Map([...map].filter(([id]) => ids.has(id)));
704
1199
  function useStoryRegistry(index, options) {
705
- const { frameUrl, globals = NO_GLOBALS } = options;
1200
+ const { frameUrl, globals = NO_GLOBALS, frames } = options;
706
1201
  const [readies, setReadies] = useState(() => /* @__PURE__ */ new Map());
707
1202
  const [revisions, setRevisions] = useState(() => /* @__PURE__ */ new Map());
708
1203
  const onReady = useCallback((entry, ready) => {
@@ -729,20 +1224,21 @@ function useStoryRegistry(index, options) {
729
1224
  const answers = previous.books.get(entry.id) ?? createAnswerBook();
730
1225
  books.set(entry.id, answers);
731
1226
  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 : {
1227
+ const kept = held && held.entryKey === entryKey && held.ready === ready && held.frameUrl === frameUrl && held.globals === globals && held.frames === frames && held.revision === revision ? held : {
733
1228
  entryKey,
734
1229
  ready,
735
1230
  frameUrl,
736
1231
  globals,
1232
+ frames,
737
1233
  revision,
738
- instrument: storyInstrument({ entry, ready, answers, frameUrl, onReady, globals })
1234
+ instrument: storyInstrument({ entry, ready, answers, frameUrl, onReady, globals, ...frames ? { frames } : {} })
739
1235
  };
740
1236
  built.set(entry.id, kept);
741
1237
  return kept.instrument;
742
1238
  });
743
1239
  const unchanged = list.length === previous.list.length && list.every((i, n) => i === previous.list[n]);
744
1240
  return { built, books, list: unchanged ? previous.list : list };
745
- }, [index, readies, revisions, frameUrl, globals, onReady]);
1241
+ }, [index, readies, revisions, frameUrl, globals, frames, onReady]);
746
1242
  useLayoutEffect(() => {
747
1243
  committed.current = cache;
748
1244
  }, [cache]);
@@ -754,10 +1250,10 @@ function useStoryRegistry(index, options) {
754
1250
  for (const off of offs) off();
755
1251
  };
756
1252
  }, [cache.books]);
757
- return { instruments: cache.list, onReady };
1253
+ const isReady = useCallback((id) => readies.has(id), [readies]);
1254
+ return { instruments: cache.list, isReady, onReady };
758
1255
  }
759
1256
  var NO_DECLARATIONS = {};
760
- var TRIAL_CHROME = [CSS_VARS_SECTION];
761
1257
  function initialStory(index, fallback) {
762
1258
  const route = readRoute();
763
1259
  return route !== null && index.some((entry) => entry.id === route) ? route : fallback;
@@ -773,11 +1269,27 @@ function RouteOpener({ index }) {
773
1269
  }, [route, index, lab]);
774
1270
  return null;
775
1271
  }
1272
+ function useInfoShortcut(open) {
1273
+ useEffect(() => {
1274
+ const onKeyDown = (event) => {
1275
+ if (event.key !== "i" && event.key !== "I") return;
1276
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
1277
+ const target = event.target;
1278
+ if (target?.isContentEditable || /^(input|textarea|select)$/i.test(target?.tagName ?? "")) return;
1279
+ event.preventDefault();
1280
+ open(true);
1281
+ };
1282
+ window.addEventListener("keydown", onKeyDown);
1283
+ return () => window.removeEventListener("keydown", onKeyDown);
1284
+ }, [open]);
1285
+ }
776
1286
  function Workshop({ index, frameUrl, config, stories = [], storageKey, storage }) {
777
1287
  const declarations = config?.globals ?? NO_DECLARATIONS;
778
- const registry = useStoryRegistry(index, { frameUrl, globals: declarations });
779
1288
  const [frames] = useState(createTrialFrames);
1289
+ const registry = useStoryRegistry(index, { frameUrl, globals: declarations, frames });
780
1290
  const [labValues, setLabValues] = useState(() => labGlobals(declarations, void 0));
1291
+ const [infoOpen, setInfoOpen] = useState(false);
1292
+ useInfoShortcut(setInfoOpen);
781
1293
  const reportLabValues = useCallback(
782
1294
  (next) => setLabValues((prev) => stableStringify(prev) === stableStringify(next) ? prev : next),
783
1295
  []
@@ -785,6 +1297,13 @@ function Workshop({ index, frameUrl, config, stories = [], storageKey, storage }
785
1297
  const labChrome = useMemo(
786
1298
  () => [
787
1299
  { id: "fg-stories", region: "sidebar", render: (ctx) => /* @__PURE__ */ jsx(StoryTree, { ctx, index }) },
1300
+ {
1301
+ id: "fg-info",
1302
+ region: "palette",
1303
+ item: { icon: InfoIcon, label: "Info", shortcut: "\u2318I", onActivate: () => setInfoOpen(true) }
1304
+ },
1305
+ CSS_VARS_SECTION,
1306
+ A11Y_SECTION,
788
1307
  ...Object.keys(declarations).length > 0 ? [{ id: "fg-globals", region: "header", render: () => /* @__PURE__ */ jsx(GlobalsToolbar, { declarations }) }] : [],
789
1308
  ...config?.labChrome ?? []
790
1309
  ],
@@ -801,16 +1320,27 @@ function Workshop({ index, frameUrl, config, stories = [], storageKey, storage }
801
1320
  Lab,
802
1321
  {
803
1322
  title: "weaselforge",
1323
+ density: "roomy",
804
1324
  instruments: registry.instruments,
805
1325
  defaultInstrument: initialStory(index, first.id),
806
1326
  storageKey: storageKey ?? "weaselforge",
807
1327
  ...storage ? { storage } : {},
808
1328
  labChrome,
809
- chrome: TRIAL_CHROME,
810
1329
  addTrial: false,
811
1330
  ...config?.controls ? { controls: config.controls } : {},
1331
+ ...config?.pages ? { pages: config.pages } : {},
1332
+ ...config?.path !== void 0 ? { path: config.path } : {},
812
1333
  children: [
813
1334
  /* @__PURE__ */ jsx(RouteOpener, { index }),
1335
+ /* @__PURE__ */ jsx(
1336
+ StoryInfoDialog,
1337
+ {
1338
+ index,
1339
+ isReady: registry.isReady,
1340
+ isOpen: infoOpen,
1341
+ onOpenChange: setInfoOpen
1342
+ }
1343
+ ),
814
1344
  /* @__PURE__ */ jsx(LabGlobals, { declarations, onChange: reportLabValues })
815
1345
  ]
816
1346
  }
@@ -831,6 +1361,6 @@ function mountWorkshop({ container, ...props }) {
831
1361
  };
832
1362
  }
833
1363
 
834
- export { StoryGlobalsContext, Workshop, mountWorkshop, useStoryRegistry };
1364
+ export { A11Y_SECTION, A11yPanel, StoryGlobalsContext, TrialFramesContext, Workshop, mountWorkshop, useStoryRegistry, useTrialFrame };
835
1365
  //# sourceMappingURL=index.js.map
836
1366
  //# sourceMappingURL=index.js.map