@weasel-js/forge 1.9.4 → 1.10.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 (48) hide show
  1. package/README.md +13 -7
  2. package/dist/{FrameController-BQjKhdwk.d.ts → FrameController-UeTY7ubF.d.ts} +25 -1
  3. package/dist/{chunk-B3PPHJTS.js → chunk-AP3DRQLI.js} +16 -16
  4. package/dist/chunk-AP3DRQLI.js.map +1 -0
  5. package/dist/{chunk-MQSFPRF6.js → chunk-BIAEGWGJ.js} +37 -19
  6. package/dist/chunk-BIAEGWGJ.js.map +1 -0
  7. package/dist/{chunk-JHL4ERDA.js → chunk-DFKCLZKS.js} +3 -3
  8. package/dist/chunk-DFKCLZKS.js.map +1 -0
  9. package/dist/{chunk-FRUA5WJF.js → chunk-DKWHGSBZ.js} +3 -3
  10. package/dist/{chunk-FRUA5WJF.js.map → chunk-DKWHGSBZ.js.map} +1 -1
  11. package/dist/{chunk-ZZCCIXEJ.js → chunk-E2FHBDNQ.js} +16 -25
  12. package/dist/chunk-E2FHBDNQ.js.map +1 -0
  13. package/dist/{chunk-TXLWQFF6.js → chunk-FL2MUPUL.js} +2 -2
  14. package/dist/chunk-FL2MUPUL.js.map +1 -0
  15. package/dist/{chunk-5KI5YSQ5.js → chunk-GB6PRJET.js} +3 -3
  16. package/dist/{chunk-5KI5YSQ5.js.map → chunk-GB6PRJET.js.map} +1 -1
  17. package/dist/{chunk-HRGPFQBW.js → chunk-ZAWXMUGZ.js} +6 -3
  18. package/dist/chunk-ZAWXMUGZ.js.map +1 -0
  19. package/dist/{config-Dfu-NKxd.d.ts → config-Cz7ZyGF-.d.ts} +5 -5
  20. package/dist/csf/preview-api.d.ts +5 -2
  21. package/dist/csf/preview-api.js +2 -2
  22. package/dist/csf/preview-api.js.map +1 -1
  23. package/dist/frame/index.css +19 -2
  24. package/dist/frame/index.css.map +1 -1
  25. package/dist/frame/index.d.ts +2 -2
  26. package/dist/frame/index.js +5 -5
  27. package/dist/frame/index.js.map +1 -1
  28. package/dist/index.d.ts +4 -4
  29. package/dist/index.js +3 -3
  30. package/dist/shell/index.css +41 -5
  31. package/dist/shell/index.css.map +1 -1
  32. package/dist/shell/index.d.ts +7 -6
  33. package/dist/shell/index.js +537 -334
  34. package/dist/shell/index.js.map +1 -1
  35. package/dist/test/index.d.ts +1 -1
  36. package/dist/test/index.js +6 -6
  37. package/dist/test/play.d.ts +3 -5
  38. package/dist/test/play.js.map +1 -1
  39. package/dist/vite/index.d.ts +28 -1
  40. package/dist/vite/index.js +413 -79
  41. package/dist/vite/index.js.map +1 -1
  42. package/package.json +10 -5
  43. package/dist/chunk-B3PPHJTS.js.map +0 -1
  44. package/dist/chunk-HRGPFQBW.js.map +0 -1
  45. package/dist/chunk-JHL4ERDA.js.map +0 -1
  46. package/dist/chunk-MQSFPRF6.js.map +0 -1
  47. package/dist/chunk-TXLWQFF6.js.map +0 -1
  48. package/dist/chunk-ZZCCIXEJ.js.map +0 -1
@@ -1,25 +1,27 @@
1
- import { parseRoute, formatRoute, paramsToKnobs, knobPaths, sameKnob, knobsToParams, PLAYHEAD_PARAM, reservedParams } from '../chunk-FRUA5WJF.js';
1
+ import { parseRoute, formatRoute, paramsToKnobs, knobPaths, sameKnob, knobsToParams, PLAYHEAD_PARAM, reservedParams } from '../chunk-DKWHGSBZ.js';
2
2
  import { DEFAULT_TITLE } from '../chunk-5DXUNLV2.js';
3
- import { TITLE_LIBRARIES, isIndexId, indexId, indexEntries, librariesFrom, LibrariesContext, schemaFromDescription, LibraryBadge, openChannel, IndexPage } from '../chunk-MQSFPRF6.js';
3
+ import { TITLE_LIBRARIES, isIndexId, indexId, indexEntries, librariesFrom, LibrariesContext, schemaFromDescription, LibraryBadge, openChannel, IndexPage } from '../chunk-BIAEGWGJ.js';
4
4
  import { isGallery } from '../chunk-WEQSLPKT.js';
5
- import { useTrialFrame, createTrialFrames, createTrialClocks, StoryGlobalsContext, TrialFramesContext, TrialClocksContext, StoryTrial, useCssOverrides, useTrialClock, TrialHost } from '../chunk-ZZCCIXEJ.js';
6
- export { StoryGlobalsContext, StoryTrial, TrialFramesContext, TrialHost, useTrialFrame } from '../chunk-ZZCCIXEJ.js';
7
- import { labGlobals, withGlobals, followApp, storyConfig, GLOBALS_KEY, effectiveGlobals, PLAYHEAD_KEY, labOptions, isGlobalsPath } from '../chunk-JHL4ERDA.js';
8
- import { loadStories, indexRenderOf, useNearViewport, IN_VIEW_MARGIN } from '../chunk-B3PPHJTS.js';
5
+ import { useTrialFrame, createTrialFrames, createTrialClocks, StoryGlobalsContext, TrialFramesContext, TrialClocksContext, StoryTrial, useCssOverrides, useTrialClock, TrialHost } from '../chunk-E2FHBDNQ.js';
6
+ export { StoryGlobalsContext, StoryTrial, TrialFramesContext, TrialHost, useTrialFrame } from '../chunk-E2FHBDNQ.js';
7
+ import { labGlobals, withGlobals, followApp, storyConfig, GLOBALS_KEY, effectiveGlobals, PLAYHEAD_KEY, labOptions, isGlobalsPath } from '../chunk-DFKCLZKS.js';
8
+ import { loadStories, indexRenderOf, useNearViewport, IN_VIEW_MARGIN } from '../chunk-AP3DRQLI.js';
9
9
  import '../chunk-L5XM5VKB.js';
10
- import '../chunk-HRGPFQBW.js';
11
- import '../chunk-5KI5YSQ5.js';
12
- import { stableStringify, PORT_HANDOFF, FRAME_HELLO, PROTOCOL_VERSION } from '../chunk-TXLWQFF6.js';
10
+ import '../chunk-ZAWXMUGZ.js';
11
+ import '../chunk-GB6PRJET.js';
12
+ import { stableStringify, PORT_HANDOFF, FRAME_HELLO, PROTOCOL_VERSION } from '../chunk-FL2MUPUL.js';
13
13
  import { useTrialId, Button, Lab, useLabContext, TrialIdContext, usePersistedState, Select, useTrialState, inferTokenType, ToggleBar as ToggleBar$1, Input, TokenPanel, refitScale } from '@weasel-js/labkit';
14
14
  import num from '@weasel-js/theme/numeric.module.css';
15
15
  import { createContext, useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect, useId, useContext, Fragment as Fragment$1, useSyncExternalStore } from 'react';
16
16
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
17
17
  import { createRoot } from 'react-dom/client';
18
+ import { createEmitter } from '@weasel-js/registry';
18
19
  import { f, valueAtPath, isConfigBranch } from '@weasel-js/labkit/config';
19
20
  import { InfoIcon, ToggleBar, Checkbox, Dialog, TOKEN_CATEGORIES, Icon, DisclosureMark, Button as Button$1, Callout, Slider, Transport, SearchIcon } from '@weasel-js/ui';
20
- import { TOKEN_MANIFEST, THEME_SOURCES, isByAxis } from '@weasel-js/theme';
21
+ import { themeAxes, weaselTheme, TOKEN_MANIFEST, THEME_SOURCES, resolveTheme, isColorModePreference, isByAxis } from '@weasel-js/theme';
21
22
  import { scale, httpThemeApi } from '@weasel-js/theme/engine';
22
- import { useLatest } from '@weasel-js/core';
23
+ import { useLatest, historyKey, createHistory, swapOp, isPlainObject } from '@weasel-js/core';
24
+ import { useResolvedColorMode } from '@weasel-js/theme/react';
23
25
 
24
26
  function Finding({ finding }) {
25
27
  return /* @__PURE__ */ jsxs("li", { className: "fg-a11y__finding", children: [
@@ -100,19 +102,9 @@ var A11Y_SECTION = {
100
102
  region: "aside",
101
103
  item: { title: "Accessibility", body: /* @__PURE__ */ jsx(FocusedA11y, {}) }
102
104
  };
103
-
104
- // src/shell/storyChanges.ts
105
105
  function createStoryChanges() {
106
- const listeners2 = /* @__PURE__ */ new Set();
107
- return {
108
- subscribe(listener) {
109
- listeners2.add(listener);
110
- return () => listeners2.delete(listener);
111
- },
112
- emit(file) {
113
- for (const listener of [...listeners2]) listener(file);
114
- }
115
- };
106
+ const changes = createEmitter();
107
+ return { subscribe: changes.subscribe, emit: changes.emit };
116
108
  }
117
109
  var DependenciesContext = createContext(null);
118
110
  function readRoute() {
@@ -233,17 +225,12 @@ function crumbs(title, name) {
233
225
  function breadcrumb(title, name) {
234
226
  return crumbs(title, name).map((step) => step.label).join(" > ");
235
227
  }
236
-
237
- // src/shell/treeReveal.ts
238
- var listeners = /* @__PURE__ */ new Set();
228
+ var reveals = createEmitter();
239
229
  function revealInTree(path) {
240
- for (const listener of listeners) listener(path);
230
+ reveals.emit(path);
241
231
  }
242
232
  function onRevealInTree(listener) {
243
- listeners.add(listener);
244
- return () => {
245
- listeners.delete(listener);
246
- };
233
+ return reveals.subscribe(listener);
247
234
  }
248
235
  function Crumbs({ entry }) {
249
236
  const steps = crumbs(entry.title, entry.name);
@@ -352,6 +339,73 @@ function missingInstrument(id, globals = {}) {
352
339
  render: () => /* @__PURE__ */ jsx("div", { className: "fg-story fg-story--loading", "data-fg-layout": "fullscreen", children: /* @__PURE__ */ jsx(Fault, { phase: "route", message: `No story ${id}` }) })
353
340
  };
354
341
  }
342
+ var ICONS = {
343
+ color: "drop",
344
+ type: "type",
345
+ size: "measure",
346
+ motion: "curveEaseInOut",
347
+ depth: "layers",
348
+ other: "more"
349
+ };
350
+ var PANEL = ".fg-css-vars";
351
+ var HOST = ".lk-sidebar-section, .lk-panel-tile";
352
+ var MANUAL = ["wheel", "touchmove", "keydown"];
353
+ var LIST = ".fg-css-vars__list";
354
+ function readLine(panel) {
355
+ return (panel.querySelector(LIST) ?? panel).getBoundingClientRect().top;
356
+ }
357
+ function sectionsOf(panel) {
358
+ return [...panel.querySelectorAll("[data-token-category]")];
359
+ }
360
+ function SectionJump() {
361
+ const ref = useRef(null);
362
+ const [present, setPresent] = useState([]);
363
+ const [current, setCurrent] = useState(null);
364
+ const held = useRef(false);
365
+ useEffect(() => {
366
+ const root = ref.current?.closest(HOST) ?? document.body;
367
+ const read = () => {
368
+ const panel = root.querySelector(PANEL);
369
+ const sections = panel ? sectionsOf(panel) : [];
370
+ setPresent(sections.map((s) => s.dataset.tokenCategory));
371
+ if (!panel || held.current) return;
372
+ const line = readLine(panel) + 1;
373
+ let top = sections[0]?.dataset.tokenCategory;
374
+ for (const s of sections) if (s.getBoundingClientRect().top <= line) top = s.dataset.tokenCategory;
375
+ setCurrent(top ?? null);
376
+ };
377
+ const release = () => {
378
+ held.current = false;
379
+ };
380
+ read();
381
+ const observer = new MutationObserver(read);
382
+ observer.observe(root, { childList: true, subtree: true });
383
+ document.addEventListener("scroll", read, { capture: true, passive: true });
384
+ for (const type of MANUAL) document.addEventListener(type, release, { capture: true, passive: true });
385
+ return () => {
386
+ observer.disconnect();
387
+ document.removeEventListener("scroll", read, { capture: true });
388
+ for (const type of MANUAL) document.removeEventListener(type, release, { capture: true });
389
+ };
390
+ }, []);
391
+ const items = TOKEN_CATEGORIES.map(({ id, title }) => ({
392
+ value: id,
393
+ label: /* @__PURE__ */ jsx(Icon, { name: ICONS[id], size: 14 }),
394
+ ariaLabel: title,
395
+ tooltip: title,
396
+ disabled: !present.includes(id)
397
+ }));
398
+ const jump = (next) => {
399
+ const panel = ref.current?.closest(HOST)?.querySelector(PANEL);
400
+ const target = next && panel?.querySelector(`[data-token-category="${next}"]`);
401
+ const scroller = panel?.querySelector(LIST);
402
+ if (!panel || !target || !scroller) return;
403
+ held.current = true;
404
+ setCurrent(next);
405
+ scroller.scrollTop += target.getBoundingClientRect().top - readLine(panel);
406
+ };
407
+ return /* @__PURE__ */ jsx("div", { ref, className: "fg-css-vars__jump", children: /* @__PURE__ */ jsx(ToggleBar, { ariaLabel: "Jump to section", variant: "flat", size: "sm", items, value: current, onChange: jump }) });
408
+ }
355
409
  var SEED_REF = /^\{seeds\.([^}]+)\}$/;
356
410
  var RULE_KEYS = { factors: "factors", ratio: "ratio", step: "step" };
357
411
  function selectionFor(axes, globals) {
@@ -365,24 +419,24 @@ function selectionFor(axes, globals) {
365
419
  }
366
420
  var EditError = class extends Error {
367
421
  };
368
- function slotOf(root, holder, key, where, selection, depth = 0) {
369
- if (depth > 16) throw new EditError(`${where} references itself`);
370
- let h = holder;
371
- let k = key;
372
- let w = where;
373
- let value = h[k];
374
- while (isByAxis(value)) {
422
+ function branchOf(holder, key, where, selection) {
423
+ let slot = { holder, key, where };
424
+ for (let value = holder[key]; isByAxis(value); value = slot.holder[slot.key]) {
375
425
  const axis = value.by;
376
426
  const picked = selection[axis];
377
427
  if (picked === void 0 || picked === "by" || !(picked in value)) {
378
- throw new EditError(`${w} differs by ${axis}, and which ${axis} this trial shows is not known`);
428
+ throw new EditError(`${slot.where} differs by ${axis}, and which ${axis} this trial shows is not known`);
379
429
  }
380
- h = value;
381
- k = picked;
382
- w = `${w}.${picked}`;
383
- value = h[k];
430
+ slot = { holder: value, key: picked, where: `${slot.where}.${picked}` };
384
431
  }
385
- if (typeof value === "number") return { holder: h, key: k, where: w };
432
+ return slot;
433
+ }
434
+ function slotOf(root, holder, key, where, selection, depth = 0) {
435
+ if (depth > 16) throw new EditError(`${where} references itself`);
436
+ const slot = branchOf(holder, key, where, selection);
437
+ const { holder: h, key: k, where: w } = slot;
438
+ const value = h[k];
439
+ if (typeof value === "number") return slot;
386
440
  if (typeof value === "string") {
387
441
  const ref = SEED_REF.exec(value);
388
442
  if (!ref) throw new EditError(`${w} is "${value}", which is not a number or a seed`);
@@ -394,18 +448,10 @@ function slotOf(root, holder, key, where, selection, depth = 0) {
394
448
  throw new EditError(`${w} is not a number`);
395
449
  }
396
450
  function factorsOf(holder, where, selection) {
397
- let value = holder.factors;
398
- let w = `${where}.factors`;
399
- while (isByAxis(value)) {
400
- const picked = selection[value.by];
401
- if (picked === void 0 || !(picked in value)) {
402
- throw new EditError(`${w} differs by ${value.by}, and which ${value.by} this trial shows is not known`);
403
- }
404
- w = `${w}.${picked}`;
405
- value = value[picked];
406
- }
407
- if (!Array.isArray(value)) throw new EditError(`${w} is not a list`);
408
- return { list: value, where: w };
451
+ const slot = branchOf(holder, "factors", `${where}.factors`, selection);
452
+ const value = slot.holder[slot.key];
453
+ if (!Array.isArray(value)) throw new EditError(`${slot.where} is not a list`);
454
+ return { list: value, where: slot.where };
409
455
  }
410
456
  var kindOf = (scale) => scale.factors !== void 0 ? "factors" : scale.ratio !== void 0 ? "ratio" : scale.step !== void 0 ? "step" : null;
411
457
  var plainLiteral = (value) => typeof value === "number" || Array.isArray(value) && value.every((v) => typeof v === "number");
@@ -461,90 +507,219 @@ function applyScaleEdits(definition, edits, selection) {
461
507
  for (const [holder, byKey] of writes) for (const [key, value] of byKey) holder[key] = value;
462
508
  return { ok: true, definition: root };
463
509
  }
464
- var ICONS = {
465
- color: "drop",
466
- type: "type",
467
- size: "measure",
468
- motion: "curveEaseInOut",
469
- depth: "layers",
470
- other: "more"
471
- };
472
- var PANEL = ".fg-css-vars";
473
- var HOST = ".lk-sidebar-section, .lk-panel-tile";
474
- var MANUAL = ["wheel", "touchmove", "keydown"];
475
- function scrollerOf(el) {
476
- for (let at = el.parentElement; at; at = at.parentElement) {
477
- const { overflowY } = getComputedStyle(at);
478
- if ((overflowY === "auto" || overflowY === "scroll") && at.scrollHeight > at.clientHeight) return at;
510
+ var REFERENCE = /\{[^}]+\}/;
511
+ var LAYERS = ["pins", "semantics", "components"];
512
+ function derivation(entry) {
513
+ const value = isPlainObject(entry) ? entry.value : entry;
514
+ if (isPlainObject(entry) && value === void 0) return "follows a rule";
515
+ if (isPlainObject(entry) && entry.alpha !== void 0) return "is drawn at an alpha";
516
+ if (typeof value === "string" && REFERENCE.test(value)) return `follows ${REFERENCE.exec(value)?.[0]}`;
517
+ return typeof value === "string" || typeof value === "number" ? null : "is a list";
518
+ }
519
+ function literalSlot(definition, token, selection) {
520
+ for (const layer of LAYERS) {
521
+ const entries = definition[layer];
522
+ if (!entries || !(token in entries)) continue;
523
+ const slot = branchOf(entries, token, `${layer}.${token}`, selection);
524
+ const why = derivation(slot.holder[slot.key]);
525
+ if (why) throw new EditError(`${slot.where} ${why}, and only a value written out in the theme can be saved`);
526
+ return slot;
479
527
  }
480
- return null;
528
+ for (const layer of ["ramps", "scales"]) {
529
+ const from = Object.keys(definition[layer] ?? {}).find((name) => token.startsWith(`${name}-`));
530
+ if (from) throw new EditError(`${token} is a step ${layer}.${from} generates, and the theme pins no value over it`);
531
+ }
532
+ throw new EditError(`the theme defines no token "${token}"`);
481
533
  }
482
- function readLine(panel) {
483
- return panel.querySelector(".fg-css-vars__controls")?.getBoundingClientRect().bottom ?? panel.getBoundingClientRect().top;
534
+ function writtenOut(definition, token, selection) {
535
+ try {
536
+ literalSlot(definition, token, selection);
537
+ return true;
538
+ } catch (e) {
539
+ if (e instanceof EditError) return false;
540
+ throw e;
541
+ }
484
542
  }
485
- function sectionsOf(panel) {
486
- return [...panel.querySelectorAll("[data-token-category]")];
543
+ function applyTokenEdits(definition, edits, selection) {
544
+ const root = structuredClone(definition);
545
+ try {
546
+ for (const [token, value] of Object.entries(edits)) {
547
+ const { holder, key } = literalSlot(root, token, selection);
548
+ const entry = holder[key];
549
+ holder[key] = isPlainObject(entry) ? { ...entry, value } : value;
550
+ }
551
+ } catch (e) {
552
+ if (e instanceof EditError) return { ok: false, message: `Cannot save: ${e.message}.` };
553
+ throw e;
554
+ }
555
+ return { ok: true, definition: root };
487
556
  }
488
- function SectionJump() {
489
- const ref = useRef(null);
490
- const [present, setPresent] = useState([]);
491
- const [current, setCurrent] = useState(null);
492
- const held = useRef(false);
557
+ function useTrialSelection(axes, globals) {
558
+ const { mode } = globals;
559
+ const osMode = useResolvedColorMode(isColorModePreference(mode) ? mode : "auto");
560
+ return useMemo(() => {
561
+ const selection = selectionFor(axes, globals);
562
+ return axes.mode && osMode in axes.mode.values ? { mode: osMode, ...selection } : selection;
563
+ }, [axes, globals, osMode]);
564
+ }
565
+
566
+ // src/shell/cssVars/useThemeSave.ts
567
+ var THEME_NAME = "weasel";
568
+ var TOKEN_PREFIX = "--wzl-";
569
+ var NO_AXES = {};
570
+ function useThemeSave({ trialId, globals, themeApi, scales, overrides, history: history2 }) {
571
+ const definition = THEME_SOURCES[THEME_NAME];
572
+ const selection = useTrialSelection(definition?.axes ?? NO_AXES, globals);
573
+ const tokens = useMemo(() => {
574
+ if (!definition) return {};
575
+ const generated = new Set(scales.flatMap((scale) => scale.tokens));
576
+ return Object.fromEntries(
577
+ Object.entries(overrides).flatMap(([name, value]) => {
578
+ if (!name.startsWith(TOKEN_PREFIX) || generated.has(name)) return [];
579
+ const token = name.slice(TOKEN_PREFIX.length);
580
+ return writtenOut(definition, token, selection) ? [[token, value]] : [];
581
+ })
582
+ );
583
+ }, [definition, selection, scales, overrides]);
584
+ const [saving, setSaving] = useState(false);
585
+ const [held, setHeld] = useState(null);
586
+ const setNotice = (next) => setHeld(next && { ...next, trialId });
587
+ const save = async () => {
588
+ setSaving(true);
589
+ setNotice(null);
590
+ try {
591
+ const api = themeApi ?? httpThemeApi();
592
+ const stored = await api.get(THEME_NAME);
593
+ const file = `themes/${THEME_NAME}.json`;
594
+ if (JSON.stringify(stored.definition) !== JSON.stringify(THEME_SOURCES[THEME_NAME])) {
595
+ setNotice({ tone: "error", text: `${file} has changed since this page loaded. Reload to edit against it.` });
596
+ return;
597
+ }
598
+ const scaled = applyScaleEdits(stored.definition, Object.fromEntries(scales.map((s) => [s.name, s.edit])), selection);
599
+ const next = scaled.ok ? applyTokenEdits(scaled.definition, tokens, selection) : scaled;
600
+ if (!next.ok) {
601
+ setNotice({ tone: "error", text: next.message });
602
+ return;
603
+ }
604
+ const result = await api.put(THEME_NAME, next.definition, stored.hash);
605
+ if (result.status === "conflict") {
606
+ setNotice({ tone: "error", text: `${file} changed on disk while saving. Nothing was written; reload to edit against it.` });
607
+ return;
608
+ }
609
+ if (result.status === "invalid") {
610
+ setNotice({ tone: "error", text: result.message });
611
+ return;
612
+ }
613
+ const saved = /* @__PURE__ */ new Set([
614
+ ...scales.flatMap((scale) => scale.tokens),
615
+ ...Object.keys(tokens).map((token) => `${TOKEN_PREFIX}${token}`)
616
+ ]);
617
+ const groups = new Set(scales.map((scale) => scale.group));
618
+ const now = history2.current();
619
+ history2.reset({
620
+ overrides: Object.fromEntries(Object.entries(now.overrides).filter(([name]) => !saved.has(name))),
621
+ scales: Object.fromEntries(Object.entries(now.scales).filter(([group]) => !groups.has(group)))
622
+ });
623
+ setNotice(
624
+ result.regenerated || result.problems.length === 0 ? { tone: "saved", text: `Saved to ${file}.` } : { tone: "error", text: `Saved to ${file}, but the tokens were not regenerated: ${result.problems.join("; ")}` }
625
+ );
626
+ } catch (e) {
627
+ setNotice({ tone: "error", text: `Saving needs forge's dev server, which did not answer: ${e.message}` });
628
+ } finally {
629
+ setSaving(false);
630
+ }
631
+ };
632
+ return {
633
+ pending: scales.length > 0 || Object.keys(tokens).length > 0,
634
+ saving,
635
+ notice: held?.trialId === trialId ? held : null,
636
+ save
637
+ };
638
+ }
639
+ var AXES = themeAxes(weaselTheme);
640
+ function useThemeTokens(globals, vars) {
641
+ const selection = useTrialSelection(AXES, globals);
642
+ const themed = useMemo(() => resolveTheme(weaselTheme, selection), [selection]);
643
+ return useMemo(() => {
644
+ const reported = new Map(vars.map((v) => [v.name, v.value]));
645
+ return TOKEN_MANIFEST.map((token) => ({
646
+ name: token.name,
647
+ type: token.type,
648
+ group: token.group,
649
+ value: reported.get(token.name) ?? themed[token.name] ?? token.defaultValue,
650
+ description: token.description
651
+ }));
652
+ }, [vars, themed]);
653
+ }
654
+ var SWAP = "fg.vars.swap";
655
+ var COALESCE_MS = 800;
656
+ var LIMIT = 100;
657
+ function useVarsHistory(state, apply) {
658
+ const trialId = useTrialId();
659
+ const [stored, setStored] = usePersistedState("fg-css-vars-history", null, { scope: "trial" });
660
+ const rendered = useLatest(state);
661
+ const ahead = useRef(null);
493
662
  useEffect(() => {
494
- const root = ref.current?.closest(HOST) ?? document.body;
495
- const read = () => {
496
- const panel = root.querySelector(PANEL);
497
- const sections = panel ? sectionsOf(panel) : [];
498
- setPresent(sections.map((s) => s.dataset.tokenCategory));
499
- if (!panel || held.current) return;
500
- const line = readLine(panel) + 1;
501
- let top = sections[0]?.dataset.tokenCategory;
502
- for (const s of sections) if (s.getBoundingClientRect().top <= line) top = s.dataset.tokenCategory;
503
- setCurrent(top ?? null);
504
- };
505
- const release = () => {
506
- held.current = false;
507
- };
508
- read();
509
- const observer = new MutationObserver(read);
510
- observer.observe(root, { childList: true, subtree: true });
511
- document.addEventListener("scroll", read, { capture: true, passive: true });
512
- for (const type of MANUAL) document.addEventListener(type, release, { capture: true, passive: true });
513
- return () => {
514
- observer.disconnect();
515
- document.removeEventListener("scroll", read, { capture: true });
516
- for (const type of MANUAL) document.removeEventListener(type, release, { capture: true });
517
- };
518
- }, []);
519
- const items = TOKEN_CATEGORIES.map(({ id, title }) => ({
520
- value: id,
521
- label: /* @__PURE__ */ jsx(Icon, { name: ICONS[id], size: 14 }),
522
- ariaLabel: title,
523
- tooltip: title,
524
- disabled: !present.includes(id)
525
- }));
526
- const jump = (next) => {
527
- const panel = ref.current?.closest(HOST)?.querySelector(PANEL);
528
- const target = next && panel?.querySelector(`[data-token-category="${next}"]`);
529
- const scroller = panel && scrollerOf(panel);
530
- if (!panel || !target || !scroller) return;
531
- held.current = true;
532
- setCurrent(next);
533
- scroller.scrollTop += target.getBoundingClientRect().top - readLine(panel);
663
+ ahead.current = null;
664
+ }, [state]);
665
+ const current = () => ahead.current ?? rendered.current;
666
+ const latestApply = useLatest(apply);
667
+ const emit = (to) => {
668
+ const from = current();
669
+ ahead.current = to;
670
+ latestApply.current(from, to);
671
+ };
672
+ const swap = (before, after, coalesceKey) => swapOp({ name: SWAP, label: "edit vars", before, after, emit, ...coalesceKey !== void 0 ? { coalesceKey } : {} });
673
+ const history2 = useMemo(() => {
674
+ const opened = createHistory(null, {
675
+ coalesceWindowMs: COALESCE_MS,
676
+ historyLimit: LIMIT,
677
+ rebuildOp: (name, args) => {
678
+ const { before, after } = args;
679
+ return name === SWAP ? swap(before, after) : null;
680
+ }
681
+ });
682
+ if (stored) opened.restore(stored);
683
+ return opened;
684
+ }, [trialId]);
685
+ useSyncExternalStore(history2.subscribe, history2.getVersion);
686
+ const keep = () => setStored(history2.canUndo() || history2.canRedo() ? history2.serialize() : null);
687
+ const tick = useRef(null);
688
+ return {
689
+ current,
690
+ commit(next, key) {
691
+ if (tick.current === null) {
692
+ tick.current = key;
693
+ queueMicrotask(() => {
694
+ tick.current = null;
695
+ });
696
+ }
697
+ history2.applyOps([swap(current(), next, tick.current)], "edit vars");
698
+ keep();
699
+ },
700
+ step(to) {
701
+ history2[to]();
702
+ keep();
703
+ },
704
+ canUndo: history2.canUndo(),
705
+ canRedo: history2.canRedo(),
706
+ reset(next) {
707
+ emit(next);
708
+ history2.clear();
709
+ setStored(null);
710
+ }
534
711
  };
535
- return /* @__PURE__ */ jsx("div", { ref, className: "fg-css-vars__jump", children: /* @__PURE__ */ jsx(ToggleBar, { ariaLabel: "Jump to section", variant: "flat", size: "sm", items, value: current, onChange: jump }) });
536
712
  }
537
713
  var TABS = [
538
714
  { value: "theme", label: "Theme" },
539
715
  { value: "story", label: "Story" }
540
716
  ];
541
717
  var MANIFEST = new Map(TOKEN_MANIFEST.map((token) => [token.name, token]));
542
- var THEME_NAME = "weasel";
543
718
  var literal = (v) => typeof v === "number";
544
719
  var SCALE_SOURCES = Object.entries(THEME_SOURCES[THEME_NAME]?.scales ?? {}).flatMap(([name, def]) => {
545
720
  if (!Array.isArray(def.steps)) return [];
546
721
  const steps = def.steps;
547
- const tokens = steps.map((step) => `--wzl-${name}-${step}`);
722
+ const tokens = steps.map((step) => `${TOKEN_PREFIX}${name}-${step}`);
548
723
  const group = MANIFEST.get(tokens[0] ?? "")?.group;
549
724
  if (!group) return [];
550
725
  const factors = Array.isArray(def.factors) && def.factors.every(literal) ? def.factors : null;
@@ -589,25 +764,17 @@ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
589
764
  {},
590
765
  { scope: "lab" }
591
766
  );
592
- const [edited, setEdited] = usePersistedState("fg-css-vars-scales", {}, { scope: "lab" });
767
+ const [edited, setEdited] = usePersistedState("fg-css-vars-scales", {}, { scope: "trial" });
593
768
  const [tab, setTab] = useState("theme");
594
769
  const [filter, setFilter] = useState("");
770
+ const themeTokens = useThemeTokens(globals, frame.vars);
595
771
  const tokens = useMemo(() => {
596
- if (tab === "story") {
597
- return frame.vars.map(({ name, value }) => {
598
- const known = MANIFEST.get(name);
599
- return { name, type: known?.type ?? inferTokenType(value), group: known?.group ?? groupOf(name), value };
600
- });
601
- }
602
- const reported = new Map(frame.vars.map((v) => [v.name, v.value]));
603
- return TOKEN_MANIFEST.map((token) => ({
604
- name: token.name,
605
- type: token.type,
606
- group: token.group,
607
- value: reported.get(token.name) ?? token.defaultValue,
608
- description: token.description
609
- }));
610
- }, [tab, frame.vars]);
772
+ if (tab === "theme") return themeTokens;
773
+ return frame.vars.map(({ name, value }) => {
774
+ const known = MANIFEST.get(name);
775
+ return { name, type: known?.type ?? inferTokenType(value), group: known?.group ?? groupOf(name), value };
776
+ });
777
+ }, [tab, frame.vars, themeTokens]);
611
778
  const query = filter.trim().toLowerCase();
612
779
  const shown = tokens.map((token) => {
613
780
  const override = overrides[token.name];
@@ -626,135 +793,115 @@ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
626
793
  return out;
627
794
  }, [tab, shown, overrides, edited]);
628
795
  const send = frame.send;
629
- const write = useCallback(
630
- (name, value) => {
631
- setOverrides((prev) => {
632
- const { [name]: _dropped, ...rest } = prev;
633
- return value === null ? rest : { ...rest, [name]: value };
634
- });
635
- send?.({ type: "vars.set", name, value });
636
- },
637
- [setOverrides, send]
638
- );
639
- const onScaleChange = useCallback(
640
- (group, next) => {
641
- const source = SCALE_SOURCES.find((sc) => sc.group === group);
642
- if (!source) return;
643
- setEdited((prev) => ({ ...prev, [group]: next }));
644
- for (const [name, value] of scaleValues(source, next)) write(name, value);
645
- },
646
- [setEdited, write]
647
- );
796
+ const state = useMemo(() => ({ overrides, scales: edited }), [overrides, edited]);
797
+ const history2 = useVarsHistory(state, (from, to) => {
798
+ setOverrides(to.overrides);
799
+ setEdited(to.scales);
800
+ for (const name of /* @__PURE__ */ new Set([...Object.keys(from.overrides), ...Object.keys(to.overrides)])) {
801
+ if (from.overrides[name] !== to.overrides[name]) send?.({ type: "vars.set", name, value: to.overrides[name] ?? null });
802
+ }
803
+ });
804
+ const write = (name, value) => {
805
+ const now = history2.current();
806
+ const { [name]: _dropped, ...rest } = now.overrides;
807
+ history2.commit({ ...now, overrides: value === null ? rest : { ...rest, [name]: value } }, `token:${name}`);
808
+ };
809
+ const onScaleChange = (group, next) => {
810
+ const source = SCALE_SOURCES.find((sc) => sc.group === group);
811
+ if (!source) return;
812
+ const now = history2.current();
813
+ history2.commit(
814
+ { overrides: { ...now.overrides, ...Object.fromEntries(scaleValues(source, next)) }, scales: { ...now.scales, [group]: next } },
815
+ `scale:${group}`
816
+ );
817
+ };
648
818
  const unsaved = useMemo(
649
819
  () => SCALE_SOURCES.flatMap((source) => {
650
820
  const edit = edited[source.group];
651
- if (!edit || !source.tokens.some((name) => overrides[name] !== void 0)) return [];
652
- const values = scaleValues(source, edit);
653
- return values.every(([name, value]) => overrides[name] === value) ? [{ source, edit }] : [];
821
+ if (!edit || !source.tokens.some((name2) => overrides[name2] !== void 0)) return [];
822
+ const { name, group, tokens: tokens2 } = source;
823
+ const clean = scaleValues(source, edit).every(([token, value]) => overrides[token] === value);
824
+ return clean ? [{ name, group, tokens: tokens2, edit: { base: edit.base, rule: edit.rule } }] : [];
654
825
  }),
655
826
  [edited, overrides]
656
827
  );
657
- const [saving, setSaving] = useState(false);
658
- const [held, setHeld] = useState(null);
659
- const notice = held?.trialId === trialId ? held : null;
660
- const setNotice = (next) => setHeld(next && { ...next, trialId });
661
- const save = async () => {
662
- setSaving(true);
663
- setNotice(null);
664
- try {
665
- const api = themeApi ?? httpThemeApi();
666
- const stored = await api.get(THEME_NAME);
667
- const file = `themes/${THEME_NAME}.json`;
668
- if (JSON.stringify(stored.definition) !== JSON.stringify(THEME_SOURCES[THEME_NAME])) {
669
- setNotice({ tone: "error", text: `${file} has changed since this page loaded. Reload to edit against it.` });
670
- return;
671
- }
672
- const edits = Object.fromEntries(
673
- unsaved.map(({ source, edit }) => [source.name, { base: edit.base, rule: edit.rule }])
674
- );
675
- const next = applyScaleEdits(stored.definition, edits, selectionFor(stored.definition.axes ?? {}, globals));
676
- if (!next.ok) {
677
- setNotice({ tone: "error", text: next.message });
678
- return;
679
- }
680
- const result = await api.put(THEME_NAME, next.definition, stored.hash);
681
- if (result.status === "conflict") {
682
- setNotice({ tone: "error", text: `${file} changed on disk while saving. Nothing was written; reload to edit against it.` });
683
- return;
684
- }
685
- if (result.status === "invalid") {
686
- setNotice({ tone: "error", text: result.message });
687
- return;
688
- }
689
- for (const { source } of unsaved) for (const name of source.tokens) write(name, null);
690
- const groups = new Set(unsaved.map(({ source }) => source.group));
691
- setEdited((prev) => Object.fromEntries(Object.entries(prev).filter(([group]) => !groups.has(group))));
692
- setNotice(
693
- result.regenerated || result.problems.length === 0 ? { tone: "saved", text: `Saved to ${file}.` } : { tone: "error", text: `Saved to ${file}, but the tokens were not regenerated: ${result.problems.join("; ")}` }
694
- );
695
- } catch (e) {
696
- setNotice({ tone: "error", text: `Saving needs forge's dev server, which did not answer: ${e.message}` });
697
- } finally {
698
- setSaving(false);
699
- }
700
- };
828
+ const { pending, saving, notice, save } = useThemeSave({ trialId, globals, themeApi, scales: unsaved, overrides, history: history2 });
701
829
  const onCollapsedChange = useCallback(
702
830
  (category, collapsed) => setFolds((prev) => ({ ...prev, [category]: collapsed })),
703
831
  [setFolds]
704
832
  );
705
- return /* @__PURE__ */ jsxs("div", { className: "fg-css-vars", children: [
706
- /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__controls", children: [
707
- /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__bar", children: [
708
- /* @__PURE__ */ jsx(
709
- ToggleBar$1,
710
- {
711
- ariaLabel: "Variables",
712
- variant: "flat",
713
- size: "sm",
714
- items: TABS,
715
- value: tab,
716
- onChange: (next) => {
717
- if (next) setTab(next);
718
- }
719
- }
720
- ),
721
- /* @__PURE__ */ jsx(
722
- Input,
723
- {
724
- className: "fg-css-vars__filter",
725
- "aria-label": "Filter",
726
- placeholder: "Filter",
727
- value: filter,
728
- onChange: setFilter,
729
- leadingAdornment: /* @__PURE__ */ jsx(SearchIcon, { size: 12 })
730
- }
731
- )
732
- ] }),
733
- tab === "theme" && (unsaved.length > 0 || notice) ? /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__save", children: [
734
- unsaved.length > 0 ? /* @__PURE__ */ jsx(Button$1, { variant: "primary", size: "sm", loading: saving, disabled: saving, onClick: () => void save(), children: "Save scales to theme" }) : null,
735
- notice ? /* @__PURE__ */ jsx(
736
- "p",
737
- {
738
- className: `fg-css-vars__notice fg-css-vars__notice--${notice.tone}`,
739
- role: notice.tone === "error" ? "alert" : "status",
740
- children: notice.text
741
- }
742
- ) : null
743
- ] }) : null
744
- ] }),
745
- shown.length === 0 ? /* @__PURE__ */ jsx("p", { className: "fg-css-vars__empty", children: tokens.length === 0 ? "The story\u2019s frame has reported no variables." : "No variables match." }) : /* @__PURE__ */ jsx(
746
- TokenPanel,
833
+ return (
834
+ // Capture: React Aria's fields stop a keydown from bubbling past them.
835
+ /* @__PURE__ */ jsxs(
836
+ "div",
747
837
  {
748
- tokens: shown,
749
- namePrefix: "--wzl-",
750
- onChange: write,
751
- scales,
752
- onScaleChange,
753
- collapsed: folds,
754
- onCollapsedChange
838
+ className: "fg-css-vars",
839
+ onKeyDownCapture: (e) => {
840
+ const to = e.target.closest(".fg-css-vars__filter") ? null : historyKey(e);
841
+ if (!to) return;
842
+ e.preventDefault();
843
+ history2.step(to);
844
+ },
845
+ children: [
846
+ /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__controls", children: [
847
+ /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__bar", children: [
848
+ /* @__PURE__ */ jsx(
849
+ ToggleBar$1,
850
+ {
851
+ ariaLabel: "Variables",
852
+ variant: "flat",
853
+ size: "sm",
854
+ items: TABS,
855
+ value: tab,
856
+ onChange: (next) => {
857
+ if (next) setTab(next);
858
+ }
859
+ }
860
+ ),
861
+ /* @__PURE__ */ jsx(
862
+ Input,
863
+ {
864
+ className: "fg-css-vars__filter",
865
+ "aria-label": "Filter",
866
+ placeholder: "Filter",
867
+ value: filter,
868
+ onChange: setFilter,
869
+ leadingAdornment: /* @__PURE__ */ jsx(SearchIcon, { size: 12 })
870
+ }
871
+ ),
872
+ /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__steps", role: "group", "aria-label": "History", children: [
873
+ /* @__PURE__ */ jsx(Button$1, { iconOnly: true, ariaLabel: "Undo", variant: "ghost", size: "sm", disabled: !history2.canUndo, onClick: () => history2.step("undo"), children: /* @__PURE__ */ jsx(Icon, { name: "undo", size: 14 }) }),
874
+ /* @__PURE__ */ jsx(Button$1, { iconOnly: true, ariaLabel: "Redo", variant: "ghost", size: "sm", disabled: !history2.canRedo, onClick: () => history2.step("redo"), children: /* @__PURE__ */ jsx(Icon, { name: "redo", size: 14 }) })
875
+ ] })
876
+ ] }),
877
+ tab === "theme" && (pending || notice) ? /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__save", children: [
878
+ pending ? /* @__PURE__ */ jsx(Button$1, { variant: "primary", size: "sm", loading: saving, disabled: saving, onClick: () => void save(), children: "Save to theme" }) : null,
879
+ notice ? /* @__PURE__ */ jsx(
880
+ "p",
881
+ {
882
+ className: `fg-css-vars__notice fg-css-vars__notice--${notice.tone}`,
883
+ role: notice.tone === "error" ? "alert" : "status",
884
+ children: notice.text
885
+ }
886
+ ) : null
887
+ ] }) : null
888
+ ] }),
889
+ shown.length === 0 ? /* @__PURE__ */ jsx("p", { className: "fg-css-vars__empty", children: tokens.length === 0 ? "The story\u2019s frame has reported no variables." : "No variables match." }) : /* @__PURE__ */ jsx("div", { className: "fg-css-vars__list", children: /* @__PURE__ */ jsx(
890
+ TokenPanel,
891
+ {
892
+ tokens: shown,
893
+ namePrefix: TOKEN_PREFIX,
894
+ onChange: write,
895
+ scales,
896
+ onScaleChange,
897
+ collapsed: folds,
898
+ onCollapsedChange
899
+ }
900
+ ) })
901
+ ]
755
902
  }
756
903
  )
757
- ] });
904
+ );
758
905
  }
759
906
  function FocusedCssVars() {
760
907
  const { focusedTrialId, trials, instruments } = useLabContext();
@@ -771,7 +918,7 @@ function FocusedCssVars() {
771
918
  var CSS_VARS_SECTION = {
772
919
  id: "fg-css-vars",
773
920
  region: "aside",
774
- item: { title: "CSS Vars", actions: /* @__PURE__ */ jsx(SectionJump, {}), body: /* @__PURE__ */ jsx(FocusedCssVars, {}) }
921
+ item: { title: "CSS Vars", actions: /* @__PURE__ */ jsx(SectionJump, {}), fill: true, body: /* @__PURE__ */ jsx(FocusedCssVars, {}) }
775
922
  };
776
923
  var GLOBALS = "$globals";
777
924
  function leaves(shape, prefix = "") {
@@ -905,10 +1052,10 @@ var whenIdle = (fn) => {
905
1052
  return () => clearTimeout(handle);
906
1053
  };
907
1054
  function createFramePool(frameUrl, size = 2) {
908
- const shelf = document.createElement("div");
909
- shelf.className = "fg-frame-pool";
910
- shelf.setAttribute("aria-hidden", "true");
911
- document.body.append(shelf);
1055
+ const shelf2 = document.createElement("div");
1056
+ shelf2.className = "fg-frame-pool";
1057
+ shelf2.setAttribute("aria-hidden", "true");
1058
+ document.body.append(shelf2);
912
1059
  const warm = [];
913
1060
  const waits = /* @__PURE__ */ new Set();
914
1061
  let cancelRefill = null;
@@ -935,7 +1082,7 @@ function createFramePool(frameUrl, size = 2) {
935
1082
  }, HELLO_TIMEOUT_MS);
936
1083
  waits.add(stop);
937
1084
  window.addEventListener("message", onMessage);
938
- shelf.append(frame);
1085
+ shelf2.append(frame);
939
1086
  };
940
1087
  const refill = () => {
941
1088
  if (disposed || cancelRefill) return;
@@ -956,7 +1103,7 @@ function createFramePool(frameUrl, size = 2) {
956
1103
  disposed = true;
957
1104
  cancelRefill?.();
958
1105
  for (const stop of [...waits]) stop();
959
- shelf.remove();
1106
+ shelf2.remove();
960
1107
  }
961
1108
  };
962
1109
  }
@@ -966,7 +1113,7 @@ var FramePoolContext = createContext(null);
966
1113
  function routedTrial(trials, focusedTrialId, route) {
967
1114
  return trials.find((t) => t.id === focusedTrialId && t.instrumentName === route) ?? trials.find((t) => t.instrumentName === route);
968
1115
  }
969
- function PlayheadSync({ trialId, route, applied }) {
1116
+ function PlayheadSync({ trialId, route, appliedRef }) {
970
1117
  const clock = useTrialClock(trialId);
971
1118
  useEffect(() => {
972
1119
  if (!clock) return;
@@ -984,8 +1131,8 @@ function PlayheadSync({ trialId, route, applied }) {
984
1131
  held = next;
985
1132
  writePlayheadParam(next);
986
1133
  };
987
- if (applied.current !== route) {
988
- applied.current = route;
1134
+ if (appliedRef.current !== route) {
1135
+ appliedRef.current = route;
989
1136
  fromUrl();
990
1137
  }
991
1138
  toUrl();
@@ -1001,7 +1148,7 @@ function PlayheadSync({ trialId, route, applied }) {
1001
1148
  off();
1002
1149
  window.removeEventListener("hashchange", onHashChange);
1003
1150
  };
1004
- }, [clock, route, applied]);
1151
+ }, [clock, route, appliedRef]);
1005
1152
  return null;
1006
1153
  }
1007
1154
  function RoutePlayhead() {
@@ -1010,7 +1157,7 @@ function RoutePlayhead() {
1010
1157
  const applied = useRef(null);
1011
1158
  const trial = route === null ? void 0 : routedTrial(lab.trials, lab.focusedTrialId, route);
1012
1159
  if (!trial || route === null) return null;
1013
- return /* @__PURE__ */ jsx(PlayheadSync, { trialId: trial.id, route, applied }, `${trial.id} ${route}`);
1160
+ return /* @__PURE__ */ jsx(PlayheadSync, { trialId: trial.id, route, appliedRef: applied }, `${trial.id} ${route}`);
1014
1161
  }
1015
1162
  var RECORD = "fg-globals";
1016
1163
  function useLabGlobals(declarations) {
@@ -1350,13 +1497,13 @@ function StoryTree({ ctx, index }) {
1350
1497
  }, [index]);
1351
1498
  const enabled = (value) => views.some((item) => item.value === value && !item.disabled);
1352
1499
  const view = [chosen, "components", "gallery"].find(enabled) ?? chosen;
1353
- const kept = useMemo(() => index.filter((entry) => !hidden[table.of(entry)]), [index, hidden, table]);
1500
+ const kept2 = useMemo(() => index.filter((entry) => !hidden[table.of(entry)]), [index, hidden, table]);
1354
1501
  const [galleryEntries, rest] = useMemo(() => {
1355
1502
  const yes = [];
1356
1503
  const no = [];
1357
- for (const entry of kept) (isGallery(entry) ? yes : no).push(entry);
1504
+ for (const entry of kept2) (isGallery(entry) ? yes : no).push(entry);
1358
1505
  return [yes, no];
1359
- }, [kept]);
1506
+ }, [kept2]);
1360
1507
  const tree = useMemo(() => buildTree(rest), [rest]);
1361
1508
  const components = useMemo(() => buildComponents(rest, table), [rest, table]);
1362
1509
  const galleries = useMemo(() => buildComponents(galleryEntries, table), [galleryEntries, table]);
@@ -1364,7 +1511,7 @@ function StoryTree({ ctx, index }) {
1364
1511
  () => view === "tree" ? filterTree(tree, query) : componentNodes(filterComponents(view === "gallery" ? galleries : components, query)),
1365
1512
  [view, components, galleries, tree, query]
1366
1513
  );
1367
- const routable = useMemo(() => [...kept, ...indexEntries(kept)], [kept]);
1514
+ const routable = useMemo(() => [...kept2, ...indexEntries(kept2)], [kept2]);
1368
1515
  const routed = routable.find((entry) => entry.id === route);
1369
1516
  const routeAncestors = useMemo(() => ancestorsOf(routed), [routed]);
1370
1517
  const filtering = query.trim() !== "";
@@ -1615,13 +1762,11 @@ function StoryTree({ ctx, index }) {
1615
1762
  /* @__PURE__ */ jsx("div", { role: "tree", "aria-labelledby": headingId, className: "fg-tree__items", onKeyDown, children: renderNodes(shown, 1) })
1616
1763
  ] });
1617
1764
  }
1618
-
1619
- // src/shell/answers.ts
1620
1765
  var KEPT_CONFIGS = 50;
1621
1766
  var UNANSWERED = stableStringify({ hidden: [], errors: {} });
1622
1767
  function createAnswerBook() {
1623
1768
  const byConfig = /* @__PURE__ */ new Map();
1624
- const listeners2 = /* @__PURE__ */ new Set();
1769
+ const answered = createEmitter();
1625
1770
  const held = /* @__PURE__ */ new Map();
1626
1771
  const keyOf2 = (config) => stableStringify(storyConfig(config));
1627
1772
  const answerFor = (config) => byConfig.get(keyOf2(config));
@@ -1635,7 +1780,7 @@ function createAnswerBook() {
1635
1780
  const oldest = byConfig.keys().next().value;
1636
1781
  if (oldest !== void 0) byConfig.delete(oldest);
1637
1782
  }
1638
- if (changed && held.has(answers.configKey)) for (const fn of listeners2) fn();
1783
+ if (changed && held.has(answers.configKey)) answered.emit();
1639
1784
  },
1640
1785
  hold(config) {
1641
1786
  const key = keyOf2(config);
@@ -1651,17 +1796,60 @@ function createAnswerBook() {
1651
1796
  return hidden?.some((h) => path === h || path.startsWith(`${h}.`)) ?? false;
1652
1797
  },
1653
1798
  errors: (path, config) => answerFor(config)?.errors[path] ?? [],
1654
- subscribe(fn) {
1655
- listeners2.add(fn);
1656
- return () => {
1657
- listeners2.delete(fn);
1658
- };
1659
- }
1799
+ subscribe: answered.subscribe
1660
1800
  };
1661
1801
  }
1662
1802
 
1663
1803
  // src/shell/readyKey.ts
1664
1804
  var readyKey = (ready) => stableStringify({ schema: ready.schema, layout: ready.layout, viewport: ready.viewport });
1805
+ var kept = /* @__PURE__ */ new Map();
1806
+ var shelf = null;
1807
+ function shelfElement() {
1808
+ if (!shelf) {
1809
+ shelf = document.createElement("div");
1810
+ shelf.className = "fg-frame-pool";
1811
+ shelf.setAttribute("aria-hidden", "true");
1812
+ }
1813
+ if (!shelf.isConnected) document.body.append(shelf);
1814
+ return shelf;
1815
+ }
1816
+ function adopt({ key, slot, frame, link, onAdopt }) {
1817
+ const waiting = key === null ? void 0 : kept.get(key);
1818
+ if (key === null || !waiting || !slot.current) return;
1819
+ kept.delete(key);
1820
+ moveFrame(slot.current, waiting.frame);
1821
+ frame.current = waiting.frame;
1822
+ link.current = waiting.link;
1823
+ onAdopt(waiting.link);
1824
+ }
1825
+ function shelve(options) {
1826
+ const { key, frame, link, onDrop } = options.current;
1827
+ const element = frame.current;
1828
+ const current = link.current;
1829
+ if (key === null || !element?.isConnected || !current || !canMoveFrames()) return false;
1830
+ if (current.awaiting === null && current.sent === null) return false;
1831
+ const entry = { frame: element, link: current };
1832
+ moveFrame(shelfElement(), element);
1833
+ kept.set(key, entry);
1834
+ queueMicrotask(() => {
1835
+ if (kept.get(key) !== entry) return;
1836
+ kept.delete(key);
1837
+ onDrop();
1838
+ element.remove();
1839
+ });
1840
+ return true;
1841
+ }
1842
+ function useKeptFrame(options) {
1843
+ const handedOn = useRef(false);
1844
+ const latest = useLatest(options);
1845
+ useLayoutEffect(() => {
1846
+ adopt(latest.current);
1847
+ return () => {
1848
+ if (shelve(latest)) handedOn.current = true;
1849
+ };
1850
+ }, [latest]);
1851
+ return useCallback(() => handedOn.current, []);
1852
+ }
1665
1853
  var START_TIMEOUT_MS = 1e4;
1666
1854
  var requests = 0;
1667
1855
  function closeLink(link) {
@@ -1676,6 +1864,27 @@ function closeLink(link) {
1676
1864
  function mismatchMessage(mismatch) {
1677
1865
  return mismatch.reason === "version" ? `Frame speaks protocol ${String(mismatch.version)}; this workshop speaks ${PROTOCOL_VERSION}` : "Frame sent a message that is not a forge envelope";
1678
1866
  }
1867
+ function sendInputs(current, sent, next) {
1868
+ const before = current.inputs;
1869
+ const { state, globals } = next;
1870
+ const config = storyConfig(next.config, sent.config);
1871
+ if (config !== sent.config) {
1872
+ sent.config = config;
1873
+ current.channel.send({ type: "config", config });
1874
+ current.inputs += 1;
1875
+ }
1876
+ if (state !== sent.state) {
1877
+ sent.state = state;
1878
+ current.channel.send({ type: "state", state });
1879
+ current.inputs += 1;
1880
+ }
1881
+ if (globals !== sent.globals && stableStringify(globals) !== stableStringify(sent.globals)) {
1882
+ sent.globals = globals;
1883
+ current.channel.send({ type: "globals", globals });
1884
+ current.inputs += 1;
1885
+ }
1886
+ return current.inputs !== before;
1887
+ }
1679
1888
  function FrameView(props) {
1680
1889
  const { entry, frameUrl, answers, descriptionKey, ctx } = props;
1681
1890
  const labGlobals2 = useContext(StoryGlobalsContext);
@@ -1693,29 +1902,29 @@ function FrameView(props) {
1693
1902
  const slotRef = useRef(null);
1694
1903
  const inView = useNearViewport(hostRef, IN_VIEW_MARGIN);
1695
1904
  const link = useRef(null);
1696
- const latest = useLatest({ ...props, globals, overrides, pool, lab, title });
1905
+ const latestRef = useLatest({ ...props, globals, overrides, pool, lab, title });
1697
1906
  const [fault, setFault] = useState(null);
1698
1907
  const [pending, setPending] = useState(true);
1699
1908
  const init = useCallback((current) => {
1700
- const { ctx: live, globals: liveGlobals } = latest.current;
1909
+ const { ctx: live, globals: liveGlobals } = latestRef.current;
1701
1910
  current.awaiting = null;
1702
1911
  current.sent = { config: storyConfig(live.config), state: live.state, globals: liveGlobals };
1703
1912
  current.inputs += 1;
1704
1913
  current.channel.send({ type: "init", ...current.sent });
1705
- }, [latest]);
1914
+ }, [latestRef]);
1706
1915
  const receive = (current, msg) => {
1707
- const { ctx: live, answers: answers2, onReady } = latest.current;
1916
+ const { ctx: live, answers: answers2, onReady } = latestRef.current;
1708
1917
  switch (msg.type) {
1709
1918
  case "ready": {
1710
1919
  clearTimeout(current.timer);
1711
1920
  setFault(null);
1712
- for (const [name, value] of Object.entries(latest.current.overrides)) {
1921
+ for (const [name, value] of Object.entries(latestRef.current.overrides)) {
1713
1922
  current.channel.send({ type: "vars.set", name, value });
1714
1923
  }
1715
1924
  const key = readyKey(msg);
1716
1925
  current.awaiting = key;
1717
- onReady(latest.current.entry, msg);
1718
- if (current.awaiting === key && key === latest.current.descriptionKey) init(current);
1926
+ onReady(latestRef.current.entry, msg);
1927
+ if (current.awaiting === key && key === latestRef.current.descriptionKey) init(current);
1719
1928
  break;
1720
1929
  }
1721
1930
  case "rendered":
@@ -1744,7 +1953,7 @@ function FrameView(props) {
1744
1953
  break;
1745
1954
  }
1746
1955
  case "open": {
1747
- const { lab: liveLab } = latest.current;
1956
+ const { lab: liveLab } = latestRef.current;
1748
1957
  if (!trialId || !liveLab.instruments.some((instrument) => instrument.name === msg.id)) break;
1749
1958
  liveLab.swapTrial(trialId, msg.id);
1750
1959
  setRoute(msg.id, { inPlace: true });
@@ -1782,6 +1991,7 @@ function FrameView(props) {
1782
1991
  };
1783
1992
  const current = {
1784
1993
  channel,
1994
+ deliver: (msg) => receive(current, msg),
1785
1995
  timer,
1786
1996
  awaiting: null,
1787
1997
  sent: null,
@@ -1792,23 +2002,35 @@ function FrameView(props) {
1792
2002
  queued: []
1793
2003
  };
1794
2004
  link.current = current;
1795
- channel.on((msg) => receive(current, msg));
1796
- target.postMessage({ type: PORT_HANDOFF, id: latest.current.entry.id }, location.origin, [
2005
+ channel.on((msg) => current.deliver(msg));
2006
+ target.postMessage({ type: PORT_HANDOFF, id: latestRef.current.entry.id }, location.origin, [
1797
2007
  port2
1798
2008
  ]);
1799
2009
  };
1800
2010
  const connectRef = useLatest(connect);
2011
+ const handedOn = useKeptFrame({
2012
+ key: trialId ? `${trialId} ${src}` : null,
2013
+ slot: slotRef,
2014
+ frame: iframeRef,
2015
+ link,
2016
+ onAdopt: (adopted) => {
2017
+ adopted.deliver = (msg) => receive(adopted, msg);
2018
+ setPending(!adopted.hasRendered);
2019
+ },
2020
+ onDrop: () => closeLink(link)
2021
+ });
1801
2022
  useEffect(() => {
1802
2023
  const slot = slotRef.current;
1803
2024
  if (!inView || !slot) return;
1804
- const claimed = latest.current.pool?.claim() ?? null;
1805
- const frame = claimed ?? document.createElement("iframe");
2025
+ const adopted = iframeRef.current;
2026
+ const claimed = adopted ? null : latestRef.current.pool?.claim() ?? null;
2027
+ const frame = adopted ?? claimed ?? document.createElement("iframe");
1806
2028
  frame.className = "fg-frame-view";
1807
- frame.title = latest.current.title;
2029
+ frame.title = latestRef.current.title;
1808
2030
  frame.removeAttribute("tabindex");
1809
2031
  frame.toggleAttribute("data-pending", true);
1810
2032
  iframeRef.current = frame;
1811
- const unheard = claimed ? void 0 : setTimeout(() => setFault({ phase: null, message: `Frame did not start: ${src}` }), HELLO_TIMEOUT_MS);
2033
+ const unheard = adopted || claimed ? void 0 : setTimeout(() => setFault({ phase: null, message: `Frame did not start: ${src}` }), HELLO_TIMEOUT_MS);
1812
2034
  const onMessage = (event) => {
1813
2035
  if (event.source !== frame.contentWindow || event.origin !== location.origin) return;
1814
2036
  if (event.data?.type !== FRAME_HELLO) return;
@@ -1819,20 +2041,21 @@ function FrameView(props) {
1819
2041
  if (claimed) {
1820
2042
  moveFrame(slot, claimed);
1821
2043
  connectRef.current(claimed);
1822
- } else {
2044
+ } else if (!adopted) {
1823
2045
  frame.src = src;
1824
2046
  slot.append(frame);
1825
2047
  }
1826
2048
  return () => {
1827
2049
  window.removeEventListener("message", onMessage);
1828
2050
  clearTimeout(unheard);
2051
+ if (handedOn()) return;
1829
2052
  closeLink(link);
1830
2053
  frame.remove();
1831
2054
  iframeRef.current = null;
1832
2055
  setPending(true);
1833
2056
  setFault(null);
1834
2057
  };
1835
- }, [inView, src, connectRef, latest]);
2058
+ }, [inView, src, connectRef, latestRef, handedOn]);
1836
2059
  useEffect(() => {
1837
2060
  iframeRef.current?.toggleAttribute("data-pending", pending && !fault);
1838
2061
  }, [pending, fault, inView, src]);
@@ -1847,29 +2070,9 @@ function FrameView(props) {
1847
2070
  if (current.awaiting === descriptionKey) init(current);
1848
2071
  return;
1849
2072
  }
1850
- const sent = current.sent;
1851
- if (!sent) return;
1852
- let input = false;
1853
- const config = storyConfig(ctx.config, sent.config);
1854
- if (config !== sent.config) {
1855
- sent.config = config;
1856
- current.channel.send({ type: "config", config });
1857
- current.inputs += 1;
1858
- input = true;
1859
- }
1860
- if (ctx.state !== sent.state) {
1861
- sent.state = ctx.state;
1862
- current.channel.send({ type: "state", state: ctx.state });
1863
- current.inputs += 1;
1864
- input = true;
1865
- }
1866
- if (globals !== sent.globals && stableStringify(globals) !== stableStringify(sent.globals)) {
1867
- sent.globals = globals;
1868
- current.channel.send({ type: "globals", globals });
1869
- current.inputs += 1;
1870
- input = true;
2073
+ if (current.sent && sendInputs(current, current.sent, { config: ctx.config, state: ctx.state, globals })) {
2074
+ setFault((shown) => shown?.phase === "render" ? null : shown);
1871
2075
  }
1872
- if (input) setFault((shown) => shown?.phase === "render" ? null : shown);
1873
2076
  }, [ctx.config, ctx.state, globals, descriptionKey, init]);
1874
2077
  return /* @__PURE__ */ jsxs("div", { ref: hostRef, className: "fg-frame-host", children: [
1875
2078
  /* @__PURE__ */ jsx("div", { ref: slotRef, className: "fg-frame-slot" }),
@@ -1939,21 +2142,21 @@ function useStoryRegistry(index, options) {
1939
2142
  setRevisions((prev) => pruned(prev, ids));
1940
2143
  }
1941
2144
  const imports = useRef(/* @__PURE__ */ new Map());
1942
- const live = useLatest({ index, importers, setup, load, libraries });
2145
+ const liveRef = useLatest({ index, importers, setup, load, libraries });
1943
2146
  const missing = useRef(/* @__PURE__ */ new Set());
1944
2147
  const importOf = useCallback((file) => {
1945
2148
  let loading = imports.current.get(file);
1946
2149
  if (!loading) {
1947
- const importer = live.current.importers?.[file];
2150
+ const importer = liveRef.current.importers?.[file];
1948
2151
  if (!importer) missing.current.add(file);
1949
2152
  loading = importer ? importer() : Promise.reject(new Error(`No importer for ${file}`));
1950
2153
  imports.current.set(file, loading);
1951
2154
  }
1952
2155
  return loading;
1953
- }, [live]);
2156
+ }, [liveRef]);
1954
2157
  const request = useCallback(
1955
2158
  (entry) => {
1956
- const { setup: current, load: loadModule } = live.current;
2159
+ const { setup: current, load: loadModule } = liveRef.current;
1957
2160
  const settle = (ids2, result) => {
1958
2161
  setLoaded((prev) => {
1959
2162
  const next = new Map(prev);
@@ -1971,7 +2174,7 @@ function useStoryRegistry(index, options) {
1971
2174
  });
1972
2175
  };
1973
2176
  if (isIndexId(entry.id)) {
1974
- const entries = live.current.index.filter((e) => !isIndexId(e.id) && indexId(e.title) === entry.id);
2177
+ const entries = liveRef.current.index.filter((e) => !isIndexId(e.id) && indexId(e.title) === entry.id);
1975
2178
  const files = [...new Set(entries.map((e) => e.file))];
1976
2179
  void Promise.all(files.map(importOf)).then(async (mods) => {
1977
2180
  const byFile = new Map(files.map((file, i) => [file, loadModule(mods[i] ?? {}, entry.title, current.parameters)]));
@@ -1984,7 +2187,7 @@ function useStoryRegistry(index, options) {
1984
2187
  kind: "index",
1985
2188
  bundle: {
1986
2189
  title: entry.title,
1987
- library: live.current.libraries.of(entry),
2190
+ library: liveRef.current.libraries.of(entry),
1988
2191
  ...entry.componentDescription === void 0 ? {} : { description: entry.componentDescription },
1989
2192
  ...entry.tags === void 0 ? {} : { tags: entry.tags },
1990
2193
  galleries: entries.filter(isGallery).map((e) => e.id),
@@ -1997,7 +2200,7 @@ function useStoryRegistry(index, options) {
1997
2200
  }).catch((error) => fail([entry.id], error));
1998
2201
  return;
1999
2202
  }
2000
- const siblings = live.current.index.filter((e) => e.file === entry.file && !isIndexId(e.id));
2203
+ const siblings = liveRef.current.index.filter((e) => e.file === entry.file && !isIndexId(e.id));
2001
2204
  const ids = siblings.map((e) => e.id);
2002
2205
  void importOf(entry.file).then(async (mod) => {
2003
2206
  const stories = loadModule(mod, entry.title, current.parameters);
@@ -2012,18 +2215,18 @@ function useStoryRegistry(index, options) {
2012
2215
  settle(ids, result);
2013
2216
  }).catch((error) => fail(ids, error));
2014
2217
  },
2015
- [importOf, live]
2218
+ [importOf, liveRef]
2016
2219
  );
2017
2220
  const reload = useCallback(
2018
2221
  (file) => {
2019
2222
  imports.current.delete(file);
2020
- const affected = live.current.index.filter((e) => e.file === file || isIndexId(e.id) && live.current.index.some((s) => s.file === file && indexId(s.title) === e.id));
2223
+ const affected = liveRef.current.index.filter((e) => e.file === file || isIndexId(e.id) && liveRef.current.index.some((s) => s.file === file && indexId(s.title) === e.id));
2021
2224
  const ids = new Set(affected.map((e) => e.id));
2022
2225
  setLoaded((prev) => pruned(prev, new Set([...prev.keys()].filter((id) => !ids.has(id)))));
2023
2226
  setFaults((prev) => pruned(prev, new Set([...prev.keys()].filter((id) => !ids.has(id)))));
2024
2227
  setGenerations((prev) => new Map(prev).set(file, (prev.get(file) ?? 0) + 1));
2025
2228
  },
2026
- [live]
2229
+ [liveRef]
2027
2230
  );
2028
2231
  useEffect(() => {
2029
2232
  for (const file of [...missing.current]) {
@@ -2048,7 +2251,7 @@ function useStoryRegistry(index, options) {
2048
2251
  const answers = previous.books.get(entry.id) ?? createAnswerBook();
2049
2252
  books.set(entry.id, answers);
2050
2253
  const held = previous.built.get(entry.id);
2051
- const kept = held && held.entryKey === entryKey && held.ready === ready && held.loaded === had && held.fault === fault && held.frameUrl === frameUrl && held.globals === globals && held.setup === setup && held.inDocument === inDocument && held.generation === generation && held.revision === revision ? held : {
2254
+ const kept2 = held && held.entryKey === entryKey && held.ready === ready && held.loaded === had && held.fault === fault && held.frameUrl === frameUrl && held.globals === globals && held.setup === setup && held.inDocument === inDocument && held.generation === generation && held.revision === revision ? held : {
2052
2255
  entryKey,
2053
2256
  ready,
2054
2257
  loaded: had,
@@ -2061,8 +2264,8 @@ function useStoryRegistry(index, options) {
2061
2264
  revision,
2062
2265
  instrument: !inDocument ? storyInstrument({ entry, ready, answers, frameUrl, onReady, globals }) : had ? documentInstrument({ entry, loaded: had, setup, globals }) : pendingInstrument({ entry, globals, load: request, generation, ...fault ? { fault } : {} })
2063
2266
  };
2064
- built.set(entry.id, kept);
2065
- return kept.instrument;
2267
+ built.set(entry.id, kept2);
2268
+ return kept2.instrument;
2066
2269
  });
2067
2270
  const unchanged = list.length === previous.list.length && list.every((i, n) => i === previous.list[n]);
2068
2271
  return { built, books, list: unchanged ? previous.list : list };
@@ -2167,10 +2370,10 @@ function Workshop({ index, frameUrl, importers, setup, changes, config, stories
2167
2370
  const labChrome = useMemo(
2168
2371
  () => [
2169
2372
  { id: "fg-stories", region: "sidebar", render: (ctx) => /* @__PURE__ */ jsx(StoryTree, { ctx, index }) },
2170
- { id: "fg-info", region: "palette", item: infoCommand },
2171
2373
  ...config?.cssVars === false ? [] : [CSS_VARS_SECTION],
2172
2374
  A11Y_SECTION,
2173
2375
  ...Object.keys(declarations).length > 0 ? [{ id: "fg-globals", region: "header", render: () => /* @__PURE__ */ jsx(GlobalsToolbar, { declarations }) }] : [],
2376
+ { id: "fg-info", region: "header", group: "story", item: infoCommand },
2174
2377
  ...config?.labChrome ?? []
2175
2378
  ],
2176
2379
  [index, infoCommand, declarations, config?.cssVars, config?.labChrome]