@weasel-js/forge 1.13.0 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/dist/{FrameController-DgfVaENb.d.ts → FrameController-BM6xuEfY.d.ts} +6 -10
  3. package/dist/{chunk-7XJNVJB5.js → chunk-B26DWEYT.js} +3 -3
  4. package/dist/{chunk-7XJNVJB5.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-GHPMNT2A.js → chunk-PGLOYXDP.js} +184 -184
  10. package/dist/chunk-PGLOYXDP.js.map +1 -0
  11. package/dist/{chunk-WFT5IOHX.js → chunk-PPB7VP6R.js} +5 -4
  12. package/dist/chunk-PPB7VP6R.js.map +1 -0
  13. package/dist/{chunk-COT4I347.js → chunk-SFKDHXVE.js} +26 -4
  14. package/dist/chunk-SFKDHXVE.js.map +1 -0
  15. package/dist/chunk-STK2AZH2.js +11 -0
  16. package/dist/chunk-STK2AZH2.js.map +1 -0
  17. package/dist/chunk-TT4ZOYB6.js +143 -0
  18. package/dist/chunk-TT4ZOYB6.js.map +1 -0
  19. package/dist/{chunk-XIBKYSRU.js → chunk-UDEUT7AG.js} +124 -80
  20. package/dist/chunk-UDEUT7AG.js.map +1 -0
  21. package/dist/{config-6fWf87Tt.d.ts → config-DOjWyIui.d.ts} +1 -1
  22. package/dist/csf/preview-api.js +2 -2
  23. package/dist/frame/index.css +0 -2
  24. package/dist/frame/index.css.map +1 -1
  25. package/dist/frame/index.d.ts +4 -4
  26. package/dist/frame/index.js +9 -6
  27. package/dist/frame/index.js.map +1 -1
  28. package/dist/index.d.ts +11 -5
  29. package/dist/index.js +4 -4
  30. package/dist/{mountFrame-Bs7u1ro-.d.ts → mountFrame-CRPAjg1M.d.ts} +1 -1
  31. package/dist/shell/index.css +164 -147
  32. package/dist/shell/index.css.map +1 -1
  33. package/dist/shell/index.d.ts +23 -5
  34. package/dist/shell/index.js +454 -318
  35. package/dist/shell/index.js.map +1 -1
  36. package/dist/test/index.d.ts +1 -1
  37. package/dist/test/index.js +8 -10
  38. package/dist/test/index.js.map +1 -1
  39. package/dist/vite/index.js +118 -30
  40. package/dist/vite/index.js.map +1 -1
  41. package/package.json +11 -9
  42. package/dist/chunk-2ZBZRBHI.js +0 -10
  43. package/dist/chunk-2ZBZRBHI.js.map +0 -1
  44. package/dist/chunk-3BAM6M26.js.map +0 -1
  45. package/dist/chunk-COT4I347.js.map +0 -1
  46. package/dist/chunk-GHPMNT2A.js.map +0 -1
  47. package/dist/chunk-L5XM5VKB.js +0 -47
  48. package/dist/chunk-L5XM5VKB.js.map +0 -1
  49. package/dist/chunk-ORMMZ5PV.js.map +0 -1
  50. package/dist/chunk-WFT5IOHX.js.map +0 -1
  51. package/dist/chunk-XIBKYSRU.js.map +0 -1
@@ -1,24 +1,22 @@
1
- import { parseRoute, formatRoute, paramsToKnobs, knobPaths, sameKnob, knobsToParams, PLAYHEAD_PARAM, reservedParams } from '../chunk-COT4I347.js';
2
- import { DEFAULT_TITLE, CAPTURE_PATH, messageOf, MIN_DPR, MAX_DPR } from '../chunk-ORMMZ5PV.js';
3
- import { TITLE_LIBRARIES, loadIndexBundle, librariesFrom, LibrariesContext, LAB_DENSITY, createKitRoot, schemaFromDescription, LibraryBadge, Description, IndexPage, indexEnvOf, LabBadge, openChannel } from '../chunk-WFT5IOHX.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
4
  import { isIndexId, indexId, indexEntries } from '../chunk-RVJHUY7W.js';
5
5
  import { 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, scrollerOf, withArgs } from '../chunk-XIBKYSRU.js';
7
- export { StoryGlobalsContext, StoryTrial, TrialFramesContext, TrialHost, useTrialFrame } from '../chunk-XIBKYSRU.js';
8
- import { labGlobals, withGlobals, followApp, storyConfig, GLOBALS_KEY, effectiveGlobals, PLAYHEAD_KEY, labOptions, isForgePath, isGlobalsPath } from '../chunk-3BAM6M26.js';
9
- import { loadStories, useNearViewport, IN_VIEW_MARGIN, applyGlobalsTo, varRefs, cssRulesOf } from '../chunk-GHPMNT2A.js';
10
- import '../chunk-L5XM5VKB.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-7XJNVJB5.js';
13
- import { stableStringify, FRAME_HELLO, PORT_HANDOFF, PROTOCOL_VERSION } from '../chunk-2ZBZRBHI.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
22
  import { useLatest, useActionsRegistry, useVisibleRaf, effectivePose, useMenuHost, useMenus, sceneToAdapter, parseColor, contrastRatio, wcagLevels, historyKey, isPlainObject, createHistory, keymapShortcuts, actionBindings, keySpecShortcut, keymapLeaderId, swapOp } from '@weasel-js/core';
@@ -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,108 +309,6 @@ 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);
@@ -353,14 +324,18 @@ function IndexTrial({ bundle, setup, config }) {
353
324
  /* @__PURE__ */ jsx(LiveCopies, { trialId, layout: "fullscreen", setup, trialConfig: config, children: render })
354
325
  ] });
355
326
  }
356
- function CaptureInputs({ entry, layout, config, state }) {
327
+ function CaptureInputs({ entry, layout, config, state, timed = false }) {
357
328
  const capture = useContext(LoupeCaptureContext);
358
329
  const trialId = useContext(TrialIdContext);
359
330
  const [overrides] = useCssOverrides();
360
331
  const withdraw = useRef(null);
332
+ const handle = useTrialClockHandle();
333
+ const clock = timed ? handle?.clock ?? null : null;
361
334
  useEffect(() => {
362
- if (capture && trialId) withdraw.current = capture.trials.publish(trialId, { entry, layout, config, state, overrides });
363
- }, [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]);
364
339
  useEffect(
365
340
  () => () => {
366
341
  withdraw.current?.();
@@ -381,14 +356,81 @@ function LoupeProvider({ captureBase, revision = STILL, children }) {
381
356
  );
382
357
  return /* @__PURE__ */ jsx(LiveLensesContext.Provider, { value: lenses, children: /* @__PURE__ */ jsx(LoupeCaptureContext.Provider, { value: capture, children }) });
383
358
  }
384
- function withPlayhead(schema) {
385
- 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
+ };
386
428
  }
387
429
  function documentInstrument(options) {
388
- const { entry, loaded, setup, globals = {} } = options;
430
+ const { entry, loaded, setup, globals = {}, hosts } = options;
389
431
  const schema = loaded.kind === "story" ? loaded.story.config : f.schema({});
390
- const timed = loaded.kind === "story" && loaded.story.timeline !== null;
391
- const config = withGlobals(timed ? withPlayhead(schema) : schema, globals);
432
+ const timeline = loaded.kind === "story" ? loaded.story.timeline : null;
433
+ const config = withGlobals(schema, globals);
392
434
  const initialState = loaded.kind === "story" ? loaded.story.initialState : null;
393
435
  return {
394
436
  name: entry.id,
@@ -404,12 +446,17 @@ function documentInstrument(options) {
404
446
  entry,
405
447
  layout: loaded.kind === "story" ? loaded.story.layout : "fullscreen",
406
448
  config: ctx.config,
407
- state: ctx.state
449
+ state: ctx.state,
450
+ timed: timeline !== null
408
451
  }
409
452
  )
410
453
  ] }),
454
+ ...hosts ? { annotations: storyMarks(hosts) } : {},
411
455
  ...loaded.kind === "story" && loaded.story.viewport ? { stage: { size: { width: loaded.story.viewport.width, height: loaded.story.viewport.height } } } : {},
412
- ...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
+ } : {}
413
460
  };
414
461
  }
415
462
  function Fault({ phase, message }) {
@@ -1046,7 +1093,7 @@ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
1046
1093
  }
1047
1094
  ),
1048
1095
  /* @__PURE__ */ jsx(
1049
- Input,
1096
+ Input$1,
1050
1097
  {
1051
1098
  className: "fg-css-vars__filter",
1052
1099
  "aria-label": "Filter",
@@ -1268,6 +1315,137 @@ function infoItem(open) {
1268
1315
  onActivate: open
1269
1316
  };
1270
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
+ }
1271
1449
  var GLOBALS = "$globals";
1272
1450
  function leaves(shape, prefix = "") {
1273
1451
  const out = [];
@@ -2048,14 +2226,13 @@ function withoutUndefined(value) {
2048
2226
  }
2049
2227
  var clampDpr = (dpr) => Math.min(Math.max(dpr, MIN_DPR), MAX_DPR);
2050
2228
  function capturePayload(input) {
2051
- 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;
2052
2230
  if (!sendable(state)) return { unavailable: STATE_UNSENDABLE };
2053
2231
  const sent = withoutUndefined(withArgs(storyConfig(config), resolved.args));
2054
2232
  if (!sendable(sent)) return { unavailable: CONFIG_UNSENDABLE };
2055
2233
  const box = { width: host.offsetWidth, height: host.offsetHeight };
2056
2234
  if (!(box.width > 0 && box.height > 0)) return { unavailable: NO_BOX };
2057
2235
  const isolate = entry.isolate !== void 0;
2058
- const kept2 = config?.[PLAYHEAD_KEY];
2059
2236
  return {
2060
2237
  story: entry.id,
2061
2238
  isolate,
@@ -2063,7 +2240,7 @@ function capturePayload(input) {
2063
2240
  state,
2064
2241
  globals,
2065
2242
  overrides,
2066
- playhead: !isolate && typeof kept2 === "number" && Number.isFinite(kept2) ? kept2 : null,
2243
+ playhead: isolate ? null : playhead,
2067
2244
  box,
2068
2245
  layout,
2069
2246
  dpr: clampDpr(resolved.dpr ?? dpr),
@@ -2078,8 +2255,10 @@ function trialPayload(trial, lab, hit, dpr, c) {
2078
2255
  const own = effectiveGlobals(lab.labValues, trial.config?.[GLOBALS_KEY]);
2079
2256
  const resolved = resolveComparison(c, own, ctx);
2080
2257
  const colorScheme = ctx.prefersDark ? "dark" : "light";
2258
+ const { playhead, ...inputs } = trial;
2081
2259
  return capturePayload({
2082
- ...trial,
2260
+ ...inputs,
2261
+ playhead: playhead(),
2083
2262
  globals: resolved.globals,
2084
2263
  host: hit.host,
2085
2264
  dpr,
@@ -2536,20 +2715,41 @@ function ForgeLoupe({ declarations, modeGlobal }) {
2536
2715
  function routedTrial(trials, focusedTrialId, route) {
2537
2716
  return trials.find((t) => t.id === focusedTrialId && t.instrumentName === route) ?? trials.find((t) => t.instrumentName === route);
2538
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
+ }
2539
2736
  function PlayheadSync({ trialId, route, appliedRef }) {
2540
- const clock = useTrialClock(trialId);
2737
+ const handle = useTrialClockHandle(trialId);
2541
2738
  useEffect(() => {
2542
- if (!clock) return;
2739
+ if (!handle) return;
2740
+ const { clock } = handle;
2741
+ let hold = null;
2543
2742
  const fromUrl = () => {
2544
2743
  const t = readPlayheadParam();
2545
- if (t === null || t === clock.get().time) return;
2546
- clock.request(t);
2744
+ if (t === null || t === shownTime(clock)) return;
2745
+ hold?.release();
2746
+ hold = holdAt(handle, t);
2547
2747
  };
2548
2748
  let held;
2549
2749
  const toUrl = () => {
2550
- if (clock.pending() !== null) return;
2551
- const { playing, time, span } = clock.get();
2552
- 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;
2553
2753
  if (next === held) return;
2554
2754
  held = next;
2555
2755
  writePlayheadParam(next);
@@ -2569,9 +2769,10 @@ function PlayheadSync({ trialId, route, appliedRef }) {
2569
2769
  window.addEventListener("hashchange", onHashChange);
2570
2770
  return () => {
2571
2771
  off();
2772
+ hold?.release();
2572
2773
  window.removeEventListener("hashchange", onHashChange);
2573
2774
  };
2574
- }, [clock, route, appliedRef]);
2775
+ }, [handle, route, appliedRef]);
2575
2776
  return null;
2576
2777
  }
2577
2778
  function RoutePlayhead() {
@@ -2726,7 +2927,8 @@ function KnobSync({ schema, config }) {
2726
2927
  if (reading.current && takeRouteFromTrial()) reading.current = false;
2727
2928
  if (reading.current) {
2728
2929
  reading.current = false;
2729
- const knobs2 = paramsToKnobs(schema, readRouteParams());
2930
+ const params = readRouteParams();
2931
+ const knobs2 = paramsToKnobs(schema, params);
2730
2932
  let wrote = false;
2731
2933
  for (const { path, leaf } of knobPaths(schema)) {
2732
2934
  const target = path in knobs2 ? knobs2[path] : leaf.default;
@@ -2735,10 +2937,15 @@ function KnobSync({ schema, config }) {
2735
2937
  setConfig(path, target);
2736
2938
  wrote = true;
2737
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
+ }
2738
2945
  if (wrote) return;
2739
2946
  }
2740
2947
  const knobs = knobsToParams(schema, config);
2741
- replaceRouteParams((params) => ({ ...knobs, ...reservedParams(params) }));
2948
+ replaceRouteParams((params) => ({ ...knobs, ...withoutPins(reservedParams(params)) }));
2742
2949
  }, [schema, config, setConfig, typed]);
2743
2950
  return null;
2744
2951
  }
@@ -2956,6 +3163,8 @@ var VIEWS = [
2956
3163
  { value: "gallery", label: "Gallery" }
2957
3164
  ];
2958
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)}`;
2959
3168
  function ancestorsOf(entry) {
2960
3169
  const paths = /* @__PURE__ */ new Set();
2961
3170
  if (!entry) return paths;
@@ -2969,7 +3178,8 @@ function ancestorsOf(entry) {
2969
3178
  function LibraryBadge2({ library }) {
2970
3179
  return /* @__PURE__ */ jsx(LibraryBadge, { library, className: "fg-tree__tag" });
2971
3180
  }
2972
- function RowTags({ lab, library }) {
3181
+ function rowTags(lab, library) {
3182
+ if (!lab && !library) return void 0;
2973
3183
  return /* @__PURE__ */ jsxs(Fragment, { children: [
2974
3184
  lab ? /* @__PURE__ */ jsx(LabBadge, { className: "fg-tree__lab" }) : null,
2975
3185
  library ? /* @__PURE__ */ jsx(LibraryBadge2, { library }) : null
@@ -2982,8 +3192,6 @@ function StoryTree({ ctx, index }) {
2982
3192
  const [folds, setFolds] = usePersistedState("fg-tree-open", {}, { scope: "lab" });
2983
3193
  const [chosen, setView] = usePersistedState("fg-tree-view", "components", { scope: "lab" });
2984
3194
  const [hidden, setHidden] = usePersistedState("fg-tree-libraries", {}, { scope: "lab" });
2985
- const [focusKey, setFocusKey] = useState(null);
2986
- const items = useRef(/* @__PURE__ */ new Map());
2987
3195
  const table = useContext(LibrariesContext);
2988
3196
  const libraries = useMemo(() => librariesIn(index, table), [index, table]);
2989
3197
  const views = useMemo(() => {
@@ -3011,24 +3219,45 @@ function StoryTree({ ctx, index }) {
3011
3219
  const routed = routable.find((entry) => entry.id === route);
3012
3220
  const routeAncestors = useMemo(() => ancestorsOf(routed), [routed]);
3013
3221
  const filtering = query.trim() !== "";
3014
- const isOpen = (path) => filtering || (folds[path] ?? routeAncestors.has(path));
3015
- const rows = [];
3016
- const collect2 = (nodes, parent) => {
3017
- for (const node of nodes) {
3018
- rows.push({ key: keyOf(node), node, parent });
3019
- if (node.kind === "folder" && isOpen(node.path)) collect2(node.children, keyOf(node));
3020
- }
3021
- };
3022
- collect2(shown, null);
3023
- const active = rows.some((row) => row.key === focusKey) ? focusKey : rows[0]?.key ?? null;
3024
- const openIds = new Set(ctx.trials.map((trial) => trial.instrumentName));
3025
- const setOpen = (path, open) => {
3026
- if (!filtering) setFolds((prev) => ({ ...prev, [path]: open }));
3027
- };
3028
- const moveTo = (key) => {
3029
- if (key === void 0) return;
3030
- setFocusKey(key);
3031
- 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)])) }));
3032
3261
  };
3033
3262
  const activate = (entry, another) => {
3034
3263
  const focused = ctx.trials.find((trial) => trial.id === ctx.focusedTrialId);
@@ -3044,20 +3273,29 @@ function StoryTree({ ctx, index }) {
3044
3273
  }
3045
3274
  setRoute2(entry.id);
3046
3275
  };
3047
- const openFolder = (node, another) => {
3048
- if (!node.index) {
3049
- setOpen(node.path, !isOpen(node.path));
3050
- return;
3051
- }
3052
- if (folds[node.path] === false) {
3053
- setFolds(({ [node.path]: _dropped, ...rest2 }) => rest2);
3054
- }
3055
- 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);
3056
3285
  };
3286
+ const treeEl = useRef(null);
3057
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
+ });
3058
3296
  const reveal = (path) => {
3059
- const find = (nodes, at) => {
3060
- for (const node2 of nodes) {
3297
+ const find = (list, at) => {
3298
+ for (const node2 of list) {
3061
3299
  if (node2.kind !== "folder") continue;
3062
3300
  if (node2.path === at) return node2;
3063
3301
  const inner = find(node2.children, at);
@@ -3085,135 +3323,11 @@ function StoryTree({ ctx, index }) {
3085
3323
  const segments = found.path.split("/");
3086
3324
  const above = segments.slice(0, -1).map((_, i) => segments.slice(0, i + 1).join("/"));
3087
3325
  setFolds((prev) => ({ ...prev, ...Object.fromEntries(above.map((p) => [p, true])) }));
3088
- setFocusKey(keyOf(found));
3089
3326
  scrollTo.current = keyOf(found);
3090
- if (found.index) openFolder(found, false);
3327
+ if (found.index) openIndex(found.path, found.index, false);
3091
3328
  else setFolds((prev) => ({ ...prev, [found.path]: true }));
3092
3329
  };
3093
3330
  useEffect(() => onRevealInTree(reveal));
3094
- const onKeyDown = (event) => {
3095
- const at = rows.findIndex((row2) => row2.key === active);
3096
- const row = rows[at];
3097
- if (!row) return;
3098
- const { node } = row;
3099
- switch (event.key) {
3100
- case "ArrowDown":
3101
- moveTo(rows[at + 1]?.key);
3102
- break;
3103
- case "ArrowUp":
3104
- moveTo(rows[at - 1]?.key);
3105
- break;
3106
- case "Home":
3107
- moveTo(rows[0]?.key);
3108
- break;
3109
- case "End":
3110
- moveTo(rows[rows.length - 1]?.key);
3111
- break;
3112
- case "ArrowRight":
3113
- if (node.kind !== "folder") break;
3114
- if (!isOpen(node.path)) setOpen(node.path, true);
3115
- else if (rows[at + 1]?.parent === row.key) moveTo(rows[at + 1]?.key);
3116
- break;
3117
- case "ArrowLeft":
3118
- if (node.kind === "folder" && isOpen(node.path) && !filtering) setOpen(node.path, false);
3119
- else moveTo(row.parent ?? void 0);
3120
- break;
3121
- case "Enter":
3122
- case " ":
3123
- if (node.kind === "story") activate(node.entry, event.shiftKey);
3124
- else openFolder(node, event.shiftKey);
3125
- break;
3126
- default:
3127
- return;
3128
- }
3129
- event.preventDefault();
3130
- };
3131
- const itemProps = (node, level) => {
3132
- const key = keyOf(node);
3133
- return {
3134
- role: "treeitem",
3135
- "aria-level": level,
3136
- tabIndex: key === active ? 0 : -1,
3137
- ref: (el) => {
3138
- if (el) items.current.set(key, el);
3139
- else items.current.delete(key);
3140
- if (el && scrollTo.current === key) {
3141
- scrollTo.current = null;
3142
- el.scrollIntoView({ block: "nearest" });
3143
- }
3144
- },
3145
- // A folder item contains its children, so their focus bubbles through it.
3146
- onFocus: (event) => {
3147
- if (event.target === event.currentTarget) setFocusKey(key);
3148
- }
3149
- };
3150
- };
3151
- const renderNodes = (nodes, level) => nodes.map((node) => {
3152
- if (node.kind === "folder") {
3153
- const open = isOpen(node.path);
3154
- return /* @__PURE__ */ jsxs(
3155
- "div",
3156
- {
3157
- ...itemProps(node, level),
3158
- "aria-expanded": open,
3159
- "aria-label": node.label,
3160
- "aria-current": node.index && openIds.has(node.index.id) ? "true" : void 0,
3161
- className: "fg-tree__node",
3162
- children: [
3163
- /* @__PURE__ */ jsxs(
3164
- "div",
3165
- {
3166
- className: "fg-tree__item fg-tree__folder",
3167
- onClick: (event) => {
3168
- setFocusKey(keyOf(node));
3169
- openFolder(node, event.shiftKey);
3170
- },
3171
- children: [
3172
- /* @__PURE__ */ jsx(
3173
- "span",
3174
- {
3175
- "aria-hidden": "true",
3176
- className: "fg-tree__fold",
3177
- onClick: (event) => {
3178
- event.stopPropagation();
3179
- setOpen(node.path, !open);
3180
- },
3181
- children: /* @__PURE__ */ jsx(DisclosureMark, { open })
3182
- }
3183
- ),
3184
- /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: node.label }),
3185
- /* @__PURE__ */ jsx(RowTags, { lab: !!node.index && isLab(node.index), library: node.tag })
3186
- ]
3187
- }
3188
- ),
3189
- open ? /* @__PURE__ */ jsx("div", { role: "group", className: "fg-tree__group", children: renderNodes(node.children, level + 1) }) : null
3190
- ]
3191
- },
3192
- keyOf(node)
3193
- );
3194
- }
3195
- const { entry } = node;
3196
- return /* @__PURE__ */ jsxs(
3197
- "a",
3198
- {
3199
- ...itemProps(node, level),
3200
- href: `#/${encodeURIComponent(entry.id)}`,
3201
- "aria-current": openIds.has(entry.id) ? "true" : void 0,
3202
- className: "fg-tree__item fg-tree__story",
3203
- onClick: (event) => {
3204
- if (event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey) return;
3205
- event.preventDefault();
3206
- setFocusKey(keyOf(node));
3207
- activate(entry, event.shiftKey);
3208
- },
3209
- children: [
3210
- /* @__PURE__ */ jsx("span", { className: "fg-tree__label", children: node.label ?? entry.name }),
3211
- /* @__PURE__ */ jsx(RowTags, { lab: isLab(entry), library: node.tag })
3212
- ]
3213
- },
3214
- keyOf(node)
3215
- );
3216
- });
3217
3331
  return /* @__PURE__ */ jsxs("section", { className: "fg-tree", children: [
3218
3332
  /* @__PURE__ */ jsx("h2", { id: headingId, className: "fg-tree__heading", children: "Stories" }),
3219
3333
  route && !routed ? /* @__PURE__ */ jsxs("p", { className: "fg-route-miss", children: [
@@ -3255,7 +3369,19 @@ function StoryTree({ ctx, index }) {
3255
3369
  onChange: (event) => setQuery(event.target.value)
3256
3370
  }
3257
3371
  ),
3258
- /* @__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
+ )
3259
3385
  ] });
3260
3386
  }
3261
3387
  var KEPT_CONFIGS = 50;
@@ -3618,6 +3744,10 @@ function FrameView(props) {
3618
3744
  () => copyId && setup && hostRef.current ? applyGlobalsTo(hostRef.current, copyId, globals, setup) : void 0,
3619
3745
  [copyId, globals, setup]
3620
3746
  );
3747
+ const factor = copy?.factor;
3748
+ useEffect(() => {
3749
+ if (factor !== void 0) iframeRef.current?.contentWindow?.dispatchEvent(new Event("resize"));
3750
+ }, [factor]);
3621
3751
  const main = copy?.main;
3622
3752
  useEffect(() => {
3623
3753
  if (!main || pending2) return void 0;
@@ -3662,7 +3792,7 @@ function storyInstrument(options) {
3662
3792
  ...setup ? { setup } : {}
3663
3793
  }
3664
3794
  ),
3665
- /* @__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 })
3666
3796
  ] }),
3667
3797
  // Taken from the index until the frame reports, so the trial does not move the frame into a stage and reload it.
3668
3798
  ...viewport ? { stage: { size: { width: viewport.width, height: viewport.height } } } : {}
@@ -3675,7 +3805,7 @@ var NO_SETUP = {};
3675
3805
  var pruned = (map, ids) => [...map.keys()].every((id) => ids.has(id)) ? map : new Map([...map].filter(([id]) => ids.has(id)));
3676
3806
  var toError = (error) => error instanceof Error ? error : new Error(String(error));
3677
3807
  function useStoryRegistry(index, options) {
3678
- 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;
3679
3809
  const [readies, setReadies] = useState(() => /* @__PURE__ */ new Map());
3680
3810
  const [loaded, setLoaded] = useState(() => /* @__PURE__ */ new Map());
3681
3811
  const [faults, setFaults] = useState(() => /* @__PURE__ */ new Map());
@@ -3798,14 +3928,14 @@ function useStoryRegistry(index, options) {
3798
3928
  inDocument,
3799
3929
  generation,
3800
3930
  revision,
3801
- instrument: !inDocument ? storyInstrument({ entry, ready, answers, frameUrl, onReady, globals, setup }) : 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 } : {} })
3802
3932
  };
3803
3933
  built.set(entry.id, kept2);
3804
3934
  return kept2.instrument;
3805
3935
  });
3806
3936
  const unchanged = list.length === previous.list.length && list.every((i, n) => i === previous.list[n]);
3807
3937
  return { built, books, list: unchanged ? previous.list : list };
3808
- }, [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]);
3809
3939
  useLayoutEffect(() => {
3810
3940
  committed.current = cache;
3811
3941
  }, [cache]);
@@ -3844,6 +3974,19 @@ function useDocumentTitle(title) {
3844
3974
  document.title = title;
3845
3975
  }, [title]);
3846
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
+ }
3847
3990
  function NoStories({ stories }) {
3848
3991
  return /* @__PURE__ */ jsxs("div", { className: "fg-empty", children: [
3849
3992
  /* @__PURE__ */ jsx("p", { className: "fg-empty__title", children: "No stories found" }),
@@ -3856,18 +3999,8 @@ function Workshop(props) {
3856
3999
  const title = config?.title ?? DEFAULT_TITLE;
3857
4000
  useDocumentTitle(title);
3858
4001
  const [frames2] = useState(createTrialFrames);
3859
- const [clocks] = useState(createTrialClocks);
3860
- const [pool, setPool] = useState(null);
3861
- const framed = importers === void 0 || index.some((entry) => entry.isolate !== void 0);
3862
- useEffect(() => {
3863
- if (!framed) return;
3864
- const next = createFramePool(frameUrl);
3865
- setPool(next);
3866
- return () => {
3867
- next.dispose();
3868
- setPool(null);
3869
- };
3870
- }, [frameUrl, framed]);
4002
+ const [hosts] = useState(createStoryHosts);
4003
+ const pool = useFramePool(frameUrl, importers === void 0 || index.some((entry) => entry.isolate !== void 0));
3871
4004
  const entries = useMemo(() => [...index, ...indexEntries(index)], [index]);
3872
4005
  const table = config?.libraries;
3873
4006
  const libraries = useMemo(() => librariesFrom(table), [table]);
@@ -3875,6 +4008,7 @@ function Workshop(props) {
3875
4008
  frameUrl,
3876
4009
  globals: declarations,
3877
4010
  libraries,
4011
+ hosts,
3878
4012
  ...importers ? { importers } : {},
3879
4013
  ...setup ? { setup } : {}
3880
4014
  });
@@ -3904,18 +4038,19 @@ function Workshop(props) {
3904
4038
  { id: "fg-stories", region: "sidebar", render: (ctx) => /* @__PURE__ */ jsx(StoryTree, { ctx, index }) },
3905
4039
  ...showsCssVars ? cssVarsChrome2 : [],
3906
4040
  A11Y_SECTION,
4041
+ issuesSection(index, props.issuesBase),
3907
4042
  ...keysChrome2,
3908
4043
  ...Object.keys(declarations).length > 0 ? [{ id: "fg-globals", region: "header", render: () => /* @__PURE__ */ jsx(GlobalsToolbar, { declarations }) }] : [],
3909
4044
  { id: "fg-info", region: "header", group: "story", item: infoCommand },
3910
4045
  { id: "fg-loupe", region: "overlay", render: () => /* @__PURE__ */ jsx(ForgeLoupe, { declarations, modeGlobal }) },
3911
4046
  ...config?.labChrome ?? []
3912
4047
  ],
3913
- [index, infoCommand, declarations, showsCssVars, cssVarsChrome2, keysChrome2, config?.labChrome, modeGlobal]
4048
+ [index, props.issuesBase, infoCommand, declarations, showsCssVars, cssVarsChrome2, keysChrome2, config?.labChrome, modeGlobal]
3914
4049
  );
3915
4050
  const [menus, setMenus] = useState();
3916
4051
  const first = index[0];
3917
4052
  if (!first) return /* @__PURE__ */ jsx(NoStories, { stories });
3918
- 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(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(
3919
4054
  Lab,
3920
4055
  {
3921
4056
  title,
@@ -3927,6 +4062,7 @@ function Workshop(props) {
3927
4062
  ...storage ? { storage } : {},
3928
4063
  labChrome,
3929
4064
  addTrial: false,
4065
+ marks: false,
3930
4066
  ...menus ? { menus } : {},
3931
4067
  ...config?.controls ? { controls: config.controls } : {},
3932
4068
  ...config?.pages ? { pages: config.pages } : {},