@weasel-js/forge 1.11.1 → 1.13.0-rc.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 (50) hide show
  1. package/README.md +110 -5
  2. package/dist/{FrameController-Cj_i-Rdk.d.ts → FrameController-Tv3jrHyT.d.ts} +3 -3
  3. package/dist/{chunk-JTCUIJPZ.js → chunk-35DOPXHZ.js} +119 -10
  4. package/dist/chunk-35DOPXHZ.js.map +1 -0
  5. package/dist/{chunk-7BQPHMA2.js → chunk-3BAM6M26.js} +2 -2
  6. package/dist/chunk-3BAM6M26.js.map +1 -0
  7. package/dist/{chunk-ZAWXMUGZ.js → chunk-5WS67HMU.js} +8 -3
  8. package/dist/chunk-5WS67HMU.js.map +1 -0
  9. package/dist/{chunk-BKPDKUDN.js → chunk-BUXUWAI4.js} +45 -60
  10. package/dist/chunk-BUXUWAI4.js.map +1 -0
  11. package/dist/chunk-ORMMZ5PV.js +59 -0
  12. package/dist/chunk-ORMMZ5PV.js.map +1 -0
  13. package/dist/chunk-RKQ277P5.js +501 -0
  14. package/dist/chunk-RKQ277P5.js.map +1 -0
  15. package/dist/chunk-STF6NQ5N.js +60 -0
  16. package/dist/chunk-STF6NQ5N.js.map +1 -0
  17. package/dist/chunk-VUPN73JP.js +16 -0
  18. package/dist/chunk-VUPN73JP.js.map +1 -0
  19. package/dist/{config-DN6IaNf6.d.ts → config-Bd6FVlh7.d.ts} +22 -5
  20. package/dist/frame/index.css +37 -0
  21. package/dist/frame/index.css.map +1 -1
  22. package/dist/frame/index.d.ts +62 -29
  23. package/dist/frame/index.js +138 -8
  24. package/dist/frame/index.js.map +1 -1
  25. package/dist/index.d.ts +28 -5
  26. package/dist/index.js +2 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/mountFrame-FdJDb3TB.d.ts +35 -0
  29. package/dist/shell/index.css +147 -2
  30. package/dist/shell/index.css.map +1 -1
  31. package/dist/shell/index.d.ts +44 -8
  32. package/dist/shell/index.js +1614 -277
  33. package/dist/shell/index.js.map +1 -1
  34. package/dist/test/index.css +58 -0
  35. package/dist/test/index.css.map +1 -0
  36. package/dist/test/index.d.ts +1 -1
  37. package/dist/test/index.js +4 -4
  38. package/dist/vite/index.js +571 -121
  39. package/dist/vite/index.js.map +1 -1
  40. package/package.json +13 -8
  41. package/dist/chunk-5DXUNLV2.js +0 -6
  42. package/dist/chunk-5DXUNLV2.js.map +0 -1
  43. package/dist/chunk-7BQPHMA2.js.map +0 -1
  44. package/dist/chunk-BKPDKUDN.js.map +0 -1
  45. package/dist/chunk-FZNABWJC.js +0 -282
  46. package/dist/chunk-FZNABWJC.js.map +0 -1
  47. package/dist/chunk-JTCUIJPZ.js.map +0 -1
  48. package/dist/chunk-WEQSLPKT.js +0 -9
  49. package/dist/chunk-WEQSLPKT.js.map +0 -1
  50. package/dist/chunk-ZAWXMUGZ.js.map +0 -1
@@ -1,18 +1,19 @@
1
1
  import { parseRoute, formatRoute, paramsToKnobs, knobPaths, sameKnob, knobsToParams, PLAYHEAD_PARAM, reservedParams } from '../chunk-LTTEILXY.js';
2
- import { DEFAULT_TITLE } from '../chunk-5DXUNLV2.js';
3
- import { TITLE_LIBRARIES, isIndexId, indexId, indexEntries, librariesFrom, LibrariesContext, createKitRoot, schemaFromDescription, LibraryBadge, openChannel, IndexPage } from '../chunk-BKPDKUDN.js';
4
- import { isGallery } from '../chunk-WEQSLPKT.js';
5
- import { useTrialFrame, createTrialFrames, createTrialClocks, StoryGlobalsContext, TrialFramesContext, TrialClocksContext, StoryTrial, useCssOverrides, useTrialClock, TrialHost } from '../chunk-FZNABWJC.js';
6
- export { StoryGlobalsContext, StoryTrial, TrialFramesContext, TrialHost, useTrialFrame } from '../chunk-FZNABWJC.js';
7
- import { labGlobals, withGlobals, followApp, storyConfig, GLOBALS_KEY, effectiveGlobals, PLAYHEAD_KEY, labOptions, isGlobalsPath } from '../chunk-7BQPHMA2.js';
8
- import { loadStories, indexRenderOf, useNearViewport, IN_VIEW_MARGIN } from '../chunk-JTCUIJPZ.js';
2
+ import { DEFAULT_TITLE, CAPTURE_PATH, messageOf, MIN_DPR, MAX_DPR } from '../chunk-ORMMZ5PV.js';
3
+ import { TITLE_LIBRARIES, librariesFrom, LibrariesContext, LAB_DENSITY, createKitRoot, schemaFromDescription, LibraryBadge, Description, IndexPage, LabBadge, openChannel } from '../chunk-BUXUWAI4.js';
4
+ import { isIndexId, indexId, indexEntries } from '../chunk-STF6NQ5N.js';
5
+ import { taggedIds, isGallery, isLab } from '../chunk-VUPN73JP.js';
6
+ import { useTrialFrame, createTrialFrames, createTrialClocks, StoryGlobalsContext, TrialFramesContext, TrialClocksContext, StoryTrial, LiveLensesContext, trialAt, describeLens, createLiveLenses, useCopyConfig, useCssOverrides, LiveFrameCopies, useTrialClock, TrialHost, LiveCopies, resolveContextOf, resolveComparison, withArgs, scrollerOf } from '../chunk-RKQ277P5.js';
7
+ export { StoryGlobalsContext, StoryTrial, TrialFramesContext, TrialHost, useTrialFrame } from '../chunk-RKQ277P5.js';
8
+ import { labGlobals, withGlobals, followApp, storyConfig, GLOBALS_KEY, effectiveGlobals, PLAYHEAD_KEY, labOptions, isForgePath, isGlobalsPath } from '../chunk-3BAM6M26.js';
9
+ import { loadStories, indexRenderOf, useNearViewport, IN_VIEW_MARGIN, varRefs, cssRulesOf } from '../chunk-35DOPXHZ.js';
9
10
  import '../chunk-L5XM5VKB.js';
10
- import '../chunk-ZAWXMUGZ.js';
11
+ import '../chunk-5WS67HMU.js';
11
12
  import '../chunk-KEQBGDUZ.js';
12
- import { stableStringify, PORT_HANDOFF, FRAME_HELLO, PROTOCOL_VERSION } from '../chunk-GH5RE5X5.js';
13
- import { useTrialId, Button, Lab, useLabContext, TrialIdContext, usePersistedState, Select, useTrialState, ToggleBar as ToggleBar$1, Input, TokenPanel, inferTokenType, refitScale } from '@weasel-js/labkit';
13
+ import { stableStringify, FRAME_HELLO, PORT_HANDOFF, PROTOCOL_VERSION } from '../chunk-GH5RE5X5.js';
14
+ import { useTrialId, Button, Lab, useLabContext, TrialIdContext, usePersistedState, Select, useTrialState, ToggleBar as ToggleBar$1, Input, TokenPanel, useLabEntries, inferTokenType, refitScale } from '@weasel-js/labkit';
14
15
  import num from '@weasel-js/theme/numeric.module.css';
15
- import { createContext, useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect, useId, useContext, Fragment as Fragment$1, useSyncExternalStore } from 'react';
16
+ import { createContext, useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect, useId, useContext, useReducer, useSyncExternalStore, Fragment as Fragment$1 } from 'react';
16
17
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
17
18
  import { createEmitter } from '@weasel-js/registry';
18
19
  import { f, valueAtPath, isConfigBranch } from '@weasel-js/labkit/config';
@@ -20,17 +21,20 @@ import { Slider } from '@weasel-js/ui/components/Slider';
20
21
  import { Transport } from '@weasel-js/ui/components/Timeline';
21
22
  import { TOKEN_MANIFEST, themeAxes, weaselTheme, THEME_SOURCES, resolveTheme, isColorModePreference, isByAxis } from '@weasel-js/theme';
22
23
  import { scale, httpThemeApi } from '@weasel-js/theme/engine';
23
- import { useLatest, historyKey, createHistory, swapOp, isPlainObject } from '@weasel-js/core';
24
+ import { useLatest, useActionsRegistry, effectivePose, useMenuHost, useMenus, sceneToAdapter, parseColor, contrastRatio, wcagLevels, historyKey, isPlainObject, createHistory, keymapShortcuts, actionBindings, keySpecShortcut, keymapLeaderId, swapOp } from '@weasel-js/core';
24
25
  import { Button as Button$1 } from '@weasel-js/ui/components/Button';
25
26
  import { Checkbox } from '@weasel-js/ui/components/Checkbox';
26
27
  import { Icon } from '@weasel-js/ui/components/Icon';
28
+ import { TOKEN_CATEGORIES, tokenCategory } from '@weasel-js/ui/components/TokenPanel';
27
29
  import { ToggleBar } from '@weasel-js/ui/components/ToggleBar';
28
- import { TOKEN_CATEGORIES } from '@weasel-js/ui/components/TokenPanel';
29
30
  import { useResolvedColorMode } from '@weasel-js/theme/react';
30
31
  import { formatShortcut } from '@weasel-js/ui/components/ToolPalette';
31
32
  import { Dialog } from '@weasel-js/ui/components/Dialog';
32
- import { Callout } from '@weasel-js/ui/components/Callout';
33
+ import { createLensScene, settingsOf, resolveSource, LensOverlay, LensActions, lensIdsOf, reticleAt, lensesOf, MAX_LENSES, addLensOps, unavailableText, useShownSource, requestsOf, BODY_SIZE, setLensOps, readBitmap, LENS_REST_MS, probeUnderLens } from '@weasel-js/labkit/loupe';
34
+ import { KeymapHint } from '@weasel-js/ui/components/KeymapHint';
33
35
  import { pref } from '@weasel-js/prefs';
36
+ import { splitTopLevel, specificity, compareSpecificity } from '@weasel-js/svg';
37
+ import { Callout } from '@weasel-js/ui/components/Callout';
34
38
  import { DisclosureMark } from '@weasel-js/ui/components/Disclosure';
35
39
 
36
40
  function pointAt(point, nodes) {
@@ -89,7 +93,7 @@ function A11yPanel() {
89
93
  };
90
94
  const outcome = frame.a11y;
91
95
  return /* @__PURE__ */ jsxs("div", { className: "fg-a11y", children: [
92
- /* @__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" }) }),
96
+ /* @__PURE__ */ jsx("div", { className: "fg-a11y__controls", children: /* @__PURE__ */ jsx(Button, { size: "sm", onClick: rerun, disabled: !audit || running, children: running ? "Checking\u2026" : "Re-check" }) }),
93
97
  !audit ? /* @__PURE__ */ jsx("p", { className: "fg-a11y__note", children: "No frame is connected." }) : null,
94
98
  audit && !outcome ? /* @__PURE__ */ jsx("p", { className: "fg-a11y__note", children: running ? "Running axe\u2026" : "Not checked yet." }) : null,
95
99
  outcome && !outcome.ok ? /* @__PURE__ */ jsxs("p", { className: "fg-a11y__note", role: "alert", children: [
@@ -126,6 +130,56 @@ var A11Y_SECTION = {
126
130
  region: "aside",
127
131
  item: { title: "Accessibility", body: /* @__PURE__ */ jsx(FocusedA11y, {}) }
128
132
  };
133
+
134
+ // src/shell/followEdits.ts
135
+ function followEdits(hot, workshop) {
136
+ const stale = /* @__PURE__ */ new Set();
137
+ hot?.on("forge:index", (next) => workshop.setIndex(next));
138
+ hot?.on("forge:deps", (next) => workshop.setDependencies(next));
139
+ hot?.on("forge:story", ({ file }) => {
140
+ stale.add(file);
141
+ workshop.revise();
142
+ });
143
+ hot?.on("vite:beforeUpdate", () => workshop.revise());
144
+ return {
145
+ importers(next) {
146
+ if (!next) return;
147
+ workshop.setImporters(next);
148
+ for (const file of stale) workshop.reloadStory(file);
149
+ stale.clear();
150
+ }
151
+ };
152
+ }
153
+ function createRevision() {
154
+ let n = 0;
155
+ const bumped = createEmitter();
156
+ return {
157
+ get: () => n,
158
+ subscribe: bumped.subscribe,
159
+ bump() {
160
+ n += 1;
161
+ bumped.emit();
162
+ }
163
+ };
164
+ }
165
+ function createCaptureTrials() {
166
+ const trials = /* @__PURE__ */ new Map();
167
+ const changed = createEmitter();
168
+ return {
169
+ publish(trialId, inputs) {
170
+ trials.set(trialId, inputs);
171
+ changed.emit();
172
+ return () => {
173
+ if (trials.get(trialId) !== inputs) return;
174
+ trials.delete(trialId);
175
+ changed.emit();
176
+ };
177
+ },
178
+ get: (trialId) => trials.get(trialId) ?? null,
179
+ subscribe: changed.subscribe
180
+ };
181
+ }
182
+ var LoupeCaptureContext = createContext(null);
129
183
  function createStoryChanges() {
130
184
  const changes = createEmitter();
131
185
  return { subscribe: changes.subscribe, emit: changes.emit };
@@ -208,7 +262,9 @@ function TransportBar({ clock }) {
208
262
  {
209
263
  className: "fg-transport__scrub",
210
264
  ariaLabel: "Scrub",
211
- density: "slim",
265
+ thumbShape: "dot",
266
+ trackHeight: "var(--wzl-slider-track-h)",
267
+ thumbSize: "var(--wzl-slider-thumb-size)",
212
268
  readoutPlacement: "none",
213
269
  min: span.start,
214
270
  max: span.end > span.start ? span.end : span.start + 1,
@@ -240,10 +296,15 @@ function TrialTransport({ trialId }) {
240
296
  }
241
297
 
242
298
  // src/shell/breadcrumb.ts
299
+ var bare = (label) => label.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
300
+ function restatesTitle(title, name) {
301
+ return bare(title.slice(title.lastIndexOf("/") + 1)) === bare(name);
302
+ }
243
303
  function crumbs(title, name) {
244
304
  const segments = title.split("/");
245
305
  const steps = segments.map((label, i) => ({ label, path: segments.slice(0, i + 1).join("/") }));
246
- steps.push({ label: name, path: null });
306
+ if (restatesTitle(title, name)) steps[steps.length - 1] = { label: segments[segments.length - 1], path: null };
307
+ else steps.push({ label: name, path: null });
247
308
  return steps.filter((step, i) => i === 0 || step.label.toLowerCase() !== "index");
248
309
  }
249
310
  function breadcrumb(title, name) {
@@ -297,6 +358,7 @@ function IndexTrial({ bundle, setup, config }) {
297
358
  descriptions: bundle.descriptions,
298
359
  gallery,
299
360
  galleries: bundle.galleries,
361
+ labs: bundle.labs,
300
362
  ...dependencies ? { dependencies } : {},
301
363
  globals,
302
364
  decorators: setup.decorators ?? [],
@@ -306,7 +368,38 @@ function IndexTrial({ bundle, setup, config }) {
306
368
  };
307
369
  return /* @__PURE__ */ jsx(IndexPage, { env, render: bundle.render });
308
370
  };
309
- return /* @__PURE__ */ jsx(TrialHost, { layout: "fullscreen", setup, config, children: render });
371
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
372
+ /* @__PURE__ */ jsx(TrialHost, { layout: "fullscreen", setup, config, children: render }),
373
+ /* @__PURE__ */ jsx(LiveCopies, { trialId, layout: "fullscreen", setup, trialConfig: config, children: render })
374
+ ] });
375
+ }
376
+ function CaptureInputs({ entry, layout, config, state }) {
377
+ const capture = useContext(LoupeCaptureContext);
378
+ const trialId = useContext(TrialIdContext);
379
+ const [overrides] = useCssOverrides();
380
+ const withdraw = useRef(null);
381
+ useEffect(() => {
382
+ if (capture && trialId) withdraw.current = capture.trials.publish(trialId, { entry, layout, config, state, overrides });
383
+ }, [capture, trialId, entry, layout, config, state, overrides]);
384
+ useEffect(
385
+ () => () => {
386
+ withdraw.current?.();
387
+ withdraw.current = null;
388
+ },
389
+ [capture, trialId]
390
+ );
391
+ return null;
392
+ }
393
+ var STILL = { get: () => 0, subscribe: () => () => {
394
+ } };
395
+ function LoupeProvider({ captureBase, revision = STILL, children }) {
396
+ const [lenses] = useState(createLiveLenses);
397
+ const [trials] = useState(createCaptureTrials);
398
+ const capture = useMemo(
399
+ () => captureBase === void 0 ? null : { base: captureBase, trials, revision },
400
+ [captureBase, trials, revision]
401
+ );
402
+ return /* @__PURE__ */ jsx(LiveLensesContext.Provider, { value: lenses, children: /* @__PURE__ */ jsx(LoupeCaptureContext.Provider, { value: capture, children }) });
310
403
  }
311
404
  function withPlayhead(schema) {
312
405
  return f.schema({ ...schema.nodes, [PLAYHEAD_KEY]: f.custom("forge-playhead", null).hidden() });
@@ -323,7 +416,10 @@ function documentInstrument(options) {
323
416
  config,
324
417
  defaultConfig: () => config.defaults(),
325
418
  initialState: (c) => initialState?.(c) ?? null,
326
- render: (ctx) => loaded.kind === "story" ? /* @__PURE__ */ jsx(StoryTrial, { story: loaded.story, setup, ctx }, loaded.story.id) : /* @__PURE__ */ jsx(IndexTrial, { bundle: loaded.bundle, setup, config: ctx.config }),
419
+ render: (ctx) => loaded.kind === "story" ? /* @__PURE__ */ jsxs(Fragment, { children: [
420
+ /* @__PURE__ */ jsx(StoryTrial, { story: loaded.story, setup, ctx }, loaded.story.id),
421
+ /* @__PURE__ */ jsx(CaptureInputs, { entry, layout: loaded.story.layout, config: ctx.config, state: ctx.state })
422
+ ] }) : /* @__PURE__ */ jsx(IndexTrial, { bundle: loaded.bundle, setup, config: ctx.config }),
327
423
  ...loaded.kind === "story" && loaded.story.viewport ? { stage: { size: { width: loaded.story.viewport.width, height: loaded.story.viewport.height } } } : {},
328
424
  ...timed ? { chrome: [{ id: "fg-transport", region: "status", render: (c) => /* @__PURE__ */ jsx(TrialTransport, { trialId: c.trialId }) }] } : {}
329
425
  };
@@ -363,6 +459,50 @@ function missingInstrument(id, globals = {}) {
363
459
  render: () => /* @__PURE__ */ jsx("div", { className: "fg-story fg-story--loading", "data-fg-layout": "fullscreen", children: /* @__PURE__ */ jsx(Fault, { phase: "route", message: `No story ${id}` }) })
364
460
  };
365
461
  }
462
+ var listeners = /* @__PURE__ */ new Set();
463
+ var pending = null;
464
+ function revealVar(name) {
465
+ pending = name;
466
+ for (const fn of [...listeners]) fn(name);
467
+ }
468
+ function subscribeReveal(fn) {
469
+ listeners.add(fn);
470
+ if (pending !== null) fn(pending);
471
+ return () => {
472
+ listeners.delete(fn);
473
+ };
474
+ }
475
+ var MARK = "data-revealed";
476
+ function useRevealToken(list, tokens, show, storyReported) {
477
+ const asked = useRef(null);
478
+ const [, rerender] = useState(0);
479
+ useEffect(
480
+ () => subscribeReveal((name) => {
481
+ const tab = ["theme", "story"].find((t) => tokens[t].some((token2) => token2.name === name));
482
+ const token = tab && tokens[tab].find((t) => t.name === name);
483
+ if (!tab || !token) {
484
+ if (storyReported && pending === name) pending = null;
485
+ return;
486
+ }
487
+ show(name, tab, tokenCategory(token));
488
+ asked.current = name;
489
+ rerender((n) => n + 1);
490
+ }),
491
+ [tokens, show, storyReported]
492
+ );
493
+ useLayoutEffect(() => {
494
+ const root = list.current;
495
+ const revealed = asked.current;
496
+ if (!root || revealed === null) return;
497
+ const target = root.querySelector(`[aria-label="${revealed.replace(/["\\]/g, "\\$&")}"]`);
498
+ for (const el of Array.from(root.querySelectorAll(`[${MARK}]`))) if (el !== target) el.removeAttribute(MARK);
499
+ if (!target) return;
500
+ target.setAttribute(MARK, "");
501
+ target.scrollIntoView?.({ block: "nearest" });
502
+ if (pending === revealed) pending = null;
503
+ asked.current = null;
504
+ });
505
+ }
366
506
  var ICONS = {
367
507
  color: (
368
508
  /* @icon */
@@ -509,8 +649,8 @@ function applyScaleEdits(definition, edits, selection) {
509
649
  const write = (slot, value) => {
510
650
  let byKey = writes.get(slot.holder);
511
651
  if (!byKey) writes.set(slot.holder, byKey = /* @__PURE__ */ new Map());
512
- const pending = byKey.get(slot.key);
513
- if (pending !== void 0 && pending !== value) throw new EditError(`${slot.where} would need to be both ${pending} and ${value}`);
652
+ const pending2 = byKey.get(slot.key);
653
+ if (pending2 !== void 0 && pending2 !== value) throw new EditError(`${slot.where} would need to be both ${pending2} and ${value}`);
514
654
  byKey.set(slot.key, value);
515
655
  };
516
656
  try {
@@ -634,22 +774,22 @@ function useThemeSave({ trialId, globals, themeApi, scales, overrides, history:
634
774
  const stored = await api.get(THEME_NAME);
635
775
  const file = `themes/${THEME_NAME}.json`;
636
776
  if (JSON.stringify(stored.definition) !== JSON.stringify(THEME_SOURCES[THEME_NAME])) {
637
- setNotice({ tone: "error", text: `${file} has changed since this page loaded. Reload to edit against it.` });
777
+ setNotice({ outcome: "error", text: `${file} has changed since this page loaded. Reload to edit against it.` });
638
778
  return;
639
779
  }
640
780
  const scaled = applyScaleEdits(stored.definition, Object.fromEntries(scales.map((s) => [s.name, s.edit])), selection);
641
781
  const next = scaled.ok ? applyTokenEdits(scaled.definition, tokens, selection) : scaled;
642
782
  if (!next.ok) {
643
- setNotice({ tone: "error", text: next.message });
783
+ setNotice({ outcome: "error", text: next.message });
644
784
  return;
645
785
  }
646
786
  const result = await api.put(THEME_NAME, next.definition, stored.hash);
647
787
  if (result.status === "conflict") {
648
- setNotice({ tone: "error", text: `${file} changed on disk while saving. Nothing was written; reload to edit against it.` });
788
+ setNotice({ outcome: "error", text: `${file} changed on disk while saving. Nothing was written; reload to edit against it.` });
649
789
  return;
650
790
  }
651
791
  if (result.status === "invalid") {
652
- setNotice({ tone: "error", text: result.message });
792
+ setNotice({ outcome: "error", text: result.message });
653
793
  return;
654
794
  }
655
795
  const saved = /* @__PURE__ */ new Set([
@@ -663,10 +803,10 @@ function useThemeSave({ trialId, globals, themeApi, scales, overrides, history:
663
803
  scales: Object.fromEntries(Object.entries(now.scales).filter(([group]) => !groups.has(group)))
664
804
  });
665
805
  setNotice(
666
- 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("; ")}` }
806
+ result.regenerated || result.problems.length === 0 ? { outcome: "saved", text: `Saved to ${file}.` } : { outcome: "error", text: `Saved to ${file}, but the tokens were not regenerated: ${result.problems.join("; ")}` }
667
807
  );
668
808
  } catch (e) {
669
- setNotice({ tone: "error", text: `Saving needs forge's dev server, which did not answer: ${e.message}` });
809
+ setNotice({ outcome: "error", text: `Saving needs forge's dev server, which did not answer: ${e.message}` });
670
810
  } finally {
671
811
  setSaving(false);
672
812
  }
@@ -810,6 +950,15 @@ function standingScale(source, amounts) {
810
950
  const base = (amounts[at] ?? 0) / (rule.factors[at] ?? 1);
811
951
  return { ...seed, base: Math.round(base * 100) / 100, rule };
812
952
  }
953
+ function unsavedScales(edited, overrides) {
954
+ return SCALE_SOURCES.flatMap((source) => {
955
+ const edit = edited[source.group];
956
+ if (!edit || !source.tokens.some((name2) => overrides[name2] !== void 0)) return [];
957
+ const { name, group, tokens } = source;
958
+ const clean = scaleValues(source, edit).every(([token, value]) => overrides[token] === value);
959
+ return clean ? [{ name, group, tokens, edit: { base: edit.base, rule: edit.rule } }] : [];
960
+ });
961
+ }
813
962
  var NO_GLOBALS = {};
814
963
  function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
815
964
  const trialId = useTrialId();
@@ -826,6 +975,14 @@ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
826
975
  const themeTokens = useThemeTokens(globals, frame.vars);
827
976
  const story = useStoryTokens(frame.vars);
828
977
  const tokens = tab === "theme" ? themeTokens : story.tokens;
978
+ const listRef = useRef(null);
979
+ const byTab = useMemo(() => ({ theme: themeTokens, story: story.tokens }), [themeTokens, story.tokens]);
980
+ const show = useCallback((name, at, category) => {
981
+ setTab(at);
982
+ setFilter((was) => name.toLowerCase().includes(was.trim().toLowerCase()) ? was : "");
983
+ setFolds((prev) => prev[category] ? { ...prev, [category]: false } : prev);
984
+ }, [setFolds]);
985
+ useRevealToken(listRef, byTab, show, frame.vars.length > 0);
829
986
  const query = filter.trim().toLowerCase();
830
987
  const shown = tokens.map((token) => {
831
988
  const override = overrides[token.name];
@@ -866,17 +1023,8 @@ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
866
1023
  `scale:${group}`
867
1024
  );
868
1025
  };
869
- const unsaved = useMemo(
870
- () => SCALE_SOURCES.flatMap((source) => {
871
- const edit = edited[source.group];
872
- if (!edit || !source.tokens.some((name2) => overrides[name2] !== void 0)) return [];
873
- const { name, group, tokens: tokens2 } = source;
874
- const clean = scaleValues(source, edit).every(([token, value]) => overrides[token] === value);
875
- return clean ? [{ name, group, tokens: tokens2, edit: { base: edit.base, rule: edit.rule } }] : [];
876
- }),
877
- [edited, overrides]
878
- );
879
- const { pending, saving, notice, save } = useThemeSave({ trialId, globals, themeApi, scales: unsaved, overrides, history: history2 });
1026
+ const unsaved = useMemo(() => unsavedScales(edited, overrides), [edited, overrides]);
1027
+ const { pending: pending2, saving, notice, save } = useThemeSave({ trialId, globals, themeApi, scales: unsaved, overrides, history: history2 });
880
1028
  const onCollapsedChange = useCallback(
881
1029
  (category, collapsed) => setFolds((prev) => ({ ...prev, [category]: collapsed })),
882
1030
  [setFolds]
@@ -935,9 +1083,9 @@ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
935
1083
  ] })
936
1084
  ] }),
937
1085
  tab === "story" ? /* @__PURE__ */ jsx(Checkbox, { className: "fg-css-vars__unused", isSelected: story.showUnused, onChange: story.setShowUnused, children: "Show variables the story doesn\u2019t use" }) : null,
938
- tab === "theme" ? /* @__PURE__ */ jsx(SaveRow, { pending, saving, notice, onSave: () => void save() }) : null
1086
+ tab === "theme" ? /* @__PURE__ */ jsx(SaveRow, { pending: pending2, saving, notice, onSave: () => void save() }) : null
939
1087
  ] }),
940
- shown.length === 0 ? /* @__PURE__ */ jsx("p", { className: "fg-css-vars__empty", children: tokens.length > 0 ? "No variables match." : tab === "story" ? story.emptyText : "The story\u2019s frame has reported no variables." }) : /* @__PURE__ */ jsx("div", { className: "fg-css-vars__list", children: /* @__PURE__ */ jsx(
1088
+ shown.length === 0 ? /* @__PURE__ */ jsx("p", { className: "fg-css-vars__empty", children: tokens.length > 0 ? "No variables match." : tab === "story" ? story.emptyText : "The story\u2019s frame has reported no variables." }) : /* @__PURE__ */ jsx("div", { ref: listRef, className: "fg-css-vars__list", children: /* @__PURE__ */ jsx(
941
1089
  TokenPanel,
942
1090
  {
943
1091
  tokens: shown,
@@ -954,11 +1102,11 @@ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
954
1102
  )
955
1103
  );
956
1104
  }
957
- function SaveRow({ pending, saving, notice, onSave }) {
958
- if (!pending && !notice) return null;
1105
+ function SaveRow({ pending: pending2, saving, notice, onSave }) {
1106
+ if (!pending2 && !notice) return null;
959
1107
  return /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__save", children: [
960
- pending ? /* @__PURE__ */ jsx(Button$1, { variant: "primary", size: "sm", loading: saving, disabled: saving, onClick: onSave, children: "Save to theme" }) : null,
961
- notice ? /* @__PURE__ */ jsx("p", { className: `fg-css-vars__notice fg-css-vars__notice--${notice.tone}`, role: notice.tone === "error" ? "alert" : "status", children: notice.text }) : null
1108
+ pending2 ? /* @__PURE__ */ jsx(Button$1, { stance: "confirm", size: "sm", loading: saving, disabled: saving, onClick: onSave, children: "Save to theme" }) : null,
1109
+ notice ? /* @__PURE__ */ jsx("p", { className: `fg-css-vars__notice fg-css-vars__notice--${notice.outcome}`, role: notice.outcome === "error" ? "alert" : "status", children: notice.text }) : null
962
1110
  ] });
963
1111
  }
964
1112
  function FocusedCssVars() {
@@ -999,6 +1147,124 @@ function CssVarsChrome({ onChrome }) {
999
1147
  const [shown, setShown] = usePersistedState(RECORD, false, { scope: "lab" });
1000
1148
  const chrome = useMemo(() => cssVarsChrome(shown, () => setShown((was) => !was)), [shown, setShown]);
1001
1149
  useEffect(() => onChrome(chrome), [chrome, onChrome]);
1150
+ useEffect(() => subscribeReveal(() => setShown(true)), [setShown]);
1151
+ return null;
1152
+ }
1153
+ function describeBinding(spec) {
1154
+ const chip = keySpecShortcut(spec);
1155
+ if (chip) return spec.kind === "key-held" ? `Hold ${formatShortcut(chip)}` : formatShortcut(chip) ?? "";
1156
+ switch (spec.kind) {
1157
+ case "wheel":
1158
+ return "Wheel";
1159
+ case "hover":
1160
+ return "Hover";
1161
+ case "drag":
1162
+ return "Drag";
1163
+ case "click":
1164
+ return "Click";
1165
+ default:
1166
+ return spec.kind[0].toUpperCase() + spec.kind.slice(1);
1167
+ }
1168
+ }
1169
+ function keymapDefs(entries) {
1170
+ const defs = [];
1171
+ for (const entry of entries.values()) {
1172
+ if (!entry.eligibility.keymap) continue;
1173
+ const open = entries.get(`${entry.id}.leader`)?.actions?.find((a) => a.id === keymapLeaderId(entry.id));
1174
+ const leader = open ? actionBindings(open).map((b) => b.spec) : [];
1175
+ if (leader.length === 0) continue;
1176
+ defs.push({ id: entry.id, label: entry.presentation?.label ?? entry.id, leader, bindings: entry.bindings ?? [] });
1177
+ }
1178
+ return defs;
1179
+ }
1180
+ function keyRows(actions, defs, overrides) {
1181
+ const inKeymap = /* @__PURE__ */ new Map();
1182
+ for (const def of defs) {
1183
+ for (const [id, chips] of keymapShortcuts(def, overrides)) {
1184
+ inKeymap.set(id, [...inKeymap.get(id) ?? [], ...chips.flatMap((c) => formatShortcut(c) ?? [])]);
1185
+ }
1186
+ }
1187
+ const rows = [];
1188
+ for (const action of actions) {
1189
+ const keys = /* @__PURE__ */ new Set();
1190
+ const others = /* @__PURE__ */ new Set();
1191
+ for (const { spec } of actionBindings(action)) {
1192
+ (keySpecShortcut(spec) ? keys : others).add(describeBinding(spec));
1193
+ }
1194
+ for (const chip of inKeymap.get(action.id) ?? []) keys.add(chip);
1195
+ if (keys.size + others.size === 0) continue;
1196
+ rows.push({ id: action.id, label: action.label, group: action.group ?? "other", keys: [...keys], others: [...others] });
1197
+ }
1198
+ return rows.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
1199
+ }
1200
+ function useKeyRows() {
1201
+ const registry = useActionsRegistry();
1202
+ const entries = useLabEntries();
1203
+ const [version, bump] = useReducer((n) => n + 1, 0);
1204
+ useEffect(() => registry?.subscribe(bump), [registry]);
1205
+ return useMemo(
1206
+ () => registry ? keyRows(registry.list(), keymapDefs(entries), registry.bindingOverrides()) : [],
1207
+ // `version` is the registry's change signal: `list()` has no identity to key on.
1208
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1209
+ [registry, entries, version]
1210
+ );
1211
+ }
1212
+ function KeysPanel() {
1213
+ const rows = useKeyRows();
1214
+ if (rows.length === 0) return /* @__PURE__ */ jsx("p", { className: "fg-keys__empty", children: "No key bindings." });
1215
+ const groups = /* @__PURE__ */ new Map();
1216
+ for (const row of rows) groups.set(row.group, [...groups.get(row.group) ?? [], row]);
1217
+ return /* @__PURE__ */ jsx("div", { className: "fg-keys", children: [...groups].map(([group, members]) => /* @__PURE__ */ jsxs("section", { className: "fg-keys__group", "aria-label": group, children: [
1218
+ /* @__PURE__ */ jsx("h3", { className: "fg-keys__heading", children: group }),
1219
+ /* @__PURE__ */ jsx("ul", { className: "fg-keys__list", children: members.map((row) => /* @__PURE__ */ jsxs("li", { className: "fg-keys__row", children: [
1220
+ /* @__PURE__ */ jsx("span", { className: "fg-keys__label", children: row.label }),
1221
+ /* @__PURE__ */ jsxs("span", { className: "fg-keys__chips", children: [
1222
+ row.keys.map((chip) => /* @__PURE__ */ jsx("kbd", { className: "fg-keys__chip", children: chip }, chip)),
1223
+ row.others.map((chip) => /* @__PURE__ */ jsx("kbd", { className: "fg-keys__chip fg-keys__chip--gesture", children: chip }, chip))
1224
+ ] })
1225
+ ] }, row.id)) })
1226
+ ] }, group)) });
1227
+ }
1228
+ var KEYS_SECTION = {
1229
+ id: "fg-keys",
1230
+ region: "aside",
1231
+ item: { title: "Keys", body: /* @__PURE__ */ jsx(KeysPanel, {}) }
1232
+ };
1233
+
1234
+ // src/shell/keys/keysChrome.ts
1235
+ var RECORD2 = "fg-keys-shown";
1236
+ var KEYS_TOGGLE_ID = "forge.keys.toggle";
1237
+ var OPEN_KEY = { kind: "key", key: "/", code: "Slash", mods: { alt: true } };
1238
+ function keysChrome(shown, toggle) {
1239
+ return [
1240
+ {
1241
+ id: "fg-keys-toggle",
1242
+ region: "header",
1243
+ group: "story",
1244
+ item: { icon: (
1245
+ /* @icon */
1246
+ "ui:keyboard"
1247
+ ), label: "Keys panel", pressed: shown, onActivate: toggle }
1248
+ },
1249
+ ...shown ? [KEYS_SECTION] : []
1250
+ ];
1251
+ }
1252
+ function KeysChrome({ onChrome }) {
1253
+ const [shown, setShown] = usePersistedState(RECORD2, false, { scope: "lab" });
1254
+ const registry = useActionsRegistry();
1255
+ const chrome = useMemo(() => keysChrome(shown, () => setShown((was) => !was)), [shown, setShown]);
1256
+ useEffect(() => onChrome(chrome), [chrome, onChrome]);
1257
+ useEffect(() => {
1258
+ if (!registry) return;
1259
+ const action = {
1260
+ id: KEYS_TOGGLE_ID,
1261
+ label: "Toggle the keys panel",
1262
+ group: "forge",
1263
+ defaultBinding: [{ spec: OPEN_KEY, opts: { id: KEYS_TOGGLE_ID } }],
1264
+ invoker: { timing: "immediate", run: () => setShown((was) => !was) }
1265
+ };
1266
+ return registry.register(action);
1267
+ }, [registry, setShown]);
1002
1268
  return null;
1003
1269
  }
1004
1270
  var KEYS = { kind: "key", key: "i", mods: { mod: true } };
@@ -1088,8 +1354,8 @@ function ArgRows({ args }) {
1088
1354
  function StoryDossier({ dossier }) {
1089
1355
  return /* @__PURE__ */ jsxs("div", { className: "fg-info", children: [
1090
1356
  dossier.description || dossier.componentDescription ? /* @__PURE__ */ jsxs("section", { className: "fg-info__section", "aria-label": "Docs", children: [
1091
- dossier.description ? /* @__PURE__ */ jsx("p", { className: "fg-info__doc", children: dossier.description }) : null,
1092
- dossier.componentDescription ? /* @__PURE__ */ jsx("p", { className: "fg-info__doc fg-info__doc--component", children: dossier.componentDescription }) : null
1357
+ dossier.description ? /* @__PURE__ */ jsx(Description, { text: dossier.description, className: "fg-info__doc" }) : null,
1358
+ dossier.componentDescription ? /* @__PURE__ */ jsx(Description, { text: dossier.componentDescription, className: "fg-info__doc fg-info__doc--component" }) : null
1093
1359
  ] }) : null,
1094
1360
  /* @__PURE__ */ jsx("section", { className: "fg-info__section", "aria-label": "Identity", children: /* @__PURE__ */ jsxs("dl", { className: "fg-info__rows", children: [
1095
1361
  /* @__PURE__ */ jsx(Row, { label: "Story", children: dossier.name }),
@@ -1202,6 +1468,1011 @@ function createFramePool(frameUrl, size = 2) {
1202
1468
  };
1203
1469
  }
1204
1470
  var FramePoolContext = createContext(null);
1471
+ var COMPARE_WITH_ID = "loupe.compare.with";
1472
+ var COMPARE_DPR_ID = "loupe.compare.dpr";
1473
+ var PIXELS_ONLY = "Pixels only";
1474
+ var COMPARE_MENU_ID = "loupe.compare";
1475
+ var DPRS = [1, 2, 3];
1476
+ function argChoices(shape, at) {
1477
+ return Object.entries(shape).flatMap(([key, entry]) => {
1478
+ const path = at === "" ? key : `${at}.${key}`;
1479
+ if (isForgePath(path)) return [];
1480
+ if (isConfigBranch(entry)) return argChoices(entry.children, path);
1481
+ const options = entry.kind === "boolean" ? [
1482
+ { value: true, label: "On" },
1483
+ { value: false, label: "Off" }
1484
+ ] : entry.annotations.options ?? [];
1485
+ return options.map((o) => ({
1486
+ action: COMPARE_WITH_ID,
1487
+ key: `arg.${path}.${String(o.value)}`,
1488
+ group: `Arg ${path}`,
1489
+ label: o.label,
1490
+ compare: { kind: "custom", args: { [path]: o.value } }
1491
+ }));
1492
+ });
1493
+ }
1494
+ function compareChoices(declarations, schema) {
1495
+ const globals = Object.entries(declarations).flatMap(
1496
+ ([key, declaration]) => declaration.options.map((o) => ({
1497
+ action: COMPARE_WITH_ID,
1498
+ key: `global.${key}.${o.value}`,
1499
+ group: declaration.label,
1500
+ label: o.label,
1501
+ compare: { kind: "custom", globals: { [key]: o.value } }
1502
+ }))
1503
+ );
1504
+ const dprs = DPRS.map((dpr) => ({
1505
+ action: COMPARE_DPR_ID,
1506
+ key: `dpr.${dpr}`,
1507
+ group: "DPR",
1508
+ label: `${dpr}\xD7`,
1509
+ compare: { kind: "custom", dpr }
1510
+ }));
1511
+ return [...globals, ...dprs, ...schema ? argChoices(schema.nodes, "") : []];
1512
+ }
1513
+ function compareMenuDef(choices) {
1514
+ const groups = /* @__PURE__ */ new Map();
1515
+ for (const choice of choices) {
1516
+ const rows = groups.get(choice.group) ?? {};
1517
+ rows[`c${Object.keys(rows).length}`] = pref.action(`${choice.action}:${choice.key}`);
1518
+ groups.set(choice.group, rows);
1519
+ }
1520
+ const submenus = {};
1521
+ [...groups].forEach(([label, rows], n) => {
1522
+ submenus[`g${n}`] = pref.group(label, rows, { as: "menu" });
1523
+ });
1524
+ return { id: COMPARE_MENU_ID, menu: pref.group("", submenus) };
1525
+ }
1526
+ function compareActions(api, request) {
1527
+ const one = (id, label, enabled) => ({
1528
+ id,
1529
+ label,
1530
+ group: "loupe",
1531
+ enabled,
1532
+ variants: request.choices.filter((c) => c.action === id).map((c) => ({ key: c.key, label: `${c.group}: ${c.label}`, params: { compare: c.compare, lensId: request.lensId } })),
1533
+ invoker: {
1534
+ timing: "immediate",
1535
+ run(_deps, params) {
1536
+ const lensId = params?.lensId ?? api.current();
1537
+ const compare = params?.compare;
1538
+ if (lensId !== null && compare) api.apply(setLensOps(api.scene, lensId, { compare }));
1539
+ }
1540
+ }
1541
+ });
1542
+ return [
1543
+ one(COMPARE_WITH_ID, "Compare with", () => true),
1544
+ one(COMPARE_DPR_ID, "Compare at DPR", () => request.pixels ? true : PIXELS_ONLY)
1545
+ ];
1546
+ }
1547
+ var NO_CHOICES = [];
1548
+ function CompareMenu({ api, request }) {
1549
+ const registry = useActionsRegistry();
1550
+ const host = useMenuHost();
1551
+ const choices = request?.choices ?? NO_CHOICES;
1552
+ const defs = useMemo(() => [compareMenuDef(choices)], [choices]);
1553
+ useMenus(defs);
1554
+ useEffect(() => {
1555
+ if (!registry || !host || !request) return;
1556
+ const offs = compareActions(api, request).map((a) => registry.register(a));
1557
+ let held = true;
1558
+ const drop = () => {
1559
+ if (!held) return;
1560
+ held = false;
1561
+ for (const off of offs) off();
1562
+ };
1563
+ const opened = host.open({ menu: COMPARE_MENU_ID, client: request.client, source: "keyboard" }) ? host.current()?.seq : void 0;
1564
+ if (opened === void 0) {
1565
+ drop();
1566
+ return;
1567
+ }
1568
+ const unsubscribe = host.subscribe(() => {
1569
+ if (host.current()?.seq !== opened) queueMicrotask(drop);
1570
+ });
1571
+ return () => {
1572
+ unsubscribe();
1573
+ drop();
1574
+ };
1575
+ }, [registry, host, api, request]);
1576
+ return null;
1577
+ }
1578
+
1579
+ // src/shell/loupe/liveSource.ts
1580
+ var NOTHING_UNDER = "Nothing under the reticle";
1581
+ var sameContext = (a, b) => a.declarations === b.declarations && a.modeGlobal === b.modeGlobal && a.prefersDark === b.prefersDark && a.labMode === b.labMode;
1582
+ var liveCellId = (lensId, compare) => compare === null ? lensId : `${lensId}~compared`;
1583
+ function sameRequest(a, b) {
1584
+ return a !== null && a.slot === b.slot && a.hit.host === b.hit.host && a.hit.trialId === b.hit.trialId && a.hit.local.x === b.hit.local.x && a.hit.local.y === b.hit.local.y && a.hit.scale === b.hit.scale && a.factor === b.factor && a.size.width === b.size.width && a.size.height === b.size.height && a.args === b.args && a.compare === b.compare && sameContext(a.ctx, b.ctx);
1585
+ }
1586
+ function createLiveSource(lenses, resolve, conditions) {
1587
+ const measures = /* @__PURE__ */ new Set();
1588
+ const listeners2 = /* @__PURE__ */ new Set();
1589
+ return {
1590
+ kind: "live",
1591
+ produces: "dom",
1592
+ label: () => "Live",
1593
+ status: () => true,
1594
+ // A copy renders after the lens asks for it and changes as its story does; a reader of the copy hears of both.
1595
+ subscribe(listener) {
1596
+ listeners2.add(listener);
1597
+ return () => {
1598
+ listeners2.delete(listener);
1599
+ };
1600
+ },
1601
+ attach(slot, first) {
1602
+ const doc = slot.ownerDocument;
1603
+ const target = doc.createElement("div");
1604
+ target.className = "fg-lens-live";
1605
+ const empty = doc.createElement("div");
1606
+ empty.className = "fg-lens-empty";
1607
+ empty.hidden = true;
1608
+ slot.append(target, empty);
1609
+ const watch = typeof MutationObserver === "undefined" ? null : new MutationObserver(() => {
1610
+ for (const listener of [...listeners2]) listener();
1611
+ });
1612
+ watch?.observe(target, { subtree: true, childList: true, characterData: true, attributes: true });
1613
+ let request = first;
1614
+ let posted = null;
1615
+ const post = (next) => {
1616
+ if (next === null ? posted === null : sameRequest(posted, next)) return;
1617
+ posted = next;
1618
+ lenses.set(liveCellId(request.lensId, request.conditions), next);
1619
+ };
1620
+ const measure = () => {
1621
+ const { lensId, reticle, conditions: compare } = request;
1622
+ const hit = trialAt(reticle, doc);
1623
+ empty.hidden = hit !== null;
1624
+ if (!hit) {
1625
+ post(null);
1626
+ empty.textContent = NOTHING_UNDER;
1627
+ return;
1628
+ }
1629
+ const { base, ctx } = conditions();
1630
+ const resolved = resolve(compare, base, ctx);
1631
+ post({
1632
+ lensId: liveCellId(lensId, compare),
1633
+ slot: target,
1634
+ hit,
1635
+ factor: request.factor,
1636
+ size: request.size,
1637
+ compare,
1638
+ ctx,
1639
+ args: resolved.args
1640
+ });
1641
+ };
1642
+ measures.add(measure);
1643
+ measure();
1644
+ return {
1645
+ update(next) {
1646
+ request = next;
1647
+ measure();
1648
+ },
1649
+ detach() {
1650
+ watch?.disconnect();
1651
+ measures.delete(measure);
1652
+ lenses.set(liveCellId(request.lensId, request.conditions), null);
1653
+ target.remove();
1654
+ empty.remove();
1655
+ }
1656
+ };
1657
+ },
1658
+ remeasure() {
1659
+ for (const measure of [...measures]) measure();
1660
+ }
1661
+ };
1662
+ }
1663
+ var INLINE = "style attribute";
1664
+ function topmostIn(within, client) {
1665
+ return probeUnderLens(
1666
+ within,
1667
+ "interior",
1668
+ () => (within.ownerDocument.elementsFromPoint?.(client.x, client.y) ?? []).find((el) => within.contains(el))
1669
+ );
1670
+ }
1671
+ function backgroundOf(el, stop) {
1672
+ for (let at = el; at; at = at === stop ? null : at.parentElement) {
1673
+ const color = getComputedStyle(at).backgroundColor;
1674
+ if (color.trim() === "") continue;
1675
+ try {
1676
+ if (parseColor(color)[3] === 1) return { element: at, color };
1677
+ } catch {
1678
+ return { unreadable: color };
1679
+ }
1680
+ }
1681
+ return null;
1682
+ }
1683
+ var inside = (r, p) => p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
1684
+ function textUnder(el, client) {
1685
+ const range = el.ownerDocument.createRange();
1686
+ return Array.from(el.childNodes).some((n) => {
1687
+ if (n.nodeType !== Node.TEXT_NODE || (n.textContent ?? "").trim() === "") return false;
1688
+ range.selectNodeContents(n);
1689
+ return Array.from(range.getClientRects()).some((r) => inside(r, client));
1690
+ });
1691
+ }
1692
+ function colorAt(client, within) {
1693
+ const el = topmostIn(within, client);
1694
+ if (!el) return null;
1695
+ if (textUnder(el, client)) return { element: el, property: "color", value: getComputedStyle(el).color };
1696
+ const bg = backgroundOf(el, within);
1697
+ if (bg === null) return null;
1698
+ if ("unreadable" in bg) return { element: el, property: "background-color", value: bg.unreadable };
1699
+ return { element: bg.element, property: "background-color", value: bg.color };
1700
+ }
1701
+ var STYLE_RULE = 1;
1702
+ var SHORTHANDS = { "background-color": ["background"] };
1703
+ var INHERITED = (property) => property.startsWith("--") || property === "color";
1704
+ function applies(rule, doc) {
1705
+ const media = rule.media;
1706
+ if (media && media.mediaText && doc.defaultView && !doc.defaultView.matchMedia(media.mediaText).matches) return false;
1707
+ if (typeof CSSSupportsRule !== "undefined" && rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText);
1708
+ return true;
1709
+ }
1710
+ function nest(selector, parent) {
1711
+ if (parent === void 0) return selector;
1712
+ return splitTopLevel(selector, ",").map((s) => s.includes("&") ? s.replaceAll("&", `:is(${parent})`) : `:is(${parent}) ${s.trim()}`).join(", ");
1713
+ }
1714
+ function collect(rules, doc, into, parent) {
1715
+ for (const rule of Array.from(rules)) {
1716
+ if (!applies(rule, doc)) continue;
1717
+ const imported = rule.styleSheet;
1718
+ if (imported) {
1719
+ const inner = cssRulesOf(imported);
1720
+ if (inner) collect(inner, doc, into);
1721
+ continue;
1722
+ }
1723
+ const children = rule.cssRules;
1724
+ if (rule.type !== STYLE_RULE) {
1725
+ if (children) collect(children, doc, into, parent);
1726
+ continue;
1727
+ }
1728
+ const style = rule;
1729
+ const selector = nest(style.selectorText, parent);
1730
+ into.push({ selector, style: style.style });
1731
+ if (children) collect(children, doc, into, selector);
1732
+ }
1733
+ }
1734
+ function rulesOf(doc) {
1735
+ const out = [];
1736
+ for (const sheet of [...Array.from(doc.styleSheets), ...doc.adoptedStyleSheets ?? []]) {
1737
+ if (sheet.disabled) continue;
1738
+ const rules = cssRulesOf(sheet);
1739
+ if (rules) collect(rules, doc, out);
1740
+ }
1741
+ return out;
1742
+ }
1743
+ function matching(el, list) {
1744
+ let best = null;
1745
+ for (const one of splitTopLevel(list, ",")) {
1746
+ let hit = false;
1747
+ try {
1748
+ hit = el.matches(one);
1749
+ } catch {
1750
+ }
1751
+ const s = hit ? specificity(one.trim()) : null;
1752
+ if (s && (best === null || compareSpecificity(s, best) > 0)) best = s;
1753
+ }
1754
+ return best;
1755
+ }
1756
+ function declaredIn(style, property) {
1757
+ for (const name of [property, ...SHORTHANDS[property] ?? []]) {
1758
+ const value = style.getPropertyValue(name).trim();
1759
+ if (value) return { value, important: style.getPropertyPriority(name) === "important" };
1760
+ }
1761
+ return null;
1762
+ }
1763
+ function indexOf(rules) {
1764
+ const index = /* @__PURE__ */ new Map();
1765
+ return (property) => {
1766
+ let got = index.get(property);
1767
+ if (!got) {
1768
+ got = rules.flatMap((r) => {
1769
+ const d = declaredIn(r.style, property);
1770
+ return d ? [{ selector: r.selector, ...d }] : [];
1771
+ });
1772
+ index.set(property, got);
1773
+ }
1774
+ return got;
1775
+ };
1776
+ }
1777
+ function winning(el, property, rules) {
1778
+ let best = null;
1779
+ for (const got of rules) {
1780
+ const spec = matching(el, got.selector);
1781
+ if (!spec) continue;
1782
+ const wins = best === null || got.important && !best.important || got.important === best.important && compareSpecificity(spec, best.spec) >= 0;
1783
+ if (wins) best = { ...got, spec };
1784
+ }
1785
+ const inline = el.style;
1786
+ const own = inline ? declaredIn(inline, property) : null;
1787
+ if (own && (best === null || own.important || !best.important)) return { property, declared: own.value, selector: INLINE, element: el };
1788
+ return best ? { property, declared: best.value, selector: best.selector, element: el } : null;
1789
+ }
1790
+ function declarationOf(el, property, rules) {
1791
+ for (let at = el; at; at = INHERITED(property) ? at.parentElement : null) {
1792
+ const link = winning(at, property, rules);
1793
+ if (link) return link;
1794
+ }
1795
+ return null;
1796
+ }
1797
+ var revisions = /* @__PURE__ */ new WeakMap();
1798
+ function revisionOf(doc) {
1799
+ let r = revisions.get(doc);
1800
+ if (!r) {
1801
+ const made = { n: 0, observer: null };
1802
+ if (typeof MutationObserver !== "undefined" && doc.head) {
1803
+ made.observer = new MutationObserver(() => {
1804
+ made.n += 1;
1805
+ });
1806
+ made.observer.observe(doc.head, { childList: true, subtree: true, characterData: true, attributes: true });
1807
+ }
1808
+ revisions.set(doc, made);
1809
+ r = made;
1810
+ }
1811
+ if ((r.observer?.takeRecords().length ?? 0) > 0) r.n += 1;
1812
+ return r.n;
1813
+ }
1814
+ var chains = /* @__PURE__ */ new WeakMap();
1815
+ function tokenChain(at) {
1816
+ const doc = at.element.ownerDocument;
1817
+ const key = `${at.value}|${revisionOf(doc)}|${doc.styleSheets.length}|${doc.adoptedStyleSheets?.length ?? 0}`;
1818
+ let byProperty = chains.get(at.element);
1819
+ const held = byProperty?.get(at.property);
1820
+ if (held?.key === key) return held.chain;
1821
+ const chain = chainOf(at);
1822
+ if (!byProperty) {
1823
+ byProperty = /* @__PURE__ */ new Map();
1824
+ chains.set(at.element, byProperty);
1825
+ }
1826
+ byProperty.set(at.property, { key, chain });
1827
+ return chain;
1828
+ }
1829
+ function chainOf(at) {
1830
+ const declaring = indexOf(rulesOf(at.element.ownerDocument));
1831
+ const chain = [];
1832
+ const seen = /* @__PURE__ */ new Set();
1833
+ const follow = (el, property) => {
1834
+ if (seen.has(property)) return;
1835
+ seen.add(property);
1836
+ const link = declarationOf(el, property, declaring(property));
1837
+ if (!link) return;
1838
+ chain.push(link);
1839
+ for (const name of varRefs(link.declared)) follow(link.element, name);
1840
+ };
1841
+ follow(at.element, at.property);
1842
+ return chain;
1843
+ }
1844
+ var NO_TEXT = "No text under the aim";
1845
+ var NO_BACKGROUND = "No opaque background behind the text";
1846
+ var unreadable = (color) => `Cannot read the color "${color}"`;
1847
+ var NO_CAPTURE = "No capture to read";
1848
+ var OUTSIDE = "The aim is outside the capture";
1849
+ var NO_COLOR = "No color under the aim";
1850
+ var NEEDS_LIVE = "Token needs a Live source for changed conditions";
1851
+ var IN_FRAME = "Cannot read inside an isolated story's frame";
1852
+ var REACH = 2;
1853
+ function isLarge(style) {
1854
+ const px = Number.parseFloat(style.fontSize);
1855
+ const w = style.fontWeight === "bold" ? 700 : Number.parseInt(style.fontWeight, 10);
1856
+ return px >= 24 || px >= 18.66 && w >= 700;
1857
+ }
1858
+ var rgb = (d, i) => `rgb(${d[i]}, ${d[i + 1]}, ${d[i + 2]})`;
1859
+ function ofPixels(capture, point) {
1860
+ const img = capture ? readBitmap(capture.bitmap) : null;
1861
+ if (!capture || !img) return { unavailable: NO_CAPTURE };
1862
+ const px = Math.floor(point.x * capture.dpr);
1863
+ const py = Math.floor(point.y * capture.dpr);
1864
+ if (px < 0 || py < 0 || px >= img.width || py >= img.height) return { unavailable: OUTSIDE };
1865
+ const { data, width, height } = img;
1866
+ const center = (py * width + px) * 4;
1867
+ let far = center;
1868
+ let best = 0;
1869
+ for (let y = Math.max(0, py - REACH); y <= Math.min(height - 1, py + REACH); y += 1) {
1870
+ for (let x = Math.max(0, px - REACH); x <= Math.min(width - 1, px + REACH); x += 1) {
1871
+ const i = (y * width + x) * 4;
1872
+ const d = [0, 1, 2].reduce((sum, c) => sum + (data[i + c] - data[center + c]) ** 2, 0);
1873
+ if (d > best) {
1874
+ best = d;
1875
+ far = i;
1876
+ }
1877
+ }
1878
+ }
1879
+ const ratio = contrastRatio(rgb(data, center), rgb(data, far));
1880
+ return ratio === null ? { unavailable: NO_CAPTURE } : { ratio, ...wcagLevels(ratio, false), large: false };
1881
+ }
1882
+ function contrastAt(source, at) {
1883
+ if (source === "pixels") return ofPixels(at.capture, at.point);
1884
+ const text = at.within ? colorAt(at.point, at.within) : null;
1885
+ if (!text || text.property !== "color") return { unavailable: NO_TEXT };
1886
+ const style = getComputedStyle(text.element);
1887
+ const background = backgroundOf(text.element, at.within);
1888
+ if (background === null) return { unavailable: NO_BACKGROUND };
1889
+ if ("unreadable" in background) return { unavailable: unreadable(background.unreadable) };
1890
+ const ratio = contrastRatio(style.color, background.color);
1891
+ if (ratio === null) return { unavailable: unreadable(style.color) };
1892
+ const large = isLarge(style);
1893
+ return { ratio, ...wcagLevels(ratio, large), large };
1894
+ }
1895
+ function copyIn(interior) {
1896
+ const copies = Array.from(interior?.querySelectorAll("[data-fg-copy]") ?? []);
1897
+ return copies.find((c) => (c.getAttribute("data-fg-host") ?? "").endsWith("~compared")) ?? copies[0];
1898
+ }
1899
+ function comparedAim(interior) {
1900
+ const box = interior?.getBoundingClientRect();
1901
+ return box ? { x: box.left + box.width / 2, y: box.top + box.height / 2 } : null;
1902
+ }
1903
+ function liveAim(interior, settings, reticle) {
1904
+ const aim2 = settings.compare === null ? { within: trialAt(reticle)?.host, point: reticle } : { within: copyIn(interior), point: comparedAim(interior) ?? reticle };
1905
+ return aim2.within?.classList.contains("fg-frame-host") ? { unavailable: IN_FRAME } : aim2;
1906
+ }
1907
+ var verdict = (pass) => pass ? "pass" : "fail";
1908
+ function textOf(reading) {
1909
+ if ("unavailable" in reading) return reading.unavailable;
1910
+ const base = `${reading.ratio.toFixed(2)}:1 \xB7 AA ${verdict(reading.aa)} \xB7 AAA ${verdict(reading.aaa)}`;
1911
+ return reading.large ? `${base} \xB7 large text` : base;
1912
+ }
1913
+ function ContrastReadout({ lens, settings, sources, reticle, size, interior }) {
1914
+ const { source } = useShownSource(settings, sources);
1915
+ const kind = source?.kind;
1916
+ const subscribe = useCallback((fn) => source?.subscribe(fn) ?? (() => {
1917
+ }), [source]);
1918
+ const live = useSyncExternalStore(subscribe, () => {
1919
+ if (kind !== "live") return null;
1920
+ const aim2 = liveAim(interior, settings, reticle);
1921
+ return textOf("unavailable" in aim2 ? aim2 : contrastAt("live", aim2));
1922
+ });
1923
+ const [pixels, setPixels] = useState(null);
1924
+ const [tick, setTick] = useState(0);
1925
+ useEffect(() => source?.subscribe(() => setTick((n) => n + 1)), [source]);
1926
+ const latest = useLatest({ lens, settings, reticle, size });
1927
+ useEffect(() => {
1928
+ if (kind !== "pixels" || !source?.capture) return;
1929
+ const capture = source.capture.bind(source);
1930
+ let current = true;
1931
+ const timer = setTimeout(() => {
1932
+ const { lens: ids, settings: s, reticle: r, size: box } = latest.current;
1933
+ const [request] = requestsOf(source, ids, s, r, box);
1934
+ if (!request) return;
1935
+ void capture(request).then((got) => {
1936
+ if (current) setPixels("reason" in got ? { unavailable: got.reason } : contrastAt("pixels", { capture: got, point: got.at }));
1937
+ });
1938
+ }, 0);
1939
+ return () => {
1940
+ current = false;
1941
+ clearTimeout(timer);
1942
+ };
1943
+ }, [source, kind, reticle.x, reticle.y, settings.factor, settings.compare, tick, latest]);
1944
+ const text = kind === "live" ? live : kind === "pixels" && pixels ? textOf(pixels) : null;
1945
+ return text ? /* @__PURE__ */ jsx("span", { className: "fg-lens-contrast", children: text }) : null;
1946
+ }
1947
+ function tokenAt(kind, interior, settings, reticle) {
1948
+ if (kind === "pixels" && settings.compare !== null) return { unavailable: NEEDS_LIVE };
1949
+ const aim2 = liveAim(interior, settings, reticle);
1950
+ if ("unavailable" in aim2) return aim2;
1951
+ const { within, point } = aim2;
1952
+ const at = within ? colorAt(point, within) : null;
1953
+ if (!at) return { unavailable: NO_COLOR };
1954
+ try {
1955
+ parseColor(at.value);
1956
+ } catch {
1957
+ return { unavailable: unreadable(at.value) };
1958
+ }
1959
+ const chain = tokenChain(at);
1960
+ const branches = chain.some((l) => varRefs(l.declared).size > 1);
1961
+ const end = branches ? at.value : chain.at(-1)?.declared ?? at.value;
1962
+ return { steps: [at.property, ...chain.slice(1).map((l) => l.property), end] };
1963
+ }
1964
+ var isToken = (step) => step.startsWith("--wzl-");
1965
+ function TokenReadout({ settings, sources, reticle, interior }) {
1966
+ const { source } = useShownSource(settings, sources);
1967
+ const kind = source?.kind;
1968
+ const subscribe = useCallback((fn) => source?.subscribe(fn) ?? (() => {
1969
+ }), [source]);
1970
+ const json = useSyncExternalStore(
1971
+ subscribe,
1972
+ () => kind === "live" || kind === "pixels" ? JSON.stringify(tokenAt(kind, interior, settings, reticle)) : null
1973
+ );
1974
+ const reading = useMemo(() => json === null ? null : JSON.parse(json), [json]);
1975
+ if (!reading) return null;
1976
+ if ("unavailable" in reading) return /* @__PURE__ */ jsx("span", { className: "fg-lens-token", children: reading.unavailable });
1977
+ return /* @__PURE__ */ jsx("span", { className: "fg-lens-token", children: reading.steps.map((step, i) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
1978
+ i > 0 ? " \u2190 " : null,
1979
+ isToken(step) && i < reading.steps.length - 1 ? /* @__PURE__ */ jsx("button", { type: "button", className: "fg-lens-token__link", onClick: () => revealVar(step), children: step }) : step
1980
+ ] }, i)) });
1981
+ }
1982
+ function renderReadouts(props) {
1983
+ const { readouts } = props.settings;
1984
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1985
+ readouts.includes("token") ? /* @__PURE__ */ jsx(TokenReadout, { ...props }) : null,
1986
+ readouts.includes("contrast") ? /* @__PURE__ */ jsx(ContrastReadout, { ...props }) : null
1987
+ ] });
1988
+ }
1989
+ var STATE_UNSENDABLE = "this story's state cannot be sent to a capture";
1990
+ var CONFIG_UNSENDABLE = "this story's config cannot be sent to a capture";
1991
+ var NO_STORY = "this trial shows no story to capture";
1992
+ var NO_BOX = "the story's box has no size";
1993
+ function survived(value, round) {
1994
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value === round;
1995
+ if (typeof value === "number") return Number.isFinite(value) && value === round;
1996
+ if (Array.isArray(value)) {
1997
+ if (!Array.isArray(round) || value.length !== round.length) return false;
1998
+ for (let i = 0; i < value.length; i += 1) if (!(i in value) || !survived(value[i], round[i])) return false;
1999
+ return true;
2000
+ }
2001
+ if (!isPlainObject(value) || !isPlainObject(round)) return false;
2002
+ const keys = Object.keys(value);
2003
+ return keys.length === Object.keys(round).length && keys.every((key) => key in round && survived(value[key], round[key]));
2004
+ }
2005
+ function sendable(state) {
2006
+ try {
2007
+ return survived(state, JSON.parse(JSON.stringify(state) ?? "undefined"));
2008
+ } catch {
2009
+ return false;
2010
+ }
2011
+ }
2012
+ function withoutUndefined(value) {
2013
+ if (Array.isArray(value)) return value.map(withoutUndefined);
2014
+ if (!isPlainObject(value)) return value;
2015
+ return Object.fromEntries(
2016
+ Object.entries(value).flatMap(([key, v]) => v === void 0 ? [] : [[key, withoutUndefined(v)]])
2017
+ );
2018
+ }
2019
+ var clampDpr = (dpr) => Math.min(Math.max(dpr, MIN_DPR), MAX_DPR);
2020
+ function capturePayload(input) {
2021
+ const { entry, layout, config, state, globals, overrides, host, dpr, resolved, lab, colorScheme } = input;
2022
+ if (!sendable(state)) return { unavailable: STATE_UNSENDABLE };
2023
+ const sent = withoutUndefined(withArgs(storyConfig(config), resolved.args));
2024
+ if (!sendable(sent)) return { unavailable: CONFIG_UNSENDABLE };
2025
+ const box = { width: host.offsetWidth, height: host.offsetHeight };
2026
+ if (!(box.width > 0 && box.height > 0)) return { unavailable: NO_BOX };
2027
+ const isolate = entry.isolate !== void 0;
2028
+ const scroller = scrollerOf(host);
2029
+ const kept2 = config?.[PLAYHEAD_KEY];
2030
+ return {
2031
+ story: entry.id,
2032
+ isolate,
2033
+ config: sent,
2034
+ state,
2035
+ globals,
2036
+ overrides,
2037
+ playhead: !isolate && typeof kept2 === "number" && Number.isFinite(kept2) ? kept2 : null,
2038
+ box,
2039
+ layout,
2040
+ dpr: clampDpr(resolved.dpr ?? dpr),
2041
+ scroll: { left: scroller?.scrollLeft ?? 0, top: scroller?.scrollTop ?? 0 },
2042
+ lab,
2043
+ colorScheme
2044
+ };
2045
+ }
2046
+ function trialPayload(trial, lab, hit, dpr, c) {
2047
+ if (!trial) return { unavailable: NO_STORY };
2048
+ const ctx = resolveContextOf(lab);
2049
+ const own = effectiveGlobals(lab.labValues, trial.config?.[GLOBALS_KEY]);
2050
+ const resolved = resolveComparison(c, own, ctx);
2051
+ const colorScheme = ctx.prefersDark ? "dark" : "light";
2052
+ return capturePayload({
2053
+ ...trial,
2054
+ globals: resolved.globals,
2055
+ host: hit.host,
2056
+ dpr,
2057
+ resolved,
2058
+ lab: { globals: lab.labValues, mode: lab.mode === "auto" ? colorScheme : lab.mode },
2059
+ colorScheme
2060
+ });
2061
+ }
2062
+
2063
+ // src/shell/loupe/captureClient.ts
2064
+ var CHECKING = "checking for a capture browser";
2065
+ var UNAVAILABLE = "captures are unavailable";
2066
+ var NO_ENDPOINT = "this server takes no captures";
2067
+ function createCaptureClient(base, send) {
2068
+ const url = `${base}${CAPTURE_PATH}`;
2069
+ const listeners2 = /* @__PURE__ */ new Set();
2070
+ let status = CHECKING;
2071
+ let polling = null;
2072
+ const setStatus = (next) => {
2073
+ if (next === status) return;
2074
+ status = next;
2075
+ for (const listener of [...listeners2]) listener();
2076
+ };
2077
+ const poll = () => {
2078
+ polling ??= (async () => {
2079
+ try {
2080
+ const res = await send(url);
2081
+ if (!res.ok) return setStatus(NO_ENDPOINT);
2082
+ const body = await res.json();
2083
+ setStatus(body.available === true ? true : body.reason ?? UNAVAILABLE);
2084
+ } catch (err) {
2085
+ setStatus(`${NO_ENDPOINT}: ${messageOf(err)}`);
2086
+ } finally {
2087
+ polling = null;
2088
+ }
2089
+ })();
2090
+ };
2091
+ return {
2092
+ status: () => status,
2093
+ subscribe(listener) {
2094
+ listeners2.add(listener);
2095
+ poll();
2096
+ return () => listeners2.delete(listener);
2097
+ },
2098
+ poll,
2099
+ async take(payload) {
2100
+ const res = await send(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
2101
+ if (res.status === 503) {
2102
+ setStatus((await res.json()).reason ?? UNAVAILABLE);
2103
+ return null;
2104
+ }
2105
+ if (!res.ok) {
2106
+ const body = await res.json().catch(() => ({}));
2107
+ throw new Error(body.error ?? `capture failed: ${res.status}`);
2108
+ }
2109
+ return createImageBitmap(await res.blob());
2110
+ }
2111
+ };
2112
+ }
2113
+ var sideOf = (r) => `${r.lensId}:${r.dpr}${r.conditions === null ? "" : ":compared"}`;
2114
+ function aim(shared, request, doc) {
2115
+ const hit = trialAt(request.reticle, doc);
2116
+ if (!hit) return { reason: NOTHING_UNDER };
2117
+ const payload = shared.payloadFor(hit, request.dpr, request.conditions);
2118
+ if ("unavailable" in payload) return { reason: unavailableText("pixels", payload.unavailable) };
2119
+ return { hit, payload, key: stableStringify(payload) };
2120
+ }
2121
+ var fresh = (shared, shot, key) => shot?.key === key && shot.revision === shared.revision();
2122
+ function paint(canvas, shot, at, request) {
2123
+ const sd = canvas.ownerDocument.defaultView?.devicePixelRatio ?? 1;
2124
+ const width = Math.max(1, Math.round(request.size.width * sd));
2125
+ const height = Math.max(1, Math.round(request.size.height * sd));
2126
+ if (canvas.width !== width) canvas.width = width;
2127
+ if (canvas.height !== height) canvas.height = height;
2128
+ const ctx = canvas.getContext("2d");
2129
+ if (!ctx) return;
2130
+ ctx.imageSmoothingEnabled = false;
2131
+ ctx.clearRect(0, 0, width, height);
2132
+ const scale = request.factor * sd / shot.payload.dpr;
2133
+ const x = Math.round(width / 2 - at.x * request.factor * sd);
2134
+ const y = Math.round(height / 2 - at.y * request.factor * sd);
2135
+ ctx.drawImage(shot.bitmap, x, y, shot.bitmap.width * scale, shot.bitmap.height * scale);
2136
+ canvas.toggleAttribute("data-shown", true);
2137
+ }
2138
+ function blank(canvas) {
2139
+ canvas.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
2140
+ canvas.toggleAttribute("data-shown", false);
2141
+ }
2142
+ function attachPixelsLens(slot, first, shared, onDetach) {
2143
+ const doc = slot.ownerDocument;
2144
+ const canvas = doc.createElement("canvas");
2145
+ canvas.className = "fg-lens-pixels";
2146
+ const empty = doc.createElement("div");
2147
+ empty.className = "fg-lens-empty";
2148
+ empty.hidden = true;
2149
+ slot.append(canvas, empty);
2150
+ let request = first;
2151
+ let wanted = null;
2152
+ let pending2 = null;
2153
+ let timer;
2154
+ let started = false;
2155
+ let gone = false;
2156
+ const say = (text) => {
2157
+ empty.hidden = text === null;
2158
+ empty.textContent = text ?? "";
2159
+ };
2160
+ const cancel = () => {
2161
+ clearTimeout(timer);
2162
+ timer = void 0;
2163
+ };
2164
+ const run = async () => {
2165
+ const target = wanted;
2166
+ if (!target) return;
2167
+ const token = {};
2168
+ pending2 = token;
2169
+ const at = shared.revision();
2170
+ const side = sideOf(request);
2171
+ try {
2172
+ const bitmap = await shared.take(target.payload);
2173
+ if (gone || pending2 !== token || bitmap === null || shared.revision() !== at) return;
2174
+ shared.shots.set(side, { key: target.key, revision: at, bitmap, payload: target.payload });
2175
+ measure();
2176
+ } catch (err) {
2177
+ if (!gone && pending2 === token) say(`Capture failed: ${messageOf(err)}`);
2178
+ } finally {
2179
+ if (pending2 === token) pending2 = null;
2180
+ }
2181
+ };
2182
+ const schedule = () => {
2183
+ cancel();
2184
+ if (!started) {
2185
+ started = true;
2186
+ void run();
2187
+ return;
2188
+ }
2189
+ timer = setTimeout(() => {
2190
+ timer = void 0;
2191
+ void run();
2192
+ }, LENS_REST_MS);
2193
+ };
2194
+ const measure = () => {
2195
+ const aimed = aim(shared, request, doc);
2196
+ if ("reason" in aimed) {
2197
+ wanted = null;
2198
+ cancel();
2199
+ blank(canvas);
2200
+ say(aimed.reason);
2201
+ return;
2202
+ }
2203
+ const { hit, payload, key } = aimed;
2204
+ const shot = shared.shots.get(sideOf(request));
2205
+ if (fresh(shared, shot, key)) {
2206
+ wanted = { key, payload };
2207
+ cancel();
2208
+ say(null);
2209
+ paint(canvas, shot, hit.local, request);
2210
+ return;
2211
+ }
2212
+ say(null);
2213
+ if (wanted?.key === key && pending2 !== null) return;
2214
+ wanted = { key, payload };
2215
+ schedule();
2216
+ };
2217
+ measure();
2218
+ return {
2219
+ measure,
2220
+ revised() {
2221
+ pending2 = null;
2222
+ wanted = null;
2223
+ blank(canvas);
2224
+ measure();
2225
+ },
2226
+ update(next) {
2227
+ request = next;
2228
+ measure();
2229
+ },
2230
+ detach() {
2231
+ gone = true;
2232
+ cancel();
2233
+ shared.shots.delete(sideOf(request));
2234
+ canvas.remove();
2235
+ empty.remove();
2236
+ onDetach();
2237
+ }
2238
+ };
2239
+ }
2240
+
2241
+ // src/shell/loupe/pixelsSource.ts
2242
+ var STORY_CHANGED = "the story changed while it was captured";
2243
+ function createPixelsSource(opts) {
2244
+ const client = createCaptureClient(opts.base, opts.fetch ?? ((input, init) => globalThis.fetch(input, init)));
2245
+ const shared = { take: client.take, payloadFor: opts.payloadFor, revision: opts.revision, shots: /* @__PURE__ */ new Map() };
2246
+ const lenses = /* @__PURE__ */ new Set();
2247
+ let unrevise = null;
2248
+ const remeasured = createEmitter();
2249
+ const revised = () => {
2250
+ shared.shots.clear();
2251
+ for (const lens of [...lenses]) lens.revised();
2252
+ };
2253
+ return {
2254
+ kind: "pixels",
2255
+ produces: "pixels",
2256
+ // A DPR comparison captures at its own DPR, as the payload does.
2257
+ label: ({ conditions, dpr }) => `Pixels ${(conditions?.kind === "custom" ? conditions.dpr : void 0) ?? dpr}\xD7`,
2258
+ status: client.status,
2259
+ // Also told whenever a capture may have gone stale, for a lens that captures without attaching, as Difference does.
2260
+ subscribe(listener) {
2261
+ const offs = [client.subscribe(listener), remeasured.subscribe(listener), opts.subscribeRevision(listener)];
2262
+ return () => {
2263
+ for (const off of offs) off();
2264
+ };
2265
+ },
2266
+ attach(slot, first) {
2267
+ client.poll();
2268
+ unrevise ??= opts.subscribeRevision(revised);
2269
+ const lens = attachPixelsLens(slot, first, shared, () => {
2270
+ lenses.delete(lens);
2271
+ if (lenses.size > 0) return;
2272
+ unrevise?.();
2273
+ unrevise = null;
2274
+ });
2275
+ lenses.add(lens);
2276
+ return lens;
2277
+ },
2278
+ async capture(request) {
2279
+ const aimed = aim(shared, request, document);
2280
+ if ("reason" in aimed) return { reason: aimed.reason };
2281
+ const side = sideOf(request);
2282
+ let shot = shared.shots.get(side);
2283
+ if (!fresh(shared, shot, aimed.key)) {
2284
+ const at = opts.revision();
2285
+ const bitmap = await client.take(aimed.payload);
2286
+ if (bitmap === null) {
2287
+ const status = client.status();
2288
+ return { reason: unavailableText("pixels", status === true ? "the capture was refused" : status) };
2289
+ }
2290
+ if (opts.revision() !== at) return { reason: STORY_CHANGED };
2291
+ shot = { key: aimed.key, revision: at, bitmap, payload: aimed.payload };
2292
+ shared.shots.set(side, shot);
2293
+ }
2294
+ return { bitmap: shot.bitmap, dpr: aimed.payload.dpr, box: aimed.payload.box, at: aimed.hit.local };
2295
+ },
2296
+ remeasure() {
2297
+ for (const lens of [...lenses]) lens.measure();
2298
+ remeasured.emit();
2299
+ }
2300
+ };
2301
+ }
2302
+
2303
+ // src/shell/loupe/usePixelsSource.ts
2304
+ function usePixelsSource(lab) {
2305
+ const capture = useContext(LoupeCaptureContext);
2306
+ const [source] = useState(
2307
+ () => capture ? createPixelsSource({
2308
+ base: capture.base,
2309
+ payloadFor: (hit, dpr, c) => trialPayload(capture.trials.get(hit.trialId), lab.current, hit, dpr, c),
2310
+ revision: capture.revision.get,
2311
+ subscribeRevision: capture.revision.subscribe
2312
+ }) : null
2313
+ );
2314
+ useEffect(() => source && capture ? capture.trials.subscribe(() => source.remeasure()) : void 0, [source, capture]);
2315
+ return source;
2316
+ }
2317
+ var STAGGER = 24;
2318
+ var OPPOSITE = { kind: "opposite-mode" };
2319
+ var clamp = (v, lo, hi) => Math.min(Math.max(v, lo), Math.max(lo, hi));
2320
+ function bodyBeside(reticle, box, n) {
2321
+ const half = BODY_SIZE / 2;
2322
+ const gap = BODY_SIZE * 0.75 + STAGGER * n;
2323
+ const x = reticle.x + gap + half > box.width ? reticle.x - gap : reticle.x + gap;
2324
+ return { x: clamp(x, half, box.width - half), y: clamp(reticle.y + STAGGER * n, half, box.height - half) };
2325
+ }
2326
+ function lensClient(scene, lensId, body) {
2327
+ const ids = lensIdsOf(scene, `${lensId}:body`);
2328
+ const reticle = ids ? scene.get(ids.reticle) : void 0;
2329
+ const lens = ids ? scene.get(ids.body) : void 0;
2330
+ if (!body || !reticle || !lens) return null;
2331
+ const box = body.getBoundingClientRect();
2332
+ const center = (p) => ({ x: box.left + p.x + p.width / 2, y: box.top + p.y + p.height / 2 });
2333
+ return { reticle: center(effectivePose(scene, reticle)), body: center(effectivePose(scene, lens)) };
2334
+ }
2335
+ function useLensFocus() {
2336
+ const focus = useRef(null);
2337
+ const onFocusLens = useCallback((lensId) => {
2338
+ focus.current = lensId;
2339
+ }, []);
2340
+ return { focus, onFocusLens };
2341
+ }
2342
+ function createApi(scene, refs) {
2343
+ let next = 0;
2344
+ let hold = null;
2345
+ const toBody = (client, box) => ({ x: client.x - box.left, y: client.y - box.top });
2346
+ const apply = (ops) => {
2347
+ if (ops.length > 0) sceneToAdapter(scene).applyOps([...ops], "lens");
2348
+ };
2349
+ const current = () => {
2350
+ const focused = refs.focus.current;
2351
+ if (focused !== null && settingsOf(scene, focused)) return focused;
2352
+ return lensesOf(scene).at(-1)?.lensId ?? null;
2353
+ };
2354
+ return {
2355
+ scene,
2356
+ current,
2357
+ apply,
2358
+ add(at) {
2359
+ const body = refs.body.current;
2360
+ const count = lensesOf(scene).length;
2361
+ if (!body || count >= MAX_LENSES) return null;
2362
+ const box = body.getBoundingClientRect();
2363
+ const client = at ?? refs.pointer.current ?? { x: box.left + box.width / 2, y: box.top + box.height / 2 };
2364
+ const reticle = toBody(client, box);
2365
+ const lensId = `lens-${next++}`;
2366
+ apply(addLensOps(scene, lensId, { body: bodyBeside(reticle, box, count), reticle }));
2367
+ refs.focus.current = lensId;
2368
+ return lensId;
2369
+ },
2370
+ follow(client) {
2371
+ const lensId = current();
2372
+ const body = refs.body.current;
2373
+ if (lensId === null || !body) return;
2374
+ const pose = reticleAt(scene, lensId, toBody(client, body.getBoundingClientRect()));
2375
+ if (pose === null) return;
2376
+ hold ??= scene.overrides.hold();
2377
+ hold.set([[`${lensId}:reticle`, pose]]);
2378
+ },
2379
+ unfollow: () => hold?.drop(),
2380
+ sourceAvailable: (kind) => refs.sources.current[kind]?.status() ?? "not provided",
2381
+ openCompareMenu: (lensId) => refs.compare?.current?.(lensId)
2382
+ };
2383
+ }
2384
+ function useRemeasure(sources, body, rerender) {
2385
+ const { trials, mode } = useLabContext();
2386
+ const labValues = useContext(StoryGlobalsContext);
2387
+ useEffect(() => {
2388
+ for (const source of sources) source.remeasure();
2389
+ }, [sources, trials, mode, labValues]);
2390
+ useEffect(() => {
2391
+ if (sources.length === 0 || !body) return;
2392
+ const remeasure = () => {
2393
+ for (const source of sources) source.remeasure();
2394
+ };
2395
+ const resize = () => {
2396
+ rerender();
2397
+ remeasure();
2398
+ };
2399
+ body.addEventListener("scroll", remeasure, { capture: true, passive: true });
2400
+ window.addEventListener("resize", resize);
2401
+ const unveiled = typeof MutationObserver === "undefined" ? null : new MutationObserver(remeasure);
2402
+ unveiled?.observe(body, { subtree: true, attributes: true, attributeFilter: ["data-pending"] });
2403
+ return () => {
2404
+ body.removeEventListener("scroll", remeasure, { capture: true });
2405
+ window.removeEventListener("resize", resize);
2406
+ unveiled?.disconnect();
2407
+ };
2408
+ }, [sources, body, rerender]);
2409
+ }
2410
+ function ForgeLoupe({ declarations, modeGlobal }) {
2411
+ const lenses = useContext(LiveLensesContext);
2412
+ const labValues = useContext(StoryGlobalsContext);
2413
+ const { mode, trials, instruments } = useLabContext();
2414
+ const [scene] = useState(createLensScene);
2415
+ const [body, setBody] = useState(null);
2416
+ const bodyRef = useRef(null);
2417
+ const pointer = useRef(null);
2418
+ const { focus, onFocusLens } = useLensFocus();
2419
+ const conditions = useLatest({ labValues, mode, declarations, modeGlobal });
2420
+ const [live] = useState(
2421
+ () => lenses ? createLiveSource(lenses, resolveComparison, () => ({ base: conditions.current.labValues, ctx: resolveContextOf(conditions.current) })) : null
2422
+ );
2423
+ const pixels = usePixelsSource(conditions);
2424
+ const sources = useMemo(
2425
+ () => ({ ...live ? { live } : {}, ...pixels ? { pixels } : {} }),
2426
+ [live, pixels]
2427
+ );
2428
+ const remeasured = useMemo(() => [live, pixels].filter((s) => s !== null), [live, pixels]);
2429
+ const sourcesRef = useLatest(sources);
2430
+ const [menu, setMenu] = useState(null);
2431
+ const compare = useLatest((lensId) => {
2432
+ const at = lensClient(scene, lensId, bodyRef.current);
2433
+ if (!at) return;
2434
+ const trialId = trialAt(at.reticle)?.trialId;
2435
+ const name = trials.find((t) => t.id === trialId)?.instrumentName;
2436
+ const schema = instruments.find((i) => i.name === name)?.config ?? null;
2437
+ const settings = settingsOf(scene, lensId);
2438
+ const pixels2 = settings !== null && (settings.view === "difference" || resolveSource(settings.source, sourcesRef.current).source?.kind === "pixels");
2439
+ const choices = compareChoices(declarations, schema);
2440
+ setMenu((last) => ({ seq: (last?.seq ?? 0) + 1, client: at.body, lensId, pixels: pixels2, choices }));
2441
+ });
2442
+ const [api] = useState(() => createApi(scene, { body: bodyRef, pointer, focus, sources: sourcesRef, compare }));
2443
+ const describe = useCallback(
2444
+ (c, at) => {
2445
+ const trialId = trialAt(at.reticle)?.trialId;
2446
+ const config = trials.find((t) => t.id === trialId)?.config;
2447
+ return describeLens(c, { labValues, mode, declarations, modeGlobal }, config, at.source === "pixels");
2448
+ },
2449
+ [declarations, labValues, mode, modeGlobal, trials]
2450
+ );
2451
+ const [, rerender] = useReducer((n) => n + 1, 0);
2452
+ useSyncExternalStore(scene.overrides.subscribe, scene.overrides.getGeneration);
2453
+ useRemeasure(remeasured, body, rerender);
2454
+ useEffect(() => {
2455
+ if (!body) return;
2456
+ const track = (e) => {
2457
+ pointer.current = { x: e.clientX, y: e.clientY };
2458
+ };
2459
+ body.addEventListener("pointermove", track, { passive: true });
2460
+ return () => body.removeEventListener("pointermove", track);
2461
+ }, [body]);
2462
+ const attachMarker = useCallback((el) => {
2463
+ const found = el?.closest(".lk-lab__body") ?? null;
2464
+ bodyRef.current = found;
2465
+ setBody(found);
2466
+ }, []);
2467
+ const opposite = useCallback(() => OPPOSITE, []);
2468
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2469
+ /* @__PURE__ */ jsx("span", { hidden: true, ref: attachMarker }),
2470
+ /* @__PURE__ */ jsx(LensOverlay, { scene, sources, describe, renderReadouts, onFocusLens }),
2471
+ /* @__PURE__ */ jsx(LensActions, { api, bodyRef, opposite }),
2472
+ /* @__PURE__ */ jsx(CompareMenu, { api, request: menu }),
2473
+ /* @__PURE__ */ jsx(KeymapHint, { className: "fg-loupe-hint" })
2474
+ ] });
2475
+ }
1205
2476
 
1206
2477
  // src/shell/routedTrial.ts
1207
2478
  function routedTrial(trials, focusedTrialId, route) {
@@ -1253,9 +2524,9 @@ function RoutePlayhead() {
1253
2524
  if (!trial || route === null) return null;
1254
2525
  return /* @__PURE__ */ jsx(PlayheadSync, { trialId: trial.id, route, appliedRef: applied }, `${trial.id} ${route}`);
1255
2526
  }
1256
- var RECORD2 = "fg-globals";
2527
+ var RECORD3 = "fg-globals";
1257
2528
  function useLabGlobals(declarations) {
1258
- const [stored, setStored] = usePersistedState(RECORD2, null, { scope: "lab" });
2529
+ const [stored, setStored] = usePersistedState(RECORD3, null, { scope: "lab" });
1259
2530
  const values = useMemo(() => labGlobals(declarations, stored), [declarations, stored]);
1260
2531
  const set = useCallback(
1261
2532
  (key, value) => setStored((prev) => ({ ...labGlobals(declarations, prev), [key]: value })),
@@ -1462,6 +2733,16 @@ function foldGalleries(nodes) {
1462
2733
  return { ...node, children };
1463
2734
  });
1464
2735
  }
2736
+ function hoistLoneStories(nodes) {
2737
+ return nodes.map((node) => {
2738
+ if (node.kind !== "folder") return node;
2739
+ const [only] = node.children;
2740
+ if (node.children.length === 1 && only?.kind === "story" && restatesTitle(node.path, only.entry.name)) {
2741
+ return { kind: "story", entry: only.entry, label: node.label, tag: node.tag ?? only.tag };
2742
+ }
2743
+ return { ...node, children: hoistLoneStories(node.children) };
2744
+ });
2745
+ }
1465
2746
  function buildTree(index) {
1466
2747
  const root = [];
1467
2748
  const folders = /* @__PURE__ */ new Map();
@@ -1482,7 +2763,7 @@ function buildTree(index) {
1482
2763
  }
1483
2764
  siblings.push({ kind: "story", entry });
1484
2765
  }
1485
- return sortFolder(root);
2766
+ return hoistLoneStories(sortFolder(root));
1486
2767
  }
1487
2768
  function filterTree(nodes, query) {
1488
2769
  const needle = query.trim().toLowerCase();
@@ -1606,7 +2887,7 @@ function componentNodes(rows) {
1606
2887
  ...row.entries.map((entry) => ({ kind: "story", entry }))
1607
2888
  ];
1608
2889
  }
1609
- return foldGalleries(root);
2890
+ return hoistLoneStories(foldGalleries(root));
1610
2891
  }
1611
2892
  function librariesIn(index, libraries) {
1612
2893
  return [...new Set(index.map(libraries.of))].sort((a, b) => a.localeCompare(b));
@@ -1630,6 +2911,12 @@ function ancestorsOf(entry) {
1630
2911
  function LibraryBadge2({ library }) {
1631
2912
  return /* @__PURE__ */ jsx(LibraryBadge, { library, className: "fg-tree__tag" });
1632
2913
  }
2914
+ function RowTags({ lab, library }) {
2915
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2916
+ lab ? /* @__PURE__ */ jsx(LabBadge, { className: "fg-tree__lab" }) : null,
2917
+ library ? /* @__PURE__ */ jsx(LibraryBadge2, { library }) : null
2918
+ ] });
2919
+ }
1633
2920
  function StoryTree({ ctx, index }) {
1634
2921
  const headingId = useId();
1635
2922
  const [route, setRoute2] = useRoute();
@@ -1668,13 +2955,13 @@ function StoryTree({ ctx, index }) {
1668
2955
  const filtering = query.trim() !== "";
1669
2956
  const isOpen = (path) => filtering || (folds[path] ?? routeAncestors.has(path));
1670
2957
  const rows = [];
1671
- const collect = (nodes, parent) => {
2958
+ const collect2 = (nodes, parent) => {
1672
2959
  for (const node of nodes) {
1673
2960
  rows.push({ key: keyOf(node), node, parent });
1674
- if (node.kind === "folder" && isOpen(node.path)) collect(node.children, keyOf(node));
2961
+ if (node.kind === "folder" && isOpen(node.path)) collect2(node.children, keyOf(node));
1675
2962
  }
1676
2963
  };
1677
- collect(shown, null);
2964
+ collect2(shown, null);
1678
2965
  const active = rows.some((row) => row.key === focusKey) ? focusKey : rows[0]?.key ?? null;
1679
2966
  const openIds = new Set(ctx.trials.map((trial) => trial.instrumentName));
1680
2967
  const setOpen = (path, open) => {
@@ -1837,7 +3124,7 @@ function StoryTree({ ctx, index }) {
1837
3124
  }
1838
3125
  ),
1839
3126
  /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: node.label }),
1840
- node.tag ? /* @__PURE__ */ jsx(LibraryBadge2, { library: node.tag }) : null
3127
+ /* @__PURE__ */ jsx(RowTags, { lab: !!node.index && isLab(node.index), library: node.tag })
1841
3128
  ]
1842
3129
  }
1843
3130
  ),
@@ -1863,7 +3150,7 @@ function StoryTree({ ctx, index }) {
1863
3150
  },
1864
3151
  children: [
1865
3152
  /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: node.label ?? entry.name }),
1866
- node.tag ? /* @__PURE__ */ jsx(LibraryBadge2, { library: node.tag }) : null
3153
+ /* @__PURE__ */ jsx(RowTags, { lab: isLab(entry), library: node.tag })
1867
3154
  ]
1868
3155
  },
1869
3156
  keyOf(node)
@@ -1953,6 +3240,171 @@ function createAnswerBook() {
1953
3240
 
1954
3241
  // src/shell/readyKey.ts
1955
3242
  var readyKey = (ready) => stableStringify({ schema: ready.schema, layout: ready.layout, viewport: ready.viewport });
3243
+ var START_TIMEOUT_MS = 1e4;
3244
+ var requests = 0;
3245
+ function closeLink(link) {
3246
+ if (!link.current) return;
3247
+ clearTimeout(link.current.timer);
3248
+ for (const settle of link.current.pending.values()) settle({ ok: false, message: "The frame went away" });
3249
+ link.current.pending.clear();
3250
+ link.current.disconnect();
3251
+ link.current.channel.close();
3252
+ link.current = null;
3253
+ }
3254
+ function mismatchMessage(mismatch) {
3255
+ 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";
3256
+ }
3257
+ function sendInputs(current, sent, next) {
3258
+ const before = current.inputs;
3259
+ const { state, globals } = next;
3260
+ const config = storyConfig(next.config, sent.config);
3261
+ if (config !== sent.config) {
3262
+ sent.config = config;
3263
+ current.channel.send({ type: "config", config });
3264
+ current.inputs += 1;
3265
+ }
3266
+ if (state !== sent.state) {
3267
+ sent.state = state;
3268
+ current.channel.send({ type: "state", state });
3269
+ current.inputs += 1;
3270
+ }
3271
+ if (globals !== sent.globals && stableStringify(globals) !== stableStringify(sent.globals)) {
3272
+ sent.globals = globals;
3273
+ current.channel.send({ type: "globals", globals });
3274
+ current.inputs += 1;
3275
+ }
3276
+ return current.inputs !== before;
3277
+ }
3278
+ function receive(deps, current, msg) {
3279
+ const { latest: latestRef, trialId, frames, setFault, setPending, init, copy } = deps;
3280
+ const { ctx: live, answers, onReady } = latestRef.current;
3281
+ switch (msg.type) {
3282
+ case "ready": {
3283
+ clearTimeout(current.timer);
3284
+ setFault(null);
3285
+ for (const [name, value] of Object.entries(latestRef.current.overrides)) {
3286
+ current.channel.send({ type: "vars.set", name, value });
3287
+ }
3288
+ if (copy) {
3289
+ init(current);
3290
+ break;
3291
+ }
3292
+ const key = readyKey(msg);
3293
+ current.awaiting = key;
3294
+ onReady(latestRef.current.entry, msg);
3295
+ if (current.awaiting === key && key === latestRef.current.descriptionKey) init(current);
3296
+ break;
3297
+ }
3298
+ case "rendered":
3299
+ setPending(false);
3300
+ current.hasRendered = true;
3301
+ for (const ask of current.queued.splice(0)) ask();
3302
+ break;
3303
+ case "setConfig":
3304
+ if (!isGlobalsPath(msg.path)) live.setConfig(msg.path, msg.value);
3305
+ break;
3306
+ case "setState":
3307
+ if (current.sent) current.sent.state = msg.state;
3308
+ live.setState(msg.state);
3309
+ break;
3310
+ case "answers":
3311
+ if (!copy) answers.record(msg.answers);
3312
+ break;
3313
+ case "vars":
3314
+ if (trialId) frames?.report(trialId, msg.vars);
3315
+ break;
3316
+ case "a11y": {
3317
+ const outcome = msg.ok ? { ok: true, report: msg.report } : { ok: false, message: msg.message };
3318
+ if (trialId) frames?.reportA11y(trialId, outcome);
3319
+ current.pending.get(msg.id)?.(outcome);
3320
+ current.pending.delete(msg.id);
3321
+ break;
3322
+ }
3323
+ case "open": {
3324
+ const { lab: liveLab } = latestRef.current;
3325
+ if (!trialId || !liveLab.instruments.some((instrument) => instrument.name === msg.id)) break;
3326
+ liveLab.swapTrial(trialId, msg.id);
3327
+ setRoute(msg.id, { inPlace: true });
3328
+ break;
3329
+ }
3330
+ case "fault":
3331
+ if (msg.phase === "render" && msg.seq !== void 0 && msg.seq < current.inputs) break;
3332
+ clearTimeout(current.timer);
3333
+ setFault({ phase: msg.phase, message: msg.message });
3334
+ break;
3335
+ }
3336
+ }
3337
+ function openLink(deps, link, frame) {
3338
+ const { latest: latestRef, src, trialId, frames, setFault, setPending } = deps;
3339
+ closeLink(link);
3340
+ setPending(true);
3341
+ const target = frame.contentWindow;
3342
+ if (!target) return;
3343
+ const { port1, port2 } = new MessageChannel();
3344
+ const timer = setTimeout(() => setFault({ phase: null, message: `Frame did not start: ${src}` }), START_TIMEOUT_MS);
3345
+ const channel = openChannel(port1, {
3346
+ onMismatch: (mismatch) => {
3347
+ clearTimeout(timer);
3348
+ setFault({ phase: "protocol", message: mismatchMessage(mismatch) });
3349
+ }
3350
+ });
3351
+ const waiting = /* @__PURE__ */ new Map();
3352
+ const audit = () => new Promise((resolve) => {
3353
+ const id = `a11y-${requests += 1}`;
3354
+ waiting.set(id, resolve);
3355
+ const ask = () => channel.send({ type: "a11y.run", id });
3356
+ if (current.hasRendered) ask();
3357
+ else current.queued.push(ask);
3358
+ });
3359
+ const disconnect = frames && trialId ? frames.connect(trialId, { send: channel.send, audit }) : () => {
3360
+ };
3361
+ const current = {
3362
+ channel,
3363
+ deliver: (msg) => receive(deps, current, msg),
3364
+ timer,
3365
+ awaiting: null,
3366
+ sent: null,
3367
+ inputs: 0,
3368
+ disconnect,
3369
+ pending: waiting,
3370
+ hasRendered: false,
3371
+ queued: []
3372
+ };
3373
+ link.current = current;
3374
+ channel.on((msg) => current.deliver(msg));
3375
+ target.postMessage({ type: PORT_HANDOFF, id: latestRef.current.entry.id }, location.origin, [
3376
+ port2
3377
+ ]);
3378
+ }
3379
+ function useFrameLink(opts) {
3380
+ const { latest: latestRef, src, trialId, frames, setFault, setPending, copy, config, state, globals, descriptionKey } = opts;
3381
+ const link = useRef(null);
3382
+ const init = useCallback((current) => {
3383
+ const { ctx: live, globals: liveGlobals } = latestRef.current;
3384
+ current.awaiting = null;
3385
+ current.sent = { config: storyConfig(live.config), state: live.state, globals: liveGlobals };
3386
+ current.inputs += 1;
3387
+ current.channel.send({ type: "init", ...current.sent });
3388
+ }, [latestRef]);
3389
+ const deps = { latest: latestRef, src, trialId, frames, setFault, setPending, copy, init };
3390
+ const connect = (frame) => openLink(deps, link, frame);
3391
+ const adopt2 = (adopted) => {
3392
+ adopted.deliver = (msg) => receive(deps, adopted, msg);
3393
+ setPending(!adopted.hasRendered);
3394
+ };
3395
+ useEffect(() => {
3396
+ const current = link.current;
3397
+ if (!current) return;
3398
+ if (current.awaiting !== null) {
3399
+ if (current.awaiting === descriptionKey) init(current);
3400
+ return;
3401
+ }
3402
+ if (current.sent && sendInputs(current, current.sent, { config, state, globals })) {
3403
+ setFault((shown) => shown?.phase === "render" ? null : shown);
3404
+ }
3405
+ }, [config, state, globals, descriptionKey, init, setFault]);
3406
+ return { connect, adopt: adopt2, link };
3407
+ }
1956
3408
  var kept = /* @__PURE__ */ new Map();
1957
3409
  var shelf = null;
1958
3410
  function shelfElement() {
@@ -2001,46 +3453,56 @@ function useKeptFrame(options) {
2001
3453
  }, [latest]);
2002
3454
  return useCallback(() => handedOn.current, []);
2003
3455
  }
2004
- var START_TIMEOUT_MS = 1e4;
2005
- var requests = 0;
2006
- function closeLink(link) {
2007
- if (!link.current) return;
2008
- clearTimeout(link.current.timer);
2009
- for (const settle of link.current.pending.values()) settle({ ok: false, message: "The frame went away" });
2010
- link.current.pending.clear();
2011
- link.current.disconnect();
2012
- link.current.channel.close();
2013
- link.current = null;
2014
- }
2015
- function mismatchMessage(mismatch) {
2016
- 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";
2017
- }
2018
- function sendInputs(current, sent, next) {
2019
- const before = current.inputs;
2020
- const { state, globals } = next;
2021
- const config = storyConfig(next.config, sent.config);
2022
- if (config !== sent.config) {
2023
- sent.config = config;
2024
- current.channel.send({ type: "config", config });
2025
- current.inputs += 1;
2026
- }
2027
- if (state !== sent.state) {
2028
- sent.state = state;
2029
- current.channel.send({ type: "state", state });
2030
- current.inputs += 1;
2031
- }
2032
- if (globals !== sent.globals && stableStringify(globals) !== stableStringify(sent.globals)) {
2033
- sent.globals = globals;
2034
- current.channel.send({ type: "globals", globals });
2035
- current.inputs += 1;
3456
+ function mountFrame(m) {
3457
+ const { inView, src, slotRef, iframeRef, latest: latestRef, connect: connectRef, link, handedOn, setFault, setPending } = m;
3458
+ const slot = slotRef.current;
3459
+ if (!inView || !slot) return void 0;
3460
+ const adopted = iframeRef.current;
3461
+ const claimed = adopted || latestRef.current.copy ? null : latestRef.current.pool?.claim() ?? null;
3462
+ const frame = adopted ?? claimed ?? document.createElement("iframe");
3463
+ frame.className = "fg-frame-view";
3464
+ frame.title = latestRef.current.title;
3465
+ frame.removeAttribute("tabindex");
3466
+ frame.toggleAttribute("data-pending", true);
3467
+ iframeRef.current = frame;
3468
+ const unheard = adopted || claimed ? void 0 : setTimeout(() => setFault({ phase: null, message: `Frame did not start: ${src}` }), HELLO_TIMEOUT_MS);
3469
+ const onMessage = (event) => {
3470
+ if (event.source !== frame.contentWindow || event.origin !== location.origin) return;
3471
+ if (event.data?.type !== FRAME_HELLO) return;
3472
+ clearTimeout(unheard);
3473
+ connectRef.current(frame);
3474
+ };
3475
+ window.addEventListener("message", onMessage);
3476
+ if (claimed) {
3477
+ moveFrame(slot, claimed);
3478
+ connectRef.current(claimed);
3479
+ } else if (!adopted) {
3480
+ frame.src = src;
3481
+ slot.append(frame);
2036
3482
  }
2037
- return current.inputs !== before;
3483
+ return () => {
3484
+ window.removeEventListener("message", onMessage);
3485
+ clearTimeout(unheard);
3486
+ if (handedOn()) return;
3487
+ closeLink(link);
3488
+ frame.remove();
3489
+ iframeRef.current = null;
3490
+ setPending(true);
3491
+ setFault(null);
3492
+ };
2038
3493
  }
3494
+ var NO_ARGS = {};
2039
3495
  function FrameView(props) {
2040
- const { entry, frameUrl, answers, descriptionKey, ctx } = props;
3496
+ const { entry, frameUrl, answers, descriptionKey, ctx: trialCtx, copy } = props;
2041
3497
  const labGlobals2 = useContext(StoryGlobalsContext);
2042
- const pins = ctx.config?.[GLOBALS_KEY];
2043
- const globals = useMemo(() => effectiveGlobals(labGlobals2, pins), [labGlobals2, pins]);
3498
+ const pins = trialCtx.config?.[GLOBALS_KEY];
3499
+ const own = useMemo(() => effectiveGlobals(labGlobals2, pins), [labGlobals2, pins]);
3500
+ const globals = copy ? copy.globals : own;
3501
+ const { config: laidConfig, setConfig: laidSetConfig } = useCopyConfig(trialCtx.config, trialCtx.setConfig, copy?.args ?? NO_ARGS);
3502
+ const ctx = useMemo(
3503
+ () => copy ? { ...trialCtx, config: laidConfig, setConfig: laidSetConfig } : trialCtx,
3504
+ [copy, trialCtx, laidConfig, laidSetConfig]
3505
+ );
2044
3506
  const trialId = useContext(TrialIdContext);
2045
3507
  const frames = useContext(TrialFramesContext);
2046
3508
  const [overrides] = useCssOverrides();
@@ -2052,185 +3514,49 @@ function FrameView(props) {
2052
3514
  const hostRef = useRef(null);
2053
3515
  const slotRef = useRef(null);
2054
3516
  const inView = useNearViewport(hostRef, IN_VIEW_MARGIN);
2055
- const link = useRef(null);
2056
- const latestRef = useLatest({ ...props, globals, overrides, pool, lab, title });
3517
+ const latestRef = useLatest({ ...props, ctx, globals, overrides, pool, lab, title });
2057
3518
  const [fault, setFault] = useState(null);
2058
- const [pending, setPending] = useState(true);
2059
- const init = useCallback((current) => {
2060
- const { ctx: live, globals: liveGlobals } = latestRef.current;
2061
- current.awaiting = null;
2062
- current.sent = { config: storyConfig(live.config), state: live.state, globals: liveGlobals };
2063
- current.inputs += 1;
2064
- current.channel.send({ type: "init", ...current.sent });
2065
- }, [latestRef]);
2066
- const receive = (current, msg) => {
2067
- const { ctx: live, answers: answers2, onReady } = latestRef.current;
2068
- switch (msg.type) {
2069
- case "ready": {
2070
- clearTimeout(current.timer);
2071
- setFault(null);
2072
- for (const [name, value] of Object.entries(latestRef.current.overrides)) {
2073
- current.channel.send({ type: "vars.set", name, value });
2074
- }
2075
- const key = readyKey(msg);
2076
- current.awaiting = key;
2077
- onReady(latestRef.current.entry, msg);
2078
- if (current.awaiting === key && key === latestRef.current.descriptionKey) init(current);
2079
- break;
2080
- }
2081
- case "rendered":
2082
- setPending(false);
2083
- current.hasRendered = true;
2084
- for (const ask of current.queued.splice(0)) ask();
2085
- break;
2086
- case "setConfig":
2087
- if (!isGlobalsPath(msg.path)) live.setConfig(msg.path, msg.value);
2088
- break;
2089
- case "setState":
2090
- if (current.sent) current.sent.state = msg.state;
2091
- live.setState(msg.state);
2092
- break;
2093
- case "answers":
2094
- answers2.record(msg.answers);
2095
- break;
2096
- case "vars":
2097
- if (trialId) frames?.report(trialId, msg.vars);
2098
- break;
2099
- case "a11y": {
2100
- const outcome = msg.ok ? { ok: true, report: msg.report } : { ok: false, message: msg.message };
2101
- if (trialId) frames?.reportA11y(trialId, outcome);
2102
- current.pending.get(msg.id)?.(outcome);
2103
- current.pending.delete(msg.id);
2104
- break;
2105
- }
2106
- case "open": {
2107
- const { lab: liveLab } = latestRef.current;
2108
- if (!trialId || !liveLab.instruments.some((instrument) => instrument.name === msg.id)) break;
2109
- liveLab.swapTrial(trialId, msg.id);
2110
- setRoute(msg.id, { inPlace: true });
2111
- break;
2112
- }
2113
- case "fault":
2114
- if (msg.phase === "render" && msg.seq !== void 0 && msg.seq < current.inputs) break;
2115
- clearTimeout(current.timer);
2116
- setFault({ phase: msg.phase, message: msg.message });
2117
- break;
2118
- }
2119
- };
2120
- const connect = (frame) => {
2121
- closeLink(link);
2122
- setPending(true);
2123
- const target = frame.contentWindow;
2124
- if (!target) return;
2125
- const { port1, port2 } = new MessageChannel();
2126
- const timer = setTimeout(() => setFault({ phase: null, message: `Frame did not start: ${src}` }), START_TIMEOUT_MS);
2127
- const channel = openChannel(port1, {
2128
- onMismatch: (mismatch) => {
2129
- clearTimeout(timer);
2130
- setFault({ phase: "protocol", message: mismatchMessage(mismatch) });
2131
- }
2132
- });
2133
- const waiting = /* @__PURE__ */ new Map();
2134
- const audit = () => new Promise((resolve) => {
2135
- const id = `a11y-${requests += 1}`;
2136
- waiting.set(id, resolve);
2137
- const ask = () => channel.send({ type: "a11y.run", id });
2138
- if (current.hasRendered) ask();
2139
- else current.queued.push(ask);
2140
- });
2141
- const disconnect = frames && trialId ? frames.connect(trialId, { send: channel.send, audit }) : () => {
2142
- };
2143
- const current = {
2144
- channel,
2145
- deliver: (msg) => receive(current, msg),
2146
- timer,
2147
- awaiting: null,
2148
- sent: null,
2149
- inputs: 0,
2150
- disconnect,
2151
- pending: waiting,
2152
- hasRendered: false,
2153
- queued: []
2154
- };
2155
- link.current = current;
2156
- channel.on((msg) => current.deliver(msg));
2157
- target.postMessage({ type: PORT_HANDOFF, id: latestRef.current.entry.id }, location.origin, [
2158
- port2
2159
- ]);
2160
- };
3519
+ const [pending2, setPending] = useState(true);
3520
+ const { connect, adopt: adopt2, link } = useFrameLink({
3521
+ latest: latestRef,
3522
+ src,
3523
+ trialId: copy ? null : trialId,
3524
+ frames: copy ? null : frames,
3525
+ setFault,
3526
+ setPending,
3527
+ copy: copy !== void 0,
3528
+ config: ctx.config,
3529
+ state: ctx.state,
3530
+ globals,
3531
+ descriptionKey
3532
+ });
2161
3533
  const connectRef = useLatest(connect);
2162
3534
  const handedOn = useKeptFrame({
2163
- key: trialId ? `${trialId} ${src}` : null,
3535
+ key: trialId && !copy ? `${trialId} ${src}` : null,
2164
3536
  slot: slotRef,
2165
3537
  frame: iframeRef,
2166
3538
  link,
2167
- onAdopt: (adopted) => {
2168
- adopted.deliver = (msg) => receive(adopted, msg);
2169
- setPending(!adopted.hasRendered);
2170
- },
3539
+ onAdopt: adopt2,
2171
3540
  onDrop: () => closeLink(link)
2172
3541
  });
3542
+ useEffect(
3543
+ () => mountFrame({ inView, src, slotRef, iframeRef, latest: latestRef, connect: connectRef, link, handedOn, setFault, setPending }),
3544
+ [inView, src, connectRef, latestRef, handedOn, link]
3545
+ );
2173
3546
  useEffect(() => {
2174
- const slot = slotRef.current;
2175
- if (!inView || !slot) return;
2176
- const adopted = iframeRef.current;
2177
- const claimed = adopted ? null : latestRef.current.pool?.claim() ?? null;
2178
- const frame = adopted ?? claimed ?? document.createElement("iframe");
2179
- frame.className = "fg-frame-view";
2180
- frame.title = latestRef.current.title;
2181
- frame.removeAttribute("tabindex");
2182
- frame.toggleAttribute("data-pending", true);
2183
- iframeRef.current = frame;
2184
- const unheard = adopted || claimed ? void 0 : setTimeout(() => setFault({ phase: null, message: `Frame did not start: ${src}` }), HELLO_TIMEOUT_MS);
2185
- const onMessage = (event) => {
2186
- if (event.source !== frame.contentWindow || event.origin !== location.origin) return;
2187
- if (event.data?.type !== FRAME_HELLO) return;
2188
- clearTimeout(unheard);
2189
- connectRef.current(frame);
2190
- };
2191
- window.addEventListener("message", onMessage);
2192
- if (claimed) {
2193
- moveFrame(slot, claimed);
2194
- connectRef.current(claimed);
2195
- } else if (!adopted) {
2196
- frame.src = src;
2197
- slot.append(frame);
2198
- }
2199
- return () => {
2200
- window.removeEventListener("message", onMessage);
2201
- clearTimeout(unheard);
2202
- if (handedOn()) return;
2203
- closeLink(link);
2204
- frame.remove();
2205
- iframeRef.current = null;
2206
- setPending(true);
2207
- setFault(null);
2208
- };
2209
- }, [inView, src, connectRef, latestRef, handedOn]);
2210
- useEffect(() => {
2211
- iframeRef.current?.toggleAttribute("data-pending", pending && !fault);
2212
- }, [pending, fault, inView, src]);
3547
+ iframeRef.current?.toggleAttribute("data-pending", pending2 && !fault);
3548
+ }, [pending2, fault, inView, src]);
2213
3549
  useEffect(() => {
2214
3550
  if (iframeRef.current) iframeRef.current.title = title;
2215
3551
  }, [title]);
2216
- useEffect(() => answers.hold(ctx.config), [answers, ctx.config]);
2217
- useEffect(() => {
2218
- const current = link.current;
2219
- if (!current) return;
2220
- if (current.awaiting !== null) {
2221
- if (current.awaiting === descriptionKey) init(current);
2222
- return;
2223
- }
2224
- if (current.sent && sendInputs(current, current.sent, { config: ctx.config, state: ctx.state, globals })) {
2225
- setFault((shown) => shown?.phase === "render" ? null : shown);
2226
- }
2227
- }, [ctx.config, ctx.state, globals, descriptionKey, init]);
2228
- return /* @__PURE__ */ jsxs("div", { ref: hostRef, className: "fg-frame-host", children: [
3552
+ useEffect(() => copy ? void 0 : answers.hold(ctx.config), [answers, ctx.config, copy]);
3553
+ return /* @__PURE__ */ jsxs("div", { ref: hostRef, className: "fg-frame-host", "data-fg-host": copy?.id ?? trialId ?? void 0, "data-fg-copy": copy ? "" : void 0, children: [
2229
3554
  /* @__PURE__ */ jsx("div", { ref: slotRef, className: "fg-frame-slot" }),
2230
3555
  fault ? /* @__PURE__ */ jsxs("div", { className: "fg-fault", role: "alert", children: [
2231
3556
  fault.phase ? /* @__PURE__ */ jsx("span", { className: "fg-fault__phase", children: fault.phase }) : null,
2232
3557
  /* @__PURE__ */ jsx("p", { className: "fg-fault__message", children: fault.message })
2233
- ] }) : null
3558
+ ] }) : null,
3559
+ copy ? null : /* @__PURE__ */ jsx(LiveFrameCopies, { trialId, trialConfig: trialCtx.config, children: (lensCopy) => /* @__PURE__ */ jsx(FrameView, { ...props, copy: lensCopy }) })
2234
3560
  ] });
2235
3561
  }
2236
3562
  function storyInstrument(options) {
@@ -2247,17 +3573,20 @@ function storyInstrument(options) {
2247
3573
  config,
2248
3574
  defaultConfig: () => config.defaults(),
2249
3575
  initialState: () => null,
2250
- render: (ctx) => /* @__PURE__ */ jsx(
2251
- FrameView,
2252
- {
2253
- entry,
2254
- frameUrl,
2255
- answers,
2256
- onReady,
2257
- descriptionKey,
2258
- ctx
2259
- }
2260
- ),
3576
+ render: (ctx) => /* @__PURE__ */ jsxs(Fragment, { children: [
3577
+ /* @__PURE__ */ jsx(
3578
+ FrameView,
3579
+ {
3580
+ entry,
3581
+ frameUrl,
3582
+ answers,
3583
+ onReady,
3584
+ descriptionKey,
3585
+ ctx
3586
+ }
3587
+ ),
3588
+ /* @__PURE__ */ jsx(CaptureInputs, { entry, layout: ready?.layout ?? "centered", config: ctx.config, state: ctx.state })
3589
+ ] }),
2261
3590
  // Taken from the index until the frame reports, so the trial does not move the frame into a stage and reload it.
2262
3591
  ...viewport ? { stage: { size: { width: viewport.width, height: viewport.height } } } : {}
2263
3592
  };
@@ -2274,7 +3603,7 @@ function useStoryRegistry(index, options) {
2274
3603
  const [loaded, setLoaded] = useState(() => /* @__PURE__ */ new Map());
2275
3604
  const [faults, setFaults] = useState(() => /* @__PURE__ */ new Map());
2276
3605
  const [generations, setGenerations] = useState(() => /* @__PURE__ */ new Map());
2277
- const [revisions, setRevisions] = useState(() => /* @__PURE__ */ new Map());
3606
+ const [revisions2, setRevisions] = useState(() => /* @__PURE__ */ new Map());
2278
3607
  const onReady = useCallback((entry, ready) => {
2279
3608
  setReadies((prev) => {
2280
3609
  const held = prev.get(entry.id);
@@ -2341,7 +3670,7 @@ function useStoryRegistry(index, options) {
2341
3670
  library: liveRef.current.libraries.of(entry),
2342
3671
  ...entry.componentDescription === void 0 ? {} : { description: entry.componentDescription },
2343
3672
  ...entry.tags === void 0 ? {} : { tags: entry.tags },
2344
- galleries: entries.filter(isGallery).map((e) => e.id),
3673
+ ...taggedIds(entries),
2345
3674
  stories,
2346
3675
  descriptions: Object.fromEntries(entries.flatMap((e) => e.description ? [[e.id, e.description]] : [])),
2347
3676
  render: mods.map(indexRenderOf).find((render) => render !== null) ?? null
@@ -2397,7 +3726,7 @@ function useStoryRegistry(index, options) {
2397
3726
  const had = loaded.get(entry.id);
2398
3727
  const fault = faults.get(entry.id);
2399
3728
  const generation = generations.get(entry.file) ?? 0;
2400
- const revision = revisions.get(entry.id) ?? 0;
3729
+ const revision = revisions2.get(entry.id) ?? 0;
2401
3730
  const entryKey = stableStringify(entry);
2402
3731
  const answers = previous.books.get(entry.id) ?? createAnswerBook();
2403
3732
  books.set(entry.id, answers);
@@ -2420,7 +3749,7 @@ function useStoryRegistry(index, options) {
2420
3749
  });
2421
3750
  const unchanged = list.length === previous.list.length && list.every((i, n) => i === previous.list[n]);
2422
3751
  return { built, books, list: unchanged ? previous.list : list };
2423
- }, [index, readies, loaded, faults, generations, revisions, frameUrl, globals, setup, importers, onReady, request]);
3752
+ }, [index, readies, loaded, faults, generations, revisions2, frameUrl, globals, setup, importers, onReady, request]);
2424
3753
  useLayoutEffect(() => {
2425
3754
  committed.current = cache;
2426
3755
  }, [cache]);
@@ -2465,7 +3794,8 @@ function NoStories({ stories }) {
2465
3794
  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
2466
3795
  ] });
2467
3796
  }
2468
- function Workshop({ index, frameUrl, importers, setup, changes, config, stories = [], storageKey, storage, dependencies }) {
3797
+ function Workshop(props) {
3798
+ const { index, frameUrl, importers, setup, changes, config, stories = [], storageKey, storage, dependencies } = props;
2469
3799
  const declarations = config?.globals ?? NO_DECLARATIONS;
2470
3800
  const title = config?.title ?? DEFAULT_TITLE;
2471
3801
  useDocumentTitle(title);
@@ -2510,26 +3840,30 @@ function Workshop({ index, frameUrl, importers, setup, changes, config, stories
2510
3840
  );
2511
3841
  const infoCommand = useMemo(() => infoItem(() => setInfoOpen(true)), []);
2512
3842
  const showsCssVars = config?.cssVars !== false;
3843
+ const modeGlobal = config?.loupe?.modeGlobal ?? "mode";
2513
3844
  const [cssVarsChrome2, setCssVarsChrome] = useState([]);
3845
+ const [keysChrome2, setKeysChrome] = useState([]);
2514
3846
  const labChrome = useMemo(
2515
3847
  () => [
2516
3848
  { id: "fg-stories", region: "sidebar", render: (ctx) => /* @__PURE__ */ jsx(StoryTree, { ctx, index }) },
2517
3849
  ...showsCssVars ? cssVarsChrome2 : [],
2518
3850
  A11Y_SECTION,
3851
+ ...keysChrome2,
2519
3852
  ...Object.keys(declarations).length > 0 ? [{ id: "fg-globals", region: "header", render: () => /* @__PURE__ */ jsx(GlobalsToolbar, { declarations }) }] : [],
2520
3853
  { id: "fg-info", region: "header", group: "story", item: infoCommand },
3854
+ { id: "fg-loupe", region: "overlay", render: () => /* @__PURE__ */ jsx(ForgeLoupe, { declarations, modeGlobal }) },
2521
3855
  ...config?.labChrome ?? []
2522
3856
  ],
2523
- [index, infoCommand, declarations, showsCssVars, cssVarsChrome2, config?.labChrome]
3857
+ [index, infoCommand, declarations, showsCssVars, cssVarsChrome2, keysChrome2, config?.labChrome, modeGlobal]
2524
3858
  );
2525
3859
  const [menus, setMenus] = useState();
2526
3860
  const first = index[0];
2527
3861
  if (!first) return /* @__PURE__ */ jsx(NoStories, { stories });
2528
- return /* @__PURE__ */ jsx(LibrariesContext.Provider, { value: libraries, children: /* @__PURE__ */ jsx(StoryGlobalsContext.Provider, { value: labValues, children: /* @__PURE__ */ jsx(DependenciesContext.Provider, { value: dependencies ?? null, children: /* @__PURE__ */ jsx(FramePoolContext.Provider, { value: pool, children: /* @__PURE__ */ jsx(TrialFramesContext.Provider, { value: frames, children: /* @__PURE__ */ jsx(TrialClocksContext.Provider, { value: clocks, children: /* @__PURE__ */ jsxs(
3862
+ return /* @__PURE__ */ jsx(LibrariesContext.Provider, { value: libraries, children: /* @__PURE__ */ jsx(StoryGlobalsContext.Provider, { value: labValues, children: /* @__PURE__ */ jsx(DependenciesContext.Provider, { value: dependencies ?? null, children: /* @__PURE__ */ jsx(FramePoolContext.Provider, { value: pool, children: /* @__PURE__ */ jsx(TrialFramesContext.Provider, { value: frames, children: /* @__PURE__ */ jsx(TrialClocksContext.Provider, { value: clocks, children: /* @__PURE__ */ jsx(LoupeProvider, { captureBase: props.captureBase, revision: props.revision, children: /* @__PURE__ */ jsxs(
2529
3863
  Lab,
2530
3864
  {
2531
3865
  title,
2532
- density: "roomy",
3866
+ density: LAB_DENSITY,
2533
3867
  ...labTheme ? { theme: labTheme } : {},
2534
3868
  instruments,
2535
3869
  defaultInstrument: readRoute() ?? first.id,
@@ -2556,17 +3890,19 @@ function Workshop({ index, frameUrl, importers, setup, changes, config, stories
2556
3890
  ),
2557
3891
  /* @__PURE__ */ jsx(LabGlobals, { declarations, onChange: reportLabValues }),
2558
3892
  /* @__PURE__ */ jsx(GlobalsMenus, { declarations, onMenus: setMenus }),
2559
- showsCssVars ? /* @__PURE__ */ jsx(CssVarsChrome, { onChrome: setCssVarsChrome }) : null
3893
+ showsCssVars ? /* @__PURE__ */ jsx(CssVarsChrome, { onChrome: setCssVarsChrome }) : null,
3894
+ /* @__PURE__ */ jsx(KeysChrome, { onChrome: setKeysChrome })
2560
3895
  ]
2561
3896
  }
2562
- ) }) }) }) }) }) });
3897
+ ) }) }) }) }) }) }) });
2563
3898
  }
2564
3899
  function mountWorkshop({ container, ...props }) {
2565
3900
  const target = container ?? document.getElementById("root");
2566
3901
  if (!target) throw new Error("mountWorkshop: no container given and no #root element");
2567
3902
  const root = createKitRoot(target, props.setup?.icons);
2568
3903
  const changes = createStoryChanges();
2569
- let current = { ...props, changes };
3904
+ const revision = createRevision();
3905
+ let current = { ...props, changes, revision };
2570
3906
  root.render(/* @__PURE__ */ jsx(Workshop, { ...current }));
2571
3907
  return {
2572
3908
  setIndex(index) {
@@ -2582,10 +3918,11 @@ function mountWorkshop({ container, ...props }) {
2582
3918
  current = { ...current, dependencies };
2583
3919
  root.render(/* @__PURE__ */ jsx(Workshop, { ...current }));
2584
3920
  },
3921
+ revise: () => revision.bump(),
2585
3922
  unmount: () => root.unmount()
2586
3923
  };
2587
3924
  }
2588
3925
 
2589
- export { A11Y_SECTION, A11yPanel, Workshop, mountWorkshop, useStoryRegistry };
3926
+ export { A11Y_SECTION, A11yPanel, Workshop, followEdits, mountWorkshop, useStoryRegistry };
2590
3927
  //# sourceMappingURL=index.js.map
2591
3928
  //# sourceMappingURL=index.js.map