isotone 1.1.3 → 1.2.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 (52) hide show
  1. package/AGENTS.md +79 -5
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +64 -14
  4. package/dist/{IterationPanel-phJRYtUO.d.ts → IterationPanel-Bq77d5NT.d.ts} +3 -1
  5. package/dist/{chunk-K5RAC2BT.js → chunk-2FY2JNPG.js} +3 -5
  6. package/dist/chunk-2FY2JNPG.js.map +1 -0
  7. package/dist/{chunk-RKZWO37H.js → chunk-BTJHQXF7.js} +85 -1
  8. package/dist/chunk-BTJHQXF7.js.map +1 -0
  9. package/dist/chunk-JCTWHR7U.js +13 -0
  10. package/dist/chunk-JCTWHR7U.js.map +1 -0
  11. package/dist/{chunk-YTEUMO3O.js → chunk-KRS57POF.js} +17 -33
  12. package/dist/chunk-KRS57POF.js.map +1 -0
  13. package/dist/chunk-L6O4HYGP.js +1150 -0
  14. package/dist/chunk-L6O4HYGP.js.map +1 -0
  15. package/dist/chunk-MX4RQJFV.js +9 -0
  16. package/dist/chunk-MX4RQJFV.js.map +1 -0
  17. package/dist/chunk-YPDV3PX4.js +142 -0
  18. package/dist/chunk-YPDV3PX4.js.map +1 -0
  19. package/dist/cli.js +1040 -148
  20. package/dist/cli.js.map +1 -1
  21. package/dist/core.d.ts +92 -2
  22. package/dist/core.js +10 -3
  23. package/dist/index.d.ts +13 -28
  24. package/dist/index.js +586 -257
  25. package/dist/index.js.map +1 -1
  26. package/dist/isotone.global.js +98 -42
  27. package/dist/serve.d.ts +5 -3
  28. package/dist/serve.js +60 -23
  29. package/dist/serve.js.map +1 -1
  30. package/dist/server.d.ts +7 -0
  31. package/dist/server.js +24 -11
  32. package/dist/server.js.map +1 -1
  33. package/dist/vite-plugin.d.ts +4 -3
  34. package/dist/vite-plugin.js +23 -19
  35. package/dist/vite-plugin.js.map +1 -1
  36. package/docs/blast.md +164 -0
  37. package/docs/integrations.md +75 -15
  38. package/docs/migration.md +19 -0
  39. package/docs/updates.md +110 -4
  40. package/docs/variants.md +65 -7
  41. package/package.json +2 -1
  42. package/dist/chunk-A4DX2V4C.js +0 -7
  43. package/dist/chunk-A4DX2V4C.js.map +0 -1
  44. package/dist/chunk-BRBXIGZP.js +0 -593
  45. package/dist/chunk-BRBXIGZP.js.map +0 -1
  46. package/dist/chunk-K5RAC2BT.js.map +0 -1
  47. package/dist/chunk-LXW6WFOC.js +0 -402
  48. package/dist/chunk-LXW6WFOC.js.map +0 -1
  49. package/dist/chunk-RKZWO37H.js.map +0 -1
  50. package/dist/chunk-RQ2YI5KS.js +0 -24
  51. package/dist/chunk-RQ2YI5KS.js.map +0 -1
  52. package/dist/chunk-YTEUMO3O.js.map +0 -1
package/dist/index.js CHANGED
@@ -4,15 +4,22 @@ import {
4
4
  describeIterationElement,
5
5
  elementAsNode,
6
6
  isRouteVisible
7
- } from "./chunk-K5RAC2BT.js";
7
+ } from "./chunk-2FY2JNPG.js";
8
+ import {
9
+ updateNoteText,
10
+ updatePrompt
11
+ } from "./chunk-JCTWHR7U.js";
8
12
  import {
9
13
  FEEDBACK_STORAGE_KEY,
10
14
  KNOBS_STORAGE_KEY,
15
+ MAX_FEEDBACK_LENGTH,
11
16
  OVERLAY_DEFAULT_OPTION,
12
17
  OVERLAY_GROUP_ID,
18
+ SECTION_SEPARATOR,
13
19
  VARIANTS_ENDPOINT,
14
20
  applyKnobValues,
15
21
  axisDefault,
22
+ axisSection,
16
23
  createOverlayVariantsSource,
17
24
  createVariantsSource,
18
25
  defaultSelections,
@@ -29,13 +36,12 @@ import {
29
36
  parseVariantParam,
30
37
  readBrowserStorage,
31
38
  removeBrowserStorage,
39
+ sectionAxes,
32
40
  serializeVariantParam,
41
+ splitSectionLabel,
33
42
  visibleAxes,
34
43
  writeBrowserStorage
35
- } from "./chunk-RKZWO37H.js";
36
- import {
37
- MAX_FEEDBACK_LENGTH
38
- } from "./chunk-A4DX2V4C.js";
44
+ } from "./chunk-BTJHQXF7.js";
39
45
 
40
46
  // src/react/tooltips.ts
41
47
  function showOverflowTitle(event) {
@@ -45,7 +51,7 @@ function showOverflowTitle(event) {
45
51
  }
46
52
 
47
53
  // src/react/IterationPanel.tsx
48
- import { useCallback, useEffect as useEffect3, useId as useId2, useLayoutEffect as useLayoutEffect4, useMemo as useMemo3, useRef as useRef4, useState as useState3 } from "react";
54
+ import { useCallback, useEffect as useEffect4, useId as useId3, useLayoutEffect as useLayoutEffect4, useMemo as useMemo3, useRef as useRef4, useState as useState3 } from "react";
49
55
 
50
56
  // src/react/appearance.ts
51
57
  var darkPalette = {
@@ -584,11 +590,16 @@ function Icon2({ name, size = 16 }) {
584
590
 
585
591
  // src/react/UpdateNotice.tsx
586
592
  import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
587
- function UpdateNotice({ endpoint, active }) {
593
+ function UpdateNotice({ endpoint, active, route = "/", requested = false, onRequested }) {
588
594
  const [info, setInfo] = useState(null);
589
595
  const [open, setOpen] = useState(false);
590
- const [copied, setCopied] = useState(false);
596
+ const [showCommand, setShowCommand] = useState(false);
597
+ const [status, setStatus] = useState("");
598
+ const [busy, setBusy] = useState(false);
599
+ const [asked, setAsked] = useState(false);
591
600
  const trigger = useRef(null);
601
+ const primary = useRef(null);
602
+ const done = useRef(null);
592
603
  const command = useRef(null);
593
604
  const closeButton = useRef(null);
594
605
  const id = useId();
@@ -618,28 +629,57 @@ function UpdateNotice({ endpoint, active }) {
618
629
  };
619
630
  }, [endpoint, active]);
620
631
  useEffect(() => {
621
- if (open) {
622
- setCopied(false);
623
- (command.current ?? closeButton.current)?.focus();
624
- }
632
+ if (requested) setAsked(false);
633
+ }, [requested]);
634
+ useEffect(() => {
635
+ if (!open) return;
636
+ setStatus("");
637
+ setShowCommand(false);
638
+ (primary.current ?? closeButton.current)?.focus();
625
639
  }, [open]);
640
+ useEffect(() => {
641
+ if (showCommand) command.current?.focus();
642
+ }, [showCommand]);
626
643
  function close() {
627
644
  setOpen(false);
628
645
  trigger.current?.focus();
629
646
  }
630
- async function copy() {
631
- if (!info?.upgrade) return;
647
+ async function ask() {
648
+ if (!endpoint || busy) return;
649
+ setBusy(true);
650
+ setStatus("");
632
651
  try {
633
- await navigator.clipboard.writeText(info.upgrade.command);
634
- setCopied(true);
652
+ const response = await fetch(endpoint, {
653
+ method: "POST",
654
+ headers: { "Content-Type": "application/json" },
655
+ body: JSON.stringify({ route })
656
+ });
657
+ if (!response.ok) throw new Error(String(response.status));
658
+ setAsked(true);
659
+ onRequested?.();
660
+ requestAnimationFrame(() => done.current?.focus());
635
661
  } catch {
636
- command.current?.focus();
637
- command.current?.select();
662
+ setStatus("Could not ask. Copy the prompt instead.");
663
+ } finally {
664
+ setBusy(false);
665
+ }
666
+ }
667
+ async function copyPrompt() {
668
+ if (!info?.upgrade || !info.latestVersion) return;
669
+ const text = updatePrompt({ from: info.currentVersion, to: info.latestVersion, ...info.upgrade });
670
+ try {
671
+ await navigator.clipboard.writeText(text);
672
+ setStatus("Prompt copied");
673
+ } catch {
674
+ setShowCommand(true);
675
+ setStatus("Could not copy. The command is below.");
638
676
  }
639
677
  }
640
678
  if (!active || !info) return null;
679
+ const pending = requested || asked;
680
+ const canAsk = Boolean(onRequested && info.upgrade);
641
681
  return /* @__PURE__ */ jsxs("div", { className: "isotone-update", children: [
642
- /* @__PURE__ */ jsxs(
682
+ /* @__PURE__ */ jsx2(
643
683
  "button",
644
684
  {
645
685
  ref: trigger,
@@ -647,11 +687,8 @@ function UpdateNotice({ endpoint, active }) {
647
687
  className: "isotone-update-trigger",
648
688
  "aria-expanded": open,
649
689
  "aria-controls": id,
650
- onClick: () => setOpen((value) => !value),
651
- children: [
652
- "Update ",
653
- info.latestVersion
654
- ]
690
+ onClick: () => setOpen(!open),
691
+ children: pending ? "Update requested" : `Isotone ${info.latestVersion} is available`
655
692
  }
656
693
  ),
657
694
  open && /* @__PURE__ */ jsxs(
@@ -670,25 +707,27 @@ function UpdateNotice({ endpoint, active }) {
670
707
  },
671
708
  children: [
672
709
  /* @__PURE__ */ jsxs("div", { className: "isotone-menu-title", children: [
673
- /* @__PURE__ */ jsx2("strong", { children: "Update available" }),
674
- /* @__PURE__ */ jsx2("button", { ref: closeButton, type: "button", className: "isotone-icon", "aria-label": "Close update instructions", title: "Close update instructions", onClick: close, children: /* @__PURE__ */ jsx2(Icon2, { name: "close" }) })
675
- ] }),
676
- /* @__PURE__ */ jsxs("p", { children: [
677
- info.currentVersion,
678
- " \u2192 ",
679
- info.latestVersion
710
+ /* @__PURE__ */ jsxs("strong", { className: "isotone-update-versions", children: [
711
+ info.currentVersion,
712
+ " \u2192 ",
713
+ info.latestVersion
714
+ ] }),
715
+ /* @__PURE__ */ jsx2("button", { ref: closeButton, type: "button", className: "isotone-icon", "aria-label": "Close update", title: "Close update", onClick: close, children: /* @__PURE__ */ jsx2(Icon2, { name: "close" }) })
680
716
  ] }),
681
717
  info.upgrade ? /* @__PURE__ */ jsxs(Fragment, { children: [
682
- /* @__PURE__ */ jsxs("p", { children: [
683
- "Run in ",
684
- /* @__PURE__ */ jsx2("span", { className: "isotone-update-path", onPointerOver: showOverflowTitle, children: info.upgrade.cwd })
685
- ] }),
686
- /* @__PURE__ */ jsx2("input", { ref: command, "aria-label": "Upgrade command", readOnly: true, value: info.upgrade.command, onFocus: (event) => event.currentTarget.select() }),
718
+ canAsk && (pending ? /* @__PURE__ */ jsx2("p", { ref: done, tabIndex: -1, className: "isotone-update-requested", children: "Update requested \xB7 in Feedback" }) : /* @__PURE__ */ jsx2("button", { ref: primary, type: "button", className: "isotone-save isotone-update-ask", disabled: busy, onClick: () => void ask(), children: "Ask agent to update" })),
687
719
  /* @__PURE__ */ jsxs("div", { className: "isotone-update-actions", children: [
688
- /* @__PURE__ */ jsx2("button", { type: "button", className: "isotone-secondary", onClick: () => void copy(), children: copied ? "Copied" : "Copy command" }),
689
- /* @__PURE__ */ jsx2("span", { role: "status", children: copied ? "Ready to paste in your terminal" : "" })
720
+ /* @__PURE__ */ jsx2("button", { ref: canAsk ? void 0 : primary, type: "button", className: "isotone-update-quiet", onClick: () => void copyPrompt(), children: "Copy prompt" }),
721
+ /* @__PURE__ */ jsx2("button", { type: "button", className: "isotone-update-quiet", "aria-expanded": showCommand, onClick: () => setShowCommand((value) => !value), children: "Command" }),
722
+ /* @__PURE__ */ jsx2("span", { role: "status", children: status })
690
723
  ] }),
691
- /* @__PURE__ */ jsx2("p", { children: "Restart the dev server after upgrading." })
724
+ showCommand && /* @__PURE__ */ jsxs("div", { className: "isotone-update-command", children: [
725
+ /* @__PURE__ */ jsx2("input", { ref: command, "aria-label": "Upgrade command", readOnly: true, value: info.upgrade.command, onFocus: (event) => event.currentTarget.select() }),
726
+ /* @__PURE__ */ jsxs("span", { className: "isotone-update-path", onPointerOver: showOverflowTitle, children: [
727
+ "in ",
728
+ info.upgrade.cwd
729
+ ] })
730
+ ] })
692
731
  ] }) : /* @__PURE__ */ jsx2("p", { children: info.message ?? "Run isotone check-update in the app directory for upgrade instructions." })
693
732
  ]
694
733
  }
@@ -696,18 +735,26 @@ function UpdateNotice({ endpoint, active }) {
696
735
  ] });
697
736
  }
698
737
  var UPDATE_NOTICE_CSS = `
699
- .isotone-update { position: absolute; z-index: 5; bottom: 10px; right: calc(var(--iso-rail-length) + 10px); pointer-events: auto; }
700
- .isotone-root[data-tools='1'] .isotone-update { right: calc(var(--iso-rail-size) + 10px); }
738
+ .isotone-notices { position: absolute; z-index: 5; bottom: 10px; right: calc(var(--iso-rail-length) + 10px); display: flex; flex-direction: row-reverse; flex-wrap: wrap-reverse; align-items: center; justify-content: flex-start; gap: 6px; width: max-content; max-width: calc(100vw - var(--iso-rail-length) - 36px); pointer-events: none; }
739
+ .isotone-root[data-tools='1'] .isotone-notices { right: calc(var(--iso-rail-size) + 10px); max-width: calc(100vw - var(--iso-rail-size) - 36px); }
740
+ .isotone-notices > * { pointer-events: auto; }
701
741
  .isotone-update-trigger { white-space: nowrap; color: var(--iso-muted); font-size: 10px; padding: 4px 6px; border-radius: 5px; background: var(--iso-bg); box-shadow: 0 0 0 1px var(--iso-line); }
702
- .isotone-update-dialog { position: absolute; right: calc(0px - var(--iso-rail-length) - 10px); bottom: 40px; width: var(--iso-dock-width); max-height: calc(100dvh - 100px); overflow: auto; padding: var(--iso-space); display: grid; gap: 8px; border-radius: 8px; background: var(--iso-bg); box-shadow: var(--iso-popup-shadow); font-size: 11px; }
742
+ .isotone-update-dialog { position: absolute; right: calc(0px - var(--iso-rail-length) - 10px); bottom: 40px; width: min(var(--iso-dock-width), 260px); max-width: calc(100vw - 32px); max-height: calc(100dvh - 100px); overflow: auto; padding: 10px var(--iso-space) var(--iso-space); display: grid; gap: 8px; border-radius: 8px; background: var(--iso-bg); box-shadow: var(--iso-popup-shadow); font-size: 11px; }
703
743
  .isotone-root[data-tools='1'] .isotone-update-dialog { right: calc(0px - var(--iso-rail-size) - 10px); }
704
744
  .isotone-update-dialog .isotone-menu-title { padding: 0; background: none; }
745
+ .isotone-update-versions { font-variant-numeric: tabular-nums; }
705
746
  .isotone-update-dialog > p { color: var(--iso-muted); }
706
- .isotone-update-path { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
707
- .isotone-root .isotone-update-dialog input { width: 100%; padding: 8px; border-radius: 5px; background: var(--iso-well); box-shadow: var(--iso-well-shadow); font-size: 11px; }
708
- .isotone-update-actions { display: flex; align-items: center; gap: 8px; }
709
- .isotone-update-actions [role='status'] { color: var(--iso-muted); font-size: 10px; }
710
- @media (hover: hover) and (pointer: fine) { .isotone-update-trigger:hover { color: var(--iso-fg); } }
747
+ .isotone-root .isotone-update-ask { min-height: 28px; padding: 4px 10px; font-weight: 500; }
748
+ .isotone-update-requested { min-height: 28px; display: flex; align-items: center; outline-offset: 2px; }
749
+ .isotone-update-actions { display: flex; align-items: center; gap: 2px; margin: -4px 0 -4px -6px; }
750
+ .isotone-root .isotone-update-quiet { padding: 4px 6px; border-radius: 5px; color: var(--iso-muted); font-size: 11px; background: none; box-shadow: none; }
751
+ .isotone-update-quiet[aria-expanded='true'] { color: var(--iso-fg); }
752
+ .isotone-update-actions [role='status'] { min-width: 0; margin-left: auto; color: var(--iso-muted); font-size: 10px; text-align: right; }
753
+ .isotone-update-command { display: grid; gap: 4px; min-width: 0; }
754
+ .isotone-update-path { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--iso-muted); font-size: 10px; }
755
+ .isotone-root .isotone-update-dialog input { width: 100%; padding: 6px 8px; border-radius: 5px; background: var(--iso-well); box-shadow: var(--iso-well-shadow); font-size: 11px; }
756
+ @media (pointer: coarse) { .isotone-root .isotone-update-quiet { padding: 9px 6px; } .isotone-update-actions { margin: -9px 0 -9px -6px; } }
757
+ @media (hover: hover) and (pointer: fine) { .isotone-update-trigger:hover, .isotone-root .isotone-update-quiet:hover { color: var(--iso-fg); } }
711
758
  `;
712
759
 
713
760
  // src/react/styles.ts
@@ -742,8 +789,11 @@ ${APPEARANCE_CSS}
742
789
  --iso-ease-out: ${MOTION.easeOut};
743
790
  --iso-fast: ${MOTION.exit}ms; --iso-enter: ${MOTION.enter}ms; --iso-exit: ${MOTION.exit}ms;
744
791
  --iso-dock-width: min(340px, calc(100vw - 32px));
745
- position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom));
746
- z-index: 2147483000; width: 44px; display: flex; flex-direction: column; isolation: isolate; max-height: calc(100dvh - 32px); color: var(--iso-fg);
792
+ /* The height the dock may fill: what is visible (above an on-screen keyboard,
793
+ see viewport.ts) less the safe areas it keeps clear of. */
794
+ --iso-view-height: calc(var(--iso-viewport, 100dvh) - env(safe-area-inset-top, 0px) - max(0px, env(safe-area-inset-bottom, 0px) - 16px));
795
+ position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(calc(16px + var(--iso-keyboard, 0px)), env(safe-area-inset-bottom));
796
+ z-index: 2147483000; width: 44px; display: flex; flex-direction: column; isolation: isolate; max-height: calc(var(--iso-view-height) - 32px); color: var(--iso-fg);
747
797
  font: 12px/1.5 var(--iso-sans); -webkit-font-smoothing: antialiased;
748
798
  text-align: left; letter-spacing: normal; color-scheme: dark;
749
799
  }
@@ -779,8 +829,8 @@ ${APPEARANCE_CSS}
779
829
  /* Panels are sections inside the dock. Only long content scrolls. */
780
830
  .isotone-panel { order: 0; position: relative; display: none; flex-direction: column; min-height: 0; flex: 0 1 auto; width: 100%; }
781
831
  .isotone-panel[data-open='true'] { display: flex; }
782
- .isotone-variants-panel { flex-shrink: 1; max-height: calc(100dvh - 76px); }
783
- .isotone-notes-panel { flex-shrink: 0; max-height: calc(100dvh - 76px); padding: var(--iso-space); gap: var(--iso-space); }
832
+ .isotone-variants-panel { flex-shrink: 1; max-height: calc(var(--iso-view-height) - 76px); }
833
+ .isotone-notes-panel { flex-shrink: 0; max-height: calc(var(--iso-view-height) - 76px); padding: var(--iso-space); gap: var(--iso-space); }
784
834
  .isotone-root .isotone-panel { background: var(--iso-bg); border: 1px solid var(--iso-line); border-radius: 12px; box-shadow: var(--iso-panel-shadow); }
785
835
  /* The close control sits on the panel's top-left corner, half outside it, with
786
836
  the same face as a note's delete button, a touch larger. It fades in while the
@@ -804,13 +854,30 @@ ${APPEARANCE_CSS}
804
854
  .isotone-icon { width: 28px; height: 28px; display: inline-grid; place-items: center; flex: none; color: var(--iso-muted); border-radius: 6px; }
805
855
  .isotone-count { margin-left: 5px; color: var(--iso-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
806
856
  .isotone-vars { flex: 1; display: flex; flex-direction: column; min-height: 0; position: relative; }
807
- .isotone-vars { min-height: min(var(--iso-menu-need, 0px), calc(100dvh - 76px)); }
857
+ .isotone-vars { min-height: min(var(--iso-menu-need, 0px), calc(var(--iso-view-height) - 76px)); }
808
858
  /* The panel no longer clips, so its corner close can overhang; the variants body clips itself. */
809
859
  .isotone-root .isotone-panel > .isotone-vars { overflow: hidden; border-radius: 11px; }
810
860
  .isotone-vars-body { flex: 1; overflow-y: auto; min-height: 0; overscroll-behavior: contain; padding: var(--iso-space); display: flex; flex-direction: column; gap: var(--iso-space); }
811
861
  /* The footer overlaps only the scroller's bottom padding: content fades beneath
812
862
  it while scrolling, but the last control remains fully visible at the end. */
813
863
  .isotone-axis { flex: none; }
864
+ /* Sections name the part of the page their selectors change. The header sticks to
865
+ the top of the scroll area while its own selectors pass beneath and fade into the
866
+ surface; the next section's header pushes it out. At rest its padding occupies
867
+ the inset or gap above it, so sticking does not move the text. */
868
+ .isotone-section, .isotone-section-body { flex: none; display: flex; flex-direction: column; gap: var(--iso-space); }
869
+ .isotone-section-body[hidden] { display: none; }
870
+ .isotone-vars-body > * + .isotone-section { padding-top: 8px; }
871
+ /* Sticky offsets start inside the scroller's padding; the negative top reaches its edge. */
872
+ .isotone-section-head { position: sticky; top: calc(0px - var(--iso-space)); z-index: 1; margin: calc(0px - var(--iso-space)) calc(0px - var(--iso-space)) -4px; padding: var(--iso-space) var(--iso-space) 0; background: var(--iso-bg); font: inherit; }
873
+ .isotone-section-head::after { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: 8px; background: linear-gradient(var(--iso-bg), transparent); pointer-events: none; }
874
+ .isotone-section-toggle { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: calc(100% + 7px); min-height: 24px; padding: 2px 7px; margin-left: -7px; border-radius: 6px; text-align: left; color: var(--iso-muted); font-size: 11px; font-weight: 600; }
875
+ .isotone-section-toggle > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
876
+ .isotone-section-toggle > svg, .isotone-section-toggle > small { flex: none; }
877
+ .isotone-section-toggle > small { color: var(--iso-faint); font-size: 10px; font-weight: 500; font-variant-numeric: tabular-nums; }
878
+ .isotone-section[data-collapsed='true'] .isotone-section-toggle > svg { transform: rotate(-90deg); }
879
+ /* Keyboard focus reveal keeps a focused control clear of the sticky header. */
880
+ .isotone-vars-body:has(> .isotone-section) { scroll-padding-top: 44px; }
814
881
  .isotone-axis-head { display: flex; align-items: center; justify-content: space-between; min-height: 24px; gap: 8px; margin-bottom: 6px; }
815
882
  .isotone-axis-label { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 100%; min-height: 24px; padding: 2px 7px; margin-left: -7px; border-radius: 6px; text-align: left; }
816
883
  .isotone-axis-label, .isotone-menu-title > strong { font: inherit; font-weight: 550; }
@@ -866,8 +933,13 @@ ${PILL_ACTIVE_CSS}
866
933
  .isotone-menu-action { display: flex; align-items: center; gap: 9px; width: 100%; min-height: 34px; border-radius: 6px; text-align: left; padding: 7px; font-size: 11px; }
867
934
  .isotone-menu-options { padding: 7px 0; margin: 5px 0; border-top: 1px solid var(--iso-line); border-bottom: 1px solid var(--iso-line); }
868
935
  .isotone-option-row { display: flex; align-items: center; padding: 2px 4px 2px 7px; gap: 4px; }
869
- .isotone-option-row > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
870
- .isotone-option-row small { font-size: 9px; color: var(--iso-muted); margin-left: 6px; }
936
+ .isotone-option-name { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 6px; }
937
+ .isotone-option-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
938
+ /* Tags never shrink, so a long label truncates before them instead of hiding them. */
939
+ .isotone-option-tags { flex: none; display: flex; gap: 6px; white-space: nowrap; }
940
+ .isotone-option-tags small { font-size: 9px; color: var(--iso-muted); }
941
+ /* A pending removal stays listed and readable; only its text quietens. */
942
+ .isotone-option-row[data-requested] .isotone-option-label { color: var(--iso-muted); }
871
943
  .isotone-menu-footer { flex-shrink: 0; padding: 9px 12px; border-top: 1px solid var(--iso-line); font-size: 10px; color: var(--iso-muted); min-height: 0; }
872
944
  .isotone-confirm { display: flex; justify-content: flex-end; gap: 6px; margin-top: 7px; }
873
945
  .isotone-danger { color: var(--iso-danger); }
@@ -883,6 +955,11 @@ ${PILL_ACTIVE_CSS}
883
955
  .isotone-note-hit { text-align: left; display: block; width: 100%; border-radius: 8px; padding: var(--iso-note-padding-y) var(--iso-note-padding-x); line-height: 18px; }
884
956
  .isotone-note-hit p { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; }
885
957
  .isotone-meta { display: flex; align-items: center; gap: 4px; min-width: 0; margin-top: 4px; }
958
+ /* The agent's question takes a second line under the note, muted, clipped like it. */
959
+ .isotone-note-hit:has(> .isotone-reply) { flex-wrap: wrap; row-gap: 1px; }
960
+ .isotone-note-hit > p.isotone-reply { order: 3; flex: 1 0 100%; color: var(--iso-muted); font-size: 11px; }
961
+ .isotone-reply > span, .isotone-editing-reply > span { color: var(--iso-fg); font-weight: 500; }
962
+ .isotone-editing-reply { flex: none; max-height: 58px; overflow-y: auto; padding: 8px var(--iso-input-padding) 0; color: var(--iso-muted); font-size: 11px; line-height: 16px; overflow-wrap: anywhere; }
886
963
  .isotone-meta .isotone-chip { flex: 0 1 auto; min-width: 0; height: 18px; padding: 0 5px; font-size: 9px; }
887
964
  /* A lone reference gets all available space; multiple references share the row. */
888
965
  .isotone-meta .isotone-chip:not(:only-child) { max-width: 140px; }
@@ -931,14 +1008,15 @@ ${FEEDBACK_LAYOUT_CSS}
931
1008
  .isotone-picking { position: absolute; inset: -4px; z-index: 1; display: flex; align-items: center; gap: 10px; padding: 0 4px 0 8px; border-radius: 6px; background: var(--iso-well); cursor: default; }
932
1009
  .isotone-picking > svg { flex: none; color: var(--iso-fg); }
933
1010
  .isotone-picking p { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; margin: 0; }
934
- .isotone-picking span { font-size: 12px; line-height: 16px; color: var(--iso-fg); font-variant-numeric: tabular-nums; }
935
- .isotone-picking small { font-size: 10px; line-height: 14px; color: var(--iso-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1011
+ /* Hints are never truncated (design.md, "Hints and instructions"): one line, copy short enough to fit. */
1012
+ .isotone-picking span { font-size: 12px; line-height: 16px; color: var(--iso-fg); font-variant-numeric: tabular-nums; white-space: nowrap; }
1013
+ .isotone-picking small { font-size: 10px; line-height: 14px; color: var(--iso-muted); white-space: nowrap; }
936
1014
  .isotone-picking button { flex: none; display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 9px; }
937
1015
  .isotone-picking kbd { font: inherit; color: var(--iso-muted); }
938
1016
  /* After a pin, the composer floats beside the element on a panel surface, placed by
939
1017
  IterationPanel inside the viewport. It grows out of the point that was clicked. */
940
1018
  .isotone-float { position: fixed; z-index: 2147483002; width: min(300px, calc(100vw - 16px)); padding: 4px; background: var(--iso-bg); border: 1px solid var(--iso-line); border-radius: 12px; box-shadow: var(--iso-panel-shadow); }
941
- .isotone-float-hint { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; color: var(--iso-muted); }
1019
+ .isotone-float-hint { flex: 1 0 auto; white-space: nowrap; font-size: 10px; color: var(--iso-muted); }
942
1020
  /* Meanwhile the dock keeps the composer's slot and says where the writing went. */
943
1021
  .isotone-away { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 50px; padding: 8px 8px 8px 10px; text-align: left; color: var(--iso-fg); background: var(--iso-well); border: 1px solid var(--iso-input-border); box-shadow: var(--iso-input-shadow); border-radius: 8px; }
944
1022
  .isotone-away > svg { flex: none; }
@@ -951,8 +1029,8 @@ ${FEEDBACK_LAYOUT_CSS}
951
1029
  .isotone-hover { position: fixed; pointer-events: none; border: 1px solid var(--iso-pick-line); background: var(--iso-pick-fill); border-radius: 5px; z-index: 2147483001; }
952
1030
  .isotone-selected { border: 2px solid var(--iso-pick-line); background: var(--iso-pick-selected-fill); }
953
1031
  @media (hover: hover) and (pointer: fine) {
954
- .isotone-root :where(.isotone-icon:not(.isotone-x), .isotone-menu-action, .isotone-axis-label):not(:disabled):not([aria-pressed='true']):hover { background: var(--iso-hover); box-shadow: var(--iso-hover-shadow); color: var(--iso-fg); }
955
- .isotone-note:not(.isotone-note-editing):hover .isotone-note-hit:not(:disabled) { background: var(--iso-hover); box-shadow: var(--iso-hover-shadow); }
1032
+ .isotone-root :where(.isotone-icon:not(.isotone-x), .isotone-menu-action, .isotone-axis-label, .isotone-section-toggle):not(:disabled):not([aria-pressed='true']):hover { background: var(--iso-hover); box-shadow: var(--iso-hover-shadow); color: var(--iso-fg); }
1033
+ .isotone-note:not(.isotone-note-editing):hover .isotone-note-hit:not(:disabled):not([data-static]) { background: var(--iso-hover); box-shadow: var(--iso-hover-shadow); }
956
1034
  .isotone-rail-button:not(:disabled):hover { color: var(--iso-fg); }
957
1035
  .isotone-seg:not(:disabled):hover { color: var(--iso-fg); }
958
1036
  .isotone-seg[aria-pressed='false']:not(:disabled):hover { box-shadow: var(--iso-hover-shadow); }
@@ -964,6 +1042,23 @@ ${FEEDBACK_LAYOUT_CSS}
964
1042
  .isotone-note .isotone-x { opacity: 0; }
965
1043
  .isotone-note:hover .isotone-x, .isotone-note:focus-within .isotone-x, .isotone-note-editing .isotone-x { opacity: 1; }
966
1044
  }
1045
+ /* Phones. Panels take turns (see viewport.ts). Variants stop at about half the
1046
+ screen, so the page they change stays in view; action dialogs keep their
1047
+ 360px. While selecting, the dock shrinks to the composer so the page can be
1048
+ reached, and the saved notes return afterwards. */
1049
+ @media (max-width: 640px) {
1050
+ .isotone-variants-panel { max-height: min(calc(var(--iso-view-height) - 76px), max(50dvh, 360px)); }
1051
+ }
1052
+ @media (max-width: 640px), (max-height: 500px) {
1053
+ .isotone-root[data-selecting='true'] .isotone-notes-panel > :is(.isotone-list, .isotone-pages) { display: none; }
1054
+ }
1055
+ /* A finger needs more than the drawn faces. Hit areas grow without moving
1056
+ anything; the faces keep Instrument's geometry. */
1057
+ @media (pointer: coarse) {
1058
+ .isotone-pick::after, .isotone-send::after { inset: -8px -10px; }
1059
+ .isotone-chip button { position: relative; }
1060
+ .isotone-chip button::after, .isotone-note > .isotone-x::after, .isotone-panel-close::after { content: ''; position: absolute; inset: -6px; border-radius: 50%; }
1061
+ }
967
1062
  @media (max-height: 580px) { .isotone-list { --iso-list-height: 72px; } .isotone-root .isotone-textarea { max-height: calc(64px + var(--iso-textarea-inset)); } }
968
1063
  @media (prefers-reduced-motion: reduce) {
969
1064
  .isotone-ring { box-shadow: 0 0 0 2px color-mix(in srgb, var(--iso-focus) 50%, transparent); }
@@ -988,7 +1083,7 @@ function ensurePanelStyles(target) {
988
1083
  }
989
1084
 
990
1085
  // src/react/VariantsView.tsx
991
- import { useEffect as useEffect2, useLayoutEffect as useLayoutEffect2, useMemo as useMemo2, useRef as useRef3, useState as useState2 } from "react";
1086
+ import { useEffect as useEffect2, useId as useId2, useLayoutEffect as useLayoutEffect2, useMemo as useMemo2, useRef as useRef3, useState as useState2 } from "react";
992
1087
 
993
1088
  // src/react/useLayoutMotion.ts
994
1089
  import { useLayoutEffect, useRef as useRef2 } from "react";
@@ -1046,13 +1141,13 @@ function useLayoutMotion(ref, revision, active = true) {
1046
1141
  previous.current = next;
1047
1142
  }, [ref, revision, active]);
1048
1143
  useLayoutEffect(() => {
1049
- const media = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : null;
1144
+ const media2 = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : null;
1050
1145
  const changed = () => {
1051
- if (media?.matches) clearPositions(previous.current);
1146
+ if (media2?.matches) clearPositions(previous.current);
1052
1147
  };
1053
- media?.addEventListener("change", changed);
1148
+ media2?.addEventListener("change", changed);
1054
1149
  return () => {
1055
- media?.removeEventListener("change", changed);
1150
+ media2?.removeEventListener("change", changed);
1056
1151
  clearPositions(previous.current);
1057
1152
  };
1058
1153
  }, []);
@@ -1072,6 +1167,7 @@ function useVariantsSource(source) {
1072
1167
  return revision;
1073
1168
  }
1074
1169
  var NO_PENDING_ACTIONS = [];
1170
+ var DEFAULT_EMPTY_TEXT = "No variants on this page yet. Ask your agent to create variants of any design and they will appear here.";
1075
1171
  function VariantsView({
1076
1172
  source,
1077
1173
  showTitle = true,
@@ -1080,26 +1176,35 @@ function VariantsView({
1080
1176
  onClose,
1081
1177
  onSaved,
1082
1178
  onManage,
1083
- pendingActions = NO_PENDING_ACTIONS
1179
+ pendingActions = NO_PENDING_ACTIONS,
1180
+ emptyText = DEFAULT_EMPTY_TEXT
1084
1181
  }) {
1085
1182
  const revision = useVariantsSource(source);
1086
1183
  useEffect2(() => {
1087
1184
  if (active) refreshSource(source);
1088
1185
  }, [active, source]);
1089
- const { selections, shown, sourceError } = useMemo2(() => {
1186
+ const { selections, shown, sections, names, sourceError } = useMemo2(() => {
1090
1187
  const selections2 = source.getSelections();
1188
+ const shown2 = visibleAxes(source.getAxes(), selections2);
1189
+ const sections2 = sectionAxes(shown2);
1091
1190
  return {
1092
1191
  selections: selections2,
1093
- shown: visibleAxes(source.getAxes(), selections2),
1192
+ shown: shown2,
1193
+ sections: sections2,
1194
+ names: new Map(sections2.flatMap((section) => section.axes).map((entry) => [entry.axis.id, entry])),
1094
1195
  sourceError: source.getError?.()
1095
1196
  };
1096
1197
  }, [source, revision]);
1198
+ const [collapsed, setCollapsed] = useState2(() => /* @__PURE__ */ new Set());
1097
1199
  const [menuId, setMenuId] = useState2(null);
1098
1200
  const menu = shown.find((axis) => axis.id === menuId) ?? null;
1201
+ const nameOf = (axis) => names.get(axis.id) ?? axisSection(axis);
1202
+ const menuName = menu ? nameOf(menu) : null;
1099
1203
  const [menuOpen, setMenuOpen] = useState2(false);
1100
1204
  const [busy, setBusy] = useState2(false);
1101
1205
  const [error, setError] = useState2("");
1102
1206
  const menuRef = useRef3(null);
1207
+ const pendingFocus = useRef3(null);
1103
1208
  const bodyRef = useRef3(null);
1104
1209
  useLayoutMotion(bodyRef, shown, active);
1105
1210
  const trigger = useRef3(null);
@@ -1111,15 +1216,28 @@ function VariantsView({
1111
1216
  ...o,
1112
1217
  isDefault: o.id === menu.defaultOption
1113
1218
  })) : [];
1114
- const closeMenu = () => {
1219
+ const closeMenu = (restoreFocus = true) => {
1115
1220
  setMenuOpen(false);
1116
1221
  setError("");
1222
+ pendingFocus.current = null;
1223
+ if (!restoreFocus) return;
1117
1224
  requestAnimationFrame(() => {
1118
1225
  if (trigger.current?.isConnected) trigger.current.focus();
1119
1226
  else
1120
1227
  bodyRef.current?.parentElement?.querySelector("button")?.focus();
1121
1228
  });
1122
1229
  };
1230
+ useEffect2(() => {
1231
+ const target = pendingFocus.current;
1232
+ const dialog = menuRef.current;
1233
+ if (busy || !target || !dialog || !menuOpen) return;
1234
+ pendingFocus.current = null;
1235
+ const rows = Array.from(dialog.querySelectorAll(".isotone-option-row"));
1236
+ const at = rows.findIndex((row) => row.dataset.optionId === target.optionId);
1237
+ const removable = (row) => row?.querySelector("button[data-remove]:not(:disabled)") ?? null;
1238
+ const next = target.retry ? removable(rows[at]) : [...rows.slice(at + 1), ...rows.slice(0, Math.max(at, 0)).reverse()].map(removable).find(Boolean);
1239
+ (next ?? dialog.querySelector("[data-reset]"))?.focus();
1240
+ }, [busy, pendingActions, menuOpen]);
1123
1241
  useEffect2(() => {
1124
1242
  if (!menu) setMenuOpen(false);
1125
1243
  }, [menu]);
@@ -1170,10 +1288,10 @@ function VariantsView({
1170
1288
  const option = axis.kind === "range" ? void 0 : (axis.kind === "color" ? axis.swatches : axis.options).find(
1171
1289
  (o) => o.id === optionId
1172
1290
  );
1173
- closeMenu();
1291
+ closeMenu(false);
1174
1292
  onFeedback?.({
1175
1293
  axisId: axis.id,
1176
- label: axis.label,
1294
+ label: nameOf(axis).fullLabel,
1177
1295
  value: value(axis),
1178
1296
  ...option ? { optionId: option.id, optionLabel: option.label } : {}
1179
1297
  });
@@ -1181,18 +1299,21 @@ function VariantsView({
1181
1299
  const requested = (action) => pendingActions.some((pending) => pending.axisId === action.axisId && pending.action === action.action && pending.optionId === action.optionId);
1182
1300
  async function manage(action) {
1183
1301
  if (!menu || busy || !onManage || requested(action)) return;
1302
+ const option = action.action === "remove-option" ? action.optionId : void 0;
1184
1303
  setBusy(true);
1185
1304
  setError("");
1186
1305
  try {
1187
- const option = menuOptions.find((o) => o.id === action.optionId);
1306
+ const chosen = menuOptions.find((o) => o.id === action.optionId);
1188
1307
  await onManage(action, {
1189
1308
  axisId: menu.id,
1190
- label: menu.label,
1309
+ label: menuName?.fullLabel ?? menu.label,
1191
1310
  value: value(menu),
1192
- ...option ? { optionId: option.id, optionLabel: option.label } : {}
1311
+ ...chosen ? { optionId: chosen.id, optionLabel: chosen.label } : {}
1193
1312
  });
1194
- closeMenu();
1313
+ if (option !== void 0) pendingFocus.current = { optionId: option, retry: false };
1314
+ else closeMenu();
1195
1315
  } catch (e) {
1316
+ if (option !== void 0) pendingFocus.current = { optionId: option, retry: true };
1196
1317
  setError(
1197
1318
  e instanceof Error ? e.message : "Could not update this selector."
1198
1319
  );
@@ -1200,6 +1321,100 @@ function VariantsView({
1200
1321
  setBusy(false);
1201
1322
  }
1202
1323
  }
1324
+ function renderAxis({ axis, label, fullLabel }) {
1325
+ const thumbnails = axis.kind !== "range" && axis.kind !== "color" && axis.options.length > 0 && axis.options.every((option) => option.image);
1326
+ return /* @__PURE__ */ jsxs2(
1327
+ "div",
1328
+ {
1329
+ className: "isotone-axis",
1330
+ "data-motion-key": axis.id,
1331
+ children: [
1332
+ /* @__PURE__ */ jsxs2("div", { className: "isotone-axis-head", children: [
1333
+ /* @__PURE__ */ jsxs2(
1334
+ "button",
1335
+ {
1336
+ type: "button",
1337
+ className: "isotone-axis-label",
1338
+ "aria-label": `${fullLabel} actions`,
1339
+ "aria-haspopup": "dialog",
1340
+ "aria-expanded": menuOpen && menu?.id === axis.id,
1341
+ onClick: (e) => {
1342
+ trigger.current = e.currentTarget;
1343
+ setMenuId(axis.id);
1344
+ setError("");
1345
+ setMenuOpen(true);
1346
+ },
1347
+ children: [
1348
+ /* @__PURE__ */ jsx3("span", { onPointerOver: showOverflowTitle, children: label }),
1349
+ /* @__PURE__ */ jsx3(Icon2, { name: "chevron", size: 12 })
1350
+ ]
1351
+ }
1352
+ ),
1353
+ (axis.kind === "range" || axis.kind === "color") && /* @__PURE__ */ jsx3("span", { className: "isotone-axis-value", children: axis.kind === "range" ? formatRangeValue(axis, value(axis)) : value(axis).toUpperCase() })
1354
+ ] }),
1355
+ axis.kind === "range" ? /* @__PURE__ */ jsx3(
1356
+ "input",
1357
+ {
1358
+ type: "range",
1359
+ className: "isotone-range",
1360
+ "aria-label": fullLabel,
1361
+ "aria-valuetext": formatRangeValue(axis, value(axis)),
1362
+ min: axis.min,
1363
+ max: axis.max,
1364
+ step: axis.step,
1365
+ value: Number(value(axis)),
1366
+ onChange: (e) => source.setSelection(axis.id, e.target.value)
1367
+ }
1368
+ ) : axis.kind === "color" ? /* @__PURE__ */ jsxs2("fieldset", { className: "isotone-swatches", "aria-label": fullLabel, children: [
1369
+ axis.swatches.map((sw) => /* @__PURE__ */ jsx3(
1370
+ "button",
1371
+ {
1372
+ type: "button",
1373
+ title: sw.label,
1374
+ "aria-label": sw.label,
1375
+ "aria-pressed": value(axis).toLowerCase() === sw.value.toLowerCase(),
1376
+ className: "isotone-swatch",
1377
+ style: { backgroundColor: sw.value },
1378
+ onClick: () => source.setSelection(axis.id, sw.value)
1379
+ },
1380
+ sw.id
1381
+ )),
1382
+ /* @__PURE__ */ jsx3("label", { className: "isotone-picker", title: "Custom colour", children: /* @__PURE__ */ jsx3(
1383
+ "input",
1384
+ {
1385
+ type: "color",
1386
+ "aria-label": `${fullLabel}: custom colour`,
1387
+ value: value(axis),
1388
+ onChange: (e) => source.setSelection(axis.id, e.target.value)
1389
+ }
1390
+ ) })
1391
+ ] }) : /* @__PURE__ */ jsx3(
1392
+ "fieldset",
1393
+ {
1394
+ "aria-label": fullLabel,
1395
+ className: thumbnails ? "isotone-grid" : "isotone-segs",
1396
+ children: axis.options.map((o) => /* @__PURE__ */ jsxs2(
1397
+ "button",
1398
+ {
1399
+ type: "button",
1400
+ "aria-label": o.label,
1401
+ "aria-pressed": value(axis) === o.id,
1402
+ className: `${thumbnails ? "isotone-thumb" : "isotone-seg"}${value(axis) === o.id ? " isotone-seg-on" : ""}`,
1403
+ onClick: () => source.setSelection(axis.id, o.id),
1404
+ children: [
1405
+ thumbnails && /* @__PURE__ */ jsx3("img", { src: o.image, alt: "" }),
1406
+ /* @__PURE__ */ jsx3("span", { onPointerOver: showOverflowTitle, children: o.label })
1407
+ ]
1408
+ },
1409
+ o.id
1410
+ ))
1411
+ }
1412
+ )
1413
+ ]
1414
+ },
1415
+ axis.id
1416
+ );
1417
+ }
1203
1418
  return /* @__PURE__ */ jsxs2("div", { className: "isotone-vars", children: [
1204
1419
  /* @__PURE__ */ jsxs2("div", { className: "isotone-panel-head", children: [
1205
1420
  showTitle && /* @__PURE__ */ jsx3("h2", { children: source.title ?? "Variants" }),
@@ -1223,101 +1438,23 @@ function VariantsView({
1223
1438
  node?.toggleAttribute("inert", menuOpen);
1224
1439
  }, children: [
1225
1440
  sourceError && /* @__PURE__ */ jsx3("p", { className: "isotone-danger", role: "alert", children: sourceError }),
1226
- shown.length === 0 && /* @__PURE__ */ jsx3("p", { className: "isotone-empty", children: "No variants on this page yet. Ask your agent to create variants of any design and they will appear here." }),
1227
- shown.map((axis) => {
1228
- const thumbnails = axis.kind !== "range" && axis.kind !== "color" && axis.options.length > 0 && axis.options.every((option) => option.image);
1229
- return /* @__PURE__ */ jsxs2(
1230
- "div",
1231
- {
1232
- className: "isotone-axis",
1233
- "data-motion-key": axis.id,
1234
- children: [
1235
- /* @__PURE__ */ jsxs2("div", { className: "isotone-axis-head", children: [
1236
- /* @__PURE__ */ jsxs2(
1237
- "button",
1238
- {
1239
- type: "button",
1240
- className: "isotone-axis-label",
1241
- "aria-label": `${axis.label} actions`,
1242
- "aria-haspopup": "dialog",
1243
- "aria-expanded": menuOpen && menu?.id === axis.id,
1244
- onClick: (e) => {
1245
- trigger.current = e.currentTarget;
1246
- setMenuId(axis.id);
1247
- setError("");
1248
- setMenuOpen(true);
1249
- },
1250
- children: [
1251
- /* @__PURE__ */ jsx3("span", { onPointerOver: showOverflowTitle, children: axis.label }),
1252
- /* @__PURE__ */ jsx3(Icon2, { name: "chevron", size: 12 })
1253
- ]
1254
- }
1255
- ),
1256
- (axis.kind === "range" || axis.kind === "color") && /* @__PURE__ */ jsx3("span", { className: "isotone-axis-value", children: axis.kind === "range" ? formatRangeValue(axis, value(axis)) : value(axis).toUpperCase() })
1257
- ] }),
1258
- axis.kind === "range" ? /* @__PURE__ */ jsx3(
1259
- "input",
1260
- {
1261
- type: "range",
1262
- className: "isotone-range",
1263
- "aria-label": axis.label,
1264
- "aria-valuetext": formatRangeValue(axis, value(axis)),
1265
- min: axis.min,
1266
- max: axis.max,
1267
- step: axis.step,
1268
- value: Number(value(axis)),
1269
- onChange: (e) => source.setSelection(axis.id, e.target.value)
1270
- }
1271
- ) : axis.kind === "color" ? /* @__PURE__ */ jsxs2("fieldset", { className: "isotone-swatches", "aria-label": axis.label, children: [
1272
- axis.swatches.map((sw) => /* @__PURE__ */ jsx3(
1273
- "button",
1274
- {
1275
- type: "button",
1276
- title: sw.label,
1277
- "aria-label": sw.label,
1278
- "aria-pressed": value(axis).toLowerCase() === sw.value.toLowerCase(),
1279
- className: "isotone-swatch",
1280
- style: { backgroundColor: sw.value },
1281
- onClick: () => source.setSelection(axis.id, sw.value)
1282
- },
1283
- sw.id
1284
- )),
1285
- /* @__PURE__ */ jsx3("label", { className: "isotone-picker", title: "Custom colour", children: /* @__PURE__ */ jsx3(
1286
- "input",
1287
- {
1288
- type: "color",
1289
- "aria-label": `${axis.label}: custom colour`,
1290
- value: value(axis),
1291
- onChange: (e) => source.setSelection(axis.id, e.target.value)
1292
- }
1293
- ) })
1294
- ] }) : /* @__PURE__ */ jsx3(
1295
- "fieldset",
1296
- {
1297
- "aria-label": axis.label,
1298
- className: thumbnails ? "isotone-grid" : "isotone-segs",
1299
- children: axis.options.map((o) => /* @__PURE__ */ jsxs2(
1300
- "button",
1301
- {
1302
- type: "button",
1303
- "aria-label": o.label,
1304
- "aria-pressed": value(axis) === o.id,
1305
- className: `${thumbnails ? "isotone-thumb" : "isotone-seg"}${value(axis) === o.id ? " isotone-seg-on" : ""}`,
1306
- onClick: () => source.setSelection(axis.id, o.id),
1307
- children: [
1308
- thumbnails && /* @__PURE__ */ jsx3("img", { src: o.image, alt: "" }),
1309
- /* @__PURE__ */ jsx3("span", { onPointerOver: showOverflowTitle, children: o.label })
1310
- ]
1311
- },
1312
- o.id
1313
- ))
1314
- }
1315
- )
1316
- ]
1317
- },
1318
- axis.id
1319
- );
1320
- })
1441
+ shown.length === 0 && /* @__PURE__ */ jsx3("p", { className: "isotone-empty", children: emptyText }),
1442
+ sections.map((section) => section.name === null ? section.axes.map(renderAxis) : /* @__PURE__ */ jsx3(
1443
+ SectionBlock,
1444
+ {
1445
+ name: section.name,
1446
+ sectionKey: section.key,
1447
+ count: section.axes.length,
1448
+ collapsed: collapsed.has(section.key),
1449
+ onToggle: () => setCollapsed((previous) => {
1450
+ const next = new Set(previous);
1451
+ if (!next.delete(section.key)) next.add(section.key);
1452
+ return next;
1453
+ }),
1454
+ children: section.axes.map(renderAxis)
1455
+ },
1456
+ `section:${section.key}`
1457
+ ))
1321
1458
  ] }),
1322
1459
  menu && /* @__PURE__ */ jsxs2(
1323
1460
  "div",
@@ -1328,7 +1465,7 @@ function VariantsView({
1328
1465
  node?.toggleAttribute("inert", !menuOpen);
1329
1466
  },
1330
1467
  role: "dialog",
1331
- "aria-label": `${menu.label} actions`,
1468
+ "aria-label": `${menuName?.fullLabel ?? menu.label} actions`,
1332
1469
  "aria-hidden": !menuOpen,
1333
1470
  "data-open": menuOpen,
1334
1471
  onKeyDown: (e) => {
@@ -1340,7 +1477,7 @@ function VariantsView({
1340
1477
  },
1341
1478
  children: [
1342
1479
  /* @__PURE__ */ jsxs2("div", { className: "isotone-menu-title", children: [
1343
- /* @__PURE__ */ jsx3("strong", { children: menu.label }),
1480
+ /* @__PURE__ */ jsx3("strong", { children: menuName?.label ?? menu.label }),
1344
1481
  /* @__PURE__ */ jsx3(
1345
1482
  "button",
1346
1483
  {
@@ -1348,7 +1485,7 @@ function VariantsView({
1348
1485
  className: "isotone-icon",
1349
1486
  "aria-label": "Close selector actions",
1350
1487
  title: "Close selector actions",
1351
- onClick: closeMenu,
1488
+ onClick: () => closeMenu(),
1352
1489
  children: /* @__PURE__ */ jsx3(Icon2, { name: "close" })
1353
1490
  }
1354
1491
  )
@@ -1366,44 +1503,52 @@ function VariantsView({
1366
1503
  ]
1367
1504
  }
1368
1505
  ),
1369
- menu.kind !== "range" && /* @__PURE__ */ jsx3("div", { className: "isotone-menu-options", children: menuOptions.map((option) => /* @__PURE__ */ jsxs2("div", { className: "isotone-option-row", children: [
1370
- /* @__PURE__ */ jsxs2("span", { children: [
1371
- option.label,
1372
- option.isDefault && /* @__PURE__ */ jsx3("small", { children: "Default" })
1373
- ] }),
1374
- onFeedback && /* @__PURE__ */ jsx3(
1375
- "button",
1376
- {
1377
- type: "button",
1378
- className: "isotone-icon",
1379
- "aria-label": `Feedback on ${option.label}`,
1380
- title: "Give feedback",
1381
- onClick: () => feedback(menu, option.id),
1382
- children: /* @__PURE__ */ jsx3(Icon2, { name: "message" })
1383
- }
1384
- ),
1385
- onManage && /* @__PURE__ */ jsx3(
1386
- "button",
1387
- {
1388
- type: "button",
1389
- className: "isotone-icon",
1390
- "aria-label": `Remove ${option.label}`,
1391
- disabled: option.isDefault || busy || requested({ axisId: menu.id, action: "remove-option", optionId: option.id }),
1392
- title: option.isDefault ? "Default option" : "Request option removal",
1393
- onClick: () => void manage({
1394
- axisId: menu.id,
1395
- action: "remove-option",
1396
- optionId: option.id
1397
- }),
1398
- children: /* @__PURE__ */ jsx3(Icon2, { name: "trash" })
1399
- }
1400
- )
1401
- ] }, option.id)) }),
1506
+ menu.kind !== "range" && /* @__PURE__ */ jsx3("div", { className: "isotone-menu-options", children: menuOptions.map((option) => {
1507
+ const pending = requested({ axisId: menu.id, action: "remove-option", optionId: option.id });
1508
+ return /* @__PURE__ */ jsxs2("div", { className: "isotone-option-row", "data-option-id": option.id, "data-requested": pending || void 0, children: [
1509
+ /* @__PURE__ */ jsxs2("span", { className: "isotone-option-name", children: [
1510
+ /* @__PURE__ */ jsx3("span", { className: "isotone-option-label", children: option.label }),
1511
+ (option.isDefault || pending) && /* @__PURE__ */ jsxs2("span", { className: "isotone-option-tags", children: [
1512
+ option.isDefault && /* @__PURE__ */ jsx3("small", { children: "Default" }),
1513
+ pending && /* @__PURE__ */ jsx3("small", { children: "Removal requested" })
1514
+ ] })
1515
+ ] }),
1516
+ onFeedback && /* @__PURE__ */ jsx3(
1517
+ "button",
1518
+ {
1519
+ type: "button",
1520
+ className: "isotone-icon",
1521
+ "aria-label": `Feedback on ${option.label}`,
1522
+ title: "Give feedback",
1523
+ onClick: () => feedback(menu, option.id),
1524
+ children: /* @__PURE__ */ jsx3(Icon2, { name: "message" })
1525
+ }
1526
+ ),
1527
+ onManage && /* @__PURE__ */ jsx3(
1528
+ "button",
1529
+ {
1530
+ type: "button",
1531
+ className: "isotone-icon",
1532
+ "data-remove": "",
1533
+ "aria-label": `Remove ${option.label}`,
1534
+ disabled: option.isDefault || busy || pending,
1535
+ title: option.isDefault ? "Default option" : pending ? "Removal requested" : "Request option removal",
1536
+ onClick: () => void manage({
1537
+ axisId: menu.id,
1538
+ action: "remove-option",
1539
+ optionId: option.id
1540
+ }),
1541
+ children: /* @__PURE__ */ jsx3(Icon2, { name: "trash" })
1542
+ }
1543
+ )
1544
+ ] }, option.id);
1545
+ }) }),
1402
1546
  /* @__PURE__ */ jsxs2(
1403
1547
  "button",
1404
1548
  {
1405
1549
  type: "button",
1406
1550
  className: "isotone-menu-action",
1551
+ "data-reset": "",
1407
1552
  onClick: () => {
1408
1553
  source.setSelection(menu.id, axisDefault(menu));
1409
1554
  closeMenu();
@@ -1449,6 +1594,39 @@ function VariantsView({
1449
1594
  )
1450
1595
  ] });
1451
1596
  }
1597
+ function SectionBlock({
1598
+ name,
1599
+ sectionKey,
1600
+ count,
1601
+ collapsed,
1602
+ onToggle,
1603
+ children
1604
+ }) {
1605
+ const id = useId2();
1606
+ return (
1607
+ // The heading carries the structure (heading navigation, a disclosure button);
1608
+ // a region or group per section would only add landmarks to a long list.
1609
+ /* @__PURE__ */ jsxs2("div", { className: "isotone-section", "data-collapsed": collapsed, children: [
1610
+ /* @__PURE__ */ jsx3("h3", { className: "isotone-section-head", "data-motion-key": `section:${sectionKey}`, children: /* @__PURE__ */ jsxs2(
1611
+ "button",
1612
+ {
1613
+ type: "button",
1614
+ className: "isotone-section-toggle",
1615
+ "aria-label": `${name}, ${count} ${count === 1 ? "selector" : "selectors"}`,
1616
+ "aria-expanded": !collapsed,
1617
+ "aria-controls": `${id}-body`,
1618
+ onClick: onToggle,
1619
+ children: [
1620
+ /* @__PURE__ */ jsx3(Icon2, { name: "chevron", size: 12 }),
1621
+ /* @__PURE__ */ jsx3("span", { onPointerOver: showOverflowTitle, children: name }),
1622
+ /* @__PURE__ */ jsx3("small", { children: count })
1623
+ ]
1624
+ }
1625
+ ) }),
1626
+ /* @__PURE__ */ jsx3("div", { className: "isotone-section-body", id: `${id}-body`, hidden: collapsed, children: collapsed ? null : children })
1627
+ ] })
1628
+ );
1629
+ }
1452
1630
  function SaveDefaults({
1453
1631
  source,
1454
1632
  onSaved
@@ -1626,8 +1804,8 @@ function observeTheme(onTheme, preference = "auto") {
1626
1804
  sample();
1627
1805
  if (preference === "light" || preference === "dark") return () => {
1628
1806
  };
1629
- const media = window.matchMedia?.("(prefers-color-scheme: dark)");
1630
- media?.addEventListener("change", schedule);
1807
+ const media2 = window.matchMedia?.("(prefers-color-scheme: dark)");
1808
+ media2?.addEventListener("change", schedule);
1631
1809
  const pageEvent = (event) => {
1632
1810
  if (!isPanel(event.target instanceof Node ? event.target : null)) schedule();
1633
1811
  };
@@ -1642,7 +1820,7 @@ function observeTheme(onTheme, preference = "auto") {
1642
1820
  disposed = true;
1643
1821
  observer?.disconnect();
1644
1822
  if (frame !== void 0) window.cancelAnimationFrame(frame);
1645
- media?.removeEventListener("change", schedule);
1823
+ media2?.removeEventListener("change", schedule);
1646
1824
  window.removeEventListener("resize", schedule);
1647
1825
  document.removeEventListener("scroll", pageEvent, true);
1648
1826
  document.removeEventListener("load", pageEvent, true);
@@ -1656,7 +1834,20 @@ import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-run
1656
1834
  function SummaryTag({ label }) {
1657
1835
  return /* @__PURE__ */ jsx4("span", { className: "isotone-chip", children: /* @__PURE__ */ jsx4("span", { onPointerOver: showOverflowTitle, children: label }) });
1658
1836
  }
1837
+ function Reply({ note }) {
1838
+ if (!note.reply?.text || note.reply.answered) return null;
1839
+ return /* @__PURE__ */ jsxs3("p", { className: "isotone-reply", onPointerOver: showOverflowTitle, children: [
1840
+ /* @__PURE__ */ jsx4("span", { children: "Agent asks" }),
1841
+ " ",
1842
+ note.reply.text
1843
+ ] });
1844
+ }
1659
1845
  function NoteSummary({ note, page }) {
1846
+ if (note.update) return /* @__PURE__ */ jsxs3(Fragment3, { children: [
1847
+ /* @__PURE__ */ jsx4("p", { children: updateNoteText(note.update) }),
1848
+ /* @__PURE__ */ jsx4(Reply, { note }),
1849
+ /* @__PURE__ */ jsx4("span", { className: "isotone-meta", children: /* @__PURE__ */ jsx4(SummaryTag, { label: "Update" }) })
1850
+ ] });
1660
1851
  if (!note.feedback.startsWith("[set-defaults]")) {
1661
1852
  const elements = note.elements ?? (note.element ? [note.element] : []);
1662
1853
  const references = [
@@ -1669,6 +1860,7 @@ function NoteSummary({ note, page }) {
1669
1860
  ];
1670
1861
  return /* @__PURE__ */ jsxs3(Fragment3, { children: [
1671
1862
  /* @__PURE__ */ jsx4("p", { children: note.feedback }),
1863
+ /* @__PURE__ */ jsx4(Reply, { note }),
1672
1864
  references.length > 0 && /* @__PURE__ */ jsx4("span", { className: "isotone-meta", children: references.map((reference) => /* @__PURE__ */ jsx4(SummaryTag, { label: reference.label }, reference.key)) })
1673
1865
  ] });
1674
1866
  }
@@ -1692,6 +1884,7 @@ function NoteSummary({ note, page }) {
1692
1884
  }
1693
1885
  return /* @__PURE__ */ jsxs3(Fragment3, { children: [
1694
1886
  /* @__PURE__ */ jsx4("p", { children: note.feedback }),
1887
+ /* @__PURE__ */ jsx4(Reply, { note }),
1695
1888
  (page || tags.length > 0) && /* @__PURE__ */ jsxs3("span", { className: "isotone-meta", children: [
1696
1889
  page && /* @__PURE__ */ jsx4(SummaryTag, { label: page }),
1697
1890
  tags.slice(0, 3).map((tag) => /* @__PURE__ */ jsx4(SummaryTag, { label: `${tag.name} ${tag.value}` }, `${tag.name}-${tag.value}`)),
@@ -1789,11 +1982,14 @@ function useDockMotion(ref, visible) {
1789
1982
  if (changed) measure();
1790
1983
  });
1791
1984
  mutations.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-open", "data-instant"] });
1985
+ const lifted = new MutationObserver(measure);
1986
+ lifted.observe(root, { attributes: true, attributeFilter: ["style"] });
1792
1987
  reduced?.addEventListener("change", measure);
1793
1988
  window.addEventListener("resize", measure);
1794
1989
  return () => {
1795
1990
  observer?.disconnect();
1796
1991
  mutations.disconnect();
1992
+ lifted.disconnect();
1797
1993
  reduced?.removeEventListener("change", measure);
1798
1994
  window.removeEventListener("resize", measure);
1799
1995
  for (const element of exits.keys()) removeExit(element);
@@ -1802,6 +1998,73 @@ function useDockMotion(ref, visible) {
1802
1998
  }, [ref, visible]);
1803
1999
  }
1804
2000
 
2001
+ // src/react/hints.ts
2002
+ var HINTS = {
2003
+ /** The select-mode cover over the docked composer: what to do, then how to pause. */
2004
+ picking: {
2005
+ pointer: { title: "Click elements on the page", pause: "Hold \u21E7 to use the page" },
2006
+ touch: { title: "Tap elements on the page", pause: "Tap Done to use the page" }
2007
+ },
2008
+ /** The floating composer beside a pinned element, while still selecting. */
2009
+ floating: {
2010
+ pointer: "Click to pin more \xB7 \u21E7 uses the page",
2011
+ touch: "Tap to pin more \xB7 crosshair pauses"
2012
+ }
2013
+ };
2014
+
2015
+ // src/react/viewport.ts
2016
+ import { useEffect as useEffect3 } from "react";
2017
+ var COMPACT_QUERY = "(max-width: 640px), (max-height: 500px)";
2018
+ var TOUCH_QUERY = "(hover: none) and (pointer: coarse)";
2019
+ var KEYBOARD_MIN = 80;
2020
+ function media(query) {
2021
+ return typeof matchMedia === "function" && Boolean(matchMedia(query)?.matches);
2022
+ }
2023
+ function isCompactViewport() {
2024
+ return media(COMPACT_QUERY);
2025
+ }
2026
+ function prefersTouch() {
2027
+ return media(TOUCH_QUERY);
2028
+ }
2029
+ function visibleBand() {
2030
+ const viewport = typeof window !== "undefined" ? window.visualViewport : null;
2031
+ if (!viewport || Math.abs(viewport.scale - 1) > 0.01 || innerHeight - viewport.height < KEYBOARD_MIN)
2032
+ return { top: 0, bottom: innerHeight };
2033
+ return { top: viewport.offsetTop, bottom: viewport.offsetTop + viewport.height };
2034
+ }
2035
+ function useKeyboardInset(ref, active) {
2036
+ useEffect3(() => {
2037
+ const node = ref.current;
2038
+ const viewport = typeof window !== "undefined" ? window.visualViewport : null;
2039
+ if (!active || !node || !viewport) return;
2040
+ let frame = 0;
2041
+ const apply = () => {
2042
+ frame = 0;
2043
+ const band = visibleBand();
2044
+ if (band.top === 0 && band.bottom === innerHeight) {
2045
+ node.style.removeProperty("--iso-keyboard");
2046
+ node.style.removeProperty("--iso-viewport");
2047
+ return;
2048
+ }
2049
+ node.style.setProperty("--iso-keyboard", `${Math.max(0, Math.round(innerHeight - band.bottom))}px`);
2050
+ node.style.setProperty("--iso-viewport", `${Math.round(band.bottom - band.top)}px`);
2051
+ };
2052
+ const schedule = () => {
2053
+ frame ||= requestAnimationFrame(apply);
2054
+ };
2055
+ apply();
2056
+ viewport.addEventListener("resize", schedule);
2057
+ viewport.addEventListener("scroll", schedule);
2058
+ return () => {
2059
+ cancelAnimationFrame(frame);
2060
+ viewport.removeEventListener("resize", schedule);
2061
+ viewport.removeEventListener("scroll", schedule);
2062
+ node.style.removeProperty("--iso-keyboard");
2063
+ node.style.removeProperty("--iso-viewport");
2064
+ };
2065
+ }, [ref, active]);
2066
+ }
2067
+
1805
2068
  // src/react/IterationPanel.tsx
1806
2069
  import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1807
2070
  var PULSE_MS = 2e3;
@@ -1859,6 +2122,7 @@ function IterationPanel({
1859
2122
  rule,
1860
2123
  getPayload,
1861
2124
  variants,
2125
+ variantsEmptyText,
1862
2126
  defaultTab = "notes",
1863
2127
  shortcut = DEFAULT_SHORTCUT
1864
2128
  }) {
@@ -1909,17 +2173,19 @@ function IterationPanel({
1909
2173
  const [notice, setNotice] = useState3("");
1910
2174
  const [theme, setTheme] = useState3(() => detectTheme(themePreference));
1911
2175
  const [instant, setInstant] = useState3(false);
2176
+ const [touch, setTouch] = useState3(prefersTouch);
2177
+ const touchRef = useRef4(touch);
1912
2178
  const rootRef = useRef4(null);
1913
2179
  const textareaRef = useRef4(null);
1914
2180
  const focusFrame = useRef4(0);
1915
- useEffect3(() => () => cancelAnimationFrame(focusFrame.current), []);
2181
+ useEffect4(() => () => cancelAnimationFrame(focusFrame.current), []);
1916
2182
  const listRef = useRef4(null);
1917
2183
  useLayoutMotion(listRef, notes, notesOpen);
1918
2184
  const fabRef = useRef4(null);
1919
2185
  const previousFocus = useRef4(null);
1920
2186
  const draftRestored = useRef4("");
1921
2187
  const restoringDraft = useRef4(false);
1922
- const id = useId2();
2188
+ const id = useId3();
1923
2189
  const notesAvailable = Boolean(storeCfg.endpoint);
1924
2190
  useVariantsSource(variants);
1925
2191
  const variantsAvailable = Boolean(
@@ -1947,7 +2213,7 @@ function IterationPanel({
1947
2213
  observer.observe(field);
1948
2214
  return () => observer.disconnect();
1949
2215
  }, [text, notesOpen, visible, hasContext, floating]);
1950
- useEffect3(() => {
2216
+ useEffect4(() => {
1951
2217
  if (!visible) {
1952
2218
  setHovered(false);
1953
2219
  setFocused(false);
@@ -1975,9 +2241,21 @@ function IterationPanel({
1975
2241
  };
1976
2242
  }, [visible]);
1977
2243
  useDockMotion(rootRef, visible);
2244
+ useKeyboardInset(rootRef, visible);
2245
+ const wasOpen = useRef4({ notes: notesOpen, variants: variantsOpen });
2246
+ useLayoutEffect4(() => {
2247
+ const was = wasOpen.current;
2248
+ wasOpen.current = { notes: notesOpen, variants: variantsOpen };
2249
+ if (!notesOpen || !variantsOpen || !isCompactViewport()) return;
2250
+ if (was.variants) setVariantsOpen(false);
2251
+ else {
2252
+ setNotesOpen(false);
2253
+ setPicking(false);
2254
+ }
2255
+ }, [notesOpen, variantsOpen]);
1978
2256
  const open = notesOpen || variantsOpen;
1979
2257
  const railOpen = expanded || hovered || focused && instant || open || picking;
1980
- useEffect3(() => {
2258
+ useEffect4(() => {
1981
2259
  if (!railOpen || !instant) return;
1982
2260
  const active = rootRef.current?.getRootNode()?.activeElement;
1983
2261
  if (active === fabRef.current) rootRef.current?.querySelector(".isotone-rail-button")?.focus();
@@ -1986,7 +2264,7 @@ function IterationPanel({
1986
2264
  const openNotes = useMemo3(() => notes.filter((n) => !n.done), [notes]);
1987
2265
  const pages = useMemo3(() => {
1988
2266
  const counts = /* @__PURE__ */ new Map();
1989
- for (const n of openNotes) counts.set(n.route, (counts.get(n.route) ?? 0) + 1);
2267
+ for (const n of openNotes) if (!n.update) counts.set(n.route, (counts.get(n.route) ?? 0) + 1);
1990
2268
  const others = [...counts.keys()].filter((r) => r !== route).sort();
1991
2269
  return { counts, others };
1992
2270
  }, [openNotes, route]);
@@ -1994,7 +2272,8 @@ function IterationPanel({
1994
2272
  const chosen = (scope ?? [route]).filter((r) => r === route || pages.counts.has(r));
1995
2273
  return new Set(chosen.length ? chosen : [route]);
1996
2274
  }, [scope, route, pages]);
1997
- const visibleNotes = useMemo3(() => openNotes.filter((n) => selectedPages.has(n.route)).sort((a, b) => a.createdAt.localeCompare(b.createdAt)), [openNotes, selectedPages]);
2275
+ const updateRequested = useMemo3(() => openNotes.some((n) => n.update), [openNotes]);
2276
+ const visibleNotes = useMemo3(() => openNotes.filter((n) => n.update || selectedPages.has(n.route)).sort((a, b) => a.createdAt.localeCompare(b.createdAt)), [openNotes, selectedPages]);
1998
2277
  const working = useMemo3(() => new Set(visibleNotes.filter((n) => n.workingAt && clock - Date.parse(n.workingAt) < WORKING_FRESH_MS).map((n) => n.id)), [visibleNotes, clock]);
1999
2278
  function togglePage(page) {
2000
2279
  const next = new Set(selectedPages);
@@ -2194,10 +2473,10 @@ function IterationPanel({
2194
2473
  finish();
2195
2474
  }
2196
2475
  }
2197
- useEffect3(() => {
2476
+ useEffect4(() => {
2198
2477
  loadNotes();
2199
2478
  }, [notesOpen, variantsOpen, route]);
2200
- useEffect3(() => {
2479
+ useEffect4(() => {
2201
2480
  if (!notesAvailable || !visible) return;
2202
2481
  let stopped = false;
2203
2482
  let running = false;
@@ -2246,13 +2525,13 @@ function IterationPanel({
2246
2525
  window.removeEventListener("focus", wake);
2247
2526
  };
2248
2527
  }, [notesAvailable, visible, store]);
2249
- useEffect3(() => {
2528
+ useEffect4(() => {
2250
2529
  if (variants && notesOpen && !variantsOpen) refreshSource(variants);
2251
2530
  }, [notesOpen, variantsOpen, variants]);
2252
- useEffect3(() => {
2531
+ useEffect4(() => {
2253
2532
  if (variants && !variantsAvailable) refreshSource(variants);
2254
2533
  }, [variants]);
2255
- useEffect3(() => {
2534
+ useEffect4(() => {
2256
2535
  if (draftRestored.current === draftKey) return;
2257
2536
  draftRestored.current = draftKey;
2258
2537
  restoringDraft.current = true;
@@ -2277,7 +2556,7 @@ function IterationPanel({
2277
2556
  } catch {
2278
2557
  }
2279
2558
  }, [draftKey]);
2280
- useEffect3(() => {
2559
+ useEffect4(() => {
2281
2560
  if (restoringDraft.current) {
2282
2561
  restoringDraft.current = false;
2283
2562
  return;
@@ -2295,7 +2574,7 @@ function IterationPanel({
2295
2574
  } catch {
2296
2575
  }
2297
2576
  }, [text, targets, variantTarget, editing, draftKey]);
2298
- useEffect3(() => {
2577
+ useEffect4(() => {
2299
2578
  const el = listRef.current;
2300
2579
  if (el) el.scrollTop = el.scrollHeight;
2301
2580
  }, [notes, notesOpen]);
@@ -2306,7 +2585,7 @@ function IterationPanel({
2306
2585
  ensurePanelStyles(root);
2307
2586
  return observeTheme(setTheme, themePreference);
2308
2587
  }, [visible, open, route, themePreference]);
2309
- useEffect3(() => {
2588
+ useEffect4(() => {
2310
2589
  if (!visible) return;
2311
2590
  const onKey = (e) => {
2312
2591
  rootRef.current?.setAttribute("data-instant", "true");
@@ -2327,9 +2606,13 @@ function IterationPanel({
2327
2606
  startFeedback(true);
2328
2607
  }
2329
2608
  };
2330
- const onPointer = () => {
2609
+ const onPointer = (e) => {
2331
2610
  rootRef.current?.setAttribute("data-instant", "false");
2332
2611
  setInstant(false);
2612
+ if (e.pointerType) {
2613
+ touchRef.current = e.pointerType === "touch";
2614
+ setTouch(touchRef.current);
2615
+ }
2333
2616
  };
2334
2617
  window.addEventListener("keydown", onKey, true);
2335
2618
  window.addEventListener("pointerdown", onPointer, true);
@@ -2338,7 +2621,7 @@ function IterationPanel({
2338
2621
  window.removeEventListener("pointerdown", onPointer, true);
2339
2622
  };
2340
2623
  });
2341
- useEffect3(() => {
2624
+ useEffect4(() => {
2342
2625
  if (!open || picking) return;
2343
2626
  const onKey = (e) => {
2344
2627
  if (e.key !== "Escape" || e.defaultPrevented) return;
@@ -2349,7 +2632,7 @@ function IterationPanel({
2349
2632
  window.addEventListener("keydown", onKey);
2350
2633
  return () => window.removeEventListener("keydown", onKey);
2351
2634
  });
2352
- useEffect3(() => {
2635
+ useEffect4(() => {
2353
2636
  if (!picking) return;
2354
2637
  const cursor = document.body.style.cursor;
2355
2638
  document.body.style.cursor = "crosshair";
@@ -2359,6 +2642,7 @@ function IterationPanel({
2359
2642
  return el && !el.closest("[data-isotone]") ? el : null;
2360
2643
  };
2361
2644
  const onMove = (e) => {
2645
+ if (e.pointerType === "touch") return;
2362
2646
  cancelAnimationFrame(frame);
2363
2647
  const deferred = e.shiftKey;
2364
2648
  frame = requestAnimationFrame(() => {
@@ -2389,7 +2673,7 @@ function IterationPanel({
2389
2673
  const y = pointed ? e.clientY : r.bottom;
2390
2674
  if (!anchorRef.current)
2391
2675
  slotHeight.current = rootRef.current?.querySelector(".isotone-notes-panel .isotone-input")?.offsetHeight ?? 0;
2392
- setAnchor({ selector: added.selector, dx: x - r.left, dy: y - r.top, x, y });
2676
+ setAnchor({ selector: added.selector, dx: x - r.left, dy: y - r.top, x, y, beside: touchRef.current && pointed });
2393
2677
  setNotesOpen(true);
2394
2678
  focusWhilePicking();
2395
2679
  }
@@ -2426,19 +2710,19 @@ function IterationPanel({
2426
2710
  setNotesOpen(true);
2427
2711
  focusComposer();
2428
2712
  };
2429
- window.addEventListener("mousemove", onMove, true);
2713
+ window.addEventListener("pointermove", onMove, true);
2430
2714
  window.addEventListener("click", onClick, true);
2431
2715
  window.addEventListener("keydown", onKey, true);
2432
2716
  return () => {
2433
2717
  document.body.style.cursor = cursor;
2434
2718
  cancelAnimationFrame(frame);
2435
2719
  setHoverRect(null);
2436
- window.removeEventListener("mousemove", onMove, true);
2720
+ window.removeEventListener("pointermove", onMove, true);
2437
2721
  window.removeEventListener("click", onClick, true);
2438
2722
  window.removeEventListener("keydown", onKey, true);
2439
2723
  };
2440
2724
  }, [picking, focusComposer, focusWhilePicking]);
2441
- useEffect3(() => {
2725
+ useEffect4(() => {
2442
2726
  const shadow = rootRef.current?.getRootNode();
2443
2727
  if (!(shadow instanceof ShadowRoot)) return;
2444
2728
  const stop = (e) => e.stopPropagation();
@@ -2448,7 +2732,7 @@ function IterationPanel({
2448
2732
  for (const type of types) shadow.removeEventListener(type, stop);
2449
2733
  };
2450
2734
  }, []);
2451
- useEffect3(() => {
2735
+ useEffect4(() => {
2452
2736
  if (!notesOpen) setAnchor(null);
2453
2737
  }, [notesOpen]);
2454
2738
  useLayoutEffect4(() => {
@@ -2465,35 +2749,45 @@ function IterationPanel({
2465
2749
  frame = void 0;
2466
2750
  let { x, y } = anchor;
2467
2751
  const el = locate(anchor.selector);
2468
- if (el) {
2469
- const r = el.getBoundingClientRect();
2752
+ const r = el?.getBoundingClientRect();
2753
+ if (r) {
2470
2754
  x = r.left + anchor.dx;
2471
2755
  y = r.top + anchor.dy;
2472
2756
  }
2473
2757
  const vw = document.documentElement.clientWidth || innerWidth;
2474
2758
  const vh = innerHeight;
2759
+ const band = visibleBand();
2760
+ const minY = band.top + FLOAT_MARGIN;
2761
+ const maxY = band.bottom - FLOAT_MARGIN;
2475
2762
  const w = float.offsetWidth;
2476
2763
  const h = float.offsetHeight;
2764
+ const fits = (below, above) => [below + FLOAT_GAP + h <= maxY, above - FLOAT_GAP - h >= minY];
2765
+ let belowY = y;
2766
+ let aboveY = y;
2477
2767
  let left = x + FLOAT_GAP;
2478
- if (left + w > vw - FLOAT_MARGIN) left = x - FLOAT_GAP - w;
2768
+ if (anchor.beside && r && fits(r.bottom, r.top).some(Boolean)) {
2769
+ belowY = r.bottom;
2770
+ aboveY = r.top;
2771
+ left = x - w / 2;
2772
+ } else if (left + w > vw - FLOAT_MARGIN) left = x - FLOAT_GAP - w;
2479
2773
  left = Math.max(FLOAT_MARGIN, Math.min(left, vw - w - FLOAT_MARGIN));
2480
- const fitsBelow = y + FLOAT_GAP + h <= vh - FLOAT_MARGIN;
2481
- const fitsAbove = y - FLOAT_GAP - h >= FLOAT_MARGIN;
2774
+ const [fitsBelow, fitsAbove] = fits(belowY, aboveY);
2482
2775
  if (side === null || (side === "below" ? !fitsBelow && fitsAbove : !fitsAbove && fitsBelow))
2483
2776
  side = fitsBelow || !fitsAbove ? "below" : "above";
2484
2777
  float.style.left = `${left}px`;
2778
+ let top;
2485
2779
  if (side === "below") {
2486
- const top = Math.max(FLOAT_MARGIN, Math.min(y + FLOAT_GAP, vh - h - FLOAT_MARGIN));
2780
+ top = Math.max(minY, Math.min(belowY + FLOAT_GAP, maxY - h));
2487
2781
  float.style.top = `${top}px`;
2488
2782
  float.style.bottom = "auto";
2489
- float.style.transformOrigin = `${x - left}px ${y - top}px`;
2490
2783
  } else {
2491
- const bottom = Math.max(FLOAT_MARGIN, Math.min(vh - y + FLOAT_GAP, vh - h - FLOAT_MARGIN));
2784
+ const bottom = Math.max(vh - maxY, Math.min(vh - aboveY + FLOAT_GAP, vh - h - minY));
2492
2785
  float.style.bottom = `${bottom}px`;
2493
2786
  float.style.top = "auto";
2494
- float.style.transformOrigin = `${x - left}px ${y - (vh - bottom - h)}px`;
2787
+ top = vh - bottom - h;
2495
2788
  }
2496
- lastFloat.current = { left, top: side === "below" ? Number.parseFloat(float.style.top) : vh - Number.parseFloat(float.style.bottom) - h };
2789
+ float.style.transformOrigin = `${x - left}px ${y - top}px`;
2790
+ lastFloat.current = { left, top };
2497
2791
  };
2498
2792
  const schedule = () => {
2499
2793
  frame ??= requestAnimationFrame(place);
@@ -2517,12 +2811,17 @@ function IterationPanel({
2517
2811
  observer?.observe(float);
2518
2812
  window.addEventListener("scroll", schedule, { capture: true, passive: true });
2519
2813
  window.addEventListener("resize", schedule);
2814
+ const viewport = window.visualViewport;
2815
+ viewport?.addEventListener("resize", schedule);
2816
+ viewport?.addEventListener("scroll", schedule);
2520
2817
  return () => {
2521
2818
  if (frame !== void 0) cancelAnimationFrame(frame);
2522
2819
  glide?.cancel();
2523
2820
  observer?.disconnect();
2524
2821
  window.removeEventListener("scroll", schedule, true);
2525
2822
  window.removeEventListener("resize", schedule);
2823
+ viewport?.removeEventListener("resize", schedule);
2824
+ viewport?.removeEventListener("scroll", schedule);
2526
2825
  };
2527
2826
  }, [anchor, locate]);
2528
2827
  useLayoutEffect4(() => {
@@ -2551,11 +2850,11 @@ function IterationPanel({
2551
2850
  ghost.remove();
2552
2851
  };
2553
2852
  }, [floating]);
2554
- useEffect3(() => {
2853
+ useEffect4(() => {
2555
2854
  const kept = new Set(targets.map((t) => t.selector));
2556
2855
  for (const selector of pinnedElements.current.keys()) if (!kept.has(selector)) pinnedElements.current.delete(selector);
2557
2856
  }, [targets]);
2558
- useEffect3(() => {
2857
+ useEffect4(() => {
2559
2858
  if (!picking && !floating) {
2560
2859
  setRects([]);
2561
2860
  return;
@@ -2621,7 +2920,8 @@ function IterationPanel({
2621
2920
  onFeedback: notesAvailable ? feedback : void 0,
2622
2921
  onSaved: loadNotes,
2623
2922
  onManage: notesAvailable ? queueRemoval : void 0,
2624
- pendingActions
2923
+ pendingActions,
2924
+ emptyText: variantsEmptyText
2625
2925
  }
2626
2926
  )
2627
2927
  ]
@@ -2643,6 +2943,11 @@ function IterationPanel({
2643
2943
  textareaRef.current?.focus({ preventScroll: true });
2644
2944
  },
2645
2945
  children: [
2946
+ editing?.reply?.text && !editing.reply.answered && /* @__PURE__ */ jsxs4("p", { className: "isotone-editing-reply", children: [
2947
+ /* @__PURE__ */ jsx5("span", { children: "Agent asks" }),
2948
+ " ",
2949
+ editing.reply.text
2950
+ ] }),
2646
2951
  /* @__PURE__ */ jsxs4("div", { className: "isotone-context", "aria-live": "polite", children: [
2647
2952
  editing && /* @__PURE__ */ jsxs4("span", { className: "isotone-chip", children: [
2648
2953
  "Editing note",
@@ -2742,7 +3047,7 @@ function IterationPanel({
2742
3047
  children: /* @__PURE__ */ jsx5(Icon2, { name: "pin", size: 14 })
2743
3048
  }
2744
3049
  ),
2745
- floating && picking && /* @__PURE__ */ jsx5("span", { ref: fadeIn, className: "isotone-float-hint", children: "Click to pin more \xB7 hold \u21E7 to use the page" }),
3050
+ floating && picking && /* @__PURE__ */ jsx5("span", { ref: fadeIn, className: "isotone-float-hint", children: HINTS.floating[touch ? "touch" : "pointer"] }),
2746
3051
  /* @__PURE__ */ jsx5(
2747
3052
  "button",
2748
3053
  {
@@ -2759,8 +3064,8 @@ function IterationPanel({
2759
3064
  picking && !floating && /* @__PURE__ */ jsxs4("div", { ref: fadeIn, className: "isotone-picking", "data-still": pickingWithPanel, children: [
2760
3065
  /* @__PURE__ */ jsx5(Icon2, { name: "pin", size: 14 }),
2761
3066
  /* @__PURE__ */ jsxs4("p", { children: [
2762
- /* @__PURE__ */ jsx5("span", { children: targets.length ? `${targets.length} selected` : "Click elements on the page" }),
2763
- /* @__PURE__ */ jsx5("small", { children: "Hold \u21E7 to use the page normally" })
3067
+ /* @__PURE__ */ jsx5("span", { children: targets.length ? `${targets.length} selected` : HINTS.picking[touch ? "touch" : "pointer"].title }),
3068
+ /* @__PURE__ */ jsx5("small", { children: HINTS.picking[touch ? "touch" : "pointer"].pause })
2764
3069
  ] }),
2765
3070
  /* @__PURE__ */ jsxs4(
2766
3071
  "button",
@@ -2772,8 +3077,8 @@ function IterationPanel({
2772
3077
  focusComposer();
2773
3078
  },
2774
3079
  children: [
2775
- "Done ",
2776
- /* @__PURE__ */ jsx5("kbd", { children: "\u21B5" })
3080
+ "Done",
3081
+ !touch && /* @__PURE__ */ jsx5("kbd", { children: "\u21B5" })
2777
3082
  ]
2778
3083
  }
2779
3084
  )
@@ -2850,7 +3155,14 @@ function IterationPanel({
2850
3155
  "data-motion-key": note.id,
2851
3156
  className: `isotone-note${editing?.id === note.id ? " isotone-note-editing" : ""}`,
2852
3157
  children: [
2853
- /* @__PURE__ */ jsxs4(
3158
+ note.update ? (
3159
+ // Isotone writes an update note's text from its request, so
3160
+ // there is nothing to edit; its corner action cancels it.
3161
+ /* @__PURE__ */ jsxs4("div", { className: "isotone-note-hit", "data-static": "", children: [
3162
+ working.has(note.id) && /* @__PURE__ */ jsx5("span", { className: "isotone-working", ref: dotPulse, role: "img", "aria-label": "An agent is working on this", title: "An agent is working on this" }),
3163
+ /* @__PURE__ */ jsx5(NoteSummary, { note })
3164
+ ] })
3165
+ ) : /* @__PURE__ */ jsxs4(
2854
3166
  "button",
2855
3167
  {
2856
3168
  type: "button",
@@ -2868,8 +3180,8 @@ function IterationPanel({
2868
3180
  {
2869
3181
  type: "button",
2870
3182
  className: "isotone-x isotone-icon",
2871
- "aria-label": note.variantAction ? "Cancel removal" : "Delete note",
2872
- title: note.variantAction ? "Cancel removal" : "Delete note",
3183
+ "aria-label": note.variantAction ? "Cancel removal" : note.update ? "Cancel update request" : "Delete note",
3184
+ title: note.variantAction ? "Cancel removal" : note.update ? "Cancel update request" : "Delete note",
2873
3185
  onClick: () => void removeNote(note),
2874
3186
  children: /* @__PURE__ */ jsx5(Icon2, { name: "close", size: 12 })
2875
3187
  }
@@ -2912,6 +3224,7 @@ function IterationPanel({
2912
3224
  "data-tools": Number(Boolean(variants)) + Number(notesAvailable),
2913
3225
  "data-instant": instant,
2914
3226
  "data-notes-open": notesOpen,
3227
+ "data-selecting": picking || floating,
2915
3228
  "data-working": working.size > 0,
2916
3229
  "data-open": open,
2917
3230
  className: "isotone-root",
@@ -2952,7 +3265,16 @@ function IterationPanel({
2952
3265
  children: composer
2953
3266
  }
2954
3267
  ),
2955
- /* @__PURE__ */ jsx5(UpdateNotice, { endpoint: updateEndpoint, active: open }),
3268
+ /* @__PURE__ */ jsx5("div", { className: "isotone-notices", children: /* @__PURE__ */ jsx5(
3269
+ UpdateNotice,
3270
+ {
3271
+ endpoint: updateEndpoint,
3272
+ active: open,
3273
+ route,
3274
+ requested: updateRequested,
3275
+ onRequested: notesAvailable ? loadNotes : void 0
3276
+ }
3277
+ ) }),
2956
3278
  /* @__PURE__ */ jsxs4(
2957
3279
  "fieldset",
2958
3280
  {
@@ -3077,9 +3399,10 @@ function withDocumentUntouched(create) {
3077
3399
  }
3078
3400
  return result;
3079
3401
  }
3402
+ var KNOBS_EMPTY_TEXT = "No knobs on this page yet. Ask your agent to add knobs for this page to .isotone/knobs.json and they will appear here.";
3080
3403
  function mountIteration(opts) {
3081
3404
  const getRoute = opts.getRoute ?? (() => window.location.pathname);
3082
- const variants = opts.variants === "overlay" ? overlaySourceWithSave(getRoute, opts.endpoint, opts.key) : opts.variants;
3405
+ const variants = opts.variants === "overlay" || opts.variants === "knobs" ? overlaySourceWithSave(getRoute, opts.endpoint, opts.key, opts.variantsEndpoint) : opts.variants;
3083
3406
  if (!opts.endpoint && !variants) return () => {
3084
3407
  };
3085
3408
  if (!opts.endpoint && variants && variants.getAxes().length === 0 && typeof variants.refresh !== "function") {
@@ -3111,6 +3434,7 @@ function mountIteration(opts) {
3111
3434
  rule: opts.rule,
3112
3435
  getPayload: opts.getPayload,
3113
3436
  variants,
3437
+ variantsEmptyText: opts.variants === "knobs" ? KNOBS_EMPTY_TEXT : void 0,
3114
3438
  defaultTab: opts.defaultTab,
3115
3439
  shortcut: opts.shortcut,
3116
3440
  updateEndpoint: opts.updateEndpoint,
@@ -3157,8 +3481,8 @@ function mountIteration(opts) {
3157
3481
  }, 0);
3158
3482
  };
3159
3483
  }
3160
- function overlaySourceWithSave(getRoute, endpoint, key) {
3161
- const source = createOverlayVariantsSource({ endpoint: VARIANTS_ENDPOINT, getRoute });
3484
+ function overlaySourceWithSave(getRoute, endpoint, key, variantsEndpoint = VARIANTS_ENDPOINT) {
3485
+ const source = createOverlayVariantsSource({ endpoint: variantsEndpoint, getRoute });
3162
3486
  if (!endpoint) return source;
3163
3487
  const notes = createIterationStore({ endpoint, key });
3164
3488
  source.saveDefaults = async (selections) => {
@@ -3167,7 +3491,8 @@ function overlaySourceWithSave(getRoute, endpoint, key) {
3167
3491
  if (!id || id === OVERLAY_DEFAULT_OPTION) return [];
3168
3492
  const variant = source.list?.variants.find((v) => v.id === id);
3169
3493
  const label = variant?.label ?? id;
3170
- const named = axis.label === label ? label : `${axis.label}: ${label}`;
3494
+ const selector = axisSection(axis).fullLabel;
3495
+ const named = selector === label || axis.label === label ? label : `${selector}: ${label}`;
3171
3496
  return [{ id, named, element: variant?.element ?? null }];
3172
3497
  });
3173
3498
  const moved = knobDelta(source.knobs.axes, source.knobs.values);
@@ -3176,7 +3501,7 @@ function overlaySourceWithSave(getRoute, endpoint, key) {
3176
3501
  parts.push(`Make the default: ${picked.map((p) => `"${p.named}" (${p.id})`).join("; ")}.`);
3177
3502
  }
3178
3503
  if (moved.length) {
3179
- parts.push(`Knobs: ${moved.map((m) => `${m.axis.label} ${m.shown} (${m.axis.id})`).join("; ")}.`);
3504
+ parts.push(`Knobs: ${moved.map((m) => `${axisSection(m.axis).fullLabel} ${m.shown} (${m.axis.id})`).join("; ")}.`);
3180
3505
  }
3181
3506
  const feedback = parts.length ? `[set-defaults] ${parts.join(" ")}` : "[set-defaults] The current defaults stay on this route.";
3182
3507
  await notes.saveNote(
@@ -3199,10 +3524,12 @@ export {
3199
3524
  OVERLAY_DEFAULT_OPTION,
3200
3525
  OVERLAY_GROUP_ID,
3201
3526
  PANEL_CSS,
3527
+ SECTION_SEPARATOR,
3202
3528
  VARIANTS_ENDPOINT,
3203
3529
  VariantsView,
3204
3530
  applyKnobValues,
3205
3531
  axisDefault,
3532
+ axisSection,
3206
3533
  createOverlayVariantsSource,
3207
3534
  createVariantsSource,
3208
3535
  defaultSelections,
@@ -3222,7 +3549,9 @@ export {
3222
3549
  overlayAxes,
3223
3550
  overlayGroups,
3224
3551
  parseVariantParam,
3552
+ sectionAxes,
3225
3553
  serializeVariantParam,
3554
+ splitSectionLabel,
3226
3555
  visibleAxes
3227
3556
  };
3228
3557
  /*! Bundled license information: