@weasel-js/forge 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +72 -10
  2. package/dist/{FrameController-BPfCt4PJ.d.ts → FrameController-BspC9Zr5.d.ts} +67 -4
  3. package/dist/chunk-A4JOEGYQ.js +325 -0
  4. package/dist/chunk-A4JOEGYQ.js.map +1 -0
  5. package/dist/{chunk-2MADHLRA.js → chunk-FWPBKKYB.js} +58 -231
  6. package/dist/chunk-FWPBKKYB.js.map +1 -0
  7. package/dist/{chunk-6SRQIHJQ.js → chunk-HRGPFQBW.js} +3 -3
  8. package/dist/{chunk-6SRQIHJQ.js.map → chunk-HRGPFQBW.js.map} +1 -1
  9. package/dist/chunk-PO5JLVYT.js +318 -0
  10. package/dist/chunk-PO5JLVYT.js.map +1 -0
  11. package/dist/{chunk-TLO6UHF2.js → chunk-VEKEBILL.js} +17 -4
  12. package/dist/chunk-VEKEBILL.js.map +1 -0
  13. package/dist/{config-CrLpfSq9.d.ts → config-DU_WuORS.d.ts} +7 -1
  14. package/dist/csf/preview-api.js +1 -2
  15. package/dist/csf/preview-api.js.map +1 -1
  16. package/dist/frame/index.css +102 -0
  17. package/dist/frame/index.css.map +1 -1
  18. package/dist/frame/index.d.ts +11 -3
  19. package/dist/frame/index.js +365 -5
  20. package/dist/frame/index.js.map +1 -1
  21. package/dist/index.d.ts +4 -3
  22. package/dist/index.js.map +1 -1
  23. package/dist/shell/index.css +202 -13
  24. package/dist/shell/index.css.map +1 -1
  25. package/dist/shell/index.d.ts +76 -12
  26. package/dist/shell/index.js +1075 -187
  27. package/dist/shell/index.js.map +1 -1
  28. package/dist/test/index.d.ts +3 -3
  29. package/dist/test/index.js +67 -72
  30. package/dist/test/index.js.map +1 -1
  31. package/dist/vite/index.js +68 -11
  32. package/dist/vite/index.js.map +1 -1
  33. package/package.json +5 -4
  34. package/dist/chunk-2JDYOBWM.js +0 -118
  35. package/dist/chunk-2JDYOBWM.js.map +0 -1
  36. package/dist/chunk-2MADHLRA.js.map +0 -1
  37. package/dist/chunk-FTFR2YPM.js +0 -13
  38. package/dist/chunk-FTFR2YPM.js.map +0 -1
  39. package/dist/chunk-TLO6UHF2.js.map +0 -1
@@ -1,75 +1,19 @@
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';
1
+ import { isIndexId, indexId, indexEntries, schemaFromDescription, openChannel, IndexPage } from '../chunk-PO5JLVYT.js';
2
+ import { useTrialFrame, createTrialFrames, labGlobals, StoryGlobalsContext, TrialFramesContext, withGlobals, StoryTrial, storyConfig, GLOBALS_KEY, effectiveGlobals, useCssOverrides, TrialHost, isGlobalsPath } from '../chunk-A4JOEGYQ.js';
3
+ export { StoryGlobalsContext, StoryTrial, TrialFramesContext, TrialHost, useTrialFrame } from '../chunk-A4JOEGYQ.js';
4
+ import { loadStories, indexRenderOf } from '../chunk-FWPBKKYB.js';
5
+ import '../chunk-2GXKNXTJ.js';
6
+ import '../chunk-HRGPFQBW.js';
7
+ import { stableStringify, PORT_HANDOFF, FRAME_HELLO, PROTOCOL_VERSION } from '../chunk-VEKEBILL.js';
8
+ import { useTrialId, Button, Lab, useLabContext, TrialIdContext, usePersistedState, Select, inferTokenType, ToggleBar as ToggleBar$1, Input, TokenPanel, refitScale } from '@weasel-js/labkit';
9
+ import { createContext, useState, useRef, useEffect, useCallback, useMemo, useLayoutEffect, useId, useContext } from 'react';
5
10
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
11
  import { createRoot } from 'react-dom/client';
7
- import { TOKEN_MANIFEST } from '@weasel-js/theme';
8
- import { f } from '@weasel-js/labkit/config';
12
+ import { InfoIcon, ToggleBar, Checkbox, Dialog, Badge, DisclosureMark, Button as Button$1, TuneIcon, Callout } from '@weasel-js/ui';
13
+ import { TOKEN_MANIFEST, THEME_SOURCES, isByAxis } from '@weasel-js/theme';
14
+ import { scale, httpThemeApi } from '@weasel-js/theme/engine';
15
+ import { f, valueAtPath, isConfigBranch } from '@weasel-js/labkit/config';
9
16
 
10
- var NO_FRAME = { send: null, vars: [], audit: null, a11y: null, capture: null, size: null };
11
- function createTrialFrames() {
12
- const frames = /* @__PURE__ */ new Map();
13
- const hosts = /* @__PURE__ */ new Map();
14
- const listeners = /* @__PURE__ */ new Set();
15
- const changed = () => {
16
- for (const listener of [...listeners]) listener();
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
- };
24
- return {
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
- });
35
- changed();
36
- return () => {
37
- if (frames.get(trialId)?.send !== calls.send) return;
38
- frames.delete(trialId);
39
- changed();
40
- };
41
- },
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 });
48
- changed();
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
- },
58
- get: (trialId) => frames.get(trialId) ?? NO_FRAME,
59
- subscribe(listener) {
60
- listeners.add(listener);
61
- return () => listeners.delete(listener);
62
- }
63
- };
64
- }
65
- var TrialFramesContext = createContext(null);
66
- function useTrialFrame(trialId) {
67
- const frames = useContext(TrialFramesContext);
68
- const subscribe = useCallback((listener) => frames?.subscribe(listener) ?? (() => {
69
- }), [frames]);
70
- const read = () => frames?.get(trialId) ?? NO_FRAME;
71
- return useSyncExternalStore(subscribe, read, read);
72
- }
73
17
  function Finding({ finding }) {
74
18
  return /* @__PURE__ */ jsxs("li", { className: "fg-a11y__finding", children: [
75
19
  /* @__PURE__ */ jsxs("p", { className: "fg-a11y__help", children: [
@@ -147,20 +91,176 @@ var A11Y_SECTION = {
147
91
  region: "aside",
148
92
  item: { title: "Accessibility", body: /* @__PURE__ */ jsx(FocusedA11y, {}) }
149
93
  };
150
- var NONE = {};
151
- function useCssOverrides() {
152
- return usePersistedState("fg-css-overrides", NONE, { scope: "trial" });
94
+
95
+ // src/shell/storyChanges.ts
96
+ function createStoryChanges() {
97
+ const listeners = /* @__PURE__ */ new Set();
98
+ return {
99
+ subscribe(listener) {
100
+ listeners.add(listener);
101
+ return () => listeners.delete(listener);
102
+ },
103
+ emit(file) {
104
+ for (const listener of [...listeners]) listener(file);
105
+ }
106
+ };
107
+ }
108
+ var SEED_REF = /^\{seeds\.([^}]+)\}$/;
109
+ var RULE_KEYS = { factors: "factors", ratio: "ratio", step: "step" };
110
+ function selectionFor(axes, globals) {
111
+ const out = {};
112
+ for (const [axis, def] of Object.entries(axes)) {
113
+ const value = globals[axis];
114
+ if (value === void 0) out[axis] = def.default;
115
+ else if (typeof value === "string" && value in def.values) out[axis] = value;
116
+ }
117
+ return out;
118
+ }
119
+ var EditError = class extends Error {
120
+ };
121
+ function slotOf(root, holder, key, where, selection, depth = 0) {
122
+ if (depth > 16) throw new EditError(`${where} references itself`);
123
+ let h = holder;
124
+ let k = key;
125
+ let w = where;
126
+ let value = h[k];
127
+ while (isByAxis(value)) {
128
+ const axis = value.by;
129
+ const picked = selection[axis];
130
+ if (picked === void 0 || picked === "by" || !(picked in value)) {
131
+ throw new EditError(`${w} differs by ${axis}, and which ${axis} this trial shows is not known`);
132
+ }
133
+ h = value;
134
+ k = picked;
135
+ w = `${w}.${picked}`;
136
+ value = h[k];
137
+ }
138
+ if (typeof value === "number") return { holder: h, key: k, where: w };
139
+ if (typeof value === "string") {
140
+ const ref = SEED_REF.exec(value);
141
+ if (!ref) throw new EditError(`${w} is "${value}", which is not a number or a seed`);
142
+ const name = ref[1];
143
+ const seeds = root.seeds;
144
+ if (!seeds || !(name in seeds)) throw new EditError(`${w} names the seed "${name}", which this theme inherits rather than defines`);
145
+ return slotOf(root, seeds, name, `seeds.${name}`, selection, depth + 1);
146
+ }
147
+ throw new EditError(`${w} is not a number`);
148
+ }
149
+ function factorsOf(holder, where, selection) {
150
+ let value = holder.factors;
151
+ let w = `${where}.factors`;
152
+ while (isByAxis(value)) {
153
+ const picked = selection[value.by];
154
+ if (picked === void 0 || !(picked in value)) {
155
+ throw new EditError(`${w} differs by ${value.by}, and which ${value.by} this trial shows is not known`);
156
+ }
157
+ w = `${w}.${picked}`;
158
+ value = value[picked];
159
+ }
160
+ if (!Array.isArray(value)) throw new EditError(`${w} is not a list`);
161
+ return { list: value, where: w };
162
+ }
163
+ var kindOf = (scale) => scale.factors !== void 0 ? "factors" : scale.ratio !== void 0 ? "ratio" : scale.step !== void 0 ? "step" : null;
164
+ var plainLiteral = (value) => typeof value === "number" || Array.isArray(value) && value.every((v) => typeof v === "number");
165
+ function swapRule(scale, from, to, value) {
166
+ const entries = Object.entries(scale);
167
+ for (const [k] of entries) delete scale[k];
168
+ for (const [k, v] of entries) scale[k === from ? to : k] = k === from ? value : v;
169
+ if (from === null) scale[to] = value;
170
+ }
171
+ function applyScaleEdits(definition, edits, selection) {
172
+ const root = structuredClone(definition);
173
+ const writes = /* @__PURE__ */ new Map();
174
+ const write = (slot, value) => {
175
+ let byKey = writes.get(slot.holder);
176
+ if (!byKey) writes.set(slot.holder, byKey = /* @__PURE__ */ new Map());
177
+ const pending = byKey.get(slot.key);
178
+ if (pending !== void 0 && pending !== value) throw new EditError(`${slot.where} would need to be both ${pending} and ${value}`);
179
+ byKey.set(slot.key, value);
180
+ };
181
+ try {
182
+ for (const [name, edit] of Object.entries(edits)) {
183
+ const scales = root.scales;
184
+ const scale = scales?.[name];
185
+ if (!scales || !scale) throw new EditError(`the theme defines no scale "${name}"`);
186
+ const where = `scales.${name}`;
187
+ write(slotOf(root, scale, "base", `${where}.base`, selection), edit.base);
188
+ const { rule } = edit;
189
+ const had = kindOf(scale);
190
+ if (had !== rule.kind) {
191
+ const from = had ? RULE_KEYS[had] : null;
192
+ if (from && !plainLiteral(scale[from])) {
193
+ throw new EditError(`${where} would lose its ${from} to become a ${rule.kind} scale, and its ${from} is not a plain number`);
194
+ }
195
+ const value = rule.kind === "factors" ? [...rule.factors] : rule.kind === "ratio" ? rule.ratio : rule.step;
196
+ swapRule(scale, from, RULE_KEYS[rule.kind], value);
197
+ continue;
198
+ }
199
+ if (rule.kind === "factors") {
200
+ const { list, where: at } = factorsOf(scale, where, selection);
201
+ if (list.length !== rule.factors.length) {
202
+ throw new EditError(`${at} has ${list.length} factors, and the edit ${rule.factors.length}`);
203
+ }
204
+ rule.factors.forEach((factor, i) => write(slotOf(root, list, i, `${at}.${i}`, selection), factor));
205
+ } else {
206
+ const key = RULE_KEYS[rule.kind];
207
+ write(slotOf(root, scale, key, `${where}.${key}`, selection), rule.kind === "ratio" ? rule.ratio : rule.step);
208
+ }
209
+ }
210
+ } catch (e) {
211
+ if (e instanceof EditError) return { ok: false, message: `Cannot save: ${e.message}.` };
212
+ throw e;
213
+ }
214
+ for (const [holder, byKey] of writes) for (const [key, value] of byKey) holder[key] = value;
215
+ return { ok: true, definition: root };
153
216
  }
154
217
  var TABS = [
155
218
  { value: "theme", label: "Theme" },
156
219
  { value: "story", label: "Story" }
157
220
  ];
158
221
  var MANIFEST = new Map(TOKEN_MANIFEST.map((token) => [token.name, token]));
222
+ var THEME_NAME = "weasel";
223
+ var literal = (v) => typeof v === "number";
224
+ var SCALE_SOURCES = Object.entries(THEME_SOURCES[THEME_NAME]?.scales ?? {}).flatMap(([name, def]) => {
225
+ if (!Array.isArray(def.steps)) return [];
226
+ const steps = def.steps;
227
+ const tokens = steps.map((step) => `--wzl-${name}-${step}`);
228
+ const group = MANIFEST.get(tokens[0] ?? "")?.group;
229
+ if (!group) return [];
230
+ const factors = Array.isArray(def.factors) && def.factors.every(literal) ? def.factors : null;
231
+ 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";
232
+ return [{ name, group, steps, tokens, rule }];
233
+ });
234
+ function scaleValues(source, next) {
235
+ const { rule } = next;
236
+ const values = scale(source.steps, {
237
+ base: next.base,
238
+ ...rule.kind === "factors" ? { factors: rule.factors } : rule.kind === "ratio" ? { ratio: rule.ratio } : { step: rule.step }
239
+ });
240
+ return source.steps.flatMap((step, i) => {
241
+ const value = values[step];
242
+ const name = source.tokens[i];
243
+ return value !== void 0 && name !== void 0 ? [[name, value]] : [];
244
+ });
245
+ }
246
+ function standingScale(source, amounts) {
247
+ const seed = { tokens: source.tokens, base: amounts[0] ?? 0, rule: { kind: "step", step: 0 } };
248
+ if (typeof source.rule === "string") return refitScale(seed, source.rule, amounts);
249
+ const { rule } = source;
250
+ if (rule.kind !== "factors") return { ...seed, rule };
251
+ let at = 0;
252
+ rule.factors.forEach((f3, i) => {
253
+ if (Math.abs(f3 - 1) < Math.abs((rule.factors[at] ?? 1) - 1)) at = i;
254
+ });
255
+ const base = (amounts[at] ?? 0) / (rule.factors[at] ?? 1);
256
+ return { ...seed, base: Math.round(base * 100) / 100, rule };
257
+ }
159
258
  function groupOf(name) {
160
259
  const segments = name.replace(/^--/, "").split("-");
161
260
  return segments.length > 1 ? segments.slice(0, -1).join("-") : name.replace(/^--/, "");
162
261
  }
163
- function CssVarsPanel() {
262
+ var NO_GLOBALS = {};
263
+ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
164
264
  const trialId = useTrialId();
165
265
  const frame = useTrialFrame(trialId);
166
266
  const [overrides, setOverrides] = useCssOverrides();
@@ -169,6 +269,7 @@ function CssVarsPanel() {
169
269
  {},
170
270
  { scope: "lab" }
171
271
  );
272
+ const [edited, setEdited] = usePersistedState("fg-css-vars-scales", {}, { scope: "lab" });
172
273
  const [tab, setTab] = useState("theme");
173
274
  const [filter, setFilter] = useState("");
174
275
  const tokens = useMemo(() => {
@@ -192,6 +293,18 @@ function CssVarsPanel() {
192
293
  const override = overrides[token.name];
193
294
  return override === void 0 ? token : { ...token, value: override, overridden: true };
194
295
  }).filter((token) => !query || token.name.toLowerCase().includes(query) || token.value.toLowerCase().includes(query));
296
+ const scales = useMemo(() => {
297
+ if (tab !== "theme") return void 0;
298
+ const values = new Map(shown.map((t) => [t.name, t.value]));
299
+ const out = {};
300
+ for (const source of SCALE_SOURCES) {
301
+ const amounts = source.tokens.map((name) => Number.parseFloat(values.get(name) ?? ""));
302
+ if (amounts.some(Number.isNaN)) continue;
303
+ const own = source.tokens.some((name) => overrides[name] !== void 0) ? edited[source.group] : void 0;
304
+ out[source.group] = own ?? standingScale(source, amounts);
305
+ }
306
+ return out;
307
+ }, [tab, shown, overrides, edited]);
195
308
  const send = frame.send;
196
309
  const write = useCallback(
197
310
  (name, value) => {
@@ -203,6 +316,68 @@ function CssVarsPanel() {
203
316
  },
204
317
  [setOverrides, send]
205
318
  );
319
+ const onScaleChange = useCallback(
320
+ (group, next) => {
321
+ const source = SCALE_SOURCES.find((sc) => sc.group === group);
322
+ if (!source) return;
323
+ setEdited((prev) => ({ ...prev, [group]: next }));
324
+ for (const [name, value] of scaleValues(source, next)) write(name, value);
325
+ },
326
+ [setEdited, write]
327
+ );
328
+ const unsaved = useMemo(
329
+ () => SCALE_SOURCES.flatMap((source) => {
330
+ const edit = edited[source.group];
331
+ if (!edit || !source.tokens.some((name) => overrides[name] !== void 0)) return [];
332
+ const values = scaleValues(source, edit);
333
+ return values.every(([name, value]) => overrides[name] === value) ? [{ source, edit }] : [];
334
+ }),
335
+ [edited, overrides]
336
+ );
337
+ const [saving, setSaving] = useState(false);
338
+ const [held, setHeld] = useState(null);
339
+ const notice = held?.trialId === trialId ? held : null;
340
+ const setNotice = (next) => setHeld(next && { ...next, trialId });
341
+ const save = async () => {
342
+ setSaving(true);
343
+ setNotice(null);
344
+ try {
345
+ const api = themeApi ?? httpThemeApi();
346
+ const stored = await api.get(THEME_NAME);
347
+ const file = `themes/${THEME_NAME}.json`;
348
+ if (JSON.stringify(stored.definition) !== JSON.stringify(THEME_SOURCES[THEME_NAME])) {
349
+ setNotice({ tone: "error", text: `${file} has changed since this page loaded. Reload to edit against it.` });
350
+ return;
351
+ }
352
+ const edits = Object.fromEntries(
353
+ unsaved.map(({ source, edit }) => [source.name, { base: edit.base, rule: edit.rule }])
354
+ );
355
+ const next = applyScaleEdits(stored.definition, edits, selectionFor(stored.definition.axes ?? {}, globals));
356
+ if (!next.ok) {
357
+ setNotice({ tone: "error", text: next.message });
358
+ return;
359
+ }
360
+ const result = await api.put(THEME_NAME, next.definition, stored.hash);
361
+ if (result.status === "conflict") {
362
+ setNotice({ tone: "error", text: `${file} changed on disk while saving. Nothing was written; reload to edit against it.` });
363
+ return;
364
+ }
365
+ if (result.status === "invalid") {
366
+ setNotice({ tone: "error", text: result.message });
367
+ return;
368
+ }
369
+ for (const { source } of unsaved) for (const name of source.tokens) write(name, null);
370
+ const groups = new Set(unsaved.map(({ source }) => source.group));
371
+ setEdited((prev) => Object.fromEntries(Object.entries(prev).filter(([group]) => !groups.has(group))));
372
+ setNotice(
373
+ result.regenerated || result.problems.length === 0 ? { tone: "saved", text: `Saved to ${file}.` } : { tone: "error", text: `Saved to ${file}, but the tokens were not regenerated: ${result.problems.join("; ")}` }
374
+ );
375
+ } catch (e) {
376
+ setNotice({ tone: "error", text: `Saving needs forge's dev server, which did not answer: ${e.message}` });
377
+ } finally {
378
+ setSaving(false);
379
+ }
380
+ };
206
381
  const onCollapsedChange = useCallback(
207
382
  (category, collapsed) => setFolds((prev) => ({ ...prev, [category]: collapsed })),
208
383
  [setFolds]
@@ -210,7 +385,7 @@ function CssVarsPanel() {
210
385
  return /* @__PURE__ */ jsxs("div", { className: "fg-css-vars", children: [
211
386
  /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__controls", children: [
212
387
  /* @__PURE__ */ jsx(
213
- ToggleBar,
388
+ ToggleBar$1,
214
389
  {
215
390
  ariaLabel: "Variables",
216
391
  variant: "flat",
@@ -221,19 +396,42 @@ function CssVarsPanel() {
221
396
  }
222
397
  }
223
398
  ),
224
- /* @__PURE__ */ jsx(Input, { "aria-label": "Filter", placeholder: "Filter by name or value", value: filter, onChange: setFilter })
399
+ /* @__PURE__ */ jsx(Input, { "aria-label": "Filter", placeholder: "Filter by name or value", value: filter, onChange: setFilter }),
400
+ tab === "theme" && (unsaved.length > 0 || notice) ? /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__save", children: [
401
+ unsaved.length > 0 ? /* @__PURE__ */ jsx(Button$1, { variant: "primary", size: "sm", loading: saving, disabled: saving, onClick: () => void save(), children: "Save scales to theme" }) : null,
402
+ notice ? /* @__PURE__ */ jsx(
403
+ "p",
404
+ {
405
+ className: `fg-css-vars__notice fg-css-vars__notice--${notice.tone}`,
406
+ role: notice.tone === "error" ? "alert" : "status",
407
+ children: notice.text
408
+ }
409
+ ) : null
410
+ ] }) : null
225
411
  ] }),
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 })
412
+ 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(
413
+ TokenPanel,
414
+ {
415
+ tokens: shown,
416
+ onChange: write,
417
+ scales,
418
+ onScaleChange,
419
+ collapsed: folds,
420
+ onCollapsedChange
421
+ }
422
+ )
227
423
  ] });
228
424
  }
229
425
  function FocusedCssVars() {
230
426
  const { focusedTrialId, trials, instruments } = useLabContext();
427
+ const labGlobals2 = useContext(StoryGlobalsContext);
231
428
  const record = trials.find((trial) => trial.id === focusedTrialId);
232
429
  if (!record) return null;
430
+ const pins = record.config?.[GLOBALS_KEY];
233
431
  const instrument = instruments.find((i) => i.name === record.instrumentName);
234
432
  return /* @__PURE__ */ jsxs("section", { className: "fg-css-vars-focus", "aria-label": "CSS Vars", children: [
235
433
  /* @__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, {}) })
434
+ /* @__PURE__ */ jsx(TrialIdContext.Provider, { value: record.id, children: /* @__PURE__ */ jsx(CssVarsPanel, { globals: effectiveGlobals(labGlobals2, pins) }) })
237
435
  ] });
238
436
  }
239
437
  var CSS_VARS_SECTION = {
@@ -241,45 +439,294 @@ var CSS_VARS_SECTION = {
241
439
  region: "aside",
242
440
  item: { title: "CSS Vars", body: /* @__PURE__ */ jsx(FocusedCssVars, {}) }
243
441
  };
244
- var GLOBALS_KEY = "$globals";
245
- var FOLLOW_LAB = "lab";
246
- var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
247
- var isGlobalsPath = (path) => path === GLOBALS_KEY || path.startsWith(`${GLOBALS_KEY}.`);
248
- function labGlobals(declarations, stored) {
249
- const from = isRecord(stored) ? stored : {};
250
- return Object.fromEntries(
251
- Object.entries(declarations).map(([key, declaration]) => {
252
- const value = from[key];
253
- return [key, declaration.options.some((option) => option.value === value) ? value : declaration.default];
254
- })
442
+
443
+ // src/shell/breadcrumb.ts
444
+ function breadcrumb(title, name) {
445
+ const segments = [...title.split("/"), name].filter((segment, i) => i === 0 || segment.toLowerCase() !== "index");
446
+ return segments.join(" > ");
447
+ }
448
+
449
+ // src/shell/tree/buildComponents.ts
450
+ var LIBRARIES = [
451
+ ["/packages/ui/", "ui"],
452
+ ["/packages/labkit/", "labkit"],
453
+ ["/packages/forge/", "forge"],
454
+ ["/apps/draw/", "draw"],
455
+ ["/apps/forge/", "forge"]
456
+ ];
457
+ function libraryOf(entry) {
458
+ const file = entry.file.replaceAll("\\", "/");
459
+ for (const [marker, label] of LIBRARIES) {
460
+ if (file.includes(marker)) return label;
461
+ }
462
+ return entry.title.split("/")[0];
463
+ }
464
+ function buildComponents(index) {
465
+ const byTitle = /* @__PURE__ */ new Map();
466
+ for (const entry of index) {
467
+ let row = byTitle.get(entry.title);
468
+ if (!row) {
469
+ const segments = entry.title.split("/");
470
+ row = {
471
+ path: entry.title,
472
+ label: segments[segments.length - 1],
473
+ library: libraryOf(entry),
474
+ index: indexEntries([entry])[0],
475
+ entries: []
476
+ };
477
+ byTitle.set(entry.title, row);
478
+ }
479
+ row.entries.push(entry);
480
+ }
481
+ const rows = [...byTitle.values()];
482
+ disambiguate(rows);
483
+ return rows.sort(
484
+ (a, b) => a.label.localeCompare(b.label) || a.library.localeCompare(b.library) || a.path.localeCompare(b.path)
255
485
  );
256
486
  }
257
- function effectiveGlobals(lab, pins) {
258
- if (!isRecord(pins)) return lab;
259
- const pinned = Object.entries(pins).filter(([key, value]) => key in lab && value !== FOLLOW_LAB);
260
- return pinned.length === 0 ? lab : { ...lab, ...Object.fromEntries(pinned) };
261
- }
262
- function storyConfig(config, previous) {
263
- if (!isRecord(config) || !(GLOBALS_KEY in config)) return config;
264
- const rest = Object.fromEntries(Object.entries(config).filter(([key]) => key !== GLOBALS_KEY));
265
- if (isRecord(previous)) {
266
- const keys = Object.keys(rest);
267
- if (keys.length === Object.keys(previous).length && keys.every((key) => rest[key] === previous[key])) return previous;
487
+ function disambiguate(rows) {
488
+ const byLabel = /* @__PURE__ */ new Map();
489
+ for (const row of rows) {
490
+ const key = `${row.library}\0${row.label}`;
491
+ const group = byLabel.get(key);
492
+ if (group) group.push(row);
493
+ else byLabel.set(key, [row]);
494
+ }
495
+ for (const group of byLabel.values()) {
496
+ if (group.length < 2) continue;
497
+ for (const row of group) {
498
+ const segments = row.path.split("/");
499
+ for (let take = 2; take <= segments.length; take++) {
500
+ row.label = segments.slice(-take).join("/");
501
+ if (group.every((other) => other === row || !other.path.endsWith(`/${row.label}`))) break;
502
+ }
503
+ }
268
504
  }
269
- return rest;
270
505
  }
271
- function withGlobals(schema, declarations) {
272
- const entries = Object.entries(declarations);
273
- if (entries.length === 0) return schema;
274
- const leaves = Object.fromEntries(
275
- entries.map(([key, declaration]) => [
276
- key,
277
- f.enum(FOLLOW_LAB, [{ value: FOLLOW_LAB, label: "Lab" }, ...declaration.options]).label(declaration.label)
278
- ])
506
+ function filterComponents(rows, query) {
507
+ const needle = query.trim().toLowerCase();
508
+ if (!needle) return [...rows];
509
+ const out = [];
510
+ for (const row of rows) {
511
+ if (`${row.path}/${row.library}`.toLowerCase().includes(needle)) {
512
+ out.push(row);
513
+ continue;
514
+ }
515
+ const entries = row.entries.filter((entry) => entry.name.toLowerCase().includes(needle));
516
+ if (entries.length > 0) out.push({ path: row.path, label: row.label, library: row.library, entries });
517
+ }
518
+ return out;
519
+ }
520
+ function componentNodes(rows) {
521
+ const root = [];
522
+ const folders = /* @__PURE__ */ new Map();
523
+ for (const row of rows) {
524
+ const labels = row.label.split("/");
525
+ const title = row.path.split("/");
526
+ let siblings = root;
527
+ let tag = row.library;
528
+ for (let i = 0; i < labels.length - 1; i++) {
529
+ const path = title.slice(0, title.length - labels.length + i + 1).join("/");
530
+ let folder = folders.get(path);
531
+ if (!folder) {
532
+ folder = { kind: "folder", label: labels[i], path, tag, children: [] };
533
+ folders.set(path, folder);
534
+ siblings.push(folder);
535
+ }
536
+ siblings = folder.children;
537
+ tag = void 0;
538
+ }
539
+ siblings.push({
540
+ kind: "folder",
541
+ label: labels[labels.length - 1],
542
+ path: row.path,
543
+ tag,
544
+ ...row.index ? { index: row.index } : {},
545
+ children: row.entries.map((entry) => ({ kind: "story", entry }))
546
+ });
547
+ }
548
+ return root;
549
+ }
550
+ function librariesIn(index) {
551
+ return [...new Set(index.map(libraryOf))].sort((a, b) => a.localeCompare(b));
552
+ }
553
+
554
+ // src/shell/info/dossier.ts
555
+ var GLOBALS = "$globals";
556
+ function leaves(shape, prefix = "") {
557
+ const out = [];
558
+ for (const [key, entry] of Object.entries(shape)) {
559
+ if (prefix === "" && key === GLOBALS) continue;
560
+ const path = prefix ? `${prefix}.${key}` : key;
561
+ if (isConfigBranch(entry)) out.push(...leaves(entry.children, path));
562
+ else if (!entry.annotations.hidden) out.push({ path, node: entry });
563
+ }
564
+ return out;
565
+ }
566
+ function storyDossier(input) {
567
+ const { entry, instrument, config, ready = true } = input;
568
+ const schema = ready ? instrument?.config : void 0;
569
+ const args = schema ? leaves(schema.nodes).map(({ path, node }) => {
570
+ const value = valueAtPath(config, path);
571
+ return {
572
+ path,
573
+ label: node.annotations.name ?? path.split(".").pop() ?? path,
574
+ kind: node.kind,
575
+ default: node.default,
576
+ value: value === void 0 ? node.default : value,
577
+ ...node.annotations.description === void 0 ? {} : { description: node.annotations.description }
578
+ };
579
+ }) : [];
580
+ return {
581
+ id: entry.id,
582
+ title: entry.title,
583
+ name: entry.name,
584
+ exportName: entry.exportName,
585
+ file: entry.file,
586
+ library: libraryOf(entry),
587
+ ...entry.componentName === void 0 ? {} : { componentName: entry.componentName },
588
+ ...entry.description === void 0 ? {} : { description: entry.description },
589
+ ...entry.componentDescription === void 0 ? {} : { componentDescription: entry.componentDescription },
590
+ args,
591
+ argsPending: !schema
592
+ };
593
+ }
594
+ function printed(value) {
595
+ if (value === void 0 || value === null) return "\u2014";
596
+ if (typeof value === "string") return value === "" ? '""' : value;
597
+ if (typeof value === "function") return "\u0192";
598
+ if (typeof value === "object") return JSON.stringify(value);
599
+ return String(value);
600
+ }
601
+ function Row({ label, children }) {
602
+ return /* @__PURE__ */ jsxs("div", { className: "fg-info__row", children: [
603
+ /* @__PURE__ */ jsx("dt", { className: "fg-info__key", children: label }),
604
+ /* @__PURE__ */ jsx("dd", { className: "fg-info__value", children })
605
+ ] });
606
+ }
607
+ function ArgRows({ args }) {
608
+ return /* @__PURE__ */ jsxs("table", { className: "fg-info__args", children: [
609
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
610
+ /* @__PURE__ */ jsx("th", { scope: "col", children: "Name" }),
611
+ /* @__PURE__ */ jsx("th", { scope: "col", children: "Kind" }),
612
+ /* @__PURE__ */ jsx("th", { scope: "col", children: "Value" }),
613
+ /* @__PURE__ */ jsx("th", { scope: "col", children: "Default" })
614
+ ] }) }),
615
+ /* @__PURE__ */ jsx("tbody", { children: args.map((arg) => /* @__PURE__ */ jsxs("tr", { children: [
616
+ /* @__PURE__ */ jsxs("th", { scope: "row", children: [
617
+ /* @__PURE__ */ jsx("span", { className: "fg-info__arg-name", children: arg.label }),
618
+ arg.description ? /* @__PURE__ */ jsx("span", { className: "fg-info__arg-doc", children: arg.description }) : null
619
+ ] }),
620
+ /* @__PURE__ */ jsx("td", { className: "fg-info__arg-kind", children: arg.kind ?? "\u2014" }),
621
+ /* @__PURE__ */ jsx("td", { className: "fg-info__value-cell", children: printed(arg.value) }),
622
+ /* @__PURE__ */ jsx("td", { className: "fg-info__value-cell", children: printed(arg.default) })
623
+ ] }, arg.path)) })
624
+ ] });
625
+ }
626
+ function StoryDossier({ dossier }) {
627
+ return /* @__PURE__ */ jsxs("div", { className: "fg-info", children: [
628
+ dossier.description || dossier.componentDescription ? /* @__PURE__ */ jsxs("section", { className: "fg-info__section", "aria-label": "Docs", children: [
629
+ dossier.description ? /* @__PURE__ */ jsx("p", { className: "fg-info__doc", children: dossier.description }) : null,
630
+ dossier.componentDescription ? /* @__PURE__ */ jsx("p", { className: "fg-info__doc fg-info__doc--component", children: dossier.componentDescription }) : null
631
+ ] }) : null,
632
+ /* @__PURE__ */ jsx("section", { className: "fg-info__section", "aria-label": "Identity", children: /* @__PURE__ */ jsxs("dl", { className: "fg-info__rows", children: [
633
+ /* @__PURE__ */ jsx(Row, { label: "Story", children: dossier.name }),
634
+ /* @__PURE__ */ jsx(Row, { label: "Component", children: dossier.componentName ?? dossier.title.split("/").pop() }),
635
+ /* @__PURE__ */ jsx(Row, { label: "Library", children: dossier.library }),
636
+ /* @__PURE__ */ jsx(Row, { label: "Title", children: dossier.title }),
637
+ /* @__PURE__ */ jsx(Row, { label: "Export", children: /* @__PURE__ */ jsx("code", { children: dossier.exportName }) }),
638
+ /* @__PURE__ */ jsx(Row, { label: "Id", children: /* @__PURE__ */ jsx("code", { children: dossier.id }) }),
639
+ /* @__PURE__ */ jsx(Row, { label: "File", children: /* @__PURE__ */ jsx("code", { className: "fg-info__path", children: dossier.file }) })
640
+ ] }) }),
641
+ /* @__PURE__ */ jsxs("section", { className: "fg-info__section", "aria-label": "Args", children: [
642
+ /* @__PURE__ */ jsx("h3", { className: "fg-info__heading", children: "Args" }),
643
+ 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 })
644
+ ] })
645
+ ] });
646
+ }
647
+ function StoryInfoDialog({ index, isReady, isOpen, onOpenChange }) {
648
+ const { focusedTrialId, trials, instruments } = useLabContext();
649
+ const trial = trials.find((record) => record.id === focusedTrialId);
650
+ const entry = trial ? index.find((candidate) => candidate.id === trial.instrumentName) : void 0;
651
+ if (!entry) return null;
652
+ const dossier = storyDossier({
653
+ entry,
654
+ instrument: instruments.find((candidate) => candidate.name === entry.id),
655
+ config: trial?.config,
656
+ ready: isReady(entry.id)
657
+ });
658
+ return /* @__PURE__ */ jsx(
659
+ Dialog,
660
+ {
661
+ isOpen,
662
+ onOpenChange,
663
+ title: breadcrumb(dossier.title, dossier.name),
664
+ className: "fg-info-dialog",
665
+ children: /* @__PURE__ */ jsx(StoryDossier, { dossier })
666
+ }
279
667
  );
280
- const group = f.group(leaves).label("").section("Globals", { layout: "inline", pack: "pairs" });
281
- return f.schema({ ...schema.nodes, [GLOBALS_KEY]: group });
282
668
  }
669
+ function canMoveFrames() {
670
+ return typeof Element !== "undefined" && "moveBefore" in Element.prototype;
671
+ }
672
+ function moveFrame(host, frame) {
673
+ host.moveBefore(frame, host.firstChild);
674
+ }
675
+ var whenIdle = (fn) => {
676
+ if (typeof requestIdleCallback === "function") {
677
+ const handle2 = requestIdleCallback(fn, { timeout: 500 });
678
+ return () => cancelIdleCallback(handle2);
679
+ }
680
+ const handle = setTimeout(fn, 200);
681
+ return () => clearTimeout(handle);
682
+ };
683
+ function createFramePool(frameUrl, size = 2) {
684
+ const shelf = document.createElement("div");
685
+ shelf.className = "fg-frame-pool";
686
+ shelf.setAttribute("aria-hidden", "true");
687
+ document.body.append(shelf);
688
+ const warm = [];
689
+ let loading = 0;
690
+ let cancelRefill = null;
691
+ let disposed = false;
692
+ const load = () => {
693
+ loading += 1;
694
+ const frame = document.createElement("iframe");
695
+ frame.tabIndex = -1;
696
+ frame.src = frameUrl;
697
+ const onMessage = (event) => {
698
+ if (event.source !== frame.contentWindow || event.origin !== location.origin) return;
699
+ if (event.data?.type !== FRAME_HELLO) return;
700
+ window.removeEventListener("message", onMessage);
701
+ loading -= 1;
702
+ warm.push(frame);
703
+ };
704
+ window.addEventListener("message", onMessage);
705
+ shelf.append(frame);
706
+ };
707
+ const refill = () => {
708
+ if (disposed || cancelRefill) return;
709
+ cancelRefill = whenIdle(() => {
710
+ cancelRefill = null;
711
+ while (!disposed && warm.length + loading < size) load();
712
+ });
713
+ };
714
+ if (canMoveFrames()) refill();
715
+ return {
716
+ claim() {
717
+ if (!canMoveFrames()) return null;
718
+ const frame = warm.shift() ?? null;
719
+ refill();
720
+ return frame;
721
+ },
722
+ dispose() {
723
+ disposed = true;
724
+ cancelRefill?.();
725
+ shelf.remove();
726
+ }
727
+ };
728
+ }
729
+ var FramePoolContext = createContext(null);
283
730
  var RECORD = "fg-globals";
284
731
  function useLabGlobals(declarations) {
285
732
  const [stored, setStored] = usePersistedState(RECORD, null, { scope: "lab" });
@@ -290,19 +737,65 @@ function useLabGlobals(declarations) {
290
737
  );
291
738
  return [values, set];
292
739
  }
293
- function GlobalsToolbar({ declarations }) {
294
- const [values, set] = useLabGlobals(declarations);
295
- return /* @__PURE__ */ jsx("div", { className: "fg-globals", role: "toolbar", "aria-label": "Globals", children: Object.entries(declarations).map(([key, declaration]) => /* @__PURE__ */ jsx(
740
+ function GlobalSelect({ name, declaration, values, set, labeled = false }) {
741
+ return /* @__PURE__ */ jsx(
296
742
  Select,
297
743
  {
298
- "aria-label": declaration.label,
299
- width: "fit",
300
- selectedKey: String(values[key]),
301
- onSelectionChange: (value) => set(key, value),
744
+ ...labeled ? { label: declaration.label } : { "aria-label": declaration.label },
745
+ width: labeled ? "fill" : "fit",
746
+ selectedKey: String(values[name]),
747
+ onSelectionChange: (value) => set(name, value),
302
748
  options: declaration.options
303
- },
304
- key
305
- )) });
749
+ }
750
+ );
751
+ }
752
+ function GlobalsToolbar({ declarations }) {
753
+ const [values, set] = useLabGlobals(declarations);
754
+ const entries = Object.entries(declarations);
755
+ return /* @__PURE__ */ jsx("div", { className: "fg-globals", role: "toolbar", "aria-label": "Globals", children: entries.filter(([, declaration]) => declaration.under === void 0).map(([key, declaration]) => {
756
+ const nested = entries.filter(([, other]) => other.under === key);
757
+ return /* @__PURE__ */ jsxs("div", { className: "fg-globals__item", children: [
758
+ /* @__PURE__ */ jsx(GlobalSelect, { name: key, declaration, values, set }),
759
+ nested.length > 0 ? /* @__PURE__ */ jsx(NestedGlobals, { label: declaration.label, nested, values, set }) : null
760
+ ] }, key);
761
+ }) });
762
+ }
763
+ function NestedGlobals({
764
+ label,
765
+ nested,
766
+ values,
767
+ set
768
+ }) {
769
+ const anchor = useRef(null);
770
+ const [open, setOpen] = useState(false);
771
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
772
+ /* @__PURE__ */ jsx(
773
+ Button$1,
774
+ {
775
+ ref: anchor,
776
+ variant: "ghost",
777
+ size: "sm",
778
+ iconOnly: true,
779
+ ariaLabel: `More ${label} settings`,
780
+ pressed: open,
781
+ onClick: () => setOpen((was) => !was),
782
+ children: /* @__PURE__ */ jsx(TuneIcon, { size: 16 })
783
+ }
784
+ ),
785
+ /* @__PURE__ */ jsx(
786
+ Callout,
787
+ {
788
+ triggerRef: anchor,
789
+ isOpen: open,
790
+ onOpenChange: setOpen,
791
+ placement: "bottom end",
792
+ showCloseButton: false,
793
+ title: label,
794
+ className: "fg-globals__popover",
795
+ children: nested.map(([key, declaration]) => /* @__PURE__ */ jsx(GlobalSelect, { name: key, declaration, values, set, labeled: true }, key))
796
+ }
797
+ )
798
+ ] });
306
799
  }
307
800
  function LabGlobals({
308
801
  declarations,
@@ -312,7 +805,6 @@ function LabGlobals({
312
805
  useEffect(() => onChange(values), [values, onChange]);
313
806
  return null;
314
807
  }
315
- var StoryGlobalsContext = createContext({});
316
808
 
317
809
  // src/shell/revealTrial.ts
318
810
  var FLASH_MS = 600;
@@ -339,33 +831,51 @@ function readRoute() {
339
831
  if (!hash.startsWith("#/")) return null;
340
832
  return decodeURIComponent(hash.slice(2)) || null;
341
833
  }
834
+ function readRouteEntry() {
835
+ const state = history.state;
836
+ const entry = state?.forgeRoute;
837
+ return typeof entry?.step === "number" ? entry : null;
838
+ }
839
+ function crossesInPlace(from, to) {
840
+ if (!from || !to || from.step === to.step) return false;
841
+ return to.step < from.step ? from.inPlace : to.inPlace;
842
+ }
843
+ function stamp(entry, hash) {
844
+ history.replaceState({ ...history.state ?? {}, forgeRoute: entry }, "", hash);
845
+ }
846
+ function setRoute(id, options = {}) {
847
+ const hash = `#/${encodeURIComponent(id)}`;
848
+ if (readRoute() === id) {
849
+ history.replaceState(history.state, "", hash);
850
+ return;
851
+ }
852
+ const from = readRouteEntry() ?? { step: 0, inPlace: false };
853
+ stamp(from, location.hash);
854
+ location.hash = hash;
855
+ stamp({ step: from.step + 1, inPlace: options.inPlace ?? false }, hash);
856
+ }
342
857
  function useRoute() {
343
- const [route, setRoute] = useState(readRoute);
858
+ const [route, setCurrent] = useState(readRoute);
344
859
  useEffect(() => {
345
- const sync = () => setRoute(readRoute());
860
+ const sync = () => setCurrent(readRoute());
346
861
  window.addEventListener("hashchange", sync);
347
862
  sync();
348
863
  return () => window.removeEventListener("hashchange", sync);
349
864
  }, []);
350
- const go = useCallback((id) => {
351
- const hash = `#/${encodeURIComponent(id)}`;
352
- if (readRoute() === id) history.replaceState(null, "", hash);
353
- else location.hash = hash;
354
- }, []);
355
- return [route, go];
865
+ return [route, setRoute];
356
866
  }
357
867
 
358
868
  // src/shell/tree/buildTree.ts
359
869
  function sortFolder(nodes) {
360
870
  const folders = nodes.filter((n) => n.kind === "folder");
361
- const stories = nodes.filter((n) => n.kind === "story");
362
871
  folders.sort((a, b) => a.label.localeCompare(b.label));
363
872
  for (const folder of folders) folder.children = sortFolder(folder.children);
364
- return [...folders, ...stories];
873
+ return [...folders, ...nodes.filter((n) => n.kind === "story")];
365
874
  }
366
875
  function buildTree(index) {
367
876
  const root = [];
368
877
  const folders = /* @__PURE__ */ new Map();
878
+ const pages = new Map(indexEntries(index).map((page) => [page.title, page]));
369
879
  for (const entry of index) {
370
880
  let siblings = root;
371
881
  let path = "";
@@ -373,7 +883,8 @@ function buildTree(index) {
373
883
  path = path ? `${path}/${label}` : label;
374
884
  let folder = folders.get(path);
375
885
  if (!folder) {
376
- folder = { kind: "folder", label, path, children: [] };
886
+ const page = pages.get(path);
887
+ folder = { kind: "folder", label, path, children: [], ...page ? { index: page } : {} };
377
888
  folders.set(path, folder);
378
889
  siblings.push(folder);
379
890
  }
@@ -391,10 +902,16 @@ function filterTree(nodes, query) {
391
902
  return `${node.entry.title}/${node.entry.name}`.toLowerCase().includes(needle) ? [node] : [];
392
903
  }
393
904
  const children = walk(node.children);
394
- return children.length > 0 ? [{ ...node, children }] : [];
905
+ if (children.length === 0) return [];
906
+ const { index: page, ...folder } = node;
907
+ return [page && node.path.toLowerCase().includes(needle) ? { ...folder, children, index: page } : { ...folder, children }];
395
908
  });
396
909
  return walk(nodes);
397
910
  }
911
+ var VIEWS = [
912
+ { value: "tree", label: "Tree" },
913
+ { value: "components", label: "Components" }
914
+ ];
398
915
  var keyOf = (node) => node.kind === "folder" ? `folder:${node.path}` : `story:${node.entry.id}`;
399
916
  function ancestorsOf(entry) {
400
917
  const paths = /* @__PURE__ */ new Set();
@@ -406,16 +923,34 @@ function ancestorsOf(entry) {
406
923
  }
407
924
  return paths;
408
925
  }
926
+ var LIBRARY_TONES = {
927
+ ui: "color-mix(in oklab, var(--wzl-swatch-blue), var(--wzl-fg) 35%)",
928
+ labkit: "color-mix(in oklab, var(--wzl-swatch-teal), var(--wzl-fg) 45%)",
929
+ forge: "color-mix(in oklab, var(--wzl-swatch-amber), var(--wzl-fg) 40%)",
930
+ draw: "color-mix(in oklab, var(--wzl-swatch-rose), var(--wzl-fg) 35%)"
931
+ };
932
+ function LibraryBadge({ library }) {
933
+ return /* @__PURE__ */ jsx(Badge, { status: "muted", tone: LIBRARY_TONES[library], variant: "subtle", size: "sm", className: "fg-tree__tag", children: library });
934
+ }
409
935
  function StoryTree({ ctx, index }) {
410
936
  const headingId = useId();
411
- const [route, setRoute] = useRoute();
937
+ const [route, setRoute2] = useRoute();
412
938
  const [query, setQuery] = useState("");
413
939
  const [folds, setFolds] = usePersistedState("fg-tree-open", {}, { scope: "lab" });
940
+ const [view, setView] = usePersistedState("fg-tree-view", "components", { scope: "lab" });
941
+ const [hidden, setHidden] = usePersistedState("fg-tree-libraries", {}, { scope: "lab" });
414
942
  const [focusKey, setFocusKey] = useState(null);
415
943
  const items = useRef(/* @__PURE__ */ new Map());
416
- const tree = useMemo(() => buildTree(index), [index]);
417
- const shown = useMemo(() => filterTree(tree, query), [tree, query]);
418
- const routed = index.find((entry) => entry.id === route);
944
+ const libraries = useMemo(() => librariesIn(index), [index]);
945
+ const kept = useMemo(() => index.filter((entry) => !hidden[libraryOf(entry)]), [index, hidden]);
946
+ const tree = useMemo(() => buildTree(kept), [kept]);
947
+ const components = useMemo(() => buildComponents(kept), [kept]);
948
+ const shown = useMemo(
949
+ () => view === "components" ? componentNodes(filterComponents(components, query)) : filterTree(tree, query),
950
+ [view, components, tree, query]
951
+ );
952
+ const routable = useMemo(() => [...kept, ...indexEntries(kept)], [kept]);
953
+ const routed = routable.find((entry) => entry.id === route);
419
954
  const routeAncestors = useMemo(() => ancestorsOf(routed), [routed]);
420
955
  const filtering = query.trim() !== "";
421
956
  const isOpen = (path) => filtering || (folds[path] ?? routeAncestors.has(path));
@@ -441,8 +976,22 @@ function StoryTree({ ctx, index }) {
441
976
  const focused = ctx.trials.find((trial) => trial.id === ctx.focusedTrialId);
442
977
  if (another || !focused) ctx.addTrial(entry.id);
443
978
  else if (focused.instrumentName === entry.id) revealTrial(focused.id);
444
- else ctx.swapTrial(focused.id, entry.id);
445
- setRoute(entry.id);
979
+ else {
980
+ ctx.swapTrial(focused.id, entry.id);
981
+ setRoute2(entry.id, { inPlace: true });
982
+ return;
983
+ }
984
+ setRoute2(entry.id);
985
+ };
986
+ const openFolder = (node, another) => {
987
+ if (!node.index) {
988
+ setOpen(node.path, !isOpen(node.path));
989
+ return;
990
+ }
991
+ if (folds[node.path] === false) {
992
+ setFolds(({ [node.path]: _dropped, ...rest }) => rest);
993
+ }
994
+ activate(node.index, another);
446
995
  };
447
996
  const onKeyDown = (event) => {
448
997
  const at = rows.findIndex((row2) => row2.key === active);
@@ -473,8 +1022,8 @@ function StoryTree({ ctx, index }) {
473
1022
  break;
474
1023
  case "Enter":
475
1024
  case " ":
476
- if (node.kind === "folder") setOpen(node.path, !isOpen(node.path));
477
- else activate(node.entry, event.shiftKey);
1025
+ if (node.kind === "story") activate(node.entry, event.shiftKey);
1026
+ else openFolder(node, event.shiftKey);
478
1027
  break;
479
1028
  default:
480
1029
  return;
@@ -506,9 +1055,35 @@ function StoryTree({ ctx, index }) {
506
1055
  ...itemProps(node, level),
507
1056
  "aria-expanded": open,
508
1057
  "aria-label": node.label,
1058
+ "aria-current": node.index && openIds.has(node.index.id) ? "true" : void 0,
509
1059
  className: "fg-tree__node",
510
1060
  children: [
511
- /* @__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 }) }),
1061
+ /* @__PURE__ */ jsxs(
1062
+ "div",
1063
+ {
1064
+ className: "fg-tree__item fg-tree__folder",
1065
+ onClick: (event) => {
1066
+ setFocusKey(keyOf(node));
1067
+ openFolder(node, event.shiftKey);
1068
+ },
1069
+ children: [
1070
+ /* @__PURE__ */ jsx(
1071
+ "span",
1072
+ {
1073
+ "aria-hidden": "true",
1074
+ className: "fg-tree__fold",
1075
+ onClick: (event) => {
1076
+ event.stopPropagation();
1077
+ setOpen(node.path, !open);
1078
+ },
1079
+ children: /* @__PURE__ */ jsx(DisclosureMark, { open })
1080
+ }
1081
+ ),
1082
+ /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: node.label }),
1083
+ node.tag ? /* @__PURE__ */ jsx(LibraryBadge, { library: node.tag }) : null
1084
+ ]
1085
+ }
1086
+ ),
512
1087
  open ? /* @__PURE__ */ jsx("div", { role: "group", className: "fg-tree__group", children: renderNodes(node.children, level + 1) }) : null
513
1088
  ]
514
1089
  },
@@ -516,7 +1091,7 @@ function StoryTree({ ctx, index }) {
516
1091
  );
517
1092
  }
518
1093
  const { entry } = node;
519
- return /* @__PURE__ */ jsx(
1094
+ return /* @__PURE__ */ jsxs(
520
1095
  "a",
521
1096
  {
522
1097
  ...itemProps(node, level),
@@ -529,7 +1104,10 @@ function StoryTree({ ctx, index }) {
529
1104
  setFocusKey(keyOf(node));
530
1105
  activate(entry, event.shiftKey);
531
1106
  },
532
- children: /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: entry.name })
1107
+ children: [
1108
+ /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: node.label ?? entry.name }),
1109
+ node.tag ? /* @__PURE__ */ jsx(LibraryBadge, { library: node.tag }) : null
1110
+ ]
533
1111
  },
534
1112
  keyOf(node)
535
1113
  );
@@ -540,6 +1118,30 @@ function StoryTree({ ctx, index }) {
540
1118
  "No story ",
541
1119
  /* @__PURE__ */ jsx("code", { children: route })
542
1120
  ] }) : null,
1121
+ /* @__PURE__ */ jsx(
1122
+ ToggleBar,
1123
+ {
1124
+ ariaLabel: "Sidebar view",
1125
+ variant: "flat",
1126
+ items: VIEWS,
1127
+ value: view,
1128
+ onChange: (next) => {
1129
+ if (next) setView(next);
1130
+ }
1131
+ }
1132
+ ),
1133
+ /* @__PURE__ */ jsxs("fieldset", { className: "fg-tree__libraries", children: [
1134
+ /* @__PURE__ */ jsx("legend", { className: "fg-tree__libraries-legend", children: "Packages" }),
1135
+ libraries.map((library) => /* @__PURE__ */ jsx(
1136
+ Checkbox,
1137
+ {
1138
+ isSelected: !hidden[library],
1139
+ onChange: (on) => setHidden((prev) => ({ ...prev, [library]: !on })),
1140
+ children: /* @__PURE__ */ jsx(LibraryBadge, { library })
1141
+ },
1142
+ library
1143
+ ))
1144
+ ] }),
543
1145
  /* @__PURE__ */ jsx(
544
1146
  "input",
545
1147
  {
@@ -597,10 +1199,90 @@ function createAnswerBook() {
597
1199
  }
598
1200
  };
599
1201
  }
1202
+ function IndexTrial({ bundle, setup, config }) {
1203
+ const lab = useLabContext();
1204
+ const trialId = useContext(TrialIdContext);
1205
+ const open = (id) => {
1206
+ if (!trialId || !lab.instruments.some((instrument) => instrument.name === id)) return;
1207
+ lab.swapTrial(trialId, id);
1208
+ setRoute(id, { inPlace: true });
1209
+ };
1210
+ const render = (globals) => {
1211
+ const env = {
1212
+ title: bundle.title,
1213
+ ...bundle.description === void 0 ? {} : { description: bundle.description },
1214
+ stories: bundle.stories,
1215
+ descriptions: bundle.descriptions,
1216
+ globals,
1217
+ decorators: setup.decorators ?? [],
1218
+ open,
1219
+ // A story that throws shows its error in its own cell; the rest of the page is still worth reading.
1220
+ onError: (error) => console.error(error)
1221
+ };
1222
+ return /* @__PURE__ */ jsx(IndexPage, { env, render: bundle.render });
1223
+ };
1224
+ return /* @__PURE__ */ jsx(TrialHost, { layout: "fullscreen", setup, config, children: render });
1225
+ }
1226
+ function annotationsOn(frames) {
1227
+ return {
1228
+ targets: (_state, _config, trial) => {
1229
+ const ref = frames.hostRef(trial.id);
1230
+ const frame = frames.get(trial.id);
1231
+ const box = frame.size ?? { width: ref.current?.clientWidth ?? 0, height: ref.current?.clientHeight ?? 0 };
1232
+ const capture = frame.capture;
1233
+ return [
1234
+ {
1235
+ id: "story",
1236
+ ref,
1237
+ content: { w: Math.max(1, box.width), h: Math.max(1, box.height) },
1238
+ ...capture ? { base: () => capture() } : {}
1239
+ }
1240
+ ];
1241
+ }
1242
+ };
1243
+ }
1244
+ function documentInstrument(options) {
1245
+ const { entry, loaded, setup, globals = {}, frames } = options;
1246
+ const schema = loaded.kind === "story" ? loaded.story.config : f.schema({});
1247
+ const config = withGlobals(schema, globals);
1248
+ const initialState = loaded.kind === "story" ? loaded.story.initialState : null;
1249
+ return {
1250
+ name: entry.id,
1251
+ title: breadcrumb(entry.title, entry.name),
1252
+ config,
1253
+ defaultConfig: () => config.defaults(),
1254
+ initialState: (c) => initialState?.(c) ?? null,
1255
+ render: (ctx) => loaded.kind === "story" ? /* @__PURE__ */ jsx(StoryTrial, { story: loaded.story, setup, ctx }) : /* @__PURE__ */ jsx(IndexTrial, { bundle: loaded.bundle, setup, config: ctx.config }),
1256
+ ...frames ? { annotations: annotationsOn(frames) } : {},
1257
+ ...loaded.kind === "story" && loaded.story.viewport ? { stage: { size: { width: loaded.story.viewport.width, height: loaded.story.viewport.height } } } : {}
1258
+ };
1259
+ }
1260
+ function StoryLoad({ entry, load, fault }) {
1261
+ useEffect(() => {
1262
+ if (!fault) load(entry);
1263
+ }, [entry, load, fault]);
1264
+ return /* @__PURE__ */ jsx("div", { className: "fg-story fg-story--loading", "data-fg-layout": "fullscreen", children: fault ? /* @__PURE__ */ jsxs("div", { className: "fg-fault", role: "alert", children: [
1265
+ /* @__PURE__ */ jsx("span", { className: "fg-fault__phase", children: "import" }),
1266
+ /* @__PURE__ */ jsx("p", { className: "fg-fault__message", children: fault.message })
1267
+ ] }) : null });
1268
+ }
1269
+ function pendingInstrument(options) {
1270
+ const { entry, globals = {} } = options;
1271
+ const config = withGlobals(f.schema({}), globals);
1272
+ return {
1273
+ name: entry.id,
1274
+ title: breadcrumb(entry.title, entry.name),
1275
+ config,
1276
+ defaultConfig: () => config.defaults(),
1277
+ initialState: () => null,
1278
+ render: () => /* @__PURE__ */ jsx(StoryLoad, { ...options }, options.generation ?? 0)
1279
+ };
1280
+ }
600
1281
 
601
1282
  // src/shell/readyKey.ts
602
1283
  var readyKey = (ready) => stableStringify({ schema: ready.schema, layout: ready.layout, viewport: ready.viewport });
603
1284
  var START_TIMEOUT_MS = 1e4;
1285
+ var HELLO_TIMEOUT_MS = 3e4;
604
1286
  var requests = 0;
605
1287
  var IN_VIEW_MARGIN = "50%";
606
1288
  function useInView(ref) {
@@ -644,13 +1326,17 @@ function FrameView(props) {
644
1326
  const trialId = useContext(TrialIdContext);
645
1327
  const frames = useContext(TrialFramesContext);
646
1328
  const [overrides] = useCssOverrides();
1329
+ const pool = useContext(FramePoolContext);
1330
+ const lab = useLabContext();
647
1331
  const src = `${frameUrl}#${entry.id}`;
1332
+ const title = breadcrumb(entry.title, entry.name);
648
1333
  const iframeRef = useRef(null);
649
1334
  const hostRef = useRef(null);
1335
+ const slotRef = useRef(null);
650
1336
  const inView = useInView(hostRef);
651
1337
  const link = useRef(null);
652
- const latest = useRef({ ...props, globals, overrides });
653
- latest.current = { ...props, globals, overrides };
1338
+ const latest = useRef({ ...props, globals, overrides, pool, lab, title });
1339
+ latest.current = { ...props, globals, overrides, pool, lab, title };
654
1340
  const [fault, setFault] = useState(null);
655
1341
  const [pending, setPending] = useState(true);
656
1342
  const init = (current) => {
@@ -707,6 +1393,13 @@ function FrameView(props) {
707
1393
  else current.pending.get(msg.id)?.fail(new Error(msg.message));
708
1394
  current.pending.delete(msg.id);
709
1395
  break;
1396
+ case "open": {
1397
+ const { lab: liveLab } = latest.current;
1398
+ if (!trialId || !liveLab.instruments.some((instrument) => instrument.name === msg.id)) break;
1399
+ liveLab.swapTrial(trialId, msg.id);
1400
+ setRoute(msg.id, { inPlace: true });
1401
+ break;
1402
+ }
710
1403
  case "fault":
711
1404
  if (msg.phase === "render" && msg.seq !== void 0 && msg.seq < current.inputs) break;
712
1405
  clearTimeout(current.timer);
@@ -714,10 +1407,10 @@ function FrameView(props) {
714
1407
  break;
715
1408
  }
716
1409
  };
717
- const onLoad = () => {
1410
+ const connect = (frame) => {
718
1411
  closeLink(link);
719
1412
  setPending(true);
720
- const target = iframeRef.current?.contentWindow;
1413
+ const target = frame.contentWindow;
721
1414
  if (!target) return;
722
1415
  const { port1, port2 } = new MessageChannel();
723
1416
  const timer = setTimeout(() => setFault({ phase: null, message: `Frame did not start: ${src}` }), START_TIMEOUT_MS);
@@ -769,15 +1462,53 @@ function FrameView(props) {
769
1462
  };
770
1463
  link.current = current;
771
1464
  channel.on((msg) => receive(current, msg));
772
- target.postMessage({ type: PORT_HANDOFF }, location.origin, [port2]);
1465
+ target.postMessage({ type: PORT_HANDOFF, id: latest.current.entry.id }, location.origin, [
1466
+ port2
1467
+ ]);
773
1468
  };
774
- useEffect(() => () => closeLink(link), []);
1469
+ const connectRef = useRef(connect);
1470
+ connectRef.current = connect;
775
1471
  useEffect(() => {
776
- if (inView) return;
777
- closeLink(link);
778
- setPending(true);
779
- setFault(null);
780
- }, [inView]);
1472
+ const slot = slotRef.current;
1473
+ if (!inView || !slot) return;
1474
+ const claimed = latest.current.pool?.claim() ?? null;
1475
+ const frame = claimed ?? document.createElement("iframe");
1476
+ frame.className = "fg-frame-view";
1477
+ frame.title = latest.current.title;
1478
+ frame.removeAttribute("tabindex");
1479
+ frame.toggleAttribute("data-pending", true);
1480
+ iframeRef.current = frame;
1481
+ const unheard = claimed ? void 0 : setTimeout(() => setFault({ phase: null, message: `Frame did not start: ${src}` }), HELLO_TIMEOUT_MS);
1482
+ const onMessage = (event) => {
1483
+ if (event.source !== frame.contentWindow || event.origin !== location.origin) return;
1484
+ if (event.data?.type !== FRAME_HELLO) return;
1485
+ clearTimeout(unheard);
1486
+ connectRef.current(frame);
1487
+ };
1488
+ window.addEventListener("message", onMessage);
1489
+ if (claimed) {
1490
+ moveFrame(slot, claimed);
1491
+ connectRef.current(claimed);
1492
+ } else {
1493
+ frame.src = src;
1494
+ slot.append(frame);
1495
+ }
1496
+ return () => {
1497
+ window.removeEventListener("message", onMessage);
1498
+ clearTimeout(unheard);
1499
+ closeLink(link);
1500
+ frame.remove();
1501
+ iframeRef.current = null;
1502
+ setPending(true);
1503
+ setFault(null);
1504
+ };
1505
+ }, [inView, src]);
1506
+ useEffect(() => {
1507
+ iframeRef.current?.toggleAttribute("data-pending", pending && !fault);
1508
+ }, [pending, fault, inView, src]);
1509
+ useEffect(() => {
1510
+ if (iframeRef.current) iframeRef.current.title = title;
1511
+ }, [title]);
781
1512
  useEffect(() => answers.hold(ctx.config), [answers, ctx.config]);
782
1513
  useEffect(() => {
783
1514
  const current = link.current;
@@ -819,17 +1550,7 @@ function FrameView(props) {
819
1550
  },
820
1551
  className: "fg-frame-host",
821
1552
  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,
1553
+ /* @__PURE__ */ jsx("div", { ref: slotRef, className: "fg-frame-slot" }),
833
1554
  fault ? /* @__PURE__ */ jsxs("div", { className: "fg-fault", role: "alert", children: [
834
1555
  fault.phase ? /* @__PURE__ */ jsx("span", { className: "fg-fault__phase", children: fault.phase }) : null,
835
1556
  /* @__PURE__ */ jsx("p", { className: "fg-fault__message", children: fault.message })
@@ -838,7 +1559,7 @@ function FrameView(props) {
838
1559
  }
839
1560
  );
840
1561
  }
841
- function annotationsOn(frames) {
1562
+ function annotationsOn2(frames) {
842
1563
  return {
843
1564
  targets: (_state, _config, trial) => {
844
1565
  const ref = frames.hostRef(trial.id);
@@ -865,7 +1586,7 @@ function storyInstrument(options) {
865
1586
  const descriptionKey = ready ? readyKey(ready) : null;
866
1587
  return {
867
1588
  name: entry.id,
868
- title: `${entry.title} / ${entry.name}`,
1589
+ title: breadcrumb(entry.title, entry.name),
869
1590
  config,
870
1591
  defaultConfig: () => config.defaults(),
871
1592
  initialState: () => null,
@@ -880,17 +1601,22 @@ function storyInstrument(options) {
880
1601
  ctx
881
1602
  }
882
1603
  ),
883
- ...frames ? { annotations: annotationsOn(frames) } : {},
1604
+ ...frames ? { annotations: annotationsOn2(frames) } : {},
884
1605
  ...ready?.viewport ? { stage: { size: { width: ready.viewport.width, height: ready.viewport.height } } } : {}
885
1606
  };
886
1607
  }
887
1608
 
888
1609
  // src/shell/useStoryRegistry.ts
889
- var NO_GLOBALS = {};
1610
+ var NO_GLOBALS2 = {};
1611
+ var NO_SETUP = {};
890
1612
  var pruned = (map, ids) => [...map.keys()].every((id) => ids.has(id)) ? map : new Map([...map].filter(([id]) => ids.has(id)));
1613
+ var toError = (error) => error instanceof Error ? error : new Error(String(error));
891
1614
  function useStoryRegistry(index, options) {
892
- const { frameUrl, globals = NO_GLOBALS, frames } = options;
1615
+ const { frameUrl, globals = NO_GLOBALS2, frames, importers, setup = NO_SETUP, load = loadStories } = options;
893
1616
  const [readies, setReadies] = useState(() => /* @__PURE__ */ new Map());
1617
+ const [loaded, setLoaded] = useState(() => /* @__PURE__ */ new Map());
1618
+ const [faults, setFaults] = useState(() => /* @__PURE__ */ new Map());
1619
+ const [generations, setGenerations] = useState(() => /* @__PURE__ */ new Map());
894
1620
  const [revisions, setRevisions] = useState(() => /* @__PURE__ */ new Map());
895
1621
  const onReady = useCallback((entry, ready) => {
896
1622
  setReadies((prev) => {
@@ -902,8 +1628,93 @@ function useStoryRegistry(index, options) {
902
1628
  useEffect(() => {
903
1629
  const ids = new Set(index.map((entry) => entry.id));
904
1630
  setReadies((prev) => pruned(prev, ids));
1631
+ setLoaded((prev) => pruned(prev, ids));
1632
+ setFaults((prev) => pruned(prev, ids));
905
1633
  setRevisions((prev) => pruned(prev, ids));
906
1634
  }, [index]);
1635
+ const imports = useRef(/* @__PURE__ */ new Map());
1636
+ const live = useRef({ index, importers, setup, load });
1637
+ live.current = { index, importers, setup, load };
1638
+ const importOf = useCallback((file) => {
1639
+ let loading = imports.current.get(file);
1640
+ if (!loading) {
1641
+ const importer = live.current.importers?.[file];
1642
+ loading = importer ? importer() : Promise.reject(new Error(`No importer for ${file}`));
1643
+ imports.current.set(file, loading);
1644
+ }
1645
+ return loading;
1646
+ }, []);
1647
+ const request = useCallback(
1648
+ (entry) => {
1649
+ const { setup: current, load: loadModule } = live.current;
1650
+ const settle2 = (ids2, result) => {
1651
+ setLoaded((prev) => {
1652
+ const next = new Map(prev);
1653
+ for (const [id, value] of result) next.set(id, value);
1654
+ return next;
1655
+ });
1656
+ setFaults((prev) => ids2.some((id) => prev.has(id)) ? pruned(prev, new Set([...prev.keys()].filter((id) => !ids2.includes(id)))) : prev);
1657
+ };
1658
+ const fail = (ids2, error) => {
1659
+ const fault = toError(error);
1660
+ setFaults((prev) => {
1661
+ const next = new Map(prev);
1662
+ for (const id of ids2) next.set(id, fault);
1663
+ return next;
1664
+ });
1665
+ };
1666
+ if (isIndexId(entry.id)) {
1667
+ const entries = live.current.index.filter((e) => !isIndexId(e.id) && indexId(e.title) === entry.id);
1668
+ const files = [...new Set(entries.map((e) => e.file))];
1669
+ void Promise.all(files.map(importOf)).then(async (mods) => {
1670
+ const byFile = new Map(files.map((file, i) => [file, loadModule(mods[i] ?? {}, entry.title, current.parameters)]));
1671
+ const stories = entries.flatMap(
1672
+ (e) => byFile.get(e.file)?.find((s) => s.exportName === e.exportName) ?? []
1673
+ );
1674
+ for (const story of stories) await current.prepare?.(story);
1675
+ const result = /* @__PURE__ */ new Map();
1676
+ result.set(entry.id, {
1677
+ kind: "index",
1678
+ bundle: {
1679
+ title: entry.title,
1680
+ ...entry.componentDescription === void 0 ? {} : { description: entry.componentDescription },
1681
+ stories,
1682
+ descriptions: Object.fromEntries(entries.flatMap((e) => e.description ? [[e.id, e.description]] : [])),
1683
+ render: mods.map(indexRenderOf).find((render) => render !== null) ?? null
1684
+ }
1685
+ });
1686
+ settle2([entry.id], result);
1687
+ }).catch((error) => fail([entry.id], error));
1688
+ return;
1689
+ }
1690
+ const siblings = live.current.index.filter((e) => e.file === entry.file && !isIndexId(e.id));
1691
+ const ids = siblings.map((e) => e.id);
1692
+ void importOf(entry.file).then(async (mod) => {
1693
+ const stories = loadModule(mod, entry.title, current.parameters);
1694
+ const result = /* @__PURE__ */ new Map();
1695
+ for (const sibling of siblings) {
1696
+ const story = stories.find((s) => s.exportName === sibling.exportName);
1697
+ if (!story) continue;
1698
+ await current.prepare?.(story);
1699
+ result.set(sibling.id, { kind: "story", story });
1700
+ }
1701
+ if (!result.has(entry.id)) throw new Error(`${entry.file} has no story export "${entry.exportName}"`);
1702
+ settle2(ids, result);
1703
+ }).catch((error) => fail(ids, error));
1704
+ },
1705
+ [importOf]
1706
+ );
1707
+ const reload = useCallback(
1708
+ (file) => {
1709
+ imports.current.delete(file);
1710
+ const affected = live.current.index.filter((e) => e.file === file || isIndexId(e.id) && live.current.index.some((s) => s.file === file && indexId(s.title) === e.id));
1711
+ const ids = new Set(affected.map((e) => e.id));
1712
+ setLoaded((prev) => pruned(prev, new Set([...prev.keys()].filter((id) => !ids.has(id)))));
1713
+ setFaults((prev) => pruned(prev, new Set([...prev.keys()].filter((id) => !ids.has(id)))));
1714
+ setGenerations((prev) => new Map(prev).set(file, (prev.get(file) ?? 0) + 1));
1715
+ },
1716
+ []
1717
+ );
907
1718
  const committed = useRef({ built: /* @__PURE__ */ new Map(), books: /* @__PURE__ */ new Map(), list: [] });
908
1719
  const cache = useMemo(() => {
909
1720
  const previous = committed.current;
@@ -911,26 +1722,35 @@ function useStoryRegistry(index, options) {
911
1722
  const books = /* @__PURE__ */ new Map();
912
1723
  const list = index.map((entry) => {
913
1724
  const ready = readies.get(entry.id);
1725
+ const inDocument = importers !== void 0 && entry.isolate === void 0;
1726
+ const had = loaded.get(entry.id);
1727
+ const fault = faults.get(entry.id);
1728
+ const generation = generations.get(entry.file) ?? 0;
914
1729
  const revision = revisions.get(entry.id) ?? 0;
915
1730
  const entryKey = stableStringify(entry);
916
1731
  const answers = previous.books.get(entry.id) ?? createAnswerBook();
917
1732
  books.set(entry.id, answers);
918
1733
  const held = previous.built.get(entry.id);
919
- const kept = held && held.entryKey === entryKey && held.ready === ready && held.frameUrl === frameUrl && held.globals === globals && held.frames === frames && held.revision === revision ? held : {
1734
+ const kept = held && held.entryKey === entryKey && held.ready === ready && held.loaded === had && held.fault === fault && held.frameUrl === frameUrl && held.globals === globals && held.frames === frames && held.setup === setup && held.inDocument === inDocument && held.generation === generation && held.revision === revision ? held : {
920
1735
  entryKey,
921
1736
  ready,
1737
+ loaded: had,
1738
+ fault,
922
1739
  frameUrl,
923
1740
  globals,
924
1741
  frames,
1742
+ setup,
1743
+ inDocument,
1744
+ generation,
925
1745
  revision,
926
- instrument: storyInstrument({ entry, ready, answers, frameUrl, onReady, globals, ...frames ? { frames } : {} })
1746
+ instrument: !inDocument ? storyInstrument({ entry, ready, answers, frameUrl, onReady, globals, ...frames ? { frames } : {} }) : had ? documentInstrument({ entry, loaded: had, setup, globals, ...frames ? { frames } : {} }) : pendingInstrument({ entry, globals, load: request, generation, ...fault ? { fault } : {} })
927
1747
  };
928
1748
  built.set(entry.id, kept);
929
1749
  return kept.instrument;
930
1750
  });
931
1751
  const unchanged = list.length === previous.list.length && list.every((i, n) => i === previous.list[n]);
932
1752
  return { built, books, list: unchanged ? previous.list : list };
933
- }, [index, readies, revisions, frameUrl, globals, frames, onReady]);
1753
+ }, [index, readies, loaded, faults, generations, revisions, frameUrl, globals, frames, setup, importers, onReady, request]);
934
1754
  useLayoutEffect(() => {
935
1755
  committed.current = cache;
936
1756
  }, [cache]);
@@ -942,29 +1762,75 @@ function useStoryRegistry(index, options) {
942
1762
  for (const off of offs) off();
943
1763
  };
944
1764
  }, [cache.books]);
945
- return { instruments: cache.list, onReady };
1765
+ const isReady = useCallback((id) => readies.has(id) || loaded.has(id), [readies, loaded]);
1766
+ return { instruments: cache.list, isReady, onReady, reload };
946
1767
  }
947
1768
  var NO_DECLARATIONS = {};
948
- function initialStory(index, fallback) {
1769
+ function initialStory(entries, fallback) {
949
1770
  const route = readRoute();
950
- return route !== null && index.some((entry) => entry.id === route) ? route : fallback;
1771
+ return route !== null && entries.some((entry) => entry.id === route) ? route : fallback;
951
1772
  }
952
1773
  function RouteOpener({ index }) {
953
1774
  const lab = useLabContext();
954
1775
  const [route] = useRoute();
955
1776
  const handled = useRef(null);
1777
+ const last = useRef({ route: readRoute(), entry: readRouteEntry() });
956
1778
  useEffect(() => {
957
- if (route === null || handled.current === route || !index.some((entry) => entry.id === route)) return;
1779
+ const from = last.current;
1780
+ if (route !== from.route) last.current = { route, entry: readRouteEntry() };
1781
+ if (route === null || handled.current === route || !index.some((e) => e.id === route)) return;
958
1782
  handled.current = route;
959
- if (!lab.trials.some((trial) => trial.instrumentName === route)) lab.addTrial(route);
1783
+ if (lab.trials.some((trial) => trial.instrumentName === route)) return;
1784
+ const previous = lab.trials.find((trial) => trial.instrumentName === from.route);
1785
+ const target = (previous && crossesInPlace(from.entry, last.current.entry) ? previous : void 0) ?? lab.trials.find((trial) => trial.id === lab.focusedTrialId) ?? lab.trials[0];
1786
+ if (target) lab.swapTrial(target.id, route);
1787
+ else lab.addTrial(route);
960
1788
  }, [route, index, lab]);
961
1789
  return null;
962
1790
  }
963
- function Workshop({ index, frameUrl, config, stories = [], storageKey, storage }) {
1791
+ function useInfoShortcut(open) {
1792
+ useEffect(() => {
1793
+ const onKeyDown = (event) => {
1794
+ if (event.key !== "i" && event.key !== "I") return;
1795
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
1796
+ const target = event.target;
1797
+ if (target?.isContentEditable || /^(input|textarea|select)$/i.test(target?.tagName ?? "")) return;
1798
+ event.preventDefault();
1799
+ open(true);
1800
+ };
1801
+ window.addEventListener("keydown", onKeyDown);
1802
+ return () => window.removeEventListener("keydown", onKeyDown);
1803
+ }, [open]);
1804
+ }
1805
+ function Workshop({ index, frameUrl, importers, setup, changes, config, stories = [], storageKey, storage }) {
964
1806
  const declarations = config?.globals ?? NO_DECLARATIONS;
965
1807
  const [frames] = useState(createTrialFrames);
966
- const registry = useStoryRegistry(index, { frameUrl, globals: declarations, frames });
1808
+ const [pool, setPool] = useState(null);
1809
+ const framed = importers === void 0 || index.some((entry) => entry.isolate !== void 0);
1810
+ useEffect(() => {
1811
+ if (!framed) return;
1812
+ const next = createFramePool(frameUrl);
1813
+ setPool(next);
1814
+ return () => {
1815
+ next.dispose();
1816
+ setPool(null);
1817
+ };
1818
+ }, [frameUrl, framed]);
1819
+ const entries = useMemo(() => [...index, ...indexEntries(index)], [index]);
1820
+ const registry = useStoryRegistry(entries, {
1821
+ frameUrl,
1822
+ globals: declarations,
1823
+ frames,
1824
+ ...importers ? { importers } : {},
1825
+ ...setup ? { setup } : {}
1826
+ });
1827
+ const reload = registry.reload;
1828
+ useEffect(() => changes?.subscribe(reload), [changes, reload]);
967
1829
  const [labValues, setLabValues] = useState(() => labGlobals(declarations, void 0));
1830
+ const themeFor = config?.labTheme;
1831
+ const labTheme = useMemo(() => themeFor?.(labValues), [themeFor, labValues]);
1832
+ const [infoOpen, setInfoOpen] = useState(false);
1833
+ useInfoShortcut(setInfoOpen);
968
1834
  const reportLabValues = useCallback(
969
1835
  (next) => setLabValues((prev) => stableStringify(prev) === stableStringify(next) ? prev : next),
970
1836
  []
@@ -972,6 +1838,11 @@ function Workshop({ index, frameUrl, config, stories = [], storageKey, storage }
972
1838
  const labChrome = useMemo(
973
1839
  () => [
974
1840
  { id: "fg-stories", region: "sidebar", render: (ctx) => /* @__PURE__ */ jsx(StoryTree, { ctx, index }) },
1841
+ {
1842
+ id: "fg-info",
1843
+ region: "palette",
1844
+ item: { icon: InfoIcon, label: "Info", shortcut: "\u2318I", onActivate: () => setInfoOpen(true) }
1845
+ },
975
1846
  CSS_VARS_SECTION,
976
1847
  A11Y_SECTION,
977
1848
  ...Object.keys(declarations).length > 0 ? [{ id: "fg-globals", region: "header", render: () => /* @__PURE__ */ jsx(GlobalsToolbar, { declarations }) }] : [],
@@ -986,12 +1857,14 @@ function Workshop({ index, frameUrl, config, stories = [], storageKey, storage }
986
1857
  stories.length > 0 ? /* @__PURE__ */ jsx("ul", { className: "fg-empty__globs", "aria-label": "Story globs", children: stories.map((glob) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("code", { children: glob }) }, glob)) }) : null
987
1858
  ] });
988
1859
  }
989
- return /* @__PURE__ */ jsx(StoryGlobalsContext.Provider, { value: labValues, children: /* @__PURE__ */ jsx(TrialFramesContext.Provider, { value: frames, children: /* @__PURE__ */ jsxs(
1860
+ return /* @__PURE__ */ jsx(StoryGlobalsContext.Provider, { value: labValues, children: /* @__PURE__ */ jsx(FramePoolContext.Provider, { value: pool, children: /* @__PURE__ */ jsx(TrialFramesContext.Provider, { value: frames, children: /* @__PURE__ */ jsxs(
990
1861
  Lab,
991
1862
  {
992
1863
  title: "weaselforge",
1864
+ density: "roomy",
1865
+ ...labTheme ? { theme: labTheme } : {},
993
1866
  instruments: registry.instruments,
994
- defaultInstrument: initialStory(index, first.id),
1867
+ defaultInstrument: initialStory(entries, first.id),
995
1868
  storageKey: storageKey ?? "weaselforge",
996
1869
  ...storage ? { storage } : {},
997
1870
  labChrome,
@@ -1000,27 +1873,42 @@ function Workshop({ index, frameUrl, config, stories = [], storageKey, storage }
1000
1873
  ...config?.pages ? { pages: config.pages } : {},
1001
1874
  ...config?.path !== void 0 ? { path: config.path } : {},
1002
1875
  children: [
1003
- /* @__PURE__ */ jsx(RouteOpener, { index }),
1876
+ /* @__PURE__ */ jsx(RouteOpener, { index: entries }),
1877
+ /* @__PURE__ */ jsx(
1878
+ StoryInfoDialog,
1879
+ {
1880
+ index,
1881
+ isReady: registry.isReady,
1882
+ isOpen: infoOpen,
1883
+ onOpenChange: setInfoOpen
1884
+ }
1885
+ ),
1004
1886
  /* @__PURE__ */ jsx(LabGlobals, { declarations, onChange: reportLabValues })
1005
1887
  ]
1006
1888
  }
1007
- ) }) });
1889
+ ) }) }) });
1008
1890
  }
1009
1891
  function mountWorkshop({ container, ...props }) {
1010
1892
  const target = container ?? document.getElementById("root");
1011
1893
  if (!target) throw new Error("mountWorkshop: no container given and no #root element");
1012
1894
  const root = createRoot(target);
1013
- let current = props;
1895
+ const changes = createStoryChanges();
1896
+ let current = { ...props, changes };
1014
1897
  root.render(/* @__PURE__ */ jsx(Workshop, { ...current }));
1015
1898
  return {
1016
1899
  setIndex(index) {
1017
1900
  current = { ...current, index };
1018
1901
  root.render(/* @__PURE__ */ jsx(Workshop, { ...current }));
1019
1902
  },
1903
+ setImporters(importers) {
1904
+ current = { ...current, importers };
1905
+ root.render(/* @__PURE__ */ jsx(Workshop, { ...current }));
1906
+ },
1907
+ reloadStory: (file) => changes.emit(file),
1020
1908
  unmount: () => root.unmount()
1021
1909
  };
1022
1910
  }
1023
1911
 
1024
- export { A11Y_SECTION, A11yPanel, StoryGlobalsContext, TrialFramesContext, Workshop, mountWorkshop, useStoryRegistry, useTrialFrame };
1912
+ export { A11Y_SECTION, A11yPanel, Workshop, mountWorkshop, useStoryRegistry };
1025
1913
  //# sourceMappingURL=index.js.map
1026
1914
  //# sourceMappingURL=index.js.map