@weasel-js/forge 1.13.0-rc.0 → 1.14.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 (57) hide show
  1. package/README.md +1 -1
  2. package/dist/{FrameController-Tv3jrHyT.d.ts → FrameController-BM6xuEfY.d.ts} +18 -10
  3. package/dist/{chunk-KEQBGDUZ.js → chunk-B26DWEYT.js} +3 -3
  4. package/dist/{chunk-KEQBGDUZ.js.map → chunk-B26DWEYT.js.map} +1 -1
  5. package/dist/{chunk-3BAM6M26.js → chunk-FPF2JSWB.js} +3 -3
  6. package/dist/chunk-FPF2JSWB.js.map +1 -0
  7. package/dist/{chunk-ORMMZ5PV.js → chunk-MRYXJTDO.js} +7 -3
  8. package/dist/chunk-MRYXJTDO.js.map +1 -0
  9. package/dist/{chunk-35DOPXHZ.js → chunk-PGLOYXDP.js} +193 -190
  10. package/dist/chunk-PGLOYXDP.js.map +1 -0
  11. package/dist/{chunk-BUXUWAI4.js → chunk-PPB7VP6R.js} +62 -8
  12. package/dist/chunk-PPB7VP6R.js.map +1 -0
  13. package/dist/{chunk-STF6NQ5N.js → chunk-RVJHUY7W.js} +2 -2
  14. package/dist/chunk-RVJHUY7W.js.map +1 -0
  15. package/dist/{chunk-LTTEILXY.js → chunk-SFKDHXVE.js} +26 -4
  16. package/dist/chunk-SFKDHXVE.js.map +1 -0
  17. package/dist/chunk-STK2AZH2.js +11 -0
  18. package/dist/chunk-STK2AZH2.js.map +1 -0
  19. package/dist/chunk-TT4ZOYB6.js +143 -0
  20. package/dist/chunk-TT4ZOYB6.js.map +1 -0
  21. package/dist/{chunk-RKQ277P5.js → chunk-UDEUT7AG.js} +125 -80
  22. package/dist/chunk-UDEUT7AG.js.map +1 -0
  23. package/dist/{config-Bd6FVlh7.d.ts → config-DOjWyIui.d.ts} +3 -21
  24. package/dist/csf/preview-api.js +2 -2
  25. package/dist/frame/index.css +0 -2
  26. package/dist/frame/index.css.map +1 -1
  27. package/dist/frame/index.d.ts +14 -8
  28. package/dist/frame/index.js +114 -79
  29. package/dist/frame/index.js.map +1 -1
  30. package/dist/index.d.ts +12 -5
  31. package/dist/index.js +4 -4
  32. package/dist/library-CdTj-hFp.d.ts +21 -0
  33. package/dist/{mountFrame-FdJDb3TB.d.ts → mountFrame-CRPAjg1M.d.ts} +1 -1
  34. package/dist/shell/index.css +166 -150
  35. package/dist/shell/index.css.map +1 -1
  36. package/dist/shell/index.d.ts +24 -5
  37. package/dist/shell/index.js +594 -402
  38. package/dist/shell/index.js.map +1 -1
  39. package/dist/test/index.css +2 -3
  40. package/dist/test/index.css.map +1 -1
  41. package/dist/test/index.d.ts +1 -1
  42. package/dist/test/index.js +8 -10
  43. package/dist/test/index.js.map +1 -1
  44. package/dist/vite/index.js +127 -32
  45. package/dist/vite/index.js.map +1 -1
  46. package/package.json +11 -9
  47. package/dist/chunk-35DOPXHZ.js.map +0 -1
  48. package/dist/chunk-3BAM6M26.js.map +0 -1
  49. package/dist/chunk-BUXUWAI4.js.map +0 -1
  50. package/dist/chunk-GH5RE5X5.js +0 -10
  51. package/dist/chunk-GH5RE5X5.js.map +0 -1
  52. package/dist/chunk-L5XM5VKB.js +0 -47
  53. package/dist/chunk-L5XM5VKB.js.map +0 -1
  54. package/dist/chunk-LTTEILXY.js.map +0 -1
  55. package/dist/chunk-ORMMZ5PV.js.map +0 -1
  56. package/dist/chunk-RKQ277P5.js.map +0 -1
  57. package/dist/chunk-STF6NQ5N.js.map +0 -1
@@ -1,27 +1,25 @@
1
- import { parseRoute, formatRoute, paramsToKnobs, knobPaths, sameKnob, knobsToParams, PLAYHEAD_PARAM, reservedParams } from '../chunk-LTTEILXY.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';
10
- import '../chunk-L5XM5VKB.js';
1
+ import { parseRoute, formatRoute, paramsToKnobs, knobPaths, sameKnob, paramsToPins, knobsToParams, PLAYHEAD_PARAM, pinParams, withoutPins, reservedParams } from '../chunk-SFKDHXVE.js';
2
+ import { DEFAULT_TITLE, ISSUES_PATH, issueLabel, CAPTURE_PATH, messageOf, MIN_DPR, MAX_DPR } from '../chunk-MRYXJTDO.js';
3
+ import { TITLE_LIBRARIES, loadIndexBundle, librariesFrom, LibrariesContext, LAB_DENSITY, createKitRoot, schemaFromDescription, LibraryBadge, Description, IndexPage, indexEnvOf, LabBadge, openChannel } from '../chunk-PPB7VP6R.js';
4
+ import { isIndexId, indexId, indexEntries } from '../chunk-RVJHUY7W.js';
5
+ import { isGallery, isLab } from '../chunk-VUPN73JP.js';
6
+ import { useTrialFrame, createTrialFrames, createStoryHosts, StoryGlobalsContext, TrialFramesContext, StoryHostsContext, StoryTrial, LiveLensesContext, trialAt, describeLens, createLiveLenses, useCopyConfig, useCssOverrides, IN_VIEW_MARGIN, LiveFrameCopies, TrialHost, LiveCopies, resolveContextOf, resolveComparison, scrollerOf, withArgs } from '../chunk-UDEUT7AG.js';
7
+ export { StoryGlobalsContext, StoryTrial, TrialFramesContext, TrialHost, useTrialFrame } from '../chunk-UDEUT7AG.js';
8
+ import { labGlobals, withGlobals, storyConfig, followApp, GLOBALS_KEY, effectiveGlobals, labOptions, isForgePath, isGlobalsPath } from '../chunk-FPF2JSWB.js';
9
+ import { loadStories, applyGlobalsTo, scanCssVars, varRefs, cssRulesOf } from '../chunk-PGLOYXDP.js';
10
+ import { clockSpecOf, shownTime } from '../chunk-TT4ZOYB6.js';
11
11
  import '../chunk-5WS67HMU.js';
12
- import '../chunk-KEQBGDUZ.js';
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';
12
+ import '../chunk-B26DWEYT.js';
13
+ import { stableStringify, DEFAULT_LAYOUT, FRAME_HELLO, PORT_HANDOFF, PROTOCOL_VERSION } from '../chunk-STK2AZH2.js';
14
+ import { useTrialId, Button, Lab, useLabContext, TrialIdContext, TrialTransport, usePersistedState, useTrialClockHandle, Select, useTrialState, ToggleBar as ToggleBar$1, Input as Input$1, TokenPanel, useLabEntries, inferTokenType, refitScale } from '@weasel-js/labkit';
15
15
  import num from '@weasel-js/theme/numeric.module.css';
16
16
  import { createContext, useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect, useId, useContext, useReducer, useSyncExternalStore, Fragment as Fragment$1 } from 'react';
17
17
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
18
18
  import { createEmitter } from '@weasel-js/registry';
19
19
  import { f, valueAtPath, isConfigBranch } from '@weasel-js/labkit/config';
20
- import { Slider } from '@weasel-js/ui/components/Slider';
21
- import { Transport } from '@weasel-js/ui/components/Timeline';
22
20
  import { TOKEN_MANIFEST, themeAxes, weaselTheme, THEME_SOURCES, resolveTheme, isColorModePreference, isByAxis } from '@weasel-js/theme';
23
21
  import { scale, httpThemeApi } from '@weasel-js/theme/engine';
24
- import { useLatest, useActionsRegistry, effectivePose, useMenuHost, useMenus, sceneToAdapter, parseColor, contrastRatio, wcagLevels, historyKey, isPlainObject, createHistory, keymapShortcuts, actionBindings, keySpecShortcut, keymapLeaderId, swapOp } from '@weasel-js/core';
22
+ import { useLatest, useActionsRegistry, useVisibleRaf, effectivePose, useMenuHost, useMenus, sceneToAdapter, parseColor, contrastRatio, wcagLevels, historyKey, isPlainObject, createHistory, keymapShortcuts, actionBindings, keySpecShortcut, keymapLeaderId, swapOp } from '@weasel-js/core';
25
23
  import { Button as Button$1 } from '@weasel-js/ui/components/Button';
26
24
  import { Checkbox } from '@weasel-js/ui/components/Checkbox';
27
25
  import { Icon } from '@weasel-js/ui/components/Icon';
@@ -29,14 +27,39 @@ import { TOKEN_CATEGORIES, tokenCategory } from '@weasel-js/ui/components/TokenP
29
27
  import { ToggleBar } from '@weasel-js/ui/components/ToggleBar';
30
28
  import { useResolvedColorMode } from '@weasel-js/theme/react';
31
29
  import { formatShortcut } from '@weasel-js/ui/components/ToolPalette';
30
+ import { Input } from '@weasel-js/ui/components/Input';
31
+ import { TextArea } from '@weasel-js/ui/components/TextArea';
32
32
  import { Dialog } from '@weasel-js/ui/components/Dialog';
33
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
34
  import { KeymapHint } from '@weasel-js/ui/components/KeymapHint';
35
35
  import { pref } from '@weasel-js/prefs';
36
36
  import { splitTopLevel, specificity, compareSpecificity } from '@weasel-js/svg';
37
+ import { holdAt } from '@weasel-js/clock';
37
38
  import { Callout } from '@weasel-js/ui/components/Callout';
38
- import { DisclosureMark } from '@weasel-js/ui/components/Disclosure';
39
+ import { Tree } from '@weasel-js/ui/components/Tree';
40
+ import { useNearViewport } from '@weasel-js/react';
39
41
 
42
+ // src/shell/a11y/reasons.ts
43
+ var PLAIN = {
44
+ bgOverlap: "axe couldn't tell the background: another element lies on top of this one.",
45
+ pseudoContent: "axe couldn't tell the background: a ::before or ::after box paints over or behind this text.",
46
+ bgGradient: "axe couldn't tell the background: it is a gradient.",
47
+ bgImage: "axe couldn't tell the background: it is an image.",
48
+ imgNode: "axe couldn't tell the background: an image or canvas sits behind this text.",
49
+ elmPartiallyObscured: "axe couldn't tell the background: it covers only part of this text.",
50
+ elmPartiallyObscuring: "axe couldn't tell the background: this text runs across more than one element.",
51
+ outsideViewport: "axe couldn't see this text: it is scrolled out of view.",
52
+ blended: "Couldn't tell the colors: this text or something around it is see-through, filtered, or blended.",
53
+ fgAlpha: "axe couldn't tell the text color: it is partly see-through.",
54
+ complexTextShadows: "axe couldn't tell the contrast: the text has a shadow.",
55
+ equalRatio: "The text is the same color as its background, so it may be hidden on purpose.",
56
+ shortTextContent: "Fails contrast, but is a single character that may be a symbol rather than text.",
57
+ nonBmp: "Holds only symbols or icons, not text."
58
+ };
59
+ function plainReason(reason) {
60
+ if (!reason) return null;
61
+ return (reason.key ? PLAIN[reason.key] : void 0) ?? reason.message ?? null;
62
+ }
40
63
  function pointAt(point, nodes) {
41
64
  const enter = (event) => {
42
65
  event.stopPropagation();
@@ -50,7 +73,13 @@ function Finding({ finding, point }) {
50
73
  /* @__PURE__ */ jsx("a", { href: finding.helpUrl, target: "_blank", rel: "noreferrer", children: finding.help }),
51
74
  finding.impact ? /* @__PURE__ */ jsx("span", { className: "fg-a11y__impact", "data-impact": finding.impact, children: finding.impact }) : null
52
75
  ] }),
53
- /* @__PURE__ */ jsx("ul", { className: "fg-a11y__nodes", children: finding.nodes.map((node) => /* @__PURE__ */ jsx("li", { ...pointAt(point, [node]), children: /* @__PURE__ */ jsx("code", { children: node.target.join(" ") }) }, `${finding.id}:${node.target.join(",")}`)) })
76
+ /* @__PURE__ */ jsx("ul", { className: "fg-a11y__nodes", children: finding.nodes.map((node) => {
77
+ const reason = plainReason(node.reason);
78
+ return /* @__PURE__ */ jsxs("li", { ...pointAt(point, [node]), children: [
79
+ /* @__PURE__ */ jsx("code", { children: node.target.join(" ") }),
80
+ reason ? /* @__PURE__ */ jsx("p", { className: "fg-a11y__reason", children: reason }) : null
81
+ ] }, `${finding.id}:${node.target.join(",")}`);
82
+ }) })
54
83
  ] });
55
84
  }
56
85
  function Findings({ title, findings, point }) {
@@ -185,6 +214,50 @@ function createStoryChanges() {
185
214
  return { subscribe: changes.subscribe, emit: changes.emit };
186
215
  }
187
216
  var DependenciesContext = createContext(null);
217
+
218
+ // src/shell/breadcrumb.ts
219
+ var bare = (label) => label.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
220
+ function restatesTitle(title, name) {
221
+ return bare(title.slice(title.lastIndexOf("/") + 1)) === bare(name);
222
+ }
223
+ function crumbs(title, name) {
224
+ const segments = title.split("/");
225
+ const steps = segments.map((label, i) => ({ label, path: segments.slice(0, i + 1).join("/") }));
226
+ if (restatesTitle(title, name)) steps[steps.length - 1] = { label: segments[segments.length - 1], path: null };
227
+ else steps.push({ label: name, path: null });
228
+ return steps.filter((step, i) => i === 0 || step.label.toLowerCase() !== "index");
229
+ }
230
+ function breadcrumb(title, name) {
231
+ return crumbs(title, name).map((step) => step.label).join(" > ");
232
+ }
233
+ var reveals = createEmitter();
234
+ function revealInTree(path) {
235
+ reveals.emit(path);
236
+ }
237
+ function onRevealInTree(listener) {
238
+ return reveals.subscribe(listener);
239
+ }
240
+ function Crumbs({ entry }) {
241
+ const steps = crumbs(entry.title, entry.name);
242
+ return /* @__PURE__ */ jsx("span", { className: "fg-crumbs", children: steps.map((step, i) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
243
+ i > 0 ? /* @__PURE__ */ jsx("span", { className: "fg-crumbs__sep", "aria-hidden": "true", children: "\u203A" }) : null,
244
+ step.path === null ? /* @__PURE__ */ jsx("span", { className: "fg-crumbs__here", children: step.label }) : /* @__PURE__ */ jsx(
245
+ "button",
246
+ {
247
+ type: "button",
248
+ className: "fg-crumbs__step",
249
+ title: `Show ${step.label} in the story tree`,
250
+ onPointerDown: (event) => event.stopPropagation(),
251
+ onClick: () => revealInTree(step.path),
252
+ children: step.label
253
+ }
254
+ )
255
+ ] }, step.path ?? `${step.label}:${i}`)) });
256
+ }
257
+ function crumbTitle(entry) {
258
+ const title = breadcrumb(entry.title, entry.name);
259
+ return { title, renderTitle: (shown) => shown === title ? /* @__PURE__ */ jsx(Crumbs, { entry }) : shown };
260
+ }
188
261
  function readRoute() {
189
262
  return parseRoute(location.hash).story;
190
263
  }
@@ -236,151 +309,33 @@ function useRoute() {
236
309
  }, []);
237
310
  return [route, setRoute];
238
311
  }
239
-
240
- // src/timeline/playheadUrl.ts
241
- function formatSeconds(ms) {
242
- const rounded = Math.round(ms) / 1e3;
243
- return String(rounded === 0 ? 0 : rounded);
244
- }
245
- function parseSeconds(text) {
246
- if (text == null || !/^\s*-?(\d+\.?\d*|\.\d+)\s*$/.test(text)) return null;
247
- return Math.round(Number(text) * 1e3);
248
- }
249
- function readPlayheadParam() {
250
- return parseSeconds(readRouteParams()[PLAYHEAD_PARAM]);
251
- }
252
- function writePlayheadParam(ms) {
253
- replaceRouteParams(
254
- ({ [PLAYHEAD_PARAM]: _held, ...rest }) => ms === null ? rest : { ...rest, [PLAYHEAD_PARAM]: formatSeconds(ms) }
255
- );
256
- }
257
- function TransportBar({ clock }) {
258
- const { time, playing, loop, rate, span } = useSyncExternalStore(clock.subscribe, clock.get);
259
- return /* @__PURE__ */ jsxs("div", { className: "fg-transport", children: [
260
- /* @__PURE__ */ jsx(
261
- Slider,
262
- {
263
- className: "fg-transport__scrub",
264
- ariaLabel: "Scrub",
265
- thumbShape: "dot",
266
- trackHeight: "var(--wzl-slider-track-h)",
267
- thumbSize: "var(--wzl-slider-thumb-size)",
268
- readoutPlacement: "none",
269
- min: span.start,
270
- max: span.end > span.start ? span.end : span.start + 1,
271
- thumbs: [{ value: time, valueText: `${formatSeconds(time)} seconds` }],
272
- onInput: ([thumb]) => {
273
- if (thumb) clock.seek(thumb.value);
274
- }
275
- }
276
- ),
277
- /* @__PURE__ */ jsx(
278
- Transport,
279
- {
280
- paused: !playing,
281
- loop,
282
- rate,
283
- playhead: time,
284
- duration: span.end,
285
- onPlay: clock.play,
286
- onPause: clock.pause,
287
- onLoopChange: (next) => clock.setLoop(next !== false && next !== 0),
288
- onRateChange: clock.setRate
289
- }
290
- )
291
- ] });
292
- }
293
- function TrialTransport({ trialId }) {
294
- const clock = useTrialClock(trialId);
295
- return clock ? /* @__PURE__ */ jsx(TransportBar, { clock }) : null;
296
- }
297
-
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
- }
303
- function crumbs(title, name) {
304
- const segments = title.split("/");
305
- const steps = segments.map((label, i) => ({ label, path: segments.slice(0, i + 1).join("/") }));
306
- if (restatesTitle(title, name)) steps[steps.length - 1] = { label: segments[segments.length - 1], path: null };
307
- else steps.push({ label: name, path: null });
308
- return steps.filter((step, i) => i === 0 || step.label.toLowerCase() !== "index");
309
- }
310
- function breadcrumb(title, name) {
311
- return crumbs(title, name).map((step) => step.label).join(" > ");
312
- }
313
- var reveals = createEmitter();
314
- function revealInTree(path) {
315
- reveals.emit(path);
316
- }
317
- function onRevealInTree(listener) {
318
- return reveals.subscribe(listener);
319
- }
320
- function Crumbs({ entry }) {
321
- const steps = crumbs(entry.title, entry.name);
322
- return /* @__PURE__ */ jsx("span", { className: "fg-crumbs", children: steps.map((step, i) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
323
- i > 0 ? /* @__PURE__ */ jsx("span", { className: "fg-crumbs__sep", "aria-hidden": "true", children: "\u203A" }) : null,
324
- step.path === null ? /* @__PURE__ */ jsx("span", { className: "fg-crumbs__here", children: step.label }) : /* @__PURE__ */ jsx(
325
- "button",
326
- {
327
- type: "button",
328
- className: "fg-crumbs__step",
329
- title: `Show ${step.label} in the story tree`,
330
- onPointerDown: (event) => event.stopPropagation(),
331
- onClick: () => revealInTree(step.path),
332
- children: step.label
333
- }
334
- )
335
- ] }, step.path ?? `${step.label}:${i}`)) });
336
- }
337
- function crumbTitle(entry) {
338
- const title = breadcrumb(entry.title, entry.name);
339
- return { title, renderTitle: (shown) => shown === title ? /* @__PURE__ */ jsx(Crumbs, { entry }) : shown };
340
- }
341
312
  function IndexTrial({ bundle, setup, config }) {
342
313
  const lab = useLabContext();
343
314
  const trialId = useContext(TrialIdContext);
344
315
  const graph = useContext(DependenciesContext);
345
- const gallery = isGallery(bundle);
346
- const dependencies = gallery ? void 0 : graph?.[bundle.title];
347
316
  const open = (id) => {
348
317
  if (!trialId || !lab.instruments.some((instrument) => instrument.name === id)) return;
349
318
  lab.swapTrial(trialId, id);
350
319
  setRoute(id, { inPlace: true });
351
320
  };
352
- const render = (globals) => {
353
- const env = {
354
- title: bundle.title,
355
- library: bundle.library,
356
- ...bundle.description === void 0 ? {} : { description: bundle.description },
357
- stories: bundle.stories,
358
- descriptions: bundle.descriptions,
359
- gallery,
360
- galleries: bundle.galleries,
361
- labs: bundle.labs,
362
- ...dependencies ? { dependencies } : {},
363
- globals,
364
- decorators: setup.decorators ?? [],
365
- open,
366
- // A story that throws shows its error in its own cell; the rest of the page is still worth reading.
367
- onError: (error) => console.error(error)
368
- };
369
- return /* @__PURE__ */ jsx(IndexPage, { env, render: bundle.render });
370
- };
321
+ const render = (globals) => /* @__PURE__ */ jsx(IndexPage, { env: indexEnvOf(bundle, { graph, globals, decorators: setup.decorators ?? [], open }), render: bundle.render });
371
322
  return /* @__PURE__ */ jsxs(Fragment, { children: [
372
323
  /* @__PURE__ */ jsx(TrialHost, { layout: "fullscreen", setup, config, children: render }),
373
324
  /* @__PURE__ */ jsx(LiveCopies, { trialId, layout: "fullscreen", setup, trialConfig: config, children: render })
374
325
  ] });
375
326
  }
376
- function CaptureInputs({ entry, layout, config, state }) {
327
+ function CaptureInputs({ entry, layout, config, state, timed = false }) {
377
328
  const capture = useContext(LoupeCaptureContext);
378
329
  const trialId = useContext(TrialIdContext);
379
330
  const [overrides] = useCssOverrides();
380
331
  const withdraw = useRef(null);
332
+ const handle = useTrialClockHandle();
333
+ const clock = timed ? handle?.clock ?? null : null;
381
334
  useEffect(() => {
382
- if (capture && trialId) withdraw.current = capture.trials.publish(trialId, { entry, layout, config, state, overrides });
383
- }, [capture, trialId, entry, layout, config, state, overrides]);
335
+ if (!capture || !trialId) return;
336
+ const playhead = () => clock && clock.rate === 0 ? shownTime(clock) : null;
337
+ withdraw.current = capture.trials.publish(trialId, { entry, layout, config, state, overrides, playhead });
338
+ }, [capture, trialId, entry, layout, config, state, overrides, clock]);
384
339
  useEffect(
385
340
  () => () => {
386
341
  withdraw.current?.();
@@ -401,14 +356,81 @@ function LoupeProvider({ captureBase, revision = STILL, children }) {
401
356
  );
402
357
  return /* @__PURE__ */ jsx(LiveLensesContext.Provider, { value: lenses, children: /* @__PURE__ */ jsx(LoupeCaptureContext.Provider, { value: capture, children }) });
403
358
  }
404
- function withPlayhead(schema) {
405
- return f.schema({ ...schema.nodes, [PLAYHEAD_KEY]: f.custom("forge-playhead", null).hidden() });
359
+
360
+ // src/shell/storyMarks.ts
361
+ var SVG_NS = "http://www.w3.org/2000/svg";
362
+ function documentCss(doc) {
363
+ const out = [];
364
+ for (const sheet of Array.from(doc.styleSheets)) {
365
+ try {
366
+ for (const rule of Array.from(sheet.cssRules)) out.push(rule.cssText);
367
+ } catch {
368
+ }
369
+ }
370
+ return out.join("\n");
371
+ }
372
+ function storyBox(host) {
373
+ const w = Math.max(1, host?.offsetWidth ?? 0);
374
+ const h = Math.max(1, host?.offsetHeight ?? 0);
375
+ const scroller = host?.querySelector(":scope > .fg-story__scroll") ?? null;
376
+ return {
377
+ content: { w: Math.max(w, scroller?.scrollWidth ?? 0), h: Math.max(h, scroller?.scrollHeight ?? 0) },
378
+ shown: { x: scroller?.scrollLeft ?? 0, y: scroller?.scrollTop ?? 0, w, h }
379
+ };
380
+ }
381
+ function captureHost(host) {
382
+ const doc = host.ownerDocument;
383
+ const { w, h } = storyBox(host).content;
384
+ const root = doc.createElement("div");
385
+ root.setAttribute("data-fg-capture", "");
386
+ const tokens = scanCssVars(doc, void 0, host).filter((v) => v.value).map((v) => `${v.name}: ${v.value};`).join("");
387
+ const style = doc.createElement("style");
388
+ style.textContent = [
389
+ documentCss(doc),
390
+ `[data-fg-capture]{${tokens}}`,
391
+ `[data-fg-capture] > .fg-story{width:${w}px;height:${h}px}`
392
+ ].join("\n");
393
+ const copy = host.cloneNode(true);
394
+ copy.removeAttribute("data-pending");
395
+ root.append(style, copy);
396
+ const inner = new XMLSerializer().serializeToString(root);
397
+ return {
398
+ kind: "svg",
399
+ markup: `<svg xmlns="${SVG_NS}" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><foreignObject x="0" y="0" width="${w}" height="${h}">${inner}</foreignObject></svg>`
400
+ };
401
+ }
402
+ function storyMarks(hosts) {
403
+ return {
404
+ targets: (_state, _config, trial) => {
405
+ const ref = hosts.ref(trial.id);
406
+ const { content, shown } = storyBox(ref.current);
407
+ return [
408
+ {
409
+ id: "story",
410
+ ref,
411
+ content,
412
+ shown,
413
+ // Out of view, a host holds no content; it is rendered for the capture rather than scrolled to, which
414
+ // would move the reader's view.
415
+ base: async () => {
416
+ const release = await hosts.show(trial.id);
417
+ try {
418
+ if (!ref.current) throw new Error("[forge] the story has no host to capture");
419
+ return captureHost(ref.current);
420
+ } finally {
421
+ release();
422
+ }
423
+ }
424
+ }
425
+ ];
426
+ }
427
+ };
406
428
  }
407
429
  function documentInstrument(options) {
408
- const { entry, loaded, setup, globals = {} } = options;
430
+ const { entry, loaded, setup, globals = {}, hosts } = options;
409
431
  const schema = loaded.kind === "story" ? loaded.story.config : f.schema({});
410
- const timed = loaded.kind === "story" && loaded.story.timeline !== null;
411
- const config = withGlobals(timed ? withPlayhead(schema) : schema, globals);
432
+ const timeline = loaded.kind === "story" ? loaded.story.timeline : null;
433
+ const config = withGlobals(schema, globals);
412
434
  const initialState = loaded.kind === "story" ? loaded.story.initialState : null;
413
435
  return {
414
436
  name: entry.id,
@@ -416,12 +438,25 @@ function documentInstrument(options) {
416
438
  config,
417
439
  defaultConfig: () => config.defaults(),
418
440
  initialState: (c) => initialState?.(c) ?? null,
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 }),
441
+ render: (ctx) => /* @__PURE__ */ jsxs(Fragment, { children: [
442
+ loaded.kind === "story" ? /* @__PURE__ */ jsx(StoryTrial, { story: loaded.story, setup, ctx }, loaded.story.id) : /* @__PURE__ */ jsx(IndexTrial, { bundle: loaded.bundle, setup, config: ctx.config }),
443
+ /* @__PURE__ */ jsx(
444
+ CaptureInputs,
445
+ {
446
+ entry,
447
+ layout: loaded.kind === "story" ? loaded.story.layout : "fullscreen",
448
+ config: ctx.config,
449
+ state: ctx.state,
450
+ timed: timeline !== null
451
+ }
452
+ )
453
+ ] }),
454
+ ...hosts ? { annotations: storyMarks(hosts) } : {},
423
455
  ...loaded.kind === "story" && loaded.story.viewport ? { stage: { size: { width: loaded.story.viewport.width, height: loaded.story.viewport.height } } } : {},
424
- ...timed ? { chrome: [{ id: "fg-transport", region: "status", render: (c) => /* @__PURE__ */ jsx(TrialTransport, { trialId: c.trialId }) }] } : {}
456
+ ...timeline ? {
457
+ clock: (c) => clockSpecOf(timeline(storyConfig(c))),
458
+ chrome: [{ id: "fg-transport", region: "status", render: (c) => /* @__PURE__ */ jsx(TrialTransport, { trialId: c.trialId, className: "fg-transport" }) }]
459
+ } : {}
425
460
  };
426
461
  }
427
462
  function Fault({ phase, message }) {
@@ -797,10 +832,10 @@ function useThemeSave({ trialId, globals, themeApi, scales, overrides, history:
797
832
  ...Object.keys(tokens).map((token) => `${TOKEN_PREFIX}${token}`)
798
833
  ]);
799
834
  const groups = new Set(scales.map((scale) => scale.group));
800
- const now = history2.current();
835
+ const now2 = history2.current();
801
836
  history2.reset({
802
- overrides: Object.fromEntries(Object.entries(now.overrides).filter(([name]) => !saved.has(name))),
803
- scales: Object.fromEntries(Object.entries(now.scales).filter(([group]) => !groups.has(group)))
837
+ overrides: Object.fromEntries(Object.entries(now2.overrides).filter(([name]) => !saved.has(name))),
838
+ scales: Object.fromEntries(Object.entries(now2.scales).filter(([group]) => !groups.has(group)))
804
839
  });
805
840
  setNotice(
806
841
  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("; ")}` }
@@ -1010,16 +1045,16 @@ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
1010
1045
  }
1011
1046
  });
1012
1047
  const write = (name, value) => {
1013
- const now = history2.current();
1014
- const { [name]: _dropped, ...rest } = now.overrides;
1015
- history2.commit({ ...now, overrides: value === null ? rest : { ...rest, [name]: value } }, `token:${name}`);
1048
+ const now2 = history2.current();
1049
+ const { [name]: _dropped, ...rest } = now2.overrides;
1050
+ history2.commit({ ...now2, overrides: value === null ? rest : { ...rest, [name]: value } }, `token:${name}`);
1016
1051
  };
1017
1052
  const onScaleChange = (group, next) => {
1018
1053
  const source = SCALE_SOURCES.find((sc) => sc.group === group);
1019
1054
  if (!source) return;
1020
- const now = history2.current();
1055
+ const now2 = history2.current();
1021
1056
  history2.commit(
1022
- { overrides: { ...now.overrides, ...Object.fromEntries(scaleValues(source, next)) }, scales: { ...now.scales, [group]: next } },
1057
+ { overrides: { ...now2.overrides, ...Object.fromEntries(scaleValues(source, next)) }, scales: { ...now2.scales, [group]: next } },
1023
1058
  `scale:${group}`
1024
1059
  );
1025
1060
  };
@@ -1058,7 +1093,7 @@ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
1058
1093
  }
1059
1094
  ),
1060
1095
  /* @__PURE__ */ jsx(
1061
- Input,
1096
+ Input$1,
1062
1097
  {
1063
1098
  className: "fg-css-vars__filter",
1064
1099
  "aria-label": "Filter",
@@ -1280,6 +1315,137 @@ function infoItem(open) {
1280
1315
  onActivate: open
1281
1316
  };
1282
1317
  }
1318
+ var dateFormat = new Intl.DateTimeFormat(void 0, { dateStyle: "medium" });
1319
+ function IssueList({ component, issues, note }) {
1320
+ if (!issues) return /* @__PURE__ */ jsx("p", { className: "fg-issues__note", children: note ?? "Not connected to an issue tracker." });
1321
+ if (issues.length === 0) return /* @__PURE__ */ jsxs("p", { className: "fg-issues__note", children: [
1322
+ "No open issues for ",
1323
+ component,
1324
+ "."
1325
+ ] });
1326
+ return /* @__PURE__ */ jsx("ul", { className: "fg-issues__list", children: issues.map((issue) => /* @__PURE__ */ jsxs("li", { className: "fg-issues__item", children: [
1327
+ /* @__PURE__ */ jsxs("a", { href: issue.url, target: "_blank", rel: "noreferrer", className: "fg-issues__link", children: [
1328
+ /* @__PURE__ */ jsxs("span", { className: `fg-issues__number ${num.numeric}`, children: [
1329
+ "#",
1330
+ issue.number
1331
+ ] }),
1332
+ /* @__PURE__ */ jsx("span", { className: "fg-issues__title", children: issue.title })
1333
+ ] }),
1334
+ /* @__PURE__ */ jsx("time", { className: `fg-issues__opened ${num.numeric}`, dateTime: issue.opened, children: dateFormat.format(new Date(issue.opened)) })
1335
+ ] }, issue.number)) });
1336
+ }
1337
+ function IssuesPanel({ component, storyId, issues, listNote, onReport }) {
1338
+ const [title, setTitle] = useState("");
1339
+ const [body, setBody] = useState("");
1340
+ const canReport = onReport !== void 0 && title.trim() !== "";
1341
+ const submit = (event) => {
1342
+ event.preventDefault();
1343
+ if (!canReport) return;
1344
+ onReport({ component, storyId, title: title.trim(), body });
1345
+ setTitle("");
1346
+ setBody("");
1347
+ };
1348
+ return /* @__PURE__ */ jsxs("div", { className: "fg-issues", children: [
1349
+ /* @__PURE__ */ jsxs("section", { className: "fg-issues__group", "aria-label": "Open issues", children: [
1350
+ /* @__PURE__ */ jsx("h3", { className: "fg-issues__heading", children: "Open" }),
1351
+ /* @__PURE__ */ jsx(IssueList, { component, ...issues ? { issues } : {}, ...listNote ? { note: listNote } : {} })
1352
+ ] }),
1353
+ /* @__PURE__ */ jsxs("form", { className: "fg-issues__group", "aria-label": "Report an issue", onSubmit: submit, children: [
1354
+ /* @__PURE__ */ jsx("h3", { className: "fg-issues__heading", children: "Report" }),
1355
+ /* @__PURE__ */ jsx(Input, { label: "Title", value: title, onChange: setTitle }),
1356
+ /* @__PURE__ */ jsx(TextArea, { label: "Description", value: body, onChange: setBody, placeholder: "What did you expect, and what happened?" }),
1357
+ /* @__PURE__ */ jsx("div", { className: "fg-issues__actions", children: /* @__PURE__ */ jsx(Button$1, { size: "sm", type: "submit", disabled: !canReport, children: "Report" }) }),
1358
+ onReport ? null : /* @__PURE__ */ jsx("p", { className: "fg-issues__note", children: "Reporting is not connected yet." })
1359
+ ] })
1360
+ ] });
1361
+ }
1362
+
1363
+ // src/shell/issues/tracker.ts
1364
+ async function fetchIssues(base, component, signal) {
1365
+ const res = await fetch(`${base}${ISSUES_PATH}?${new URLSearchParams({ component })}`, signal ? { signal } : {});
1366
+ const type = res.headers.get("Content-Type") ?? "";
1367
+ if (!type.includes("application/json")) throw new Error(`The dev server answered ${res.status} with no issues.`);
1368
+ return await res.json();
1369
+ }
1370
+ function reopenLink(page, story, schema, config, globals) {
1371
+ return `${page}${formatRoute({ story, params: { ...knobsToParams(schema, config), ...pinParams(globals) } })}`;
1372
+ }
1373
+ function reportBody(report, context) {
1374
+ const code = (text) => `\`${text}\``;
1375
+ const checkout = [
1376
+ context.branch === null ? null : `**Branch:** ${code(context.branch)}`,
1377
+ context.commit === null ? null : `**Commit:** ${context.commit}`
1378
+ ].filter((line) => line !== null);
1379
+ return [
1380
+ report.body.trim(),
1381
+ "---",
1382
+ [`**Story:** ${code(report.storyId)}`, `**Reopen:** ${context.link}`, ...checkout].join("\n"),
1383
+ "<!-- Paste a screenshot here. -->"
1384
+ ].filter((part) => part !== "").join("\n\n");
1385
+ }
1386
+ function newIssueUrl(repo, report, context) {
1387
+ const params = new URLSearchParams({
1388
+ title: report.title,
1389
+ body: reportBody(report, context),
1390
+ labels: issueLabel(report.component)
1391
+ });
1392
+ return `${repo.url}/issues/new?${params}`;
1393
+ }
1394
+ var couldNot = (reason) => ({ note: `Could not list issues: ${reason}` });
1395
+ var listingOf = (answer) => answer.issues ? { issues: answer.issues } : answer.unavailable ? { note: answer.unavailable } : couldNot(answer.error ?? "no reason given");
1396
+ var NO_KNOBS = { nodes: {} };
1397
+ function TrackedIssues({ base, storyId, component }) {
1398
+ const lab = useLabContext();
1399
+ const labGlobals2 = useContext(StoryGlobalsContext);
1400
+ const [answer, setAnswer] = useState(null);
1401
+ const [listing, setListing] = useState({ note: "Listing issues\u2026" });
1402
+ useEffect(() => {
1403
+ const abort = new AbortController();
1404
+ fetchIssues(base, component, abort.signal).then(
1405
+ (next) => {
1406
+ setAnswer(next);
1407
+ setListing(listingOf(next));
1408
+ },
1409
+ (err) => {
1410
+ if (!abort.signal.aborted) setListing(couldNot(err instanceof Error ? err.message : String(err)));
1411
+ }
1412
+ );
1413
+ return () => abort.abort();
1414
+ }, [base, component]);
1415
+ const report = (made) => {
1416
+ if (!answer?.repo) return;
1417
+ const trial = lab.trials.find((t) => t.id === lab.focusedTrialId);
1418
+ const config = trial?.config ?? null;
1419
+ const schema = lab.instruments.find((instrument) => instrument.name === storyId)?.config;
1420
+ const globals = effectiveGlobals(labGlobals2, config?.[GLOBALS_KEY]);
1421
+ const page = `${location.origin}${location.pathname}${location.search}`;
1422
+ const link = reopenLink(page, storyId, schema ?? NO_KNOBS, config, globals);
1423
+ window.open(newIssueUrl(answer.repo, made, { link, branch: answer.branch, commit: answer.commit }), "_blank", "noopener");
1424
+ };
1425
+ return /* @__PURE__ */ jsx(
1426
+ IssuesPanel,
1427
+ {
1428
+ component,
1429
+ storyId,
1430
+ ..."issues" in listing ? { issues: listing.issues } : { listNote: listing.note },
1431
+ ...answer?.repo ? { onReport: report } : {}
1432
+ }
1433
+ );
1434
+ }
1435
+ function FocusedIssues({ index, base }) {
1436
+ const { focusedTrialId, trials } = useLabContext();
1437
+ const record = trials.find((trial) => trial.id === focusedTrialId);
1438
+ const entry = record && index.find((e) => e.id === record.instrumentName);
1439
+ if (!entry) return null;
1440
+ const component = entry.componentName ?? entry.title.split("/").at(-1) ?? entry.title;
1441
+ return /* @__PURE__ */ jsxs("section", { className: "fg-issues-focus", "aria-label": "Issues", children: [
1442
+ /* @__PURE__ */ jsx("p", { className: "fg-issues__component", children: component }),
1443
+ base === void 0 ? /* @__PURE__ */ jsx(IssuesPanel, { component, storyId: entry.id }, entry.id) : /* @__PURE__ */ jsx(TrackedIssues, { base, storyId: entry.id, component }, entry.id)
1444
+ ] });
1445
+ }
1446
+ function issuesSection(index, base) {
1447
+ return { id: "fg-issues", region: "aside", item: { title: "Issues", body: /* @__PURE__ */ jsx(FocusedIssues, { index, base }) } };
1448
+ }
1283
1449
  var GLOBALS = "$globals";
1284
1450
  function leaves(shape, prefix = "") {
1285
1451
  const out = [];
@@ -1583,15 +1749,40 @@ var liveCellId = (lensId, compare) => compare === null ? lensId : `${lensId}~com
1583
1749
  function sameRequest(a, b) {
1584
1750
  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
1751
  }
1586
- function createLiveSource(lenses, resolve, conditions) {
1752
+ var LOOK_EVENTS = ["pointerover", "pointerout", "focusin", "focusout", "transitionend"];
1753
+ function watchOf(notify) {
1754
+ const observer = typeof MutationObserver === "undefined" ? null : new MutationObserver(notify);
1755
+ let watched = null;
1756
+ return (el) => {
1757
+ if (el === watched) return;
1758
+ observer?.disconnect();
1759
+ for (const type of LOOK_EVENTS) watched?.removeEventListener(type, notify);
1760
+ watched = el;
1761
+ if (!el) return;
1762
+ observer?.observe(el, { subtree: true, childList: true, characterData: true, attributes: true });
1763
+ for (const type of LOOK_EVENTS) el.addEventListener(type, notify);
1764
+ };
1765
+ }
1766
+ var now = (run) => run();
1767
+ function createLiveSource(lenses, resolve, conditions, schedule = now) {
1587
1768
  const measures = /* @__PURE__ */ new Set();
1588
1769
  const listeners2 = /* @__PURE__ */ new Set();
1770
+ let due = false;
1771
+ const flush = () => {
1772
+ due = false;
1773
+ for (const listener of [...listeners2]) listener();
1774
+ };
1775
+ const notify = () => {
1776
+ if (due) return;
1777
+ due = true;
1778
+ schedule(flush);
1779
+ };
1589
1780
  return {
1590
1781
  kind: "live",
1591
1782
  produces: "dom",
1592
1783
  label: () => "Live",
1593
1784
  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.
1785
+ // A reader hears of what it reads: the lens's copy, and with nothing compared, the main story under the reticle.
1595
1786
  subscribe(listener) {
1596
1787
  listeners2.add(listener);
1597
1788
  return () => {
@@ -1606,10 +1797,9 @@ function createLiveSource(lenses, resolve, conditions) {
1606
1797
  empty.className = "fg-lens-empty";
1607
1798
  empty.hidden = true;
1608
1799
  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 });
1800
+ const watchCopy = watchOf(notify);
1801
+ watchCopy(target);
1802
+ const watchStory = watchOf(notify);
1613
1803
  let request = first;
1614
1804
  let posted = null;
1615
1805
  const post = (next) => {
@@ -1620,6 +1810,7 @@ function createLiveSource(lenses, resolve, conditions) {
1620
1810
  const measure = () => {
1621
1811
  const { lensId, reticle, conditions: compare } = request;
1622
1812
  const hit = trialAt(reticle, doc);
1813
+ watchStory(compare === null ? hit?.host ?? null : null);
1623
1814
  empty.hidden = hit !== null;
1624
1815
  if (!hit) {
1625
1816
  post(null);
@@ -1647,7 +1838,8 @@ function createLiveSource(lenses, resolve, conditions) {
1647
1838
  measure();
1648
1839
  },
1649
1840
  detach() {
1650
- watch?.disconnect();
1841
+ watchCopy(null);
1842
+ watchStory(null);
1651
1843
  measures.delete(measure);
1652
1844
  lenses.set(liveCellId(request.lensId, request.conditions), null);
1653
1845
  target.remove();
@@ -1986,6 +2178,22 @@ function renderReadouts(props) {
1986
2178
  readouts.includes("contrast") ? /* @__PURE__ */ jsx(ContrastReadout, { ...props }) : null
1987
2179
  ] });
1988
2180
  }
2181
+
2182
+ // src/shell/loupe/frameScroll.ts
2183
+ var ORIGIN = { left: 0, top: 0 };
2184
+ var frames = /* @__PURE__ */ new Map();
2185
+ var trialOf = (host) => host.dataset.fgHost ?? "";
2186
+ function reportFrameScroll(host, scroll) {
2187
+ frames.set(trialOf(host), { left: scroll.left, top: scroll.top });
2188
+ host.dispatchEvent(new Event("scroll"));
2189
+ }
2190
+ function scrollOf(host) {
2191
+ const scroller = scrollerOf(host);
2192
+ if (scroller) return { left: scroller.scrollLeft, top: scroller.scrollTop };
2193
+ return frames.get(trialOf(host)) ?? ORIGIN;
2194
+ }
2195
+
2196
+ // src/shell/loupe/capturePayload.ts
1989
2197
  var STATE_UNSENDABLE = "this story's state cannot be sent to a capture";
1990
2198
  var CONFIG_UNSENDABLE = "this story's config cannot be sent to a capture";
1991
2199
  var NO_STORY = "this trial shows no story to capture";
@@ -2018,15 +2226,13 @@ function withoutUndefined(value) {
2018
2226
  }
2019
2227
  var clampDpr = (dpr) => Math.min(Math.max(dpr, MIN_DPR), MAX_DPR);
2020
2228
  function capturePayload(input) {
2021
- const { entry, layout, config, state, globals, overrides, host, dpr, resolved, lab, colorScheme } = input;
2229
+ const { entry, layout, config, state, globals, overrides, host, dpr, resolved, lab, colorScheme, playhead } = input;
2022
2230
  if (!sendable(state)) return { unavailable: STATE_UNSENDABLE };
2023
2231
  const sent = withoutUndefined(withArgs(storyConfig(config), resolved.args));
2024
2232
  if (!sendable(sent)) return { unavailable: CONFIG_UNSENDABLE };
2025
2233
  const box = { width: host.offsetWidth, height: host.offsetHeight };
2026
2234
  if (!(box.width > 0 && box.height > 0)) return { unavailable: NO_BOX };
2027
2235
  const isolate = entry.isolate !== void 0;
2028
- const scroller = scrollerOf(host);
2029
- const kept2 = config?.[PLAYHEAD_KEY];
2030
2236
  return {
2031
2237
  story: entry.id,
2032
2238
  isolate,
@@ -2034,11 +2240,11 @@ function capturePayload(input) {
2034
2240
  state,
2035
2241
  globals,
2036
2242
  overrides,
2037
- playhead: !isolate && typeof kept2 === "number" && Number.isFinite(kept2) ? kept2 : null,
2243
+ playhead: isolate ? null : playhead,
2038
2244
  box,
2039
2245
  layout,
2040
2246
  dpr: clampDpr(resolved.dpr ?? dpr),
2041
- scroll: { left: scroller?.scrollLeft ?? 0, top: scroller?.scrollTop ?? 0 },
2247
+ scroll: scrollOf(host),
2042
2248
  lab,
2043
2249
  colorScheme
2044
2250
  };
@@ -2049,8 +2255,10 @@ function trialPayload(trial, lab, hit, dpr, c) {
2049
2255
  const own = effectiveGlobals(lab.labValues, trial.config?.[GLOBALS_KEY]);
2050
2256
  const resolved = resolveComparison(c, own, ctx);
2051
2257
  const colorScheme = ctx.prefersDark ? "dark" : "light";
2258
+ const { playhead, ...inputs } = trial;
2052
2259
  return capturePayload({
2053
- ...trial,
2260
+ ...inputs,
2261
+ playhead: playhead(),
2054
2262
  globals: resolved.globals,
2055
2263
  host: hit.host,
2056
2264
  dpr,
@@ -2341,7 +2549,14 @@ function useLensFocus() {
2341
2549
  }
2342
2550
  function createApi(scene, refs) {
2343
2551
  let next = 0;
2344
- let hold = null;
2552
+ const holds = /* @__PURE__ */ new Map();
2553
+ const release = (lensId) => {
2554
+ holds.get(lensId)?.drop();
2555
+ holds.delete(lensId);
2556
+ };
2557
+ scene.subscribe(() => {
2558
+ for (const lensId of [...holds.keys()]) if (settingsOf(scene, lensId)?.aim !== "follow") release(lensId);
2559
+ });
2345
2560
  const toBody = (client, box) => ({ x: client.x - box.left, y: client.y - box.top });
2346
2561
  const apply = (ops) => {
2347
2562
  if (ops.length > 0) sceneToAdapter(scene).applyOps([...ops], "lens");
@@ -2373,10 +2588,11 @@ function createApi(scene, refs) {
2373
2588
  if (lensId === null || !body) return;
2374
2589
  const pose = reticleAt(scene, lensId, toBody(client, body.getBoundingClientRect()));
2375
2590
  if (pose === null) return;
2376
- hold ??= scene.overrides.hold();
2591
+ const hold = holds.get(lensId) ?? scene.overrides.hold();
2592
+ holds.set(lensId, hold);
2377
2593
  hold.set([[`${lensId}:reticle`, pose]]);
2378
2594
  },
2379
- unfollow: () => hold?.drop(),
2595
+ unfollow: release,
2380
2596
  sourceAvailable: (kind) => refs.sources.current[kind]?.status() ?? "not provided",
2381
2597
  openCompareMenu: (lensId) => refs.compare?.current?.(lensId)
2382
2598
  };
@@ -2407,6 +2623,21 @@ function useRemeasure(sources, body, rerender) {
2407
2623
  };
2408
2624
  }, [sources, body, rerender]);
2409
2625
  }
2626
+ function useFrameSchedule() {
2627
+ const next = useRef(null);
2628
+ const frame = useVisibleRaf(() => {
2629
+ const run = next.current;
2630
+ next.current = null;
2631
+ run?.();
2632
+ });
2633
+ return useCallback(
2634
+ (run) => {
2635
+ next.current = run;
2636
+ frame.request();
2637
+ },
2638
+ [frame]
2639
+ );
2640
+ }
2410
2641
  function ForgeLoupe({ declarations, modeGlobal }) {
2411
2642
  const lenses = useContext(LiveLensesContext);
2412
2643
  const labValues = useContext(StoryGlobalsContext);
@@ -2417,8 +2648,14 @@ function ForgeLoupe({ declarations, modeGlobal }) {
2417
2648
  const pointer = useRef(null);
2418
2649
  const { focus, onFocusLens } = useLensFocus();
2419
2650
  const conditions = useLatest({ labValues, mode, declarations, modeGlobal });
2651
+ const onFrame = useFrameSchedule();
2420
2652
  const [live] = useState(
2421
- () => lenses ? createLiveSource(lenses, resolveComparison, () => ({ base: conditions.current.labValues, ctx: resolveContextOf(conditions.current) })) : null
2653
+ () => lenses ? createLiveSource(
2654
+ lenses,
2655
+ resolveComparison,
2656
+ () => ({ base: conditions.current.labValues, ctx: resolveContextOf(conditions.current) }),
2657
+ onFrame
2658
+ ) : null
2422
2659
  );
2423
2660
  const pixels = usePixelsSource(conditions);
2424
2661
  const sources = useMemo(
@@ -2478,20 +2715,41 @@ function ForgeLoupe({ declarations, modeGlobal }) {
2478
2715
  function routedTrial(trials, focusedTrialId, route) {
2479
2716
  return trials.find((t) => t.id === focusedTrialId && t.instrumentName === route) ?? trials.find((t) => t.instrumentName === route);
2480
2717
  }
2718
+
2719
+ // src/timeline/playheadUrl.ts
2720
+ function formatSeconds(ms) {
2721
+ const rounded = Math.round(ms) / 1e3;
2722
+ return String(rounded === 0 ? 0 : rounded);
2723
+ }
2724
+ function parseSeconds(text) {
2725
+ if (text == null || !/^\s*-?(\d+\.?\d*|\.\d+)\s*$/.test(text)) return null;
2726
+ return Math.round(Number(text) * 1e3);
2727
+ }
2728
+ function readPlayheadParam() {
2729
+ return parseSeconds(readRouteParams()[PLAYHEAD_PARAM]);
2730
+ }
2731
+ function writePlayheadParam(ms) {
2732
+ replaceRouteParams(
2733
+ ({ [PLAYHEAD_PARAM]: _held, ...rest }) => ms === null ? rest : { ...rest, [PLAYHEAD_PARAM]: formatSeconds(ms) }
2734
+ );
2735
+ }
2481
2736
  function PlayheadSync({ trialId, route, appliedRef }) {
2482
- const clock = useTrialClock(trialId);
2737
+ const handle = useTrialClockHandle(trialId);
2483
2738
  useEffect(() => {
2484
- if (!clock) return;
2739
+ if (!handle) return;
2740
+ const { clock } = handle;
2741
+ let hold = null;
2485
2742
  const fromUrl = () => {
2486
2743
  const t = readPlayheadParam();
2487
- if (t === null || t === clock.get().time) return;
2488
- clock.request(t);
2744
+ if (t === null || t === shownTime(clock)) return;
2745
+ hold?.release();
2746
+ hold = holdAt(handle, t);
2489
2747
  };
2490
2748
  let held;
2491
2749
  const toUrl = () => {
2492
- if (clock.pending() !== null) return;
2493
- const { playing, time, span } = clock.get();
2494
- const next = playing || time === span.start ? null : time;
2750
+ if (hold && hold.pending() !== null) return;
2751
+ const time = shownTime(clock);
2752
+ const next = clock.rate !== 0 || time === clock.origin ? null : time;
2495
2753
  if (next === held) return;
2496
2754
  held = next;
2497
2755
  writePlayheadParam(next);
@@ -2511,9 +2769,10 @@ function PlayheadSync({ trialId, route, appliedRef }) {
2511
2769
  window.addEventListener("hashchange", onHashChange);
2512
2770
  return () => {
2513
2771
  off();
2772
+ hold?.release();
2514
2773
  window.removeEventListener("hashchange", onHashChange);
2515
2774
  };
2516
- }, [clock, route, appliedRef]);
2775
+ }, [handle, route, appliedRef]);
2517
2776
  return null;
2518
2777
  }
2519
2778
  function RoutePlayhead() {
@@ -2668,7 +2927,8 @@ function KnobSync({ schema, config }) {
2668
2927
  if (reading.current && takeRouteFromTrial()) reading.current = false;
2669
2928
  if (reading.current) {
2670
2929
  reading.current = false;
2671
- const knobs2 = paramsToKnobs(schema, readRouteParams());
2930
+ const params = readRouteParams();
2931
+ const knobs2 = paramsToKnobs(schema, params);
2672
2932
  let wrote = false;
2673
2933
  for (const { path, leaf } of knobPaths(schema)) {
2674
2934
  const target = path in knobs2 ? knobs2[path] : leaf.default;
@@ -2677,10 +2937,15 @@ function KnobSync({ schema, config }) {
2677
2937
  setConfig(path, target);
2678
2938
  wrote = true;
2679
2939
  }
2940
+ for (const [path, pin] of Object.entries(paramsToPins(schema, params))) {
2941
+ if (valueAtPath(config, path) === pin) continue;
2942
+ setConfig(path, pin);
2943
+ wrote = true;
2944
+ }
2680
2945
  if (wrote) return;
2681
2946
  }
2682
2947
  const knobs = knobsToParams(schema, config);
2683
- replaceRouteParams((params) => ({ ...knobs, ...reservedParams(params) }));
2948
+ replaceRouteParams((params) => ({ ...knobs, ...withoutPins(reservedParams(params)) }));
2684
2949
  }, [schema, config, setConfig, typed]);
2685
2950
  return null;
2686
2951
  }
@@ -2898,6 +3163,8 @@ var VIEWS = [
2898
3163
  { value: "gallery", label: "Gallery" }
2899
3164
  ];
2900
3165
  var keyOf = (node) => node.kind === "folder" ? `folder:${node.path}` : `story:${node.entry.id}`;
3166
+ var pathOf = (folderKey) => folderKey.slice("folder:".length);
3167
+ var hrefOf = (entry) => `#/${encodeURIComponent(entry.id)}`;
2901
3168
  function ancestorsOf(entry) {
2902
3169
  const paths = /* @__PURE__ */ new Set();
2903
3170
  if (!entry) return paths;
@@ -2911,7 +3178,8 @@ function ancestorsOf(entry) {
2911
3178
  function LibraryBadge2({ library }) {
2912
3179
  return /* @__PURE__ */ jsx(LibraryBadge, { library, className: "fg-tree__tag" });
2913
3180
  }
2914
- function RowTags({ lab, library }) {
3181
+ function rowTags(lab, library) {
3182
+ if (!lab && !library) return void 0;
2915
3183
  return /* @__PURE__ */ jsxs(Fragment, { children: [
2916
3184
  lab ? /* @__PURE__ */ jsx(LabBadge, { className: "fg-tree__lab" }) : null,
2917
3185
  library ? /* @__PURE__ */ jsx(LibraryBadge2, { library }) : null
@@ -2924,8 +3192,6 @@ function StoryTree({ ctx, index }) {
2924
3192
  const [folds, setFolds] = usePersistedState("fg-tree-open", {}, { scope: "lab" });
2925
3193
  const [chosen, setView] = usePersistedState("fg-tree-view", "components", { scope: "lab" });
2926
3194
  const [hidden, setHidden] = usePersistedState("fg-tree-libraries", {}, { scope: "lab" });
2927
- const [focusKey, setFocusKey] = useState(null);
2928
- const items = useRef(/* @__PURE__ */ new Map());
2929
3195
  const table = useContext(LibrariesContext);
2930
3196
  const libraries = useMemo(() => librariesIn(index, table), [index, table]);
2931
3197
  const views = useMemo(() => {
@@ -2953,24 +3219,45 @@ function StoryTree({ ctx, index }) {
2953
3219
  const routed = routable.find((entry) => entry.id === route);
2954
3220
  const routeAncestors = useMemo(() => ancestorsOf(routed), [routed]);
2955
3221
  const filtering = query.trim() !== "";
2956
- const isOpen = (path) => filtering || (folds[path] ?? routeAncestors.has(path));
2957
- const rows = [];
2958
- const collect2 = (nodes, parent) => {
2959
- for (const node of nodes) {
2960
- rows.push({ key: keyOf(node), node, parent });
2961
- if (node.kind === "folder" && isOpen(node.path)) collect2(node.children, keyOf(node));
2962
- }
2963
- };
2964
- collect2(shown, null);
2965
- const active = rows.some((row) => row.key === focusKey) ? focusKey : rows[0]?.key ?? null;
2966
- const openIds = new Set(ctx.trials.map((trial) => trial.instrumentName));
2967
- const setOpen = (path, open) => {
2968
- if (!filtering) setFolds((prev) => ({ ...prev, [path]: open }));
2969
- };
2970
- const moveTo = (key) => {
2971
- if (key === void 0) return;
2972
- setFocusKey(key);
2973
- items.current.get(key)?.focus();
3222
+ const openKey = ctx.trials.map((trial) => trial.instrumentName).join("\n");
3223
+ const openIds = useMemo(() => new Set(openKey.split("\n")), [openKey]);
3224
+ const { nodes, byId, folders: folderIds } = useMemo(() => {
3225
+ const ids = /* @__PURE__ */ new Map();
3226
+ const folders = [];
3227
+ const convert = (list) => list.map((node) => {
3228
+ const id = keyOf(node);
3229
+ ids.set(id, node);
3230
+ if (node.kind === "folder") {
3231
+ folders.push(id);
3232
+ return {
3233
+ id,
3234
+ label: node.label,
3235
+ className: "fg-tree__folder",
3236
+ href: node.index ? hrefOf(node.index) : void 0,
3237
+ current: !!node.index && openIds.has(node.index.id),
3238
+ trailing: rowTags(!!node.index && isLab(node.index), node.tag),
3239
+ children: convert(node.children)
3240
+ };
3241
+ }
3242
+ return {
3243
+ id,
3244
+ label: node.label ?? node.entry.name,
3245
+ className: "fg-tree__story",
3246
+ href: hrefOf(node.entry),
3247
+ current: openIds.has(node.entry.id),
3248
+ trailing: rowTags(isLab(node.entry), node.tag)
3249
+ };
3250
+ });
3251
+ return { nodes: convert(shown), byId: ids, folders };
3252
+ }, [shown, openIds]);
3253
+ const expanded = useMemo(
3254
+ () => new Set(folderIds.filter((id) => filtering || (folds[pathOf(id)] ?? routeAncestors.has(pathOf(id))))),
3255
+ [folderIds, filtering, folds, routeAncestors]
3256
+ );
3257
+ const onExpandedChange = (next) => {
3258
+ if (filtering) return;
3259
+ const changed = folderIds.filter((id) => next.has(id) !== expanded.has(id));
3260
+ if (changed.length) setFolds((prev) => ({ ...prev, ...Object.fromEntries(changed.map((id) => [pathOf(id), next.has(id)])) }));
2974
3261
  };
2975
3262
  const activate = (entry, another) => {
2976
3263
  const focused = ctx.trials.find((trial) => trial.id === ctx.focusedTrialId);
@@ -2986,20 +3273,29 @@ function StoryTree({ ctx, index }) {
2986
3273
  }
2987
3274
  setRoute2(entry.id);
2988
3275
  };
2989
- const openFolder = (node, another) => {
2990
- if (!node.index) {
2991
- setOpen(node.path, !isOpen(node.path));
2992
- return;
2993
- }
2994
- if (folds[node.path] === false) {
2995
- setFolds(({ [node.path]: _dropped, ...rest2 }) => rest2);
2996
- }
2997
- activate(node.index, another);
3276
+ const openIndex = (path, page, another) => {
3277
+ const was = folds[path];
3278
+ setFolds(({ [path]: _toggled, ...rest2 }) => was ? { ...rest2, [path]: was } : rest2);
3279
+ activate(page, another);
3280
+ };
3281
+ const onAction = (id, mods) => {
3282
+ const node = byId.get(id);
3283
+ if (node?.kind === "story") activate(node.entry, mods.shiftKey);
3284
+ else if (node?.index) openIndex(node.path, node.index, mods.shiftKey);
2998
3285
  };
3286
+ const treeEl = useRef(null);
2999
3287
  const scrollTo = useRef(null);
3288
+ useEffect(() => {
3289
+ const id = scrollTo.current;
3290
+ if (id == null) return;
3291
+ const row = [...treeEl.current?.querySelectorAll("[data-tree-id]") ?? []].find((el) => el.dataset.treeId === id);
3292
+ if (!row) return;
3293
+ scrollTo.current = null;
3294
+ row.firstElementChild?.scrollIntoView({ block: "nearest" });
3295
+ });
3000
3296
  const reveal = (path) => {
3001
- const find = (nodes, at) => {
3002
- for (const node2 of nodes) {
3297
+ const find = (list, at) => {
3298
+ for (const node2 of list) {
3003
3299
  if (node2.kind !== "folder") continue;
3004
3300
  if (node2.path === at) return node2;
3005
3301
  const inner = find(node2.children, at);
@@ -3027,135 +3323,11 @@ function StoryTree({ ctx, index }) {
3027
3323
  const segments = found.path.split("/");
3028
3324
  const above = segments.slice(0, -1).map((_, i) => segments.slice(0, i + 1).join("/"));
3029
3325
  setFolds((prev) => ({ ...prev, ...Object.fromEntries(above.map((p) => [p, true])) }));
3030
- setFocusKey(keyOf(found));
3031
3326
  scrollTo.current = keyOf(found);
3032
- if (found.index) openFolder(found, false);
3327
+ if (found.index) openIndex(found.path, found.index, false);
3033
3328
  else setFolds((prev) => ({ ...prev, [found.path]: true }));
3034
3329
  };
3035
3330
  useEffect(() => onRevealInTree(reveal));
3036
- const onKeyDown = (event) => {
3037
- const at = rows.findIndex((row2) => row2.key === active);
3038
- const row = rows[at];
3039
- if (!row) return;
3040
- const { node } = row;
3041
- switch (event.key) {
3042
- case "ArrowDown":
3043
- moveTo(rows[at + 1]?.key);
3044
- break;
3045
- case "ArrowUp":
3046
- moveTo(rows[at - 1]?.key);
3047
- break;
3048
- case "Home":
3049
- moveTo(rows[0]?.key);
3050
- break;
3051
- case "End":
3052
- moveTo(rows[rows.length - 1]?.key);
3053
- break;
3054
- case "ArrowRight":
3055
- if (node.kind !== "folder") break;
3056
- if (!isOpen(node.path)) setOpen(node.path, true);
3057
- else if (rows[at + 1]?.parent === row.key) moveTo(rows[at + 1]?.key);
3058
- break;
3059
- case "ArrowLeft":
3060
- if (node.kind === "folder" && isOpen(node.path) && !filtering) setOpen(node.path, false);
3061
- else moveTo(row.parent ?? void 0);
3062
- break;
3063
- case "Enter":
3064
- case " ":
3065
- if (node.kind === "story") activate(node.entry, event.shiftKey);
3066
- else openFolder(node, event.shiftKey);
3067
- break;
3068
- default:
3069
- return;
3070
- }
3071
- event.preventDefault();
3072
- };
3073
- const itemProps = (node, level) => {
3074
- const key = keyOf(node);
3075
- return {
3076
- role: "treeitem",
3077
- "aria-level": level,
3078
- tabIndex: key === active ? 0 : -1,
3079
- ref: (el) => {
3080
- if (el) items.current.set(key, el);
3081
- else items.current.delete(key);
3082
- if (el && scrollTo.current === key) {
3083
- scrollTo.current = null;
3084
- el.scrollIntoView({ block: "nearest" });
3085
- }
3086
- },
3087
- // A folder item contains its children, so their focus bubbles through it.
3088
- onFocus: (event) => {
3089
- if (event.target === event.currentTarget) setFocusKey(key);
3090
- }
3091
- };
3092
- };
3093
- const renderNodes = (nodes, level) => nodes.map((node) => {
3094
- if (node.kind === "folder") {
3095
- const open = isOpen(node.path);
3096
- return /* @__PURE__ */ jsxs(
3097
- "div",
3098
- {
3099
- ...itemProps(node, level),
3100
- "aria-expanded": open,
3101
- "aria-label": node.label,
3102
- "aria-current": node.index && openIds.has(node.index.id) ? "true" : void 0,
3103
- className: "fg-tree__node",
3104
- children: [
3105
- /* @__PURE__ */ jsxs(
3106
- "div",
3107
- {
3108
- className: "fg-tree__item fg-tree__folder",
3109
- onClick: (event) => {
3110
- setFocusKey(keyOf(node));
3111
- openFolder(node, event.shiftKey);
3112
- },
3113
- children: [
3114
- /* @__PURE__ */ jsx(
3115
- "span",
3116
- {
3117
- "aria-hidden": "true",
3118
- className: "fg-tree__fold",
3119
- onClick: (event) => {
3120
- event.stopPropagation();
3121
- setOpen(node.path, !open);
3122
- },
3123
- children: /* @__PURE__ */ jsx(DisclosureMark, { open })
3124
- }
3125
- ),
3126
- /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: node.label }),
3127
- /* @__PURE__ */ jsx(RowTags, { lab: !!node.index && isLab(node.index), library: node.tag })
3128
- ]
3129
- }
3130
- ),
3131
- open ? /* @__PURE__ */ jsx("div", { role: "group", className: "fg-tree__group", children: renderNodes(node.children, level + 1) }) : null
3132
- ]
3133
- },
3134
- keyOf(node)
3135
- );
3136
- }
3137
- const { entry } = node;
3138
- return /* @__PURE__ */ jsxs(
3139
- "a",
3140
- {
3141
- ...itemProps(node, level),
3142
- href: `#/${encodeURIComponent(entry.id)}`,
3143
- "aria-current": openIds.has(entry.id) ? "true" : void 0,
3144
- className: "fg-tree__item fg-tree__story",
3145
- onClick: (event) => {
3146
- if (event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey) return;
3147
- event.preventDefault();
3148
- setFocusKey(keyOf(node));
3149
- activate(entry, event.shiftKey);
3150
- },
3151
- children: [
3152
- /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: node.label ?? entry.name }),
3153
- /* @__PURE__ */ jsx(RowTags, { lab: isLab(entry), library: node.tag })
3154
- ]
3155
- },
3156
- keyOf(node)
3157
- );
3158
- });
3159
3331
  return /* @__PURE__ */ jsxs("section", { className: "fg-tree", children: [
3160
3332
  /* @__PURE__ */ jsx("h2", { id: headingId, className: "fg-tree__heading", children: "Stories" }),
3161
3333
  route && !routed ? /* @__PURE__ */ jsxs("p", { className: "fg-route-miss", children: [
@@ -3197,7 +3369,19 @@ function StoryTree({ ctx, index }) {
3197
3369
  onChange: (event) => setQuery(event.target.value)
3198
3370
  }
3199
3371
  ),
3200
- /* @__PURE__ */ jsx("div", { role: "tree", "aria-labelledby": headingId, className: "fg-tree__items", onKeyDown, children: renderNodes(shown, 1) })
3372
+ /* @__PURE__ */ jsx(
3373
+ Tree,
3374
+ {
3375
+ ref: treeEl,
3376
+ "aria-labelledby": headingId,
3377
+ className: "fg-tree__items",
3378
+ nodes,
3379
+ empty: filtering ? "No stories match" : "No stories",
3380
+ expandedIds: expanded,
3381
+ onExpandedChange,
3382
+ onAction
3383
+ }
3384
+ )
3201
3385
  ] });
3202
3386
  }
3203
3387
  var KEPT_CONFIGS = 50;
@@ -3276,7 +3460,7 @@ function sendInputs(current, sent, next) {
3276
3460
  return current.inputs !== before;
3277
3461
  }
3278
3462
  function receive(deps, current, msg) {
3279
- const { latest: latestRef, trialId, frames, setFault, setPending, init, copy } = deps;
3463
+ const { latest: latestRef, trialId, frames: frames2, host, setFault, setPending, init, copy } = deps;
3280
3464
  const { ctx: live, answers, onReady } = latestRef.current;
3281
3465
  switch (msg.type) {
3282
3466
  case "ready": {
@@ -3311,15 +3495,18 @@ function receive(deps, current, msg) {
3311
3495
  if (!copy) answers.record(msg.answers);
3312
3496
  break;
3313
3497
  case "vars":
3314
- if (trialId) frames?.report(trialId, msg.vars);
3498
+ if (trialId) frames2?.report(trialId, msg.vars);
3315
3499
  break;
3316
3500
  case "a11y": {
3317
3501
  const outcome = msg.ok ? { ok: true, report: msg.report } : { ok: false, message: msg.message };
3318
- if (trialId) frames?.reportA11y(trialId, outcome);
3502
+ if (trialId) frames2?.reportA11y(trialId, outcome);
3319
3503
  current.pending.get(msg.id)?.(outcome);
3320
3504
  current.pending.delete(msg.id);
3321
3505
  break;
3322
3506
  }
3507
+ case "scroll":
3508
+ if (!copy && host.current) reportFrameScroll(host.current, msg);
3509
+ break;
3323
3510
  case "open": {
3324
3511
  const { lab: liveLab } = latestRef.current;
3325
3512
  if (!trialId || !liveLab.instruments.some((instrument) => instrument.name === msg.id)) break;
@@ -3335,7 +3522,7 @@ function receive(deps, current, msg) {
3335
3522
  }
3336
3523
  }
3337
3524
  function openLink(deps, link, frame) {
3338
- const { latest: latestRef, src, trialId, frames, setFault, setPending } = deps;
3525
+ const { latest: latestRef, src, trialId, frames: frames2, setFault, setPending } = deps;
3339
3526
  closeLink(link);
3340
3527
  setPending(true);
3341
3528
  const target = frame.contentWindow;
@@ -3356,7 +3543,7 @@ function openLink(deps, link, frame) {
3356
3543
  if (current.hasRendered) ask();
3357
3544
  else current.queued.push(ask);
3358
3545
  });
3359
- const disconnect = frames && trialId ? frames.connect(trialId, { send: channel.send, audit }) : () => {
3546
+ const disconnect = frames2 && trialId ? frames2.connect(trialId, { send: channel.send, audit }) : () => {
3360
3547
  };
3361
3548
  const current = {
3362
3549
  channel,
@@ -3377,7 +3564,7 @@ function openLink(deps, link, frame) {
3377
3564
  ]);
3378
3565
  }
3379
3566
  function useFrameLink(opts) {
3380
- const { latest: latestRef, src, trialId, frames, setFault, setPending, copy, config, state, globals, descriptionKey } = opts;
3567
+ const { latest: latestRef, src, trialId, frames: frames2, host, setFault, setPending, copy, config, state, globals, descriptionKey } = opts;
3381
3568
  const link = useRef(null);
3382
3569
  const init = useCallback((current) => {
3383
3570
  const { ctx: live, globals: liveGlobals } = latestRef.current;
@@ -3386,7 +3573,7 @@ function useFrameLink(opts) {
3386
3573
  current.inputs += 1;
3387
3574
  current.channel.send({ type: "init", ...current.sent });
3388
3575
  }, [latestRef]);
3389
- const deps = { latest: latestRef, src, trialId, frames, setFault, setPending, copy, init };
3576
+ const deps = { latest: latestRef, src, trialId, frames: frames2, host, setFault, setPending, copy, init };
3390
3577
  const connect = (frame) => openLink(deps, link, frame);
3391
3578
  const adopt2 = (adopted) => {
3392
3579
  adopted.deliver = (msg) => receive(deps, adopted, msg);
@@ -3504,7 +3691,7 @@ function FrameView(props) {
3504
3691
  [copy, trialCtx, laidConfig, laidSetConfig]
3505
3692
  );
3506
3693
  const trialId = useContext(TrialIdContext);
3507
- const frames = useContext(TrialFramesContext);
3694
+ const frames2 = useContext(TrialFramesContext);
3508
3695
  const [overrides] = useCssOverrides();
3509
3696
  const pool = useContext(FramePoolContext);
3510
3697
  const lab = useLabContext();
@@ -3521,7 +3708,8 @@ function FrameView(props) {
3521
3708
  latest: latestRef,
3522
3709
  src,
3523
3710
  trialId: copy ? null : trialId,
3524
- frames: copy ? null : frames,
3711
+ frames: copy ? null : frames2,
3712
+ host: hostRef,
3525
3713
  setFault,
3526
3714
  setPending,
3527
3715
  copy: copy !== void 0,
@@ -3550,6 +3738,24 @@ function FrameView(props) {
3550
3738
  if (iframeRef.current) iframeRef.current.title = title;
3551
3739
  }, [title]);
3552
3740
  useEffect(() => copy ? void 0 : answers.hold(ctx.config), [answers, ctx.config, copy]);
3741
+ const setup = props.setup;
3742
+ const copyId = copy?.id;
3743
+ useLayoutEffect(
3744
+ () => copyId && setup && hostRef.current ? applyGlobalsTo(hostRef.current, copyId, globals, setup) : void 0,
3745
+ [copyId, globals, setup]
3746
+ );
3747
+ const factor = copy?.factor;
3748
+ useEffect(() => {
3749
+ if (factor !== void 0) iframeRef.current?.contentWindow?.dispatchEvent(new Event("resize"));
3750
+ }, [factor]);
3751
+ const main = copy?.main;
3752
+ useEffect(() => {
3753
+ if (!main || pending2) return void 0;
3754
+ const follow = () => link.current?.channel.send({ type: "scroll", ...scrollOf(main) });
3755
+ follow();
3756
+ main.addEventListener("scroll", follow, { passive: true });
3757
+ return () => main.removeEventListener("scroll", follow);
3758
+ }, [main, pending2, link]);
3553
3759
  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: [
3554
3760
  /* @__PURE__ */ jsx("div", { ref: slotRef, className: "fg-frame-slot" }),
3555
3761
  fault ? /* @__PURE__ */ jsxs("div", { className: "fg-fault", role: "alert", children: [
@@ -3560,7 +3766,7 @@ function FrameView(props) {
3560
3766
  ] });
3561
3767
  }
3562
3768
  function storyInstrument(options) {
3563
- const { entry, ready, answers, frameUrl, onReady, globals = {} } = options;
3769
+ const { entry, ready, answers, frameUrl, onReady, globals = {}, setup } = options;
3564
3770
  const config = withGlobals(
3565
3771
  ready ? schemaFromDescription(ready.schema, answers) : f.schema({}),
3566
3772
  globals
@@ -3582,10 +3788,11 @@ function storyInstrument(options) {
3582
3788
  answers,
3583
3789
  onReady,
3584
3790
  descriptionKey,
3585
- ctx
3791
+ ctx,
3792
+ ...setup ? { setup } : {}
3586
3793
  }
3587
3794
  ),
3588
- /* @__PURE__ */ jsx(CaptureInputs, { entry, layout: ready?.layout ?? "centered", config: ctx.config, state: ctx.state })
3795
+ /* @__PURE__ */ jsx(CaptureInputs, { entry, layout: ready?.layout ?? DEFAULT_LAYOUT, config: ctx.config, state: ctx.state })
3589
3796
  ] }),
3590
3797
  // Taken from the index until the frame reports, so the trial does not move the frame into a stage and reload it.
3591
3798
  ...viewport ? { stage: { size: { width: viewport.width, height: viewport.height } } } : {}
@@ -3598,7 +3805,7 @@ var NO_SETUP = {};
3598
3805
  var pruned = (map, ids) => [...map.keys()].every((id) => ids.has(id)) ? map : new Map([...map].filter(([id]) => ids.has(id)));
3599
3806
  var toError = (error) => error instanceof Error ? error : new Error(String(error));
3600
3807
  function useStoryRegistry(index, options) {
3601
- const { frameUrl, globals = NO_GLOBALS2, importers, setup = NO_SETUP, libraries = TITLE_LIBRARIES, load = loadStories } = options;
3808
+ const { frameUrl, globals = NO_GLOBALS2, importers, setup = NO_SETUP, libraries = TITLE_LIBRARIES, load = loadStories, hosts } = options;
3602
3809
  const [readies, setReadies] = useState(() => /* @__PURE__ */ new Map());
3603
3810
  const [loaded, setLoaded] = useState(() => /* @__PURE__ */ new Map());
3604
3811
  const [faults, setFaults] = useState(() => /* @__PURE__ */ new Map());
@@ -3654,30 +3861,9 @@ function useStoryRegistry(index, options) {
3654
3861
  });
3655
3862
  };
3656
3863
  if (isIndexId(entry.id)) {
3657
- const entries = liveRef.current.index.filter((e) => !isIndexId(e.id) && indexId(e.title) === entry.id);
3658
- const files = [...new Set(entries.map((e) => e.file))];
3659
- void Promise.all(files.map(importOf)).then(async (mods) => {
3660
- const byFile = new Map(files.map((file, i) => [file, loadModule(mods[i] ?? {}, entry.title, current.parameters)]));
3661
- const stories = entries.flatMap(
3662
- (e) => byFile.get(e.file)?.find((s) => s.exportName === e.exportName) ?? []
3663
- );
3664
- for (const story of stories) await current.prepare?.(story);
3665
- const result = /* @__PURE__ */ new Map();
3666
- result.set(entry.id, {
3667
- kind: "index",
3668
- bundle: {
3669
- title: entry.title,
3670
- library: liveRef.current.libraries.of(entry),
3671
- ...entry.componentDescription === void 0 ? {} : { description: entry.componentDescription },
3672
- ...entry.tags === void 0 ? {} : { tags: entry.tags },
3673
- ...taggedIds(entries),
3674
- stories,
3675
- descriptions: Object.fromEntries(entries.flatMap((e) => e.description ? [[e.id, e.description]] : [])),
3676
- render: mods.map(indexRenderOf).find((render) => render !== null) ?? null
3677
- }
3678
- });
3679
- settle([entry.id], result);
3680
- }).catch((error) => fail([entry.id], error));
3864
+ const { index: entries, libraries: libraries2 } = liveRef.current;
3865
+ const source = { index: entries, importOf, load: loadModule, parameters: current.parameters, prepare: current.prepare, libraries: libraries2 };
3866
+ void loadIndexBundle(entry.id, source).then((bundle) => settle([entry.id], /* @__PURE__ */ new Map([[entry.id, { kind: "index", bundle }]]))).catch((error) => fail([entry.id], error));
3681
3867
  return;
3682
3868
  }
3683
3869
  const siblings = liveRef.current.index.filter((e) => e.file === entry.file && !isIndexId(e.id));
@@ -3742,14 +3928,14 @@ function useStoryRegistry(index, options) {
3742
3928
  inDocument,
3743
3929
  generation,
3744
3930
  revision,
3745
- instrument: !inDocument ? storyInstrument({ entry, ready, answers, frameUrl, onReady, globals }) : had ? documentInstrument({ entry, loaded: had, setup, globals }) : pendingInstrument({ entry, globals, load: request, generation, ...fault ? { fault } : {} })
3931
+ instrument: !inDocument ? storyInstrument({ entry, ready, answers, frameUrl, onReady, globals, setup }) : had ? documentInstrument({ entry, loaded: had, setup, globals, ...hosts ? { hosts } : {} }) : pendingInstrument({ entry, globals, load: request, generation, ...fault ? { fault } : {} })
3746
3932
  };
3747
3933
  built.set(entry.id, kept2);
3748
3934
  return kept2.instrument;
3749
3935
  });
3750
3936
  const unchanged = list.length === previous.list.length && list.every((i, n) => i === previous.list[n]);
3751
3937
  return { built, books, list: unchanged ? previous.list : list };
3752
- }, [index, readies, loaded, faults, generations, revisions2, frameUrl, globals, setup, importers, onReady, request]);
3938
+ }, [index, readies, loaded, faults, generations, revisions2, frameUrl, globals, setup, hosts, importers, onReady, request]);
3753
3939
  useLayoutEffect(() => {
3754
3940
  committed.current = cache;
3755
3941
  }, [cache]);
@@ -3788,6 +3974,19 @@ function useDocumentTitle(title) {
3788
3974
  document.title = title;
3789
3975
  }, [title]);
3790
3976
  }
3977
+ function useFramePool(frameUrl, framed) {
3978
+ const [pool, setPool] = useState(null);
3979
+ useEffect(() => {
3980
+ if (!framed) return;
3981
+ const next = createFramePool(frameUrl);
3982
+ setPool(next);
3983
+ return () => {
3984
+ next.dispose();
3985
+ setPool(null);
3986
+ };
3987
+ }, [frameUrl, framed]);
3988
+ return pool;
3989
+ }
3791
3990
  function NoStories({ stories }) {
3792
3991
  return /* @__PURE__ */ jsxs("div", { className: "fg-empty", children: [
3793
3992
  /* @__PURE__ */ jsx("p", { className: "fg-empty__title", children: "No stories found" }),
@@ -3799,19 +3998,9 @@ function Workshop(props) {
3799
3998
  const declarations = config?.globals ?? NO_DECLARATIONS;
3800
3999
  const title = config?.title ?? DEFAULT_TITLE;
3801
4000
  useDocumentTitle(title);
3802
- const [frames] = useState(createTrialFrames);
3803
- const [clocks] = useState(createTrialClocks);
3804
- const [pool, setPool] = useState(null);
3805
- const framed = importers === void 0 || index.some((entry) => entry.isolate !== void 0);
3806
- useEffect(() => {
3807
- if (!framed) return;
3808
- const next = createFramePool(frameUrl);
3809
- setPool(next);
3810
- return () => {
3811
- next.dispose();
3812
- setPool(null);
3813
- };
3814
- }, [frameUrl, framed]);
4001
+ const [frames2] = useState(createTrialFrames);
4002
+ const [hosts] = useState(createStoryHosts);
4003
+ const pool = useFramePool(frameUrl, importers === void 0 || index.some((entry) => entry.isolate !== void 0));
3815
4004
  const entries = useMemo(() => [...index, ...indexEntries(index)], [index]);
3816
4005
  const table = config?.libraries;
3817
4006
  const libraries = useMemo(() => librariesFrom(table), [table]);
@@ -3819,6 +4008,7 @@ function Workshop(props) {
3819
4008
  frameUrl,
3820
4009
  globals: declarations,
3821
4010
  libraries,
4011
+ hosts,
3822
4012
  ...importers ? { importers } : {},
3823
4013
  ...setup ? { setup } : {}
3824
4014
  });
@@ -3848,18 +4038,19 @@ function Workshop(props) {
3848
4038
  { id: "fg-stories", region: "sidebar", render: (ctx) => /* @__PURE__ */ jsx(StoryTree, { ctx, index }) },
3849
4039
  ...showsCssVars ? cssVarsChrome2 : [],
3850
4040
  A11Y_SECTION,
4041
+ issuesSection(index, props.issuesBase),
3851
4042
  ...keysChrome2,
3852
4043
  ...Object.keys(declarations).length > 0 ? [{ id: "fg-globals", region: "header", render: () => /* @__PURE__ */ jsx(GlobalsToolbar, { declarations }) }] : [],
3853
4044
  { id: "fg-info", region: "header", group: "story", item: infoCommand },
3854
4045
  { id: "fg-loupe", region: "overlay", render: () => /* @__PURE__ */ jsx(ForgeLoupe, { declarations, modeGlobal }) },
3855
4046
  ...config?.labChrome ?? []
3856
4047
  ],
3857
- [index, infoCommand, declarations, showsCssVars, cssVarsChrome2, keysChrome2, config?.labChrome, modeGlobal]
4048
+ [index, props.issuesBase, infoCommand, declarations, showsCssVars, cssVarsChrome2, keysChrome2, config?.labChrome, modeGlobal]
3858
4049
  );
3859
4050
  const [menus, setMenus] = useState();
3860
4051
  const first = index[0];
3861
4052
  if (!first) return /* @__PURE__ */ jsx(NoStories, { stories });
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(
4053
+ 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: frames2, children: /* @__PURE__ */ jsx(StoryHostsContext.Provider, { value: hosts, children: /* @__PURE__ */ jsx(LoupeProvider, { captureBase: props.captureBase, revision: props.revision, children: /* @__PURE__ */ jsxs(
3863
4054
  Lab,
3864
4055
  {
3865
4056
  title,
@@ -3871,6 +4062,7 @@ function Workshop(props) {
3871
4062
  ...storage ? { storage } : {},
3872
4063
  labChrome,
3873
4064
  addTrial: false,
4065
+ marks: false,
3874
4066
  ...menus ? { menus } : {},
3875
4067
  ...config?.controls ? { controls: config.controls } : {},
3876
4068
  ...config?.pages ? { pages: config.pages } : {},