isotone 1.1.0 → 1.1.2

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.
package/dist/index.js CHANGED
@@ -754,7 +754,7 @@ ${APPEARANCE_CSS}
754
754
  /* Panels move independently; their content and corners retain their natural size. */
755
755
  .isotone-root[data-open='true'] { width: var(--iso-dock-width); }
756
756
  .isotone-root { pointer-events: none; }
757
- .isotone-root :is(.isotone-panel, .isotone-rail-button, .isotone-fab, .isotone-hint) { pointer-events: auto; }
757
+ .isotone-root :is(.isotone-panel, .isotone-rail-button, .isotone-fab, .isotone-float) { pointer-events: auto; }
758
758
  /* The horizontal pill stays anchored while its panels open above it. */
759
759
  .isotone-rail, .isotone-rail-tools { display: contents; border: 0; margin: 0; padding: 0; min-width: 0; }
760
760
  .isotone-root[data-expanded='true'] { min-height: 44px; }
@@ -840,7 +840,7 @@ ${APPEARANCE_CSS}
840
840
  .isotone-picker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
841
841
  .isotone-picker:has(input:focus-visible) { outline: 2px solid var(--iso-focus); outline-offset: 3px; }
842
842
  /* Raised actions share one face, edge and hover treatment. */
843
- .isotone-save, .isotone-secondary, .isotone-send, .isotone-hint button { border-radius: var(--iso-control-radius); color: var(--iso-fg); background: var(--iso-control-face); box-shadow: var(--iso-control-shadow); }
843
+ .isotone-save, .isotone-secondary, .isotone-send, .isotone-picking button, .isotone-away-action { border-radius: var(--iso-control-radius); color: var(--iso-fg); background: var(--iso-control-face); box-shadow: var(--iso-control-shadow); }
844
844
  /* Persistent selections share the raised action material, never the recessed
845
845
  field/slot material. Keep the face and shadow together in this one rule. */
846
846
  .isotone-note-editing .isotone-note-hit,
@@ -915,7 +915,7 @@ ${FEEDBACK_LAYOUT_CSS}
915
915
  .isotone-chip button { display: grid; place-items: center; flex: none; width: 16px; height: 16px; border-radius: 3px; background: transparent; box-shadow: none; }
916
916
  .isotone-root .isotone-textarea { --iso-textarea-inset: 8px; display: block; width: 100%; flex: none; height: 22px; min-height: calc(22px + var(--iso-textarea-inset)); max-height: calc(120px + var(--iso-textarea-inset)); overflow-y: auto; resize: none; margin-top: calc(0px - var(--iso-textarea-inset)); padding: calc(var(--iso-textarea-inset) + 1px) 1px 1px; scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--iso-fg) 14%, transparent) transparent; color: var(--iso-fg); background: transparent; border: 0; border-radius: 0; font-size: 12px; line-height: 20px; box-shadow: none; cursor: text; }
917
917
  /* Text scrolls through the gap below tags, or the field inset without tags. */
918
- .isotone-root .isotone-context:empty + .isotone-textarea { --iso-textarea-inset: var(--iso-input-padding); }
918
+ .isotone-root .isotone-context:empty + .isotone-entry .isotone-textarea { --iso-textarea-inset: var(--iso-input-padding); }
919
919
  .isotone-textarea::placeholder { color: var(--iso-faint); }
920
920
  .isotone-root .isotone-textarea:focus-visible { outline: none; }
921
921
  .isotone-textarea[data-scrolled='true'] { mask-image: linear-gradient(to bottom, transparent, #000 8px); }
@@ -926,11 +926,28 @@ ${FEEDBACK_LAYOUT_CSS}
926
926
  .isotone-pick::after, .isotone-send::after { content: ''; position: absolute; inset: -2px; border-radius: inherit; }
927
927
  .isotone-send { display: grid; place-items: center; margin-left: auto; flex: none; }
928
928
  .isotone-notice { font-size: 10px; color: var(--iso-muted); }
929
- .isotone-hint { position: fixed; top: 16px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 12px; padding: 8px 10px 8px 14px; border-radius: 24px; background: var(--iso-bg); box-shadow: var(--iso-shadow); font-size: 11px; white-space: nowrap; transition: opacity 120ms ease; }
930
- /* Near the pointer the hint fades and lets the page underneath be hovered and pinned. */
931
- .isotone-root .isotone-hint[data-away='true'] { opacity: 0; pointer-events: none; }
932
- .isotone-hint button { display: flex; align-items: center; gap: 8px; border-radius: 14px; padding: 5px 9px; }
933
- .isotone-hint kbd { font: inherit; color: var(--iso-muted); }
929
+ .isotone-entry { position: relative; display: flex; flex-direction: column; gap: 8px; }
930
+ /* Select mode covers the field and says how to finish, next to the elements the user is picking. */
931
+ .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
+ .isotone-picking > svg { flex: none; color: var(--iso-fg); }
933
+ .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; }
936
+ .isotone-picking button { flex: none; display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 9px; }
937
+ .isotone-picking kbd { font: inherit; color: var(--iso-muted); }
938
+ /* After a pin, the composer floats beside the element on a panel surface, placed by
939
+ IterationPanel inside the viewport. It grows out of the point that was clicked. */
940
+ .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); }
942
+ /* Meanwhile the dock keeps the composer's slot and says where the writing went. */
943
+ .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
+ .isotone-away > svg { flex: none; }
945
+ .isotone-root .isotone-float.isotone-exit { pointer-events: none; }
946
+ .isotone-away-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
947
+ .isotone-away-text > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
948
+ .isotone-away-text > span { font-size: 12px; line-height: 16px; }
949
+ .isotone-away-text > small { font-size: 10px; line-height: 14px; color: var(--iso-muted); }
950
+ .isotone-away-action { flex: none; display: flex; align-items: center; height: 28px; padding: 0 9px; font-size: 11px; }
934
951
  .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; }
935
952
  .isotone-selected { border: 2px solid var(--iso-pick-line); background: var(--iso-pick-selected-fill); }
936
953
  @media (hover: hover) and (pointer: fine) {
@@ -940,7 +957,7 @@ ${FEEDBACK_LAYOUT_CSS}
940
957
  .isotone-seg:not(:disabled):hover { color: var(--iso-fg); }
941
958
  .isotone-seg[aria-pressed='false']:not(:disabled):hover { box-shadow: var(--iso-hover-shadow); }
942
959
  .isotone-thumb[aria-pressed='false']:not(:disabled):hover { box-shadow: var(--iso-thumb-hover-shadow); }
943
- .isotone-root :where(.isotone-save, .isotone-secondary, .isotone-send, .isotone-hint button):not(:disabled):hover { background: var(--iso-control-hover-face); box-shadow: var(--iso-control-hover-shadow); }
960
+ .isotone-root :where(.isotone-save, .isotone-secondary, .isotone-send, .isotone-picking button):not(:disabled):hover, .isotone-away:hover .isotone-away-action { background: var(--iso-control-hover-face); box-shadow: var(--iso-control-hover-shadow); }
944
961
  .isotone-chip button:hover { color: var(--iso-fg); background: var(--iso-hover); }
945
962
  .isotone-x:hover { color: var(--iso-fg); }
946
963
  .isotone-x:hover::before { background: var(--iso-control-hover-face); }
@@ -1788,6 +1805,8 @@ function useDockMotion(ref, visible) {
1788
1805
  // src/react/IterationPanel.tsx
1789
1806
  import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1790
1807
  var PULSE_MS = 2e3;
1808
+ var FLOAT_GAP = 12;
1809
+ var FLOAT_MARGIN = 8;
1791
1810
  function syncPulse(spread) {
1792
1811
  return (node) => {
1793
1812
  if (!node || typeof node.animate !== "function") return;
@@ -1801,6 +1820,19 @@ function syncPulse(spread) {
1801
1820
  pulse.startTime = 0;
1802
1821
  };
1803
1822
  }
1823
+ function stillMotion(node) {
1824
+ return Boolean(node.closest('[data-instant="true"]')) || typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
1825
+ }
1826
+ function fadeIn(node) {
1827
+ if (!node || typeof node.animate !== "function" || node.dataset.still === "true" || stillMotion(node)) return;
1828
+ node.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 140, easing: "ease-out" });
1829
+ }
1830
+ function fadeInContent(node) {
1831
+ if (!node || stillMotion(node)) return;
1832
+ for (const child of node.children)
1833
+ if (typeof child.animate === "function")
1834
+ child.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 140, easing: "ease-out" });
1835
+ }
1804
1836
  var dotPulse = syncPulse(6);
1805
1837
  var ringPulse = syncPulse(8);
1806
1838
  var POLL_MS = 2500;
@@ -1838,13 +1870,22 @@ function IterationPanel({
1838
1870
  const [hovered, setHovered] = useState3(false);
1839
1871
  const [focused, setFocused] = useState3(false);
1840
1872
  const [picking, setPicking] = useState3(false);
1873
+ const [pickingWithPanel, setPickingWithPanel] = useState3(false);
1841
1874
  const [targets, setTargets] = useState3([]);
1875
+ const targetsRef = useRef4(targets);
1876
+ targetsRef.current = targets;
1877
+ const [anchor, setAnchor] = useState3(null);
1878
+ const anchorRef = useRef4(anchor);
1879
+ anchorRef.current = anchor;
1880
+ const floating = anchor !== null;
1881
+ const floatRef = useRef4(null);
1882
+ const slotHeight = useRef4(0);
1883
+ const lastFloat = useRef4(null);
1884
+ const floatNode = useRef4(null);
1842
1885
  const [variantTarget, setVariantTarget] = useState3(
1843
1886
  null
1844
1887
  );
1845
1888
  const [hoverRect, setHoverRect] = useState3(null);
1846
- const [hintAway, setHintAway] = useState3(false);
1847
- const hintRef = useRef4(null);
1848
1889
  const [rects, setRects] = useState3([]);
1849
1890
  const [text, setText] = useState3("");
1850
1891
  const [notes, setNotes] = useState3([]);
@@ -1895,7 +1936,7 @@ function IterationPanel({
1895
1936
  });
1896
1937
  observer.observe(field);
1897
1938
  return () => observer.disconnect();
1898
- }, [text, notesOpen, visible, hasContext]);
1939
+ }, [text, notesOpen, visible, hasContext, floating]);
1899
1940
  useEffect3(() => {
1900
1941
  if (!visible) {
1901
1942
  setHovered(false);
@@ -1982,7 +2023,7 @@ function IterationPanel({
1982
2023
  cancelAnimationFrame(focusFrame.current);
1983
2024
  focusFrame.current = requestAnimationFrame(() => {
1984
2025
  keepPicking.current = true;
1985
- textareaRef.current?.focus();
2026
+ textareaRef.current?.focus({ preventScroll: true });
1986
2027
  keepPicking.current = false;
1987
2028
  });
1988
2029
  }, []);
@@ -1997,21 +2038,33 @@ function IterationPanel({
1997
2038
  setNotesOpen(false);
1998
2039
  setVariantsOpen(false);
1999
2040
  setPicking(false);
2041
+ setAnchor(null);
2000
2042
  setExpanded(false);
2001
2043
  setHovered(false);
2002
2044
  (previousFocus.current?.isConnected ? previousFocus.current : fabRef.current)?.focus();
2003
2045
  }
2004
- function openInitial() {
2046
+ function startFeedback(select) {
2047
+ setNotesOpen(true);
2048
+ if (select) {
2049
+ cancelAnimationFrame(focusFrame.current);
2050
+ setPickingWithPanel(!notesOpen);
2051
+ setPicking(true);
2052
+ } else focusComposer();
2053
+ }
2054
+ function openInitial(select) {
2005
2055
  previousFocus.current = document.activeElement;
2006
2056
  setExpanded(true);
2007
2057
  if ((defaultTab === "variants" || !notesAvailable) && variantsAvailable)
2008
2058
  setVariantsOpen(true);
2009
- else {
2010
- setNotesOpen(true);
2011
- focusComposer();
2012
- }
2059
+ else startFeedback(select);
2060
+ }
2061
+ function dockComposer() {
2062
+ setAnchor(null);
2063
+ setPicking(false);
2064
+ focusComposer();
2013
2065
  }
2014
2066
  function feedback(reference) {
2067
+ setAnchor(null);
2015
2068
  setVariantTarget(reference);
2016
2069
  setNotesOpen(true);
2017
2070
  setExpanded(true);
@@ -2082,6 +2135,7 @@ function IterationPanel({
2082
2135
  cancelEdit();
2083
2136
  setNotice("");
2084
2137
  setPicking(false);
2138
+ setAnchor(null);
2085
2139
  focusComposer();
2086
2140
  try {
2087
2141
  const savedId = await store.saveNote(
@@ -2110,6 +2164,7 @@ function IterationPanel({
2110
2164
  }
2111
2165
  }
2112
2166
  function beginEdit(note) {
2167
+ setAnchor(null);
2113
2168
  setEditing(note);
2114
2169
  setText(note.feedback);
2115
2170
  setTargets(note.elements ?? (note.element ? [note.element] : []));
@@ -2191,6 +2246,7 @@ function IterationPanel({
2191
2246
  if (draftRestored.current === draftKey) return;
2192
2247
  draftRestored.current = draftKey;
2193
2248
  restoringDraft.current = true;
2249
+ setAnchor(null);
2194
2250
  setEditing(null);
2195
2251
  setText("");
2196
2252
  setTargets([]);
@@ -2250,16 +2306,16 @@ function IterationPanel({
2250
2306
  return;
2251
2307
  e.preventDefault();
2252
2308
  e.stopImmediatePropagation();
2253
- if (notesOpen && textareaRef.current === rootRef.current?.getRootNode()?.activeElement)
2254
- closeAll();
2255
- else if (notesAvailable) {
2309
+ if (!notesAvailable) setVariantsOpen((v) => !v);
2310
+ else if (picking) {
2311
+ setPicking(false);
2312
+ focusComposer();
2313
+ } else {
2256
2314
  if (!open)
2257
2315
  previousFocus.current = document.activeElement;
2258
- setNotesOpen(true);
2259
2316
  setExpanded(true);
2260
- setPicking(false);
2261
- focusComposer();
2262
- } else setVariantsOpen((v) => !v);
2317
+ startFeedback(true);
2318
+ }
2263
2319
  };
2264
2320
  const onPointer = () => {
2265
2321
  rootRef.current?.setAttribute("data-instant", "false");
@@ -2276,7 +2332,8 @@ function IterationPanel({
2276
2332
  if (!open || picking) return;
2277
2333
  const onKey = (e) => {
2278
2334
  if (e.key !== "Escape" || e.defaultPrevented) return;
2279
- if (editing) cancelEdit();
2335
+ if (anchor) dockComposer();
2336
+ else if (editing) cancelEdit();
2280
2337
  else closeAll();
2281
2338
  };
2282
2339
  window.addEventListener("keydown", onKey);
@@ -2295,20 +2352,17 @@ function IterationPanel({
2295
2352
  cancelAnimationFrame(frame);
2296
2353
  const deferred = e.shiftKey;
2297
2354
  frame = requestAnimationFrame(() => {
2298
- const hint = hintRef.current?.getBoundingClientRect();
2299
- const reach = 48;
2300
- setHintAway(
2301
- !!hint && e.clientX > hint.left - reach && e.clientX < hint.right + reach && e.clientY > hint.top - reach && e.clientY < hint.bottom + reach
2302
- );
2303
2355
  const el = deferred ? null : elementAt(e);
2304
2356
  setHoverRect(el ? el.getBoundingClientRect() : null);
2305
2357
  });
2306
2358
  };
2307
2359
  const toggleTarget = (el) => {
2308
2360
  const target = describeIterationElement(elementAsNode(el));
2361
+ const added = !targetsRef.current.some((t) => t.selector === target.selector);
2309
2362
  setTargets(
2310
2363
  (prev) => prev.some((t) => t.selector === target.selector) ? prev.filter((t) => t.selector !== target.selector) : prev.length < 50 ? [...prev, target] : prev
2311
2364
  );
2365
+ return added ? target : null;
2312
2366
  };
2313
2367
  const onClick = (e) => {
2314
2368
  if (e.shiftKey) return;
@@ -2316,8 +2370,18 @@ function IterationPanel({
2316
2370
  if (!el) return;
2317
2371
  e.preventDefault();
2318
2372
  e.stopImmediatePropagation();
2319
- toggleTarget(el);
2320
- focusWhilePicking();
2373
+ const added = toggleTarget(el);
2374
+ if (added) {
2375
+ const r = el.getBoundingClientRect();
2376
+ const pointed = e.detail > 0 || e.clientX !== 0 || e.clientY !== 0;
2377
+ const x = pointed ? e.clientX : r.left;
2378
+ const y = pointed ? e.clientY : r.bottom;
2379
+ if (!anchorRef.current)
2380
+ slotHeight.current = rootRef.current?.querySelector(".isotone-notes-panel .isotone-input")?.offsetHeight ?? 0;
2381
+ setAnchor({ selector: added.selector, dx: x - r.left, dy: y - r.top, x, y });
2382
+ setNotesOpen(true);
2383
+ focusWhilePicking();
2384
+ }
2321
2385
  };
2322
2386
  const onKey = (e) => {
2323
2387
  if (e.key === " ") {
@@ -2328,11 +2392,25 @@ function IterationPanel({
2328
2392
  if (!e.repeat) toggleTarget(el);
2329
2393
  return;
2330
2394
  }
2395
+ const typing = e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && !e.isComposing;
2396
+ if (typing) {
2397
+ const el = e.composedPath().find((node) => node instanceof Element);
2398
+ if (el && !el.closest("[data-isotone]") && el.closest("input, textarea, select, [contenteditable]")) return;
2399
+ setPicking(false);
2400
+ setNotesOpen(true);
2401
+ textareaRef.current?.focus();
2402
+ return;
2403
+ }
2331
2404
  if (e.key !== "Escape" && e.key !== "Enter") return;
2332
- if (e.key === "Enter" && e.composedPath().includes(textareaRef.current) && (e.shiftKey || e.isComposing || textareaRef.current?.value.trim()))
2405
+ const field = textareaRef.current;
2406
+ if (e.key === "Enter" && anchorRef.current && field && !e.shiftKey && field.value.trim() && e.composedPath().includes(field))
2333
2407
  return;
2334
2408
  e.preventDefault();
2335
2409
  e.stopImmediatePropagation();
2410
+ if (e.key === "Escape") {
2411
+ setTargets([]);
2412
+ setAnchor(null);
2413
+ }
2336
2414
  setPicking(false);
2337
2415
  setNotesOpen(true);
2338
2416
  focusComposer();
@@ -2344,7 +2422,6 @@ function IterationPanel({
2344
2422
  document.body.style.cursor = cursor;
2345
2423
  cancelAnimationFrame(frame);
2346
2424
  setHoverRect(null);
2347
- setHintAway(false);
2348
2425
  window.removeEventListener("mousemove", onMove, true);
2349
2426
  window.removeEventListener("click", onClick, true);
2350
2427
  window.removeEventListener("keydown", onKey, true);
@@ -2361,7 +2438,113 @@ function IterationPanel({
2361
2438
  };
2362
2439
  }, []);
2363
2440
  useEffect3(() => {
2364
- if (!picking) {
2441
+ if (!notesOpen) setAnchor(null);
2442
+ }, [notesOpen]);
2443
+ useLayoutEffect4(() => {
2444
+ const float = floatRef.current;
2445
+ if (!anchor || !float) {
2446
+ lastFloat.current = null;
2447
+ return;
2448
+ }
2449
+ floatNode.current = float;
2450
+ const from = lastFloat.current;
2451
+ let frame;
2452
+ let side = null;
2453
+ const place = () => {
2454
+ frame = void 0;
2455
+ let { x, y } = anchor;
2456
+ try {
2457
+ const el = document.querySelector(anchor.selector);
2458
+ if (el) {
2459
+ const r = el.getBoundingClientRect();
2460
+ x = r.left + anchor.dx;
2461
+ y = r.top + anchor.dy;
2462
+ }
2463
+ } catch {
2464
+ }
2465
+ const vw = document.documentElement.clientWidth || innerWidth;
2466
+ const vh = innerHeight;
2467
+ const w = float.offsetWidth;
2468
+ const h = float.offsetHeight;
2469
+ let left = x + FLOAT_GAP;
2470
+ if (left + w > vw - FLOAT_MARGIN) left = x - FLOAT_GAP - w;
2471
+ left = Math.max(FLOAT_MARGIN, Math.min(left, vw - w - FLOAT_MARGIN));
2472
+ const fitsBelow = y + FLOAT_GAP + h <= vh - FLOAT_MARGIN;
2473
+ const fitsAbove = y - FLOAT_GAP - h >= FLOAT_MARGIN;
2474
+ if (side === null || (side === "below" ? !fitsBelow && fitsAbove : !fitsAbove && fitsBelow))
2475
+ side = fitsBelow || !fitsAbove ? "below" : "above";
2476
+ float.style.left = `${left}px`;
2477
+ if (side === "below") {
2478
+ const top = Math.max(FLOAT_MARGIN, Math.min(y + FLOAT_GAP, vh - h - FLOAT_MARGIN));
2479
+ float.style.top = `${top}px`;
2480
+ float.style.bottom = "auto";
2481
+ float.style.transformOrigin = `${x - left}px ${y - top}px`;
2482
+ } else {
2483
+ const bottom = Math.max(FLOAT_MARGIN, Math.min(vh - y + FLOAT_GAP, vh - h - FLOAT_MARGIN));
2484
+ float.style.bottom = `${bottom}px`;
2485
+ float.style.top = "auto";
2486
+ float.style.transformOrigin = `${x - left}px ${y - (vh - bottom - h)}px`;
2487
+ }
2488
+ lastFloat.current = { left, top: side === "below" ? Number.parseFloat(float.style.top) : vh - Number.parseFloat(float.style.bottom) - h };
2489
+ };
2490
+ const schedule = () => {
2491
+ frame ??= requestAnimationFrame(place);
2492
+ };
2493
+ place();
2494
+ const to = lastFloat.current;
2495
+ const still = stillMotion(float) || typeof float.animate !== "function";
2496
+ let glide;
2497
+ if (still) {
2498
+ } else if (!from)
2499
+ glide = float.animate(
2500
+ [{ opacity: 0, transform: "scale(.96)" }, { opacity: 1, transform: "none" }],
2501
+ { duration: MOTION.enter, easing: MOTION.easeOut }
2502
+ );
2503
+ else if (to && (from.left !== to.left || from.top !== to.top))
2504
+ glide = float.animate(
2505
+ [{ translate: `${from.left - to.left}px ${from.top - to.top}px` }, { translate: "0 0" }],
2506
+ { duration: MOTION.enter, easing: MOTION.easeOut }
2507
+ );
2508
+ const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(schedule) : void 0;
2509
+ observer?.observe(float);
2510
+ window.addEventListener("scroll", schedule, { capture: true, passive: true });
2511
+ window.addEventListener("resize", schedule);
2512
+ return () => {
2513
+ if (frame !== void 0) cancelAnimationFrame(frame);
2514
+ glide?.cancel();
2515
+ observer?.disconnect();
2516
+ window.removeEventListener("scroll", schedule, true);
2517
+ window.removeEventListener("resize", schedule);
2518
+ };
2519
+ }, [anchor]);
2520
+ useLayoutEffect4(() => {
2521
+ const last = floatNode.current;
2522
+ const root = rootRef.current;
2523
+ if (floating || !last || !root) return;
2524
+ floatNode.current = null;
2525
+ if (typeof last.animate !== "function" || stillMotion(root)) return;
2526
+ const ghost = last.cloneNode(true);
2527
+ const field = last.querySelector("textarea");
2528
+ const copy = ghost.querySelector("textarea");
2529
+ if (field && copy) copy.value = field.value;
2530
+ ghost.classList.add("isotone-exit");
2531
+ ghost.inert = true;
2532
+ ghost.setAttribute("aria-hidden", "true");
2533
+ ghost.removeAttribute("role");
2534
+ root.appendChild(ghost);
2535
+ const fade = ghost.animate([{ opacity: 1 }, { opacity: 0, transform: "scale(.98)" }], {
2536
+ duration: MOTION.exit,
2537
+ easing: MOTION.easeOut,
2538
+ fill: "forwards"
2539
+ });
2540
+ fade.onfinish = () => ghost.remove();
2541
+ return () => {
2542
+ fade.cancel();
2543
+ ghost.remove();
2544
+ };
2545
+ }, [floating]);
2546
+ useEffect3(() => {
2547
+ if (!picking && !floating) {
2365
2548
  setRects([]);
2366
2549
  return;
2367
2550
  }
@@ -2400,7 +2583,7 @@ function IterationPanel({
2400
2583
  window.removeEventListener("scroll", schedule, true);
2401
2584
  window.removeEventListener("resize", schedule);
2402
2585
  };
2403
- }, [targets, picking]);
2586
+ }, [targets, picking, floating]);
2404
2587
  const variantsPanel = variants && /* @__PURE__ */ jsxs4(
2405
2588
  "section",
2406
2589
  {
@@ -2436,6 +2619,162 @@ function IterationPanel({
2436
2619
  ]
2437
2620
  }
2438
2621
  );
2622
+ const anchored = targets.find((t) => t.selector === anchor?.selector) ?? targets[0];
2623
+ const awayLabel = anchored ? `Next to ${anchored.text || anchored.tag}${targets.length > 1 ? ` and ${targets.length - 1} more` : ""}` : "Next to where you clicked";
2624
+ const composer = (
2625
+ // biome-ignore lint/a11y/useKeyWithClickEvents: This extends the native textarea's pointer area; keyboard users focus the textarea directly.
2626
+ /* @__PURE__ */ jsxs4(
2627
+ "fieldset",
2628
+ {
2629
+ className: "isotone-input",
2630
+ "aria-label": "Feedback composer",
2631
+ onClick: (event) => {
2632
+ if (event.defaultPrevented || event.button !== 0) return;
2633
+ const target = event.target;
2634
+ if (target.closest('button, textarea, input, select, a, [contenteditable="true"]')) return;
2635
+ textareaRef.current?.focus({ preventScroll: true });
2636
+ },
2637
+ children: [
2638
+ /* @__PURE__ */ jsxs4("div", { className: "isotone-context", "aria-live": "polite", children: [
2639
+ editing && /* @__PURE__ */ jsxs4("span", { className: "isotone-chip", children: [
2640
+ "Editing note",
2641
+ /* @__PURE__ */ jsx5(
2642
+ "button",
2643
+ {
2644
+ type: "button",
2645
+ "aria-label": "Cancel edit",
2646
+ title: "Cancel edit",
2647
+ onClick: cancelEdit,
2648
+ children: /* @__PURE__ */ jsx5(Icon2, { name: "close", size: 12 })
2649
+ }
2650
+ )
2651
+ ] }),
2652
+ variantTarget && /* @__PURE__ */ jsxs4("span", { className: "isotone-chip", children: [
2653
+ /* @__PURE__ */ jsx5(Icon2, { name: "sliders", size: 12 }),
2654
+ /* @__PURE__ */ jsxs4("span", { onPointerOver: showOverflowTitle, children: [
2655
+ variantTarget.label,
2656
+ variantTarget.optionLabel ? ` / ${variantTarget.optionLabel}` : ""
2657
+ ] }),
2658
+ !editing?.variantAction && /* @__PURE__ */ jsx5(
2659
+ "button",
2660
+ {
2661
+ type: "button",
2662
+ "aria-label": "Remove variant reference",
2663
+ title: "Remove reference",
2664
+ onClick: () => setVariantTarget(null),
2665
+ children: /* @__PURE__ */ jsx5(Icon2, { name: "close", size: 12 })
2666
+ }
2667
+ )
2668
+ ] }),
2669
+ targets.map((target, index) => /* @__PURE__ */ jsxs4(
2670
+ "span",
2671
+ {
2672
+ className: "isotone-chip",
2673
+ children: [
2674
+ /* @__PURE__ */ jsx5("span", { onPointerOver: showOverflowTitle, children: target.text || target.tag }),
2675
+ /* @__PURE__ */ jsx5(
2676
+ "button",
2677
+ {
2678
+ type: "button",
2679
+ "aria-label": `Remove pinned element ${index + 1}`,
2680
+ title: "Unpin element",
2681
+ onClick: () => setTargets(
2682
+ (prev) => prev.filter((t) => t.selector !== target.selector)
2683
+ ),
2684
+ children: /* @__PURE__ */ jsx5(Icon2, { name: "close", size: 12 })
2685
+ }
2686
+ )
2687
+ ]
2688
+ },
2689
+ target.selector
2690
+ )),
2691
+ notice && /* @__PURE__ */ jsx5("p", { role: "status", className: "isotone-notice", children: notice })
2692
+ ] }),
2693
+ /* @__PURE__ */ jsxs4("div", { className: "isotone-entry", children: [
2694
+ /* @__PURE__ */ jsx5(
2695
+ "textarea",
2696
+ {
2697
+ ref: textareaRef,
2698
+ value: text,
2699
+ "aria-label": "Feedback for the agent",
2700
+ onChange: (e) => setText(e.target.value),
2701
+ onFocus: () => {
2702
+ if (!keepPicking.current) setPicking(false);
2703
+ },
2704
+ onPointerDown: () => setPicking(false),
2705
+ onScroll: (event) => {
2706
+ event.currentTarget.dataset.scrolled = String(event.currentTarget.scrollTop > 0);
2707
+ },
2708
+ onKeyDown: (e) => {
2709
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
2710
+ e.preventDefault();
2711
+ void send();
2712
+ }
2713
+ },
2714
+ placeholder: targets.length || variantTarget ? "Feedback on your selection\u2026" : "What would you change?",
2715
+ rows: 1,
2716
+ className: "isotone-textarea"
2717
+ }
2718
+ ),
2719
+ /* @__PURE__ */ jsxs4("div", { className: "isotone-toolbar", children: [
2720
+ /* @__PURE__ */ jsx5(
2721
+ "button",
2722
+ {
2723
+ type: "button",
2724
+ title: `Select elements${shortcutLabel ? ` \xB7 ${shortcutLabel}` : ""}`,
2725
+ "aria-label": "Select elements",
2726
+ "aria-pressed": picking,
2727
+ className: "isotone-pick isotone-icon",
2728
+ onMouseDown: (e) => e.preventDefault(),
2729
+ onClick: () => {
2730
+ cancelAnimationFrame(focusFrame.current);
2731
+ setPickingWithPanel(false);
2732
+ setPicking((v) => !v);
2733
+ },
2734
+ children: /* @__PURE__ */ jsx5(Icon2, { name: "pin", size: 14 })
2735
+ }
2736
+ ),
2737
+ floating && picking && /* @__PURE__ */ jsx5("span", { ref: fadeIn, className: "isotone-float-hint", children: "Click to pin more \xB7 hold \u21E7 to use the page" }),
2738
+ /* @__PURE__ */ jsx5(
2739
+ "button",
2740
+ {
2741
+ type: "button",
2742
+ "aria-label": editing ? "Save edit" : "Send note",
2743
+ disabled: !text.trim(),
2744
+ className: "isotone-send",
2745
+ title: editing ? "Save edit (Enter)" : "Send note (Enter)",
2746
+ onClick: () => void send(),
2747
+ children: /* @__PURE__ */ jsx5(Icon2, { name: "send", size: 14 })
2748
+ }
2749
+ )
2750
+ ] }),
2751
+ picking && !floating && /* @__PURE__ */ jsxs4("div", { ref: fadeIn, className: "isotone-picking", "data-still": pickingWithPanel, children: [
2752
+ /* @__PURE__ */ jsx5(Icon2, { name: "pin", size: 14 }),
2753
+ /* @__PURE__ */ jsxs4("p", { children: [
2754
+ /* @__PURE__ */ jsx5("span", { children: targets.length ? `${targets.length} selected` : "Click elements on the page" }),
2755
+ /* @__PURE__ */ jsx5("small", { children: "Hold \u21E7 to use the page normally" })
2756
+ ] }),
2757
+ /* @__PURE__ */ jsxs4(
2758
+ "button",
2759
+ {
2760
+ type: "button",
2761
+ onMouseDown: (e) => e.preventDefault(),
2762
+ onClick: () => {
2763
+ setPicking(false);
2764
+ focusComposer();
2765
+ },
2766
+ children: [
2767
+ "Done ",
2768
+ /* @__PURE__ */ jsx5("kbd", { children: "\u21B5" })
2769
+ ]
2770
+ }
2771
+ )
2772
+ ] })
2773
+ ] })
2774
+ ]
2775
+ }
2776
+ )
2777
+ );
2439
2778
  const notesPanel = notesAvailable && /* @__PURE__ */ jsxs4(
2440
2779
  "section",
2441
2780
  {
@@ -2531,131 +2870,26 @@ function IterationPanel({
2531
2870
  },
2532
2871
  note.id
2533
2872
  )) }),
2534
- /* @__PURE__ */ jsx5("div", { className: "isotone-compose", children: /* @__PURE__ */ jsxs4(
2535
- "fieldset",
2873
+ /* @__PURE__ */ jsx5("div", { className: "isotone-compose", children: floating ? /* @__PURE__ */ jsxs4(
2874
+ "button",
2536
2875
  {
2537
- className: "isotone-input",
2538
- "aria-label": "Feedback composer",
2539
- onClick: (event) => {
2540
- if (event.defaultPrevented || event.button !== 0) return;
2541
- const target = event.target;
2542
- if (target.closest('button, textarea, input, select, a, [contenteditable="true"]')) return;
2543
- textareaRef.current?.focus({ preventScroll: true });
2544
- },
2876
+ type: "button",
2877
+ ref: fadeInContent,
2878
+ className: "isotone-away",
2879
+ title: "Write here instead",
2880
+ style: { minHeight: slotHeight.current || void 0 },
2881
+ onMouseDown: (e) => e.preventDefault(),
2882
+ onClick: dockComposer,
2545
2883
  children: [
2546
- /* @__PURE__ */ jsxs4("div", { className: "isotone-context", "aria-live": "polite", children: [
2547
- editing && /* @__PURE__ */ jsxs4("span", { className: "isotone-chip", children: [
2548
- "Editing note",
2549
- /* @__PURE__ */ jsx5(
2550
- "button",
2551
- {
2552
- type: "button",
2553
- "aria-label": "Cancel edit",
2554
- title: "Cancel edit",
2555
- onClick: cancelEdit,
2556
- children: /* @__PURE__ */ jsx5(Icon2, { name: "close", size: 12 })
2557
- }
2558
- )
2559
- ] }),
2560
- variantTarget && /* @__PURE__ */ jsxs4("span", { className: "isotone-chip", children: [
2561
- /* @__PURE__ */ jsx5(Icon2, { name: "sliders", size: 12 }),
2562
- /* @__PURE__ */ jsxs4("span", { onPointerOver: showOverflowTitle, children: [
2563
- variantTarget.label,
2564
- variantTarget.optionLabel ? ` / ${variantTarget.optionLabel}` : ""
2565
- ] }),
2566
- !editing?.variantAction && /* @__PURE__ */ jsx5(
2567
- "button",
2568
- {
2569
- type: "button",
2570
- "aria-label": "Remove variant reference",
2571
- title: "Remove reference",
2572
- onClick: () => setVariantTarget(null),
2573
- children: /* @__PURE__ */ jsx5(Icon2, { name: "close", size: 12 })
2574
- }
2575
- )
2576
- ] }),
2577
- targets.map((target, index) => /* @__PURE__ */ jsxs4(
2578
- "span",
2579
- {
2580
- className: "isotone-chip",
2581
- children: [
2582
- /* @__PURE__ */ jsx5("span", { onPointerOver: showOverflowTitle, children: target.text || target.tag }),
2583
- /* @__PURE__ */ jsx5(
2584
- "button",
2585
- {
2586
- type: "button",
2587
- "aria-label": `Remove pinned element ${index + 1}`,
2588
- title: "Unpin element",
2589
- onClick: () => setTargets(
2590
- (prev) => prev.filter((t) => t.selector !== target.selector)
2591
- ),
2592
- children: /* @__PURE__ */ jsx5(Icon2, { name: "close", size: 12 })
2593
- }
2594
- )
2595
- ]
2596
- },
2597
- target.selector
2598
- )),
2599
- notice && /* @__PURE__ */ jsx5("p", { role: "status", className: "isotone-notice", children: notice })
2884
+ /* @__PURE__ */ jsx5(Icon2, { name: "pin", size: 14 }),
2885
+ /* @__PURE__ */ jsxs4("span", { className: "isotone-away-text", children: [
2886
+ /* @__PURE__ */ jsx5("span", { children: "Writing on the page" }),
2887
+ /* @__PURE__ */ jsx5("small", { children: awayLabel })
2600
2888
  ] }),
2601
- /* @__PURE__ */ jsx5(
2602
- "textarea",
2603
- {
2604
- ref: textareaRef,
2605
- value: text,
2606
- "aria-label": "Feedback for the agent",
2607
- onChange: (e) => setText(e.target.value),
2608
- onFocus: () => {
2609
- if (!keepPicking.current) setPicking(false);
2610
- },
2611
- onPointerDown: () => setPicking(false),
2612
- onScroll: (event) => {
2613
- event.currentTarget.dataset.scrolled = String(event.currentTarget.scrollTop > 0);
2614
- },
2615
- onKeyDown: (e) => {
2616
- if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
2617
- e.preventDefault();
2618
- void send();
2619
- }
2620
- },
2621
- placeholder: targets.length || variantTarget ? "Feedback on your selection\u2026" : "What would you change?",
2622
- rows: 1,
2623
- className: "isotone-textarea"
2624
- }
2625
- ),
2626
- /* @__PURE__ */ jsxs4("div", { className: "isotone-toolbar", children: [
2627
- /* @__PURE__ */ jsx5(
2628
- "button",
2629
- {
2630
- type: "button",
2631
- title: "Select elements",
2632
- "aria-label": "Select elements",
2633
- "aria-pressed": picking,
2634
- className: "isotone-pick isotone-icon",
2635
- onMouseDown: (e) => e.preventDefault(),
2636
- onClick: () => {
2637
- cancelAnimationFrame(focusFrame.current);
2638
- setPicking((v) => !v);
2639
- },
2640
- children: /* @__PURE__ */ jsx5(Icon2, { name: "pin", size: 14 })
2641
- }
2642
- ),
2643
- /* @__PURE__ */ jsx5(
2644
- "button",
2645
- {
2646
- type: "button",
2647
- "aria-label": editing ? "Save edit" : "Send note",
2648
- disabled: !text.trim(),
2649
- className: "isotone-send",
2650
- title: editing ? "Save edit (Enter)" : "Send note (Enter)",
2651
- onClick: () => void send(),
2652
- children: /* @__PURE__ */ jsx5(Icon2, { name: "send", size: 14 })
2653
- }
2654
- )
2655
- ] })
2889
+ /* @__PURE__ */ jsx5("span", { className: "isotone-away-action", children: "Move here" })
2656
2890
  ]
2657
2891
  }
2658
- ) })
2892
+ ) : composer })
2659
2893
  ]
2660
2894
  }
2661
2895
  );
@@ -2675,27 +2909,7 @@ function IterationPanel({
2675
2909
  className: "isotone-root",
2676
2910
  children: [
2677
2911
  /* @__PURE__ */ jsx5("div", { className: "isotone-rail-track", "aria-hidden": "true" }),
2678
- picking && /* @__PURE__ */ jsxs4("div", { ref: hintRef, className: "isotone-hint", "data-isotone": true, "data-away": hintAway, children: [
2679
- /* @__PURE__ */ jsxs4("span", { children: [
2680
- targets.length,
2681
- " selected \xB7 click to add or remove \xB7 hold \u21E7 to use the page"
2682
- ] }),
2683
- /* @__PURE__ */ jsxs4(
2684
- "button",
2685
- {
2686
- type: "button",
2687
- onClick: () => {
2688
- setPicking(false);
2689
- focusComposer();
2690
- },
2691
- children: [
2692
- "Done ",
2693
- /* @__PURE__ */ jsx5("kbd", { children: "\u21B5" })
2694
- ]
2695
- }
2696
- )
2697
- ] }),
2698
- picking && [
2912
+ (picking || floating) && [
2699
2913
  ...rects,
2700
2914
  ...hoverRect ? [
2701
2915
  {
@@ -2720,6 +2934,16 @@ function IterationPanel({
2720
2934
  },
2721
2935
  rect.selector
2722
2936
  )),
2937
+ floating && notesOpen && /* @__PURE__ */ jsx5(
2938
+ "div",
2939
+ {
2940
+ ref: floatRef,
2941
+ className: "isotone-float",
2942
+ role: "dialog",
2943
+ "aria-label": "Feedback on the selection",
2944
+ children: composer
2945
+ }
2946
+ ),
2723
2947
  /* @__PURE__ */ jsx5(UpdateNotice, { endpoint: updateEndpoint, active: open }),
2724
2948
  /* @__PURE__ */ jsxs4(
2725
2949
  "fieldset",
@@ -2781,10 +3005,12 @@ function IterationPanel({
2781
3005
  title: "Feedback",
2782
3006
  "aria-controls": `${id}-notes`,
2783
3007
  "aria-expanded": notesOpen,
2784
- onClick: () => {
2785
- setNotesOpen((v) => !v);
3008
+ onClick: (event) => {
2786
3009
  setExpanded(false);
2787
- if (!notesOpen) focusComposer();
3010
+ if (notesOpen) {
3011
+ setNotesOpen(false);
3012
+ setPicking(false);
3013
+ } else startFeedback(event.detail > 0);
2788
3014
  },
2789
3015
  children: [
2790
3016
  /* @__PURE__ */ jsx5(Icon2, { name: "message" }),
@@ -2809,10 +3035,10 @@ function IterationPanel({
2809
3035
  "aria-label": notesAvailable ? "Isotone" : "Variants",
2810
3036
  title: `Isotone${working.size ? ` \xB7 ${working.size} in progress` : ""}${shortcutLabel ? ` \xB7 ${shortcutLabel}` : ""}`,
2811
3037
  "aria-expanded": railOpen,
2812
- onClick: () => {
3038
+ onClick: (event) => {
2813
3039
  if (open) closeAll();
2814
3040
  else if (expanded) setExpanded(false);
2815
- else openInitial();
3041
+ else openInitial(event.detail > 0);
2816
3042
  },
2817
3043
  children: [
2818
3044
  working.size > 0 && /* @__PURE__ */ jsx5("span", { className: "isotone-ring", ref: ringPulse }),