@weasel-js/forge 1.9.4 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +12 -4
  2. package/dist/{FrameController-BQjKhdwk.d.ts → FrameController-UeTY7ubF.d.ts} +25 -1
  3. package/dist/{chunk-B3PPHJTS.js → chunk-4NM3RTYK.js} +14 -14
  4. package/dist/chunk-4NM3RTYK.js.map +1 -0
  5. package/dist/{chunk-JHL4ERDA.js → chunk-DFKCLZKS.js} +3 -3
  6. package/dist/chunk-DFKCLZKS.js.map +1 -0
  7. package/dist/{chunk-FRUA5WJF.js → chunk-DKWHGSBZ.js} +3 -3
  8. package/dist/{chunk-FRUA5WJF.js.map → chunk-DKWHGSBZ.js.map} +1 -1
  9. package/dist/{chunk-ZZCCIXEJ.js → chunk-ENXU34PI.js} +16 -25
  10. package/dist/chunk-ENXU34PI.js.map +1 -0
  11. package/dist/{chunk-TXLWQFF6.js → chunk-FL2MUPUL.js} +2 -2
  12. package/dist/chunk-FL2MUPUL.js.map +1 -0
  13. package/dist/{chunk-5KI5YSQ5.js → chunk-GB6PRJET.js} +3 -3
  14. package/dist/{chunk-5KI5YSQ5.js.map → chunk-GB6PRJET.js.map} +1 -1
  15. package/dist/{chunk-MQSFPRF6.js → chunk-V4RI7GWD.js} +37 -19
  16. package/dist/chunk-V4RI7GWD.js.map +1 -0
  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 +36 -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 +398 -274
  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 +16 -1
  40. package/dist/vite/index.js +403 -78
  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,26 @@
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-V4RI7GWD.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-ENXU34PI.js';
6
+ export { StoryGlobalsContext, StoryTrial, TrialFramesContext, TrialHost, useTrialFrame } from '../chunk-ENXU34PI.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-4NM3RTYK.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
21
  import { TOKEN_MANIFEST, THEME_SOURCES, 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';
23
24
 
24
25
  function Finding({ finding }) {
25
26
  return /* @__PURE__ */ jsxs("li", { className: "fg-a11y__finding", children: [
@@ -100,19 +101,9 @@ var A11Y_SECTION = {
100
101
  region: "aside",
101
102
  item: { title: "Accessibility", body: /* @__PURE__ */ jsx(FocusedA11y, {}) }
102
103
  };
103
-
104
- // src/shell/storyChanges.ts
105
104
  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
- };
105
+ const changes = createEmitter();
106
+ return { subscribe: changes.subscribe, emit: changes.emit };
116
107
  }
117
108
  var DependenciesContext = createContext(null);
118
109
  function readRoute() {
@@ -233,17 +224,12 @@ function crumbs(title, name) {
233
224
  function breadcrumb(title, name) {
234
225
  return crumbs(title, name).map((step) => step.label).join(" > ");
235
226
  }
236
-
237
- // src/shell/treeReveal.ts
238
- var listeners = /* @__PURE__ */ new Set();
227
+ var reveals = createEmitter();
239
228
  function revealInTree(path) {
240
- for (const listener of listeners) listener(path);
229
+ reveals.emit(path);
241
230
  }
242
231
  function onRevealInTree(listener) {
243
- listeners.add(listener);
244
- return () => {
245
- listeners.delete(listener);
246
- };
232
+ return reveals.subscribe(listener);
247
233
  }
248
234
  function Crumbs({ entry }) {
249
235
  const steps = crumbs(entry.title, entry.name);
@@ -352,6 +338,73 @@ function missingInstrument(id, globals = {}) {
352
338
  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
339
  };
354
340
  }
341
+ var ICONS = {
342
+ color: "drop",
343
+ type: "type",
344
+ size: "measure",
345
+ motion: "curveEaseInOut",
346
+ depth: "layers",
347
+ other: "more"
348
+ };
349
+ var PANEL = ".fg-css-vars";
350
+ var HOST = ".lk-sidebar-section, .lk-panel-tile";
351
+ var MANUAL = ["wheel", "touchmove", "keydown"];
352
+ var LIST = ".fg-css-vars__list";
353
+ function readLine(panel) {
354
+ return (panel.querySelector(LIST) ?? panel).getBoundingClientRect().top;
355
+ }
356
+ function sectionsOf(panel) {
357
+ return [...panel.querySelectorAll("[data-token-category]")];
358
+ }
359
+ function SectionJump() {
360
+ const ref = useRef(null);
361
+ const [present, setPresent] = useState([]);
362
+ const [current, setCurrent] = useState(null);
363
+ const held = useRef(false);
364
+ useEffect(() => {
365
+ const root = ref.current?.closest(HOST) ?? document.body;
366
+ const read = () => {
367
+ const panel = root.querySelector(PANEL);
368
+ const sections = panel ? sectionsOf(panel) : [];
369
+ setPresent(sections.map((s) => s.dataset.tokenCategory));
370
+ if (!panel || held.current) return;
371
+ const line = readLine(panel) + 1;
372
+ let top = sections[0]?.dataset.tokenCategory;
373
+ for (const s of sections) if (s.getBoundingClientRect().top <= line) top = s.dataset.tokenCategory;
374
+ setCurrent(top ?? null);
375
+ };
376
+ const release = () => {
377
+ held.current = false;
378
+ };
379
+ read();
380
+ const observer = new MutationObserver(read);
381
+ observer.observe(root, { childList: true, subtree: true });
382
+ document.addEventListener("scroll", read, { capture: true, passive: true });
383
+ for (const type of MANUAL) document.addEventListener(type, release, { capture: true, passive: true });
384
+ return () => {
385
+ observer.disconnect();
386
+ document.removeEventListener("scroll", read, { capture: true });
387
+ for (const type of MANUAL) document.removeEventListener(type, release, { capture: true });
388
+ };
389
+ }, []);
390
+ const items = TOKEN_CATEGORIES.map(({ id, title }) => ({
391
+ value: id,
392
+ label: /* @__PURE__ */ jsx(Icon, { name: ICONS[id], size: 14 }),
393
+ ariaLabel: title,
394
+ tooltip: title,
395
+ disabled: !present.includes(id)
396
+ }));
397
+ const jump = (next) => {
398
+ const panel = ref.current?.closest(HOST)?.querySelector(PANEL);
399
+ const target = next && panel?.querySelector(`[data-token-category="${next}"]`);
400
+ const scroller = panel?.querySelector(LIST);
401
+ if (!panel || !target || !scroller) return;
402
+ held.current = true;
403
+ setCurrent(next);
404
+ scroller.scrollTop += target.getBoundingClientRect().top - readLine(panel);
405
+ };
406
+ 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 }) });
407
+ }
355
408
  var SEED_REF = /^\{seeds\.([^}]+)\}$/;
356
409
  var RULE_KEYS = { factors: "factors", ratio: "ratio", step: "step" };
357
410
  function selectionFor(axes, globals) {
@@ -365,24 +418,24 @@ function selectionFor(axes, globals) {
365
418
  }
366
419
  var EditError = class extends Error {
367
420
  };
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)) {
421
+ function branchOf(holder, key, where, selection) {
422
+ let slot = { holder, key, where };
423
+ for (let value = holder[key]; isByAxis(value); value = slot.holder[slot.key]) {
375
424
  const axis = value.by;
376
425
  const picked = selection[axis];
377
426
  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`);
427
+ throw new EditError(`${slot.where} differs by ${axis}, and which ${axis} this trial shows is not known`);
379
428
  }
380
- h = value;
381
- k = picked;
382
- w = `${w}.${picked}`;
383
- value = h[k];
429
+ slot = { holder: value, key: picked, where: `${slot.where}.${picked}` };
384
430
  }
385
- if (typeof value === "number") return { holder: h, key: k, where: w };
431
+ return slot;
432
+ }
433
+ function slotOf(root, holder, key, where, selection, depth = 0) {
434
+ if (depth > 16) throw new EditError(`${where} references itself`);
435
+ const slot = branchOf(holder, key, where, selection);
436
+ const { holder: h, key: k, where: w } = slot;
437
+ const value = h[k];
438
+ if (typeof value === "number") return slot;
386
439
  if (typeof value === "string") {
387
440
  const ref = SEED_REF.exec(value);
388
441
  if (!ref) throw new EditError(`${w} is "${value}", which is not a number or a seed`);
@@ -394,18 +447,10 @@ function slotOf(root, holder, key, where, selection, depth = 0) {
394
447
  throw new EditError(`${w} is not a number`);
395
448
  }
396
449
  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 };
450
+ const slot = branchOf(holder, "factors", `${where}.factors`, selection);
451
+ const value = slot.holder[slot.key];
452
+ if (!Array.isArray(value)) throw new EditError(`${slot.where} is not a list`);
453
+ return { list: value, where: slot.where };
409
454
  }
410
455
  var kindOf = (scale) => scale.factors !== void 0 ? "factors" : scale.ratio !== void 0 ? "ratio" : scale.step !== void 0 ? "step" : null;
411
456
  var plainLiteral = (value) => typeof value === "number" || Array.isArray(value) && value.every((v) => typeof v === "number");
@@ -461,90 +506,196 @@ function applyScaleEdits(definition, edits, selection) {
461
506
  for (const [holder, byKey] of writes) for (const [key, value] of byKey) holder[key] = value;
462
507
  return { ok: true, definition: root };
463
508
  }
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;
509
+ var REFERENCE = /\{[^}]+\}/;
510
+ var LAYERS = ["pins", "semantics", "components"];
511
+ function derivation(entry) {
512
+ const value = isPlainObject(entry) ? entry.value : entry;
513
+ if (isPlainObject(entry) && value === void 0) return "follows a rule";
514
+ if (isPlainObject(entry) && entry.alpha !== void 0) return "is drawn at an alpha";
515
+ if (typeof value === "string" && REFERENCE.test(value)) return `follows ${REFERENCE.exec(value)?.[0]}`;
516
+ return typeof value === "string" || typeof value === "number" ? null : "is a list";
517
+ }
518
+ function literalSlot(definition, token, selection) {
519
+ for (const layer of LAYERS) {
520
+ const entries = definition[layer];
521
+ if (!entries || !(token in entries)) continue;
522
+ const slot = branchOf(entries, token, `${layer}.${token}`, selection);
523
+ const why = derivation(slot.holder[slot.key]);
524
+ if (why) throw new EditError(`${slot.where} ${why}, and only a value written out in the theme can be saved`);
525
+ return slot;
479
526
  }
480
- return null;
527
+ for (const layer of ["ramps", "scales"]) {
528
+ const from = Object.keys(definition[layer] ?? {}).find((name) => token.startsWith(`${name}-`));
529
+ if (from) throw new EditError(`${token} is a step ${layer}.${from} generates, and the theme pins no value over it`);
530
+ }
531
+ throw new EditError(`the theme defines no token "${token}"`);
481
532
  }
482
- function readLine(panel) {
483
- return panel.querySelector(".fg-css-vars__controls")?.getBoundingClientRect().bottom ?? panel.getBoundingClientRect().top;
533
+ function writtenOut(definition, token, selection) {
534
+ try {
535
+ literalSlot(definition, token, selection);
536
+ return true;
537
+ } catch (e) {
538
+ if (e instanceof EditError) return false;
539
+ throw e;
540
+ }
484
541
  }
485
- function sectionsOf(panel) {
486
- return [...panel.querySelectorAll("[data-token-category]")];
542
+ function applyTokenEdits(definition, edits, selection) {
543
+ const root = structuredClone(definition);
544
+ try {
545
+ for (const [token, value] of Object.entries(edits)) {
546
+ const { holder, key } = literalSlot(root, token, selection);
547
+ const entry = holder[key];
548
+ holder[key] = isPlainObject(entry) ? { ...entry, value } : value;
549
+ }
550
+ } catch (e) {
551
+ if (e instanceof EditError) return { ok: false, message: `Cannot save: ${e.message}.` };
552
+ throw e;
553
+ }
554
+ return { ok: true, definition: root };
487
555
  }
488
- function SectionJump() {
489
- const ref = useRef(null);
490
- const [present, setPresent] = useState([]);
491
- const [current, setCurrent] = useState(null);
492
- const held = useRef(false);
556
+
557
+ // src/shell/cssVars/useThemeSave.ts
558
+ var THEME_NAME = "weasel";
559
+ var TOKEN_PREFIX = "--wzl-";
560
+ function useThemeSave({ trialId, globals, themeApi, scales, overrides, history: history2 }) {
561
+ const tokens = useMemo(() => {
562
+ const definition = THEME_SOURCES[THEME_NAME];
563
+ if (!definition) return {};
564
+ const selection = selectionFor(definition.axes ?? {}, globals);
565
+ const generated = new Set(scales.flatMap((scale) => scale.tokens));
566
+ return Object.fromEntries(
567
+ Object.entries(overrides).flatMap(([name, value]) => {
568
+ if (!name.startsWith(TOKEN_PREFIX) || generated.has(name)) return [];
569
+ const token = name.slice(TOKEN_PREFIX.length);
570
+ return writtenOut(definition, token, selection) ? [[token, value]] : [];
571
+ })
572
+ );
573
+ }, [globals, scales, overrides]);
574
+ const [saving, setSaving] = useState(false);
575
+ const [held, setHeld] = useState(null);
576
+ const setNotice = (next) => setHeld(next && { ...next, trialId });
577
+ const save = async () => {
578
+ setSaving(true);
579
+ setNotice(null);
580
+ try {
581
+ const api = themeApi ?? httpThemeApi();
582
+ const stored = await api.get(THEME_NAME);
583
+ const file = `themes/${THEME_NAME}.json`;
584
+ if (JSON.stringify(stored.definition) !== JSON.stringify(THEME_SOURCES[THEME_NAME])) {
585
+ setNotice({ tone: "error", text: `${file} has changed since this page loaded. Reload to edit against it.` });
586
+ return;
587
+ }
588
+ const selection = selectionFor(stored.definition.axes ?? {}, globals);
589
+ const scaled = applyScaleEdits(stored.definition, Object.fromEntries(scales.map((s) => [s.name, s.edit])), selection);
590
+ const next = scaled.ok ? applyTokenEdits(scaled.definition, tokens, selection) : scaled;
591
+ if (!next.ok) {
592
+ setNotice({ tone: "error", text: next.message });
593
+ return;
594
+ }
595
+ const result = await api.put(THEME_NAME, next.definition, stored.hash);
596
+ if (result.status === "conflict") {
597
+ setNotice({ tone: "error", text: `${file} changed on disk while saving. Nothing was written; reload to edit against it.` });
598
+ return;
599
+ }
600
+ if (result.status === "invalid") {
601
+ setNotice({ tone: "error", text: result.message });
602
+ return;
603
+ }
604
+ const saved = /* @__PURE__ */ new Set([
605
+ ...scales.flatMap((scale) => scale.tokens),
606
+ ...Object.keys(tokens).map((token) => `${TOKEN_PREFIX}${token}`)
607
+ ]);
608
+ const groups = new Set(scales.map((scale) => scale.group));
609
+ const now = history2.current();
610
+ history2.reset({
611
+ overrides: Object.fromEntries(Object.entries(now.overrides).filter(([name]) => !saved.has(name))),
612
+ scales: Object.fromEntries(Object.entries(now.scales).filter(([group]) => !groups.has(group)))
613
+ });
614
+ setNotice(
615
+ 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("; ")}` }
616
+ );
617
+ } catch (e) {
618
+ setNotice({ tone: "error", text: `Saving needs forge's dev server, which did not answer: ${e.message}` });
619
+ } finally {
620
+ setSaving(false);
621
+ }
622
+ };
623
+ return {
624
+ pending: scales.length > 0 || Object.keys(tokens).length > 0,
625
+ saving,
626
+ notice: held?.trialId === trialId ? held : null,
627
+ save
628
+ };
629
+ }
630
+ var SWAP = "fg.vars.swap";
631
+ var COALESCE_MS = 800;
632
+ var LIMIT = 100;
633
+ function useVarsHistory(state, apply) {
634
+ const trialId = useTrialId();
635
+ const [stored, setStored] = usePersistedState("fg-css-vars-history", null, { scope: "trial" });
636
+ const rendered = useLatest(state);
637
+ const ahead = useRef(null);
493
638
  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);
639
+ ahead.current = null;
640
+ }, [state]);
641
+ const current = () => ahead.current ?? rendered.current;
642
+ const latestApply = useLatest(apply);
643
+ const emit = (to) => {
644
+ const from = current();
645
+ ahead.current = to;
646
+ latestApply.current(from, to);
647
+ };
648
+ const swap = (before, after, coalesceKey) => swapOp({ name: SWAP, label: "edit vars", before, after, emit, ...coalesceKey !== void 0 ? { coalesceKey } : {} });
649
+ const history2 = useMemo(() => {
650
+ const opened = createHistory(null, {
651
+ coalesceWindowMs: COALESCE_MS,
652
+ historyLimit: LIMIT,
653
+ rebuildOp: (name, args) => {
654
+ const { before, after } = args;
655
+ return name === SWAP ? swap(before, after) : null;
656
+ }
657
+ });
658
+ if (stored) opened.restore(stored);
659
+ return opened;
660
+ }, [trialId]);
661
+ useSyncExternalStore(history2.subscribe, history2.getVersion);
662
+ const keep = () => setStored(history2.canUndo() || history2.canRedo() ? history2.serialize() : null);
663
+ const tick = useRef(null);
664
+ return {
665
+ current,
666
+ commit(next, key) {
667
+ if (tick.current === null) {
668
+ tick.current = key;
669
+ queueMicrotask(() => {
670
+ tick.current = null;
671
+ });
672
+ }
673
+ history2.applyOps([swap(current(), next, tick.current)], "edit vars");
674
+ keep();
675
+ },
676
+ step(to) {
677
+ history2[to]();
678
+ keep();
679
+ },
680
+ canUndo: history2.canUndo(),
681
+ canRedo: history2.canRedo(),
682
+ reset(next) {
683
+ emit(next);
684
+ history2.clear();
685
+ setStored(null);
686
+ }
534
687
  };
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
688
  }
537
689
  var TABS = [
538
690
  { value: "theme", label: "Theme" },
539
691
  { value: "story", label: "Story" }
540
692
  ];
541
693
  var MANIFEST = new Map(TOKEN_MANIFEST.map((token) => [token.name, token]));
542
- var THEME_NAME = "weasel";
543
694
  var literal = (v) => typeof v === "number";
544
695
  var SCALE_SOURCES = Object.entries(THEME_SOURCES[THEME_NAME]?.scales ?? {}).flatMap(([name, def]) => {
545
696
  if (!Array.isArray(def.steps)) return [];
546
697
  const steps = def.steps;
547
- const tokens = steps.map((step) => `--wzl-${name}-${step}`);
698
+ const tokens = steps.map((step) => `${TOKEN_PREFIX}${name}-${step}`);
548
699
  const group = MANIFEST.get(tokens[0] ?? "")?.group;
549
700
  if (!group) return [];
550
701
  const factors = Array.isArray(def.factors) && def.factors.every(literal) ? def.factors : null;
@@ -589,7 +740,7 @@ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
589
740
  {},
590
741
  { scope: "lab" }
591
742
  );
592
- const [edited, setEdited] = usePersistedState("fg-css-vars-scales", {}, { scope: "lab" });
743
+ const [edited, setEdited] = usePersistedState("fg-css-vars-scales", {}, { scope: "trial" });
593
744
  const [tab, setTab] = useState("theme");
594
745
  const [filter, setFilter] = useState("");
595
746
  const tokens = useMemo(() => {
@@ -626,135 +777,115 @@ function CssVarsPanel({ globals = NO_GLOBALS, themeApi } = {}) {
626
777
  return out;
627
778
  }, [tab, shown, overrides, edited]);
628
779
  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
- );
780
+ const state = useMemo(() => ({ overrides, scales: edited }), [overrides, edited]);
781
+ const history2 = useVarsHistory(state, (from, to) => {
782
+ setOverrides(to.overrides);
783
+ setEdited(to.scales);
784
+ for (const name of /* @__PURE__ */ new Set([...Object.keys(from.overrides), ...Object.keys(to.overrides)])) {
785
+ if (from.overrides[name] !== to.overrides[name]) send?.({ type: "vars.set", name, value: to.overrides[name] ?? null });
786
+ }
787
+ });
788
+ const write = (name, value) => {
789
+ const now = history2.current();
790
+ const { [name]: _dropped, ...rest } = now.overrides;
791
+ history2.commit({ ...now, overrides: value === null ? rest : { ...rest, [name]: value } }, `token:${name}`);
792
+ };
793
+ const onScaleChange = (group, next) => {
794
+ const source = SCALE_SOURCES.find((sc) => sc.group === group);
795
+ if (!source) return;
796
+ const now = history2.current();
797
+ history2.commit(
798
+ { overrides: { ...now.overrides, ...Object.fromEntries(scaleValues(source, next)) }, scales: { ...now.scales, [group]: next } },
799
+ `scale:${group}`
800
+ );
801
+ };
648
802
  const unsaved = useMemo(
649
803
  () => SCALE_SOURCES.flatMap((source) => {
650
804
  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 }] : [];
805
+ if (!edit || !source.tokens.some((name2) => overrides[name2] !== void 0)) return [];
806
+ const { name, group, tokens: tokens2 } = source;
807
+ const clean = scaleValues(source, edit).every(([token, value]) => overrides[token] === value);
808
+ return clean ? [{ name, group, tokens: tokens2, edit: { base: edit.base, rule: edit.rule } }] : [];
654
809
  }),
655
810
  [edited, overrides]
656
811
  );
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
- };
812
+ const { pending, saving, notice, save } = useThemeSave({ trialId, globals, themeApi, scales: unsaved, overrides, history: history2 });
701
813
  const onCollapsedChange = useCallback(
702
814
  (category, collapsed) => setFolds((prev) => ({ ...prev, [category]: collapsed })),
703
815
  [setFolds]
704
816
  );
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,
817
+ return (
818
+ // Capture: React Aria's fields stop a keydown from bubbling past them.
819
+ /* @__PURE__ */ jsxs(
820
+ "div",
747
821
  {
748
- tokens: shown,
749
- namePrefix: "--wzl-",
750
- onChange: write,
751
- scales,
752
- onScaleChange,
753
- collapsed: folds,
754
- onCollapsedChange
822
+ className: "fg-css-vars",
823
+ onKeyDownCapture: (e) => {
824
+ const to = e.target.closest(".fg-css-vars__filter") ? null : historyKey(e);
825
+ if (!to) return;
826
+ e.preventDefault();
827
+ history2.step(to);
828
+ },
829
+ children: [
830
+ /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__controls", children: [
831
+ /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__bar", children: [
832
+ /* @__PURE__ */ jsx(
833
+ ToggleBar$1,
834
+ {
835
+ ariaLabel: "Variables",
836
+ variant: "flat",
837
+ size: "sm",
838
+ items: TABS,
839
+ value: tab,
840
+ onChange: (next) => {
841
+ if (next) setTab(next);
842
+ }
843
+ }
844
+ ),
845
+ /* @__PURE__ */ jsx(
846
+ Input,
847
+ {
848
+ className: "fg-css-vars__filter",
849
+ "aria-label": "Filter",
850
+ placeholder: "Filter",
851
+ value: filter,
852
+ onChange: setFilter,
853
+ leadingAdornment: /* @__PURE__ */ jsx(SearchIcon, { size: 12 })
854
+ }
855
+ ),
856
+ /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__steps", role: "group", "aria-label": "History", children: [
857
+ /* @__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 }) }),
858
+ /* @__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 }) })
859
+ ] })
860
+ ] }),
861
+ tab === "theme" && (pending || notice) ? /* @__PURE__ */ jsxs("div", { className: "fg-css-vars__save", children: [
862
+ pending ? /* @__PURE__ */ jsx(Button$1, { variant: "primary", size: "sm", loading: saving, disabled: saving, onClick: () => void save(), children: "Save to theme" }) : null,
863
+ notice ? /* @__PURE__ */ jsx(
864
+ "p",
865
+ {
866
+ className: `fg-css-vars__notice fg-css-vars__notice--${notice.tone}`,
867
+ role: notice.tone === "error" ? "alert" : "status",
868
+ children: notice.text
869
+ }
870
+ ) : null
871
+ ] }) : null
872
+ ] }),
873
+ 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(
874
+ TokenPanel,
875
+ {
876
+ tokens: shown,
877
+ namePrefix: TOKEN_PREFIX,
878
+ onChange: write,
879
+ scales,
880
+ onScaleChange,
881
+ collapsed: folds,
882
+ onCollapsedChange
883
+ }
884
+ ) })
885
+ ]
755
886
  }
756
887
  )
757
- ] });
888
+ );
758
889
  }
759
890
  function FocusedCssVars() {
760
891
  const { focusedTrialId, trials, instruments } = useLabContext();
@@ -771,7 +902,7 @@ function FocusedCssVars() {
771
902
  var CSS_VARS_SECTION = {
772
903
  id: "fg-css-vars",
773
904
  region: "aside",
774
- item: { title: "CSS Vars", actions: /* @__PURE__ */ jsx(SectionJump, {}), body: /* @__PURE__ */ jsx(FocusedCssVars, {}) }
905
+ item: { title: "CSS Vars", actions: /* @__PURE__ */ jsx(SectionJump, {}), fill: true, body: /* @__PURE__ */ jsx(FocusedCssVars, {}) }
775
906
  };
776
907
  var GLOBALS = "$globals";
777
908
  function leaves(shape, prefix = "") {
@@ -1615,13 +1746,11 @@ function StoryTree({ ctx, index }) {
1615
1746
  /* @__PURE__ */ jsx("div", { role: "tree", "aria-labelledby": headingId, className: "fg-tree__items", onKeyDown, children: renderNodes(shown, 1) })
1616
1747
  ] });
1617
1748
  }
1618
-
1619
- // src/shell/answers.ts
1620
1749
  var KEPT_CONFIGS = 50;
1621
1750
  var UNANSWERED = stableStringify({ hidden: [], errors: {} });
1622
1751
  function createAnswerBook() {
1623
1752
  const byConfig = /* @__PURE__ */ new Map();
1624
- const listeners2 = /* @__PURE__ */ new Set();
1753
+ const answered = createEmitter();
1625
1754
  const held = /* @__PURE__ */ new Map();
1626
1755
  const keyOf2 = (config) => stableStringify(storyConfig(config));
1627
1756
  const answerFor = (config) => byConfig.get(keyOf2(config));
@@ -1635,7 +1764,7 @@ function createAnswerBook() {
1635
1764
  const oldest = byConfig.keys().next().value;
1636
1765
  if (oldest !== void 0) byConfig.delete(oldest);
1637
1766
  }
1638
- if (changed && held.has(answers.configKey)) for (const fn of listeners2) fn();
1767
+ if (changed && held.has(answers.configKey)) answered.emit();
1639
1768
  },
1640
1769
  hold(config) {
1641
1770
  const key = keyOf2(config);
@@ -1651,12 +1780,7 @@ function createAnswerBook() {
1651
1780
  return hidden?.some((h) => path === h || path.startsWith(`${h}.`)) ?? false;
1652
1781
  },
1653
1782
  errors: (path, config) => answerFor(config)?.errors[path] ?? [],
1654
- subscribe(fn) {
1655
- listeners2.add(fn);
1656
- return () => {
1657
- listeners2.delete(fn);
1658
- };
1659
- }
1783
+ subscribe: answered.subscribe
1660
1784
  };
1661
1785
  }
1662
1786
 
@@ -1693,29 +1817,29 @@ function FrameView(props) {
1693
1817
  const slotRef = useRef(null);
1694
1818
  const inView = useNearViewport(hostRef, IN_VIEW_MARGIN);
1695
1819
  const link = useRef(null);
1696
- const latest = useLatest({ ...props, globals, overrides, pool, lab, title });
1820
+ const latestRef = useLatest({ ...props, globals, overrides, pool, lab, title });
1697
1821
  const [fault, setFault] = useState(null);
1698
1822
  const [pending, setPending] = useState(true);
1699
1823
  const init = useCallback((current) => {
1700
- const { ctx: live, globals: liveGlobals } = latest.current;
1824
+ const { ctx: live, globals: liveGlobals } = latestRef.current;
1701
1825
  current.awaiting = null;
1702
1826
  current.sent = { config: storyConfig(live.config), state: live.state, globals: liveGlobals };
1703
1827
  current.inputs += 1;
1704
1828
  current.channel.send({ type: "init", ...current.sent });
1705
- }, [latest]);
1829
+ }, [latestRef]);
1706
1830
  const receive = (current, msg) => {
1707
- const { ctx: live, answers: answers2, onReady } = latest.current;
1831
+ const { ctx: live, answers: answers2, onReady } = latestRef.current;
1708
1832
  switch (msg.type) {
1709
1833
  case "ready": {
1710
1834
  clearTimeout(current.timer);
1711
1835
  setFault(null);
1712
- for (const [name, value] of Object.entries(latest.current.overrides)) {
1836
+ for (const [name, value] of Object.entries(latestRef.current.overrides)) {
1713
1837
  current.channel.send({ type: "vars.set", name, value });
1714
1838
  }
1715
1839
  const key = readyKey(msg);
1716
1840
  current.awaiting = key;
1717
- onReady(latest.current.entry, msg);
1718
- if (current.awaiting === key && key === latest.current.descriptionKey) init(current);
1841
+ onReady(latestRef.current.entry, msg);
1842
+ if (current.awaiting === key && key === latestRef.current.descriptionKey) init(current);
1719
1843
  break;
1720
1844
  }
1721
1845
  case "rendered":
@@ -1744,7 +1868,7 @@ function FrameView(props) {
1744
1868
  break;
1745
1869
  }
1746
1870
  case "open": {
1747
- const { lab: liveLab } = latest.current;
1871
+ const { lab: liveLab } = latestRef.current;
1748
1872
  if (!trialId || !liveLab.instruments.some((instrument) => instrument.name === msg.id)) break;
1749
1873
  liveLab.swapTrial(trialId, msg.id);
1750
1874
  setRoute(msg.id, { inPlace: true });
@@ -1793,7 +1917,7 @@ function FrameView(props) {
1793
1917
  };
1794
1918
  link.current = current;
1795
1919
  channel.on((msg) => receive(current, msg));
1796
- target.postMessage({ type: PORT_HANDOFF, id: latest.current.entry.id }, location.origin, [
1920
+ target.postMessage({ type: PORT_HANDOFF, id: latestRef.current.entry.id }, location.origin, [
1797
1921
  port2
1798
1922
  ]);
1799
1923
  };
@@ -1801,10 +1925,10 @@ function FrameView(props) {
1801
1925
  useEffect(() => {
1802
1926
  const slot = slotRef.current;
1803
1927
  if (!inView || !slot) return;
1804
- const claimed = latest.current.pool?.claim() ?? null;
1928
+ const claimed = latestRef.current.pool?.claim() ?? null;
1805
1929
  const frame = claimed ?? document.createElement("iframe");
1806
1930
  frame.className = "fg-frame-view";
1807
- frame.title = latest.current.title;
1931
+ frame.title = latestRef.current.title;
1808
1932
  frame.removeAttribute("tabindex");
1809
1933
  frame.toggleAttribute("data-pending", true);
1810
1934
  iframeRef.current = frame;
@@ -1832,7 +1956,7 @@ function FrameView(props) {
1832
1956
  setPending(true);
1833
1957
  setFault(null);
1834
1958
  };
1835
- }, [inView, src, connectRef, latest]);
1959
+ }, [inView, src, connectRef, latestRef]);
1836
1960
  useEffect(() => {
1837
1961
  iframeRef.current?.toggleAttribute("data-pending", pending && !fault);
1838
1962
  }, [pending, fault, inView, src]);
@@ -1939,21 +2063,21 @@ function useStoryRegistry(index, options) {
1939
2063
  setRevisions((prev) => pruned(prev, ids));
1940
2064
  }
1941
2065
  const imports = useRef(/* @__PURE__ */ new Map());
1942
- const live = useLatest({ index, importers, setup, load, libraries });
2066
+ const liveRef = useLatest({ index, importers, setup, load, libraries });
1943
2067
  const missing = useRef(/* @__PURE__ */ new Set());
1944
2068
  const importOf = useCallback((file) => {
1945
2069
  let loading = imports.current.get(file);
1946
2070
  if (!loading) {
1947
- const importer = live.current.importers?.[file];
2071
+ const importer = liveRef.current.importers?.[file];
1948
2072
  if (!importer) missing.current.add(file);
1949
2073
  loading = importer ? importer() : Promise.reject(new Error(`No importer for ${file}`));
1950
2074
  imports.current.set(file, loading);
1951
2075
  }
1952
2076
  return loading;
1953
- }, [live]);
2077
+ }, [liveRef]);
1954
2078
  const request = useCallback(
1955
2079
  (entry) => {
1956
- const { setup: current, load: loadModule } = live.current;
2080
+ const { setup: current, load: loadModule } = liveRef.current;
1957
2081
  const settle = (ids2, result) => {
1958
2082
  setLoaded((prev) => {
1959
2083
  const next = new Map(prev);
@@ -1971,7 +2095,7 @@ function useStoryRegistry(index, options) {
1971
2095
  });
1972
2096
  };
1973
2097
  if (isIndexId(entry.id)) {
1974
- const entries = live.current.index.filter((e) => !isIndexId(e.id) && indexId(e.title) === entry.id);
2098
+ const entries = liveRef.current.index.filter((e) => !isIndexId(e.id) && indexId(e.title) === entry.id);
1975
2099
  const files = [...new Set(entries.map((e) => e.file))];
1976
2100
  void Promise.all(files.map(importOf)).then(async (mods) => {
1977
2101
  const byFile = new Map(files.map((file, i) => [file, loadModule(mods[i] ?? {}, entry.title, current.parameters)]));
@@ -1984,7 +2108,7 @@ function useStoryRegistry(index, options) {
1984
2108
  kind: "index",
1985
2109
  bundle: {
1986
2110
  title: entry.title,
1987
- library: live.current.libraries.of(entry),
2111
+ library: liveRef.current.libraries.of(entry),
1988
2112
  ...entry.componentDescription === void 0 ? {} : { description: entry.componentDescription },
1989
2113
  ...entry.tags === void 0 ? {} : { tags: entry.tags },
1990
2114
  galleries: entries.filter(isGallery).map((e) => e.id),
@@ -1997,7 +2121,7 @@ function useStoryRegistry(index, options) {
1997
2121
  }).catch((error) => fail([entry.id], error));
1998
2122
  return;
1999
2123
  }
2000
- const siblings = live.current.index.filter((e) => e.file === entry.file && !isIndexId(e.id));
2124
+ const siblings = liveRef.current.index.filter((e) => e.file === entry.file && !isIndexId(e.id));
2001
2125
  const ids = siblings.map((e) => e.id);
2002
2126
  void importOf(entry.file).then(async (mod) => {
2003
2127
  const stories = loadModule(mod, entry.title, current.parameters);
@@ -2012,18 +2136,18 @@ function useStoryRegistry(index, options) {
2012
2136
  settle(ids, result);
2013
2137
  }).catch((error) => fail(ids, error));
2014
2138
  },
2015
- [importOf, live]
2139
+ [importOf, liveRef]
2016
2140
  );
2017
2141
  const reload = useCallback(
2018
2142
  (file) => {
2019
2143
  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));
2144
+ 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
2145
  const ids = new Set(affected.map((e) => e.id));
2022
2146
  setLoaded((prev) => pruned(prev, new Set([...prev.keys()].filter((id) => !ids.has(id)))));
2023
2147
  setFaults((prev) => pruned(prev, new Set([...prev.keys()].filter((id) => !ids.has(id)))));
2024
2148
  setGenerations((prev) => new Map(prev).set(file, (prev.get(file) ?? 0) + 1));
2025
2149
  },
2026
- [live]
2150
+ [liveRef]
2027
2151
  );
2028
2152
  useEffect(() => {
2029
2153
  for (const file of [...missing.current]) {
@@ -2167,10 +2291,10 @@ function Workshop({ index, frameUrl, importers, setup, changes, config, stories
2167
2291
  const labChrome = useMemo(
2168
2292
  () => [
2169
2293
  { id: "fg-stories", region: "sidebar", render: (ctx) => /* @__PURE__ */ jsx(StoryTree, { ctx, index }) },
2170
- { id: "fg-info", region: "palette", item: infoCommand },
2171
2294
  ...config?.cssVars === false ? [] : [CSS_VARS_SECTION],
2172
2295
  A11Y_SECTION,
2173
2296
  ...Object.keys(declarations).length > 0 ? [{ id: "fg-globals", region: "header", render: () => /* @__PURE__ */ jsx(GlobalsToolbar, { declarations }) }] : [],
2297
+ { id: "fg-info", region: "header", group: "story", item: infoCommand },
2174
2298
  ...config?.labChrome ?? []
2175
2299
  ],
2176
2300
  [index, infoCommand, declarations, config?.cssVars, config?.labChrome]