figfoundry 0.2.1__tar.gz → 0.3.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: figfoundry
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: WYSIWYG matplotlib figure composer: load data (.npz/.pkl), drag signals onto a canvas to build plots, and export clean reproducible code.
5
5
  Project-URL: Homepage, https://github.com/ductai199x/figfoundry
6
6
  Project-URL: Repository, https://github.com/ductai199x/figfoundry
@@ -73,22 +73,34 @@ def dataset_loader_code(filename):
73
73
  if filename.endswith(".npz"):
74
74
  return [
75
75
  "import numpy as np",
76
- f"_ds = np.load({path!r}, allow_pickle=False)",
77
- "data = {k: _ds[k] for k in _ds.files}",
76
+ f"DATA_DEFAULT = {path!r}",
77
+ "def _load_data(p):",
78
+ " _ds = np.load(p, allow_pickle=False)",
79
+ " return {k: _ds[k] for k in _ds.files}",
80
+ 'if __name__ == "__main__":',
81
+ " data = None # loaded in the __main__ block below (via --data)",
82
+ "else:",
83
+ " data = _load_data(DATA_DEFAULT)",
78
84
  ]
79
85
  if filename.endswith(".csv"):
80
86
  return [
81
87
  "import numpy as np, csv",
82
- f"_f = open({path!r}, newline='', encoding='utf-8-sig'); _rows = [r for r in csv.reader(_f) if any(c.strip() for c in r)]; _f.close()",
83
- "_data = {}",
84
- "for _i, _c in enumerate(_rows[0] if _rows else []):",
85
- " _v = []",
86
- " for _r in _rows[1:]:",
87
- " if _i < len(_r):",
88
- " try: _v.append(float(_r[_i]))",
89
- " except ValueError: pass",
90
- " if _v: _data[_c.strip() or f'col{_i}'] = np.array(_v)",
91
- "data = _data",
88
+ f"DATA_DEFAULT = {path!r}",
89
+ "def _load_data(p):",
90
+ " _f = open(p, newline='', encoding='utf-8-sig'); _rows = [r for r in csv.reader(_f) if any(c.strip() for c in r)]; _f.close()",
91
+ " _data = {}",
92
+ " for _i, _c in enumerate(_rows[0] if _rows else []):",
93
+ " _v = []",
94
+ " for _r in _rows[1:]:",
95
+ " if _i < len(_r):",
96
+ " try: _v.append(float(_r[_i]))",
97
+ " except ValueError: pass",
98
+ " if _v: _data[_c.strip() or f'col{_i}'] = np.array(_v)",
99
+ " return _data",
100
+ 'if __name__ == "__main__":',
101
+ " data = None # loaded in the __main__ block below (via --data)",
102
+ "else:",
103
+ " data = _load_data(DATA_DEFAULT)",
92
104
  ]
93
105
  # .pkl -- embedded whitelist unpickler (numpy + plain containers; blocks
94
106
  # os/subprocess/socket/ctypes and non-container builtins). The numpy
@@ -109,9 +121,15 @@ def dataset_loader_code(filename):
109
121
  ' raise pickle.UnpicklingError("blocked " + m + "." + n)',
110
122
  "def _safe_load(p):",
111
123
  ' with open(p, "rb") as f: return _SafeUnpickler(f).load()',
112
- f"_obj = _safe_load({path!r})",
113
- "_items = _obj.items() if isinstance(_obj, dict) else [('data', _obj)]",
114
- "data = {str(k): np.asarray(v) for k, v in _items if np.asarray(v).dtype != object}",
124
+ f"DATA_DEFAULT = {path!r}",
125
+ "def _load_data(p):",
126
+ " _obj = _safe_load(p)",
127
+ " _items = _obj.items() if isinstance(_obj, dict) else [('data', _obj)]",
128
+ " return {str(k): np.asarray(v) for k, v in _items if np.asarray(v).dtype != object}",
129
+ 'if __name__ == "__main__":',
130
+ " data = None # loaded in the __main__ block below (via --data)",
131
+ "else:",
132
+ " data = _load_data(DATA_DEFAULT)",
115
133
  ]
116
134
 
117
135
 
@@ -286,6 +304,9 @@ def codegen_canvas(spec, detail=True):
286
304
  w = tt.get("weight", "normal")
287
305
  w = w if w in ("normal", "bold", "light", "medium", "heavy", "semibold", "black") else "normal"
288
306
  s.append(f' ax.set_title({tt.get("text", "")!r}, x={_num(tt.get("x"), 0.0)!r}, y={_num(tt.get("y"), 1.0)!r}, pad={_num(tt.get("pad"), 1.0)!r}, fontsize={_num(tt.get("fontsize"), 11)!r}, fontweight={w!r}, color={tt.get("color", "#000000")!r})')
307
+ _trot = _num(tt.get("rotation"), 0)
308
+ if _trot:
309
+ s.append(f" ax.title.set_rotation({_trot!r})")
289
310
  except (KeyError, TypeError, ValueError, IndexError, AttributeError):
290
311
  # One malformed plot blanks itself out; it must not sink the whole render.
291
312
  pid = p.get("id", "?") if isinstance(p, dict) else "?"
@@ -306,7 +327,12 @@ def codegen_canvas(spec, detail=True):
306
327
  s.append(' ap = argparse.ArgumentParser(description=__doc__)')
307
328
  s.append(' ap.add_argument("--format", default="png", choices=["png", "svg", "pdf"])')
308
329
  s.append(f" ap.add_argument(\"--dpi\", type=int, default={dpi})")
330
+ if ds:
331
+ s.append(' ap.add_argument("--data", default=DATA_DEFAULT, help="path to the dataset file (default is where this figure was made)")')
309
332
  s.append(" a = ap.parse_args()")
333
+ if ds:
334
+ s.append(" try: data = _load_data(a.data)")
335
+ s.append(' except FileNotFoundError as _e: raise SystemExit(f"dataset not found: {_e}; pass --data /path/to/your/dataset")')
310
336
  s.append(' fig = build(); fig.savefig(f"fig.{a.format}", dpi=a.dpi)')
311
337
  return "\n".join(s) + "\n"
312
338
 
@@ -508,7 +534,7 @@ def _codegen_artist(art):
508
534
  base = f'ax.text({float(art["x"])!r}, {float(art["y"])!r}, {art.get("text", "")!r}'
509
535
  if art.get("axes_frac"):
510
536
  base += ", transform=ax.transAxes"
511
- base += f', ha={art.get("ha", "left")!r}, va={art.get("va", "bottom")!r}, fontsize={_num(art.get("fontsize"), 10)!r}, color={art.get("color", "#000000")!r})'
537
+ base += f', ha={art.get("ha", "left")!r}, va={art.get("va", "bottom")!r}, fontsize={_num(art.get("fontsize"), 10)!r}, color={art.get("color", "#000000")!r}, rotation={_num(art.get("rotation"), 0)!r})'
512
538
  return base
513
539
  if t == "legend":
514
540
  h = ", ".join(f'Line2D([], [], color={x.get("color", "#000")!r}, ls={x.get("ls", "-")!r}, lw={_num(x.get("lw"), 1.5)!r}, marker={x.get("marker", "")!r}, ms={_num(x.get("ms"), 0)!r})' for x in art.get("handles", []) if isinstance(x, dict))
@@ -517,7 +543,7 @@ def _codegen_artist(art):
517
543
  return (f'ax.annotate({art.get("text", "")!r}, xy=({float(art["xy"][0])!r}, {float(art["xy"][1])!r}), '
518
544
  f'xytext=({float(art["xytext"][0])!r}, {float(art["xytext"][1])!r}), textcoords="axes fraction", '
519
545
  f'arrowprops=dict(arrowstyle="->", color={art.get("color", "#000000")!r}, lw={_num(art.get("lw"), 1.2)!r}), '
520
- f'color={art.get("color", "#000000")!r}, fontsize={_num(art.get("fontsize"), 10)!r})')
546
+ f'color={art.get("color", "#000000")!r}, fontsize={_num(art.get("fontsize"), 10)!r}, rotation={_num(art.get("rotation"), 0)!r})')
521
547
  if t == "box":
522
548
  return (f'ax.add_patch(Rectangle(({float(art["x0"])!r}, {float(art["y0"])!r}), {float(art["w"])!r}, {float(art["h"])!r}, '
523
549
  f'transform=ax.transAxes, fc={art.get("fc", "none")!r}, alpha={_num(art.get("alpha"), 0.15)!r}, '
@@ -547,7 +573,11 @@ def _codegen_figartist(fa):
547
573
  if t == "text":
548
574
  return f'fig.text({float(fa["x"])!r}, {float(fa["y"])!r}, {fa.get("text", "")!r}, ha={fa.get("ha", "left")!r}, va={fa.get("va", "bottom")!r}, fontsize={_num(fa.get("fontsize"), 12)!r}, color={fa.get("color", "#000000")!r}, rotation={_num(fa.get("rotation"), 0)!r})'
549
575
  if t == "suptitle":
550
- return f'fig.suptitle({fa.get("text", "")!r}, x={_num(fa.get("x"), 0.5)!r}, y={_num(fa.get("y"), 1.0)!r}, fontsize={_num(fa.get("fontsize"), 14)!r}, color={fa.get("color", "#000000")!r})'
576
+ _srot = _num(fa.get("rotation"), 0)
577
+ _st = f'fig.suptitle({fa.get("text", "")!r}, x={_num(fa.get("x"), 0.5)!r}, y={_num(fa.get("y"), 1.0)!r}, fontsize={_num(fa.get("fontsize"), 14)!r}, color={fa.get("color", "#000000")!r})'
578
+ if _srot:
579
+ _st = "_st = " + _st + f"; _st.set_rotation({_srot!r})"
580
+ return _st
551
581
  return f"pass # figure artist type {t!r} not emitted"
552
582
 
553
583
 
@@ -617,6 +647,13 @@ def hotspots_from_fig(fig, spec):
617
647
  add(f"plot:{pid}", ax)
618
648
  if (p.get("title") or {}).get("visible"):
619
649
  add(f"title:{pid}", ax.title)
650
+ # Per-plot TEXT artists: ax.texts holds exactly these, in spec order (tick
651
+ # labels and the title are not in ax.texts), so they get canvas overlays
652
+ # (draggable / rotatable / snappable) keyed by their spec index j.
653
+ titer = iter(ax.texts)
654
+ for j, art in enumerate(p.get("artists") or []):
655
+ if isinstance(art, dict) and art.get("type") == "text":
656
+ add(f"artist:{pid}:{j}", next(titer, None))
620
657
  # per-plot artists / overlaid lines are selected from the Layers list (robust),
621
658
  # not by canvas hit-testing (ax.lines is polluted by extra lines + error bars).
622
659
  # fig.texts also holds the suptitle (Figure.suptitle appends to it), so exclude it
@@ -46,6 +46,18 @@
46
46
  .mov:hover { outline: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); }
47
47
  .mov.selected { outline: 2px solid var(--accent); }
48
48
  .mov.inspect-only { cursor: default; }
49
+ /* rotation handle: a circle above the element (shows on hover / when selected) */
50
+ .mov .rot-handle { display: none; position: absolute; top: -20px; left: 50%; width: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%; background: var(--accent); border: 2px solid #fff; box-shadow: var(--shadow-sm); cursor: grab; z-index: 6; }
51
+ .mov .rot-handle::after { content: ""; position: absolute; top: 13px; left: 50%; width: 1.5px; height: 9px; background: var(--accent); transform: translateX(-50%); }
52
+ .mov .rot-handle:active { cursor: grabbing; }
53
+ .mov:hover .rot-handle, .mov.selected .rot-handle { display: block; }
54
+ .marquee { position: absolute; border: 1px solid var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); pointer-events: none; z-index: 20; display: none; }
55
+ .render-busy { position: absolute; top: 10px; right: 12px; z-index: 30; display: none; font-size: 11.5px; font-weight: 600; color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: 20px; padding: 3px 10px; box-shadow: var(--shadow-sm); pointer-events: none; }
56
+ .confirm-msg { color: var(--muted); margin: 10px 0 18px; line-height: 1.5; }
57
+ .help-list { margin: 14px 0; display: grid; grid-template-columns: auto 1fr; gap: 7px 16px; font-size: 13px; }
58
+ .help-list .k { font-family: var(--mono); font-size: 12px; color: var(--ink); white-space: nowrap; }
59
+ .help-list .d { color: var(--muted); }
60
+ #groupBox { position: absolute; border: 1.5px dashed var(--accent); pointer-events: none; z-index: 4; display: none; }
49
61
 
50
62
  /* ---- snap guides ---- */
51
63
  .guides { position: absolute; inset: 0; pointer-events: none; z-index: 5; }
@@ -16,6 +16,7 @@
16
16
  })();
17
17
  </script>
18
18
  <title>Fig Foundry — Canvas</title>
19
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='2' y='2' width='60' height='60' rx='15' fill='%232f6690'/%3E%3Cpolyline points='16,45 27,31 35,39 49,20' fill='none' stroke='%23fff' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
19
20
  <link rel="stylesheet" href="/static/style.css">
20
21
  <link rel="stylesheet" href="/static/canvas.css">
21
22
  </head>
@@ -40,6 +41,7 @@
40
41
  <button id="zoomFit" class="textbtn">Fit</button>
41
42
  </div>
42
43
  <button id="themeBtn" class="iconbtn" title="Theme (light / dark / system)" aria-label="Toggle theme">&#9790;</button>
44
+ <button id="helpBtn" class="iconbtn" title="Help &amp; shortcuts" aria-label="Help and shortcuts">?</button>
43
45
  <button id="exportBtn" class="primary">Export code</button>
44
46
  </header>
45
47
 
@@ -137,6 +139,14 @@
137
139
  </div>
138
140
  </div>
139
141
 
142
+ <div id="helpModal" class="modal hidden" role="dialog" aria-modal="true" aria-label="Help and shortcuts">
143
+ <div class="modal-card" style="max-width:540px">
144
+ <div class="modal-head"><h3>Help &amp; shortcuts</h3><button id="helpClose" class="iconbtn" title="Close" aria-label="Close">&#10005;</button></div>
145
+ <div class="help-list" id="helpList"></div>
146
+ <div class="modal-actions modal-actions-right"><button id="helpOk" class="primary">Got it</button></div>
147
+ </div>
148
+ </div>
149
+
140
150
  <div id="toast" class="toast hidden" role="status"></div>
141
151
 
142
152
  <script src="/static/canvas.js"></script>
@@ -10,7 +10,7 @@ const $ = (s) => document.querySelector(s);
10
10
  const figImg = $("#figImg"), overlay = $("#overlay"), guides = $("#guides"), stage = $("#stage"), stageInner = $("#stageInner");
11
11
  const SNAP_PX = 8;
12
12
 
13
- const state = { slug: null, name: null, spec: null, render: null, signals: [], selected: null, selection: new Set(), zoom: 1, pan: { x: 0, y: 0 }, gridCols: 3, nudging: false, history: [], future: [], sliderSnapshot: null, signalSel: new Set() };
13
+ const state = { slug: null, name: null, spec: null, render: null, signals: [], selected: null, selection: new Set(), zoom: 1, pan: { x: 0, y: 0 }, gridCols: 3, nudging: false, history: [], future: [], sliderSnapshot: null, signalSel: new Set(), clipboard: null };
14
14
 
15
15
  /* ---------------- api ---------------- */
16
16
  async function api(path, opts) {
@@ -86,15 +86,23 @@ async function doRender(draft) {
86
86
  if (renderInFlight) { renderPending = true; renderPendingDraft = draft; return; }
87
87
  renderInFlight = true;
88
88
  const seq = ++renderSeq;
89
+ setRenderBusy(true);
89
90
  try {
90
91
  const r = await post(`/api/canvases/${state.slug}/render`, { spec: state.spec, draft });
91
92
  if (seq === renderSeq) { state.render = r; applyRender(); }
92
93
  } catch (e) { if (seq === renderSeq) toast("Render failed: " + e.message, true); }
93
94
  finally {
94
95
  renderInFlight = false;
96
+ setRenderBusy(false);
95
97
  if (renderPending) { const d = renderPendingDraft; renderPending = false; doRender(d); }
96
98
  }
97
99
  }
100
+ // A subtle "Rendering…" pill in the stage corner while a render is in flight.
101
+ function setRenderBusy(b) {
102
+ let el = document.getElementById("renderBusy");
103
+ if (b) { if (!el) { el = document.createElement("div"); el.id = "renderBusy"; el.className = "render-busy"; el.textContent = "Rendering…"; stage.appendChild(el); } el.style.display = "block"; }
104
+ else if (el) el.style.display = "none";
105
+ }
98
106
  // fast low-res render during interaction; full-res when things settle
99
107
  function scheduleDraft() { clearTimeout(fullTimer); fullTimer = null; if (draftTimer) return; draftTimer = setTimeout(() => { draftTimer = null; doRender(true); }, 50); }
100
108
  function scheduleFull() { clearTimeout(draftTimer); draftTimer = null; if (fullTimer) return; fullTimer = setTimeout(() => { fullTimer = null; doRender(false); }, 250); }
@@ -115,7 +123,7 @@ function applyRender() {
115
123
  ? `<b style="color:var(--danger)">${oc} element${oc > 1 ? "s" : ""} off the canvas</b> &mdash; select it in Layers and drag it back, or enlarge the canvas`
116
124
  : "Drag a signal to add a plot &nbsp;&middot;&nbsp; Drag to move, handles to resize (snaps) &nbsp;&middot;&nbsp; Shift+click to multi-select &nbsp;&middot;&nbsp; Arrows to nudge (Shift = bigger) &nbsp;&middot;&nbsp; Drag empty space / space+drag to pan";
117
125
  const ae = document.activeElement;
118
- const editing = ae && (ae.tagName === "INPUT" && ae.type === "text") || ae && ae.tagName === "SELECT";
126
+ const editing = ae && (ae.tagName === "TEXTAREA" || ae.tagName === "SELECT" || (ae.tagName === "INPUT" && /text|number|search|email|tel|url|password/.test(ae.type)));
119
127
  if (!editing) buildInspector();
120
128
  } catch (err) {
121
129
  console.error("applyRender:", err);
@@ -158,6 +166,32 @@ function drawGuides(gs) {
158
166
  guides.appendChild(el);
159
167
  }
160
168
  }
169
+ // Unified snap model: every element's bbox in figure fractions. Plots come from the
170
+ // spec (they have a rect); titles/labels/legends come from the server hotspots. This is
171
+ // what lets guides work on text, not just plots.
172
+ function snapModel(excludeId) {
173
+ const boxes = []; const r = state.render; if (!r) return boxes;
174
+ for (const p of state.spec.plots) boxes.push(p.rect.slice());
175
+ for (const h of r.hotspots) {
176
+ if (h.id === excludeId || h.id.startsWith("plot:")) continue;
177
+ boxes.push([h.x / r.img_w, 1 - (h.top + h.h) / r.img_h, h.w / r.img_w, h.h / r.img_h]);
178
+ }
179
+ return boxes;
180
+ }
181
+ // Position-only snap (unlike snapRect, it never changes a box's size, and it lets
182
+ // labels sit slightly off-canvas). Mutates box[0]/box[1] and returns the guide lines.
183
+ function snapBoxPos(box, others, imgW, imgH) {
184
+ const thrX = SNAP_PX / imgW, thrY = SNAP_PX / imgH;
185
+ const tx = [0, 0.25, 0.5, 0.75, 1], ty = [0, 0.25, 0.5, 0.75, 1];
186
+ for (const o of others) { tx.push(o[0], o[0] + o[2] / 2, o[0] + o[2]); ty.push(o[1], o[1] + o[3] / 2, o[1] + o[3]); }
187
+ const g = [];
188
+ const xE = [[v => box[0] = v, box[0]], [v => box[0] = v - box[2] / 2, box[0] + box[2] / 2], [v => box[0] = v - box[2], box[0] + box[2]]];
189
+ for (const [set, val] of xE) { for (const t of tx) { if (Math.abs(val - t) < thrX) { set(t); g.push({ axis: "v", f: t }); break; } } }
190
+ const yE = [[v => box[1] = v, box[1]], [v => box[1] = v - box[3] / 2, box[1] + box[3] / 2], [v => box[1] = v - box[3], box[1] + box[3]]];
191
+ for (const [set, val] of yE) { for (const t of ty) { if (Math.abs(val - t) < thrY) { set(t); g.push({ axis: "h", f: t }); break; } } }
192
+ box[0] = clamp(box[0], -0.2, 1.2); box[1] = clamp(box[1], -0.2, 1.2);
193
+ return g;
194
+ }
161
195
 
162
196
  /* ---------------- overlays ---------------- */
163
197
  function buildOverlays() {
@@ -181,6 +215,7 @@ function buildOverlays() {
181
215
  div.style.left = (h.x / r.img_w * 100) + "%"; div.style.top = (h.top / r.img_h * 100) + "%";
182
216
  div.style.width = (h.w / r.img_w * 100) + "%"; div.style.height = (h.h / r.img_h * 100) + "%";
183
217
  makeMovable(div, h.id, "mov");
218
+ if (isCanvasMovable(h.id) && rotTarget(h.id)) addRotHandle(div, h.id);
184
219
  overlay.appendChild(div);
185
220
  }
186
221
  refreshSelection();
@@ -196,11 +231,78 @@ function isCanvasMovable(id) {
196
231
  }
197
232
  return false;
198
233
  }
234
+ // The spec field an element's rotation lives in (text-family only; matplotlib
235
+ // cannot rotate a whole axes panel, so plots get no rotation).
236
+ function rotTarget(id) {
237
+ if (id.startsWith("figartist:")) { const fa = state.spec.figure_artists[+id.slice(10)]; if (fa && ["text", "suptitle"].includes(fa.type)) return { get: () => fa.rotation ?? 0, set: (d) => { fa.rotation = d; } }; return null; }
238
+ if (id.startsWith("title:")) { const p = state.spec.plots.find((x) => x.id === id.slice(6)); if (p) return { get: () => p.title.rotation ?? 0, set: (d) => { p.title.rotation = d; } }; return null; }
239
+ if (id.startsWith("artist:")) { const [, pid, j] = id.split(":"); const p = state.spec.plots.find((x) => x.id === pid); const a = p && p.artists[+j]; if (a && ["text", "annotation"].includes(a.type)) return { get: () => a.rotation ?? 0, set: (d) => { a.rotation = d; } }; return null; }
240
+ return null;
241
+ }
242
+ // Wix/PowerPoint-style rotation handle: a circle above the element; drag around
243
+ // its center to rotate (Shift snaps to 15°). The overlay box is rotated live as
244
+ // a preview; the real rotation comes from the draft render.
245
+ function addRotHandle(movDiv, id) {
246
+ const h = document.createElement("div"); h.className = "rot-handle"; h.title = "Drag to rotate (Shift snaps to 15°)";
247
+ h.addEventListener("pointerdown", (e) => {
248
+ e.preventDefault(); e.stopPropagation();
249
+ h.setPointerCapture(e.pointerId);
250
+ const rect0 = movDiv.getBoundingClientRect();
251
+ const cx0 = rect0.left + rect0.width / 2, cy0 = rect0.top + rect0.height / 2;
252
+ const startAng = Math.atan2(e.clientY - cy0, e.clientX - cx0);
253
+ const tgt = rotTarget(id); const rot0 = tgt.get();
254
+ if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec);
255
+ // CSS rotate() is clockwise-positive (screen), matplotlib rotation is
256
+ // counterclockwise-positive, so the box preview is rotate(-spec).
257
+ movDiv.style.transform = `rotate(${-rot0}deg)`;
258
+ const onMove = (ev) => {
259
+ let d = (Math.atan2(ev.clientY - cy0, ev.clientX - cx0) - startAng) * 180 / Math.PI; // CW-positive on screen
260
+ if (ev.shiftKey) d = Math.round(d / 15) * 15;
261
+ let S = Math.round(rot0 - d); S = ((S % 360) + 360) % 360; if (S > 180) S -= 360; // spec (CCW+), normalized
262
+ tgt.set(S); movDiv.style.transform = `rotate(${-S}deg)`; scheduleDraft();
263
+ };
264
+ const onUp = (ev) => { try { h.releasePointerCapture(ev.pointerId); } catch (_) {} document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onUp); movDiv.style.transform = ""; commitSnapshot(); commit(); };
265
+ document.addEventListener("pointermove", onMove); document.addEventListener("pointerup", onUp);
266
+ });
267
+ movDiv.appendChild(h);
268
+ }
199
269
 
200
270
  function refreshSelection() {
201
271
  overlay.querySelectorAll(".plotbox").forEach((d) => d.classList.toggle("selected", state.selection.has("plot:" + d.dataset.pid)));
202
272
  overlay.querySelectorAll(".mov").forEach((d) => d.classList.toggle("selected", state.selection.has(d.dataset.id)));
203
273
  document.querySelectorAll(".layer").forEach((l) => l.classList.toggle("active", l.dataset.id && state.selection.has(l.dataset.id)));
274
+ drawGroupBox();
275
+ }
276
+ // Dashed outline around a multi-selection so the group's extent is visible.
277
+ function drawGroupBox() {
278
+ let gb = document.getElementById("groupBox");
279
+ if (state.selection.size < 2) { if (gb) gb.remove(); return; }
280
+ const r = state.render; if (!r) return;
281
+ let l = 1, b = 1, rr = 0, tt = 0, any = false;
282
+ const absorb = (box) => { if (!box) return; any = true; l = Math.min(l, box[0]); b = Math.min(b, box[1]); rr = Math.max(rr, box[0] + box[2]); tt = Math.max(tt, box[1] + box[3]); };
283
+ for (const id of state.selection) {
284
+ if (id.startsWith("plot:")) { const p = state.spec.plots.find((x) => x.id === id.slice(5)); if (p) absorb(p.rect); }
285
+ else { const h = r.hotspots.find((x) => x.id === id); if (h) absorb([h.x / r.img_w, 1 - (h.top + h.h) / r.img_h, h.w / r.img_w, h.h / r.img_h]); }
286
+ }
287
+ if (!any) return;
288
+ if (!gb) { gb = document.createElement("div"); gb.id = "groupBox"; overlay.appendChild(gb); }
289
+ gb.style.left = (l * 100) + "%"; gb.style.top = ((1 - tt) * 100) + "%";
290
+ gb.style.width = ((rr - l) * 100) + "%"; gb.style.height = ((tt - b) * 100) + "%";
291
+ gb.style.display = "block";
292
+ }
293
+ // Rubber-band select: everything whose bbox intersects the drag rect (in figure
294
+ // fractions). Shift adds to the current selection.
295
+ function marqueeSelect(x0, y0, x1, y1, base) {
296
+ const a = toFig(x0, y0), b = toFig(x1, y1);
297
+ const L = Math.min(a.fx, b.fx), R = Math.max(a.fx, b.fx);
298
+ const T = 1 - Math.min(a.fyTop, b.fyTop), B = 1 - Math.max(a.fyTop, b.fyTop);
299
+ const hit = (box) => box[0] < R && box[0] + box[2] > L && box[1] < T && box[1] + box[3] > B;
300
+ const sel = new Set(base);
301
+ for (const p of state.spec.plots) if (hit(p.rect)) sel.add("plot:" + p.id);
302
+ const r = state.render;
303
+ if (r) for (const h of r.hotspots) { if (h.id.startsWith("plot:")) continue; if (hit([h.x / r.img_w, 1 - (h.top + h.h) / r.img_h, h.w / r.img_w, h.h / r.img_h])) sel.add(h.id); }
304
+ state.selection = sel; state.selected = sel.size ? [...sel].pop() : null;
305
+ refreshSelection(); buildInspector();
204
306
  }
205
307
 
206
308
  /* ---------------- move / resize / drag ---------------- */
@@ -212,7 +314,7 @@ function makeMovable(el, id, mode) {
212
314
  const shift = e.shiftKey;
213
315
  const handle = (e.target.dataset && e.target.dataset.h) || null;
214
316
  const startF = toFig(e.clientX, e.clientY);
215
- let moved = false, onMove, onUp, sx = 0, sy = 0;
317
+ let moved = false, onMove, onUp, sx = 0, sy = 0, lastDxF = 0, lastDyF = 0, isGroup = false, groupDivs = [el];
216
318
  if (mode === "box") {
217
319
  const p = state.spec.plots.find((x) => x.id === id.slice(5));
218
320
  const rect0 = p.rect.slice();
@@ -224,6 +326,9 @@ function makeMovable(el, id, mode) {
224
326
  onMove = (ev) => {
225
327
  const F = toFig(ev.clientX, ev.clientY);
226
328
  const dxF = F.fx - startF.fx, dyBF = startF.fyTop - F.fyTop;
329
+ if (!moved && Math.abs(dxF) * r.img_w * state.zoom < 3 && Math.abs(dyBF) * r.img_h * state.zoom < 3) return; // sub-3px = a click, not a drag
330
+ if (!moved) pushHist(); // capture pre-drag state so a move/resize is undoable
331
+ moved = true;
227
332
  if (handle === null) {
228
333
  const rect = rect0.slice(); rect[0] += dxF; rect[1] += dyBF;
229
334
  const guides = snapRect(rect, others, r.img_w, r.img_h);
@@ -244,14 +349,31 @@ function makeMovable(el, id, mode) {
244
349
  Object.assign(el.style, rectToCss(rect));
245
350
  }
246
351
  scheduleDraft();
247
- moved = true;
248
352
  };
249
353
  } else {
250
354
  sx = e.clientX; sy = e.clientY;
355
+ const r0 = state.render;
356
+ const imgWpx = r0 ? r0.img_w * state.zoom : 1, imgHpx = r0 ? r0.img_h * state.zoom : 1;
357
+ const hs0 = r0 && r0.hotspots.find((h) => h.id === id);
358
+ const startBox = hs0 ? [hs0.x / r0.img_w, 1 - (hs0.top + hs0.h) / r0.img_h, hs0.w / r0.img_w, hs0.h / r0.img_h] : null;
359
+ const others = startBox ? snapModel(id) : [];
360
+ isGroup = state.selection.size > 1 && state.selection.has(id);
361
+ groupDivs = isGroup ? [...overlay.querySelectorAll(".mov")].filter((d) => state.selection.has(d.dataset.id)) : [el];
251
362
  onMove = (ev) => {
252
363
  const dx = ev.clientX - sx, dy = ev.clientY - sy;
253
- // el sits inside the zoomed stageInner, so its local px are screen px / zoom
254
- if (dx || dy) { moved = true; el.style.transform = `translate(${dx / state.zoom}px,${dy / state.zoom}px)`; scheduleDraft(); }
364
+ if (!moved && Math.abs(dx) < 3 && Math.abs(dy) < 3) return; // sub-3px = a click, not a drag
365
+ if (!moved) pushHist(); // capture pre-drag state so a move is undoable
366
+ moved = true;
367
+ let dxF = dx / imgWpx, dyF = -dy / imgHpx;
368
+ if (startBox) {
369
+ const box = startBox.slice(); box[0] += dxF; box[1] += dyF;
370
+ drawGuides(snapBoxPos(box, others, r0.img_w, r0.img_h));
371
+ lastDxF = box[0] - startBox[0]; lastDyF = box[1] - startBox[1]; // the snapped delta
372
+ } else { lastDxF = dxF; lastDyF = dyF; }
373
+ // el sits inside the zoomed stageInner, so its local px are screen px / zoom;
374
+ // a group drag moves every selected label by the same (snapped) delta.
375
+ groupDivs.forEach((d) => { d.style.transform = `translate(${lastDxF * imgWpx / state.zoom}px, ${-lastDyF * imgHpx / state.zoom}px)`; });
376
+ scheduleDraft();
255
377
  };
256
378
  }
257
379
  onUp = (ev) => {
@@ -259,9 +381,9 @@ function makeMovable(el, id, mode) {
259
381
  document.removeEventListener("pointerup", onUp);
260
382
  document.removeEventListener("pointercancel", onUp);
261
383
  drawGuides([]);
262
- el.style.transform = "";
384
+ groupDivs.forEach((d) => { d.style.transform = ""; });
263
385
  if (!moved) { select(id, shift); return; }
264
- if (mode !== "box") commitArtDrag(id, ev.clientX - sx, ev.clientY - sy);
386
+ if (mode !== "box") { const ids = isGroup ? [...state.selection] : [id]; for (const eid of ids) commitArtDrag(eid, lastDxF, lastDyF); }
265
387
  commit();
266
388
  };
267
389
  document.addEventListener("pointermove", onMove);
@@ -270,11 +392,9 @@ function makeMovable(el, id, mode) {
270
392
  });
271
393
  }
272
394
 
273
- function commitArtDrag(id, dxPx, dyPx) {
274
- // Apply the drag DELTA (in figure fractions) to the element's own coordinates, so the
275
- // element lands exactly where the user dropped it (the grab offset is preserved).
276
- const rect = figImg.getBoundingClientRect();
277
- const dxF = dxPx / rect.width, dyF = -dyPx / rect.height;
395
+ function commitArtDrag(id, dxF, dyF) {
396
+ // Apply the drag DELTA (in figure fractions, already snapped) to the element's own
397
+ // coordinates, so the element lands exactly where the user dropped it.
278
398
  const r = state.spec;
279
399
  if (id.startsWith("title:")) {
280
400
  const pid = id.slice(6); const p = r.plots.find((x) => x.id === pid);
@@ -320,6 +440,47 @@ async function commit() {
320
440
  await doRender(false);
321
441
  }
322
442
 
443
+ /* ---------------- clipboard (copy / paste / cut / duplicate) ----------------
444
+ Copies top-level elements: plots (with their artists/lines) and figure_artists.
445
+ Signal refs are name-based, so a same-figure paste is always valid. */
446
+ function copySelection() {
447
+ const ids = state.selection.size ? [...state.selection] : (state.selected ? [state.selected] : []);
448
+ if (!ids.length) return;
449
+ const plotIds = new Set(ids.filter((i) => i.startsWith("plot:")).map((i) => i.slice(5)));
450
+ const figIdxs = ids.filter((i) => i.startsWith("figartist:")).map((i) => +i.slice(10));
451
+ const plots = state.spec.plots.filter((p) => plotIds.has(p.id)).map((p) => clone(p));
452
+ const figs = figIdxs.map((j) => clone(state.spec.figure_artists[j]));
453
+ if (!plots.length && !figs.length) return;
454
+ state.clipboard = { plots, figs };
455
+ toast("Copied " + (plots.length + figs.length) + " element" + (plots.length + figs.length > 1 ? "s" : ""));
456
+ }
457
+ function pasteClipboard() {
458
+ const cb = state.clipboard; if (!cb || (!cb.plots.length && !cb.figs.length)) return;
459
+ pushHist();
460
+ for (const p of cb.plots) { const c = clone(p); c.id = nextPlotId(); c.rect = [r4(clamp(c.rect[0] + 0.05, 0, 1)), r4(clamp(c.rect[1] + 0.05, 0, 1)), c.rect[2], c.rect[3]]; state.spec.plots.push(c); }
461
+ for (const f of cb.figs) { const c = clone(f); if (c.loc) c.loc = [r4(c.loc[0] + 0.05), r4(c.loc[1] + 0.05)]; else { c.x = r4((c.x || 0) + 0.05); c.y = r4((c.y || 0) + 0.05); } state.spec.figure_artists.push(c); }
462
+ select(null); commit();
463
+ }
464
+ function deleteSelection() {
465
+ if (!state.selected && !state.selection.size) return;
466
+ pushHist();
467
+ const ids = state.selection.size ? [...state.selection] : [state.selected];
468
+ const plotIds = new Set(ids.filter((id) => id.startsWith("plot:")).map((id) => id.slice(5)));
469
+ if (plotIds.size) state.spec.plots = state.spec.plots.filter((p) => !plotIds.has(p.id));
470
+ const artDel = new Map(), lineDel = new Map(), figDel = [];
471
+ for (const id of ids) {
472
+ if (id.startsWith("artist:")) { const [, pid, j] = id.split(":"); if (!artDel.has(pid)) artDel.set(pid, []); artDel.get(pid).push(+j); }
473
+ else if (id.startsWith("line:")) { const [, pid, j] = id.split(":"); if (!lineDel.has(pid)) lineDel.set(pid, []); lineDel.get(pid).push(+j); }
474
+ else if (id.startsWith("figartist:")) figDel.push(+id.slice(10));
475
+ else if (id.startsWith("title:")) { const p = state.spec.plots.find((x) => x.id === id.slice(6)); if (p) p.title.visible = false; }
476
+ }
477
+ // splice high-to-low so deleting several items at once doesn't shift the other indices
478
+ for (const [pid, js] of artDel) { const p = state.spec.plots.find((x) => x.id === pid); if (p) js.sort((a, b) => b - a).forEach((j) => p.artists.splice(j, 1)); }
479
+ for (const [pid, js] of lineDel) { const p = state.spec.plots.find((x) => x.id === pid); if (p && p.extra_lines) js.sort((a, b) => b - a).forEach((j) => p.extra_lines.splice(j, 1)); }
480
+ figDel.sort((a, b) => b - a).forEach((j) => state.spec.figure_artists.splice(j, 1));
481
+ select(null); commit();
482
+ }
483
+
323
484
  let nudgePushed = false, nudgeTimer = null;
324
485
  function nudgeSelected(dx, dy) {
325
486
  const ids = state.selection.size ? [...state.selection] : (state.selected ? [state.selected] : []);
@@ -336,7 +497,16 @@ function nudgeOne(id, dx, dy) {
336
497
  if (kind === "plot") { const p = state.spec.plots.find((x) => x.id === pid); if (p) { p.rect[0] = clamp(r4(p.rect[0] + dx), 0, 1); p.rect[1] = clamp(r4(p.rect[1] + dy), 0, 1); } }
337
498
  else if (kind === "title") { const p = state.spec.plots.find((x) => x.id === pid); const t = p && p.title; if (t) { t.x = r4(clamp(t.x + dx / p.rect[2], -0.6, 1.6)); t.y = r4(clamp(t.y + dy / p.rect[3], -0.6, 1.6)); t.visible = true; } }
338
499
  else if (kind === "figartist") { const fa = state.spec.figure_artists[+pid]; if (fa) { if (fa.loc) { fa.loc[0] = r4(clamp(fa.loc[0] + dx, 0, 1.2)); fa.loc[1] = r4(clamp(fa.loc[1] + dy, -0.2, 1.2)); } else { fa.x = r4(clamp((fa.x || 0) + dx, -0.1, 1.1)); fa.y = r4(clamp((fa.y || 0) + dy, -0.1, 1.1)); } } }
339
- else if (kind === "artist") { const p = state.spec.plots.find((x) => x.id === pid); const a = p && p.artists[j]; if (a) { if (a.type === "text" && a.axes_frac) { a.x = r4(clamp(a.x + dx / p.rect[2], -0.6, 1.6)); a.y = r4(clamp(a.y + dy / p.rect[3], -0.6, 1.6)); } else if (a.type === "vline") a.x = r4(a.x + dx * 8); else if (a.type === "hline") a.y = r4(a.y + dy * 8); else if (a.type === "circle" || a.type === "ellipse") { a.x = r4(a.x + dx * 8); a.y = r4(a.y + dy * 8); } } }
500
+ else if (kind === "artist") { const p = state.spec.plots.find((x) => x.id === pid); const a = p && p.artists[j]; if (a) {
501
+ if (a.type === "text" && a.axes_frac) { a.x = r4(clamp(a.x + dx / p.rect[2], -0.6, 1.6)); a.y = r4(clamp(a.y + dy / p.rect[3], -0.6, 1.6)); }
502
+ else if (a.type === "vline") a.x = r4(a.x + dx * 8);
503
+ else if (a.type === "hline") a.y = r4(a.y + dy * 8);
504
+ else if (a.type === "circle" || a.type === "ellipse") { a.x = r4(a.x + dx * 8); a.y = r4(a.y + dy * 8); }
505
+ else if (a.type === "box") { a.x0 = r4(clamp(a.x0 + dx, -0.2, 1.2)); a.y0 = r4(clamp(a.y0 + dy, -0.2, 1.2)); }
506
+ else if (a.type === "vspan") { a.x0 = r4(a.x0 + dx * 8); a.x1 = r4(a.x1 + dx * 8); }
507
+ else if (a.type === "annotation") { a.xytext = [r4(a.xytext[0] + dx), r4(a.xytext[1] + dy)]; }
508
+ else if (a.type === "arrow") { a.x0 = r4(a.x0 + dx * 8); a.y0 = r4(a.y0 + dy * 8); a.x1 = r4(a.x1 + dx * 8); a.y1 = r4(a.y1 + dy * 8); }
509
+ } }
340
510
  }
341
511
 
342
512
  /* ---------------- signal drag -> create plot ---------------- */
@@ -520,6 +690,8 @@ function addPlotAt(fx, fyB, signal) {
520
690
  pushHist();
521
691
  const w = 0.3, h = 0.3;
522
692
  const rect = [r4(clamp(fx - w / 2, 0, 1 - w)), r4(clamp(fyB - h / 2, 0, 1 - h)), w, h];
693
+ const r = state.render || { img_w: 1000, img_h: 1000 };
694
+ snapRect(rect, state.spec.plots.map((p) => p.rect), r.img_w, r.img_h); // snap the new plot on drop
523
695
  const p = plotForSignal(signal, rect);
524
696
  state.spec.plots.push(p);
525
697
  select("plot:" + p.id);
@@ -701,7 +873,13 @@ function buildFigureElementsSection() {
701
873
  }
702
874
 
703
875
  function layoutAll(op, param) {
704
- const plots = state.spec.plots; if (!plots.length) return;
876
+ // Act on the selected plots when a multi-selection is active, else all plots.
877
+ let plots = state.spec.plots;
878
+ if (state.selection.size > 1) {
879
+ const selIds = new Set([...state.selection].filter((i) => i.startsWith("plot:")).map((i) => i.slice(5)));
880
+ if (selIds.size > 1) plots = plots.filter((p) => selIds.has(p.id));
881
+ }
882
+ if (!plots.length) return;
705
883
  const L = state.spec.layout || { margin: 0.05, gap: 0.04 };
706
884
  const rects = plots.map((p) => p.rect);
707
885
  pushHist();
@@ -788,7 +966,7 @@ function plotTypeSelect(pid) {
788
966
  const p = state.spec.plots[idx(pid)];
789
967
  const w = document.createElement("div"); w.className = "ctl";
790
968
  const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = "Plot type"; head.appendChild(lab); w.appendChild(head);
791
- const sel = document.createElement("select"); sel.className = "ctl-sel";
969
+ const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", "Plot type");
792
970
  const groups = {};
793
971
  for (const [k, v] of Object.entries(PLOT_TYPES)) (groups[v.group] = groups[v.group] || []).push([k, v.label]);
794
972
  for (const gname of Object.keys(groups)) {
@@ -835,9 +1013,11 @@ function buildPlotInspector(body, pid) {
835
1013
  body.appendChild(ctlHead("Axes"));
836
1014
  body.appendChild(ticksText("X ticks (blank = auto)", ["plots", idx(pid), "axes", "xticks"], p.axes.xticks));
837
1015
  body.appendChild(ticksText("Y ticks (blank = auto)", ["plots", idx(pid), "axes", "yticks"], p.axes.yticks));
838
- body.appendChild(text("X label", ["plots", idx(pid), "axes", "xlabel"], p.axes.xlabel));
1016
+ body.appendChild(labelsText("X tick labels (blank = auto)", ["plots", idx(pid), "axes", "xticklabels"], p.axes.xticklabels));
1017
+ body.appendChild(labelsText("Y tick labels (blank = auto)", ["plots", idx(pid), "axes", "yticklabels"], p.axes.yticklabels));
1018
+ body.appendChild(text("X label", ["plots", idx(pid), "axes", "xlabel"], p.axes.xlabel, true));
839
1019
  body.appendChild(check("Show X label", ["plots", idx(pid), "axes", "xlabel_visible"], p.axes.xlabel_visible));
840
- body.appendChild(text("Y label", ["plots", idx(pid), "axes", "ylabel"], p.axes.ylabel));
1020
+ body.appendChild(text("Y label", ["plots", idx(pid), "axes", "ylabel"], p.axes.ylabel, true));
841
1021
  body.appendChild(check("Show Y label", ["plots", idx(pid), "axes", "ylabel_visible"], p.axes.ylabel_visible));
842
1022
  body.appendChild(hint("Labels and titles support LaTeX mathtext, e.g. <code>$x_0$</code>."));
843
1023
  body.appendChild(num2("X range (blank = auto)", ["plots", idx(pid), "axes", "xlim"], p.axes.xlim));
@@ -855,7 +1035,7 @@ function buildPlotInspector(body, pid) {
855
1035
  body.appendChild(check("Show y tick labels", ["plots", idx(pid), "axes", "labelleft"], p.axes.labelleft));
856
1036
  body.appendChild(ctlHead("Title"));
857
1037
  body.appendChild(check("Show title", ["plots", idx(pid), "title", "visible"], p.title.visible));
858
- body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], p.title.text));
1038
+ body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], p.title.text, true));
859
1039
  body.appendChild(slider("Title size", ["plots", idx(pid), "title", "fontsize"], p.title.fontsize, 6, 24, 0.5));
860
1040
  body.appendChild(selControl("Title weight", ["plots", idx(pid), "title", "weight"], p.title.weight, [["normal", "Normal"], ["bold", "Bold"]]));
861
1041
  body.appendChild(ctlHead("Artists"));
@@ -869,10 +1049,11 @@ const idx = (pid) => state.spec.plots.findIndex((p) => p.id === pid);
869
1049
 
870
1050
  function buildTitleInspector(body, pid) {
871
1051
  const t = state.spec.plots.find((x) => x.id === pid).title;
872
- body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], t.text));
1052
+ body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], t.text, true));
873
1053
  body.appendChild(slider("X (of panel)", ["plots", idx(pid), "title", "x"], t.x, -0.6, 1.6, 0.02));
874
1054
  body.appendChild(slider("Y (of panel)", ["plots", idx(pid), "title", "y"], t.y, -0.6, 1.6, 0.02));
875
1055
  body.appendChild(slider("Size (pt)", ["plots", idx(pid), "title", "fontsize"], t.fontsize, 6, 24, 0.5));
1056
+ body.appendChild(slider("Rotation (deg)", ["plots", idx(pid), "title", "rotation"], t.rotation ?? 0, -180, 180, 1));
876
1057
  body.appendChild(selControl("Weight", ["plots", idx(pid), "title", "weight"], t.weight, [["normal", "Normal"], ["bold", "Bold"]]));
877
1058
  body.appendChild(color("Color", ["plots", idx(pid), "title", "color"], t.color));
878
1059
  body.appendChild(hint("Drag the title on the canvas to reposition it freely."));
@@ -881,14 +1062,15 @@ function buildTitleInspector(body, pid) {
881
1062
  function buildArtistInspector(body, pid, j) {
882
1063
  const a = state.spec.plots.find((x) => x.id === pid).artists[j];
883
1064
  const P = (k, v, mn, mx, st) => ["plots", idx(pid), "artists", j, k];
884
- if (a.type === "vline") { body.appendChild(slider("X value", P("x", a.x), 0, 20, 0.5)); body.appendChild(color("Color", P("color"), a.color)); body.appendChild(selControl("Style", P("ls"), a.ls || "-", [["-", "Solid"], ["dotted", "Dotted"], ["dashed", "Dashed"], ["dashdot", "Dash dot"]])); body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1)); body.appendChild(slider("Opacity", P("alpha"), a.alpha ?? 1, 0, 1, 0.05)); }
885
- else if (a.type === "hline") { body.appendChild(slider("Y value", P("y", a.y), -1, 1, 0.05)); body.appendChild(color("Color", P("color"), a.color)); body.appendChild(selControl("Style", P("ls"), a.ls || "-", [["-", "Solid"], ["dotted", "Dotted"], ["dashed", "Dashed"]])); body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1)); }
886
- else if (a.type === "text") { body.appendChild(text("Text", P("text"), a.text)); body.appendChild(slider("X", P("x"), a.x, -1, 2, 0.02)); body.appendChild(slider("Y", P("y"), a.y, -1, 2, 0.02)); body.appendChild(check("Use panel fraction", P("axes_frac"), !!a.axes_frac)); body.appendChild(slider("Size", P("fontsize"), a.fontsize ?? 10, 6, 24, 0.5)); body.appendChild(color("Color", P("color"), a.color || "#000")); }
1065
+ if (a.type === "vline") { body.appendChild(slider("X value", P("x", a.x), 0, 20, 0.5, null, true)); body.appendChild(color("Color", P("color"), a.color)); body.appendChild(selControl("Style", P("ls"), a.ls || "-", [["-", "Solid"], ["dotted", "Dotted"], ["dashed", "Dashed"], ["dashdot", "Dash dot"]])); body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1)); body.appendChild(slider("Opacity", P("alpha"), a.alpha ?? 1, 0, 1, 0.05)); }
1066
+ else if (a.type === "hline") { body.appendChild(slider("Y value", P("y", a.y), -1, 1, 0.05, null, true)); body.appendChild(color("Color", P("color"), a.color)); body.appendChild(selControl("Style", P("ls"), a.ls || "-", [["-", "Solid"], ["dotted", "Dotted"], ["dashed", "Dashed"]])); body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1)); }
1067
+ else if (a.type === "text") { body.appendChild(text("Text", P("text"), a.text, true)); body.appendChild(slider("X", P("x"), a.x, -1, 2, 0.02)); body.appendChild(slider("Y", P("y"), a.y, -1, 2, 0.02)); body.appendChild(check("Use panel fraction", P("axes_frac"), !!a.axes_frac)); body.appendChild(slider("Size", P("fontsize"), a.fontsize ?? 10, 6, 24, 0.5)); body.appendChild(slider("Rotation (deg)", P("rotation"), a.rotation ?? 0, -180, 180, 1)); body.appendChild(color("Color", P("color"), a.color || "#000")); }
887
1068
  else if (a.type === "annotation") {
888
- body.appendChild(text("Text", P("text"), a.text));
1069
+ body.appendChild(text("Text", P("text"), a.text, true));
889
1070
  body.appendChild(num2("Arrow points to (data x, y)", P("xy"), a.xy));
890
1071
  body.appendChild(num2("Label at (panel x, y)", P("xytext"), a.xytext));
891
1072
  body.appendChild(slider("Label size", P("fontsize"), a.fontsize ?? 10, 6, 20, 0.5));
1073
+ body.appendChild(slider("Rotation (deg)", P("rotation"), a.rotation ?? 0, -180, 180, 1));
892
1074
  body.appendChild(slider("Arrow width", P("lw"), a.lw ?? 1.2, 0.3, 4, 0.1));
893
1075
  body.appendChild(color("Color", P("color"), a.color || "#000"));
894
1076
  }
@@ -903,22 +1085,22 @@ function buildArtistInspector(body, pid, j) {
903
1085
  body.appendChild(slider("Edge width", P("lw"), a.lw ?? 1, 0, 4, 0.1));
904
1086
  }
905
1087
  else if (a.type === "vspan") {
906
- body.appendChild(slider("X start (data)", P("x0"), a.x0, 0, 20, 0.5));
907
- body.appendChild(slider("X end (data)", P("x1"), a.x1, 0, 20, 0.5));
1088
+ body.appendChild(slider("X start (data)", P("x0"), a.x0, 0, 20, 0.5, null, true));
1089
+ body.appendChild(slider("X end (data)", P("x1"), a.x1, 0, 20, 0.5, null, true));
908
1090
  body.appendChild(color("Color", P("color"), a.color || "#b03a2e"));
909
1091
  body.appendChild(slider("Opacity", P("alpha"), a.alpha ?? 0.12, 0, 0.6, 0.01));
910
1092
  }
911
1093
  else if (a.type === "circle") {
912
- body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2));
913
- body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2));
1094
+ body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2, null, true));
1095
+ body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2, null, true));
914
1096
  body.appendChild(slider("Radius", P("r"), a.r ?? 1, 0.05, 10, 0.1));
915
1097
  body.appendChild(check("Filled", P("fill"), !!a.fill));
916
1098
  body.appendChild(color("Color", P("color"), a.color || "#000"));
917
1099
  body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1));
918
1100
  }
919
1101
  else if (a.type === "ellipse") {
920
- body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2));
921
- body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2));
1102
+ body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2, null, true));
1103
+ body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2, null, true));
922
1104
  body.appendChild(slider("Width", P("w"), a.w ?? 2, 0.1, 10, 0.1));
923
1105
  body.appendChild(slider("Height", P("h"), a.h ?? 1, 0.1, 10, 0.1));
924
1106
  body.appendChild(slider("Angle (deg)", P("angle"), a.angle ?? 0, -180, 180, 5));
@@ -930,13 +1112,29 @@ function buildArtistInspector(body, pid, j) {
930
1112
  body.appendChild(check("Filled", P("fill"), !!a.fill));
931
1113
  body.appendChild(color("Color", P("color"), a.color || "#000"));
932
1114
  body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1));
933
- body.appendChild(hint("Default 3-point triangle; edit points in the spec for custom shapes."));
1115
+ const pts = (a.points && a.points.length) ? a.points.slice() : [[4, 0], [6, 0], [5, 1]];
1116
+ body.appendChild(ctlHead("Points (data coords)"));
1117
+ const plist = document.createElement("div"); plist.className = "art-list";
1118
+ pts.forEach((pt, vi) => {
1119
+ const r2 = document.createElement("div"); r2.className = "num-row";
1120
+ const nx = document.createElement("input"), ny = document.createElement("input"), sep = document.createElement("span");
1121
+ sep.className = "sep"; sep.textContent = ","; nx.type = ny.type = "text"; nx.value = pt[0]; ny.value = pt[1];
1122
+ const up = () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); a.points = pts.map((q, i) => (i === vi ? [parseFloat(nx.value) || 0, parseFloat(ny.value) || 0] : q)); scheduleDraft(); };
1123
+ nx.addEventListener("input", up); ny.addEventListener("input", up);
1124
+ nx.addEventListener("change", () => { commitSnapshot(); commit(); }); ny.addEventListener("change", () => { commitSnapshot(); commit(); });
1125
+ const rmv = document.createElement("span"); rmv.textContent = "×"; rmv.style.cssText = "cursor:pointer;color:var(--faint);padding:0 6px";
1126
+ rmv.onclick = () => { pushHist(); pts.splice(vi, 1); a.points = pts.slice(); select("artist:" + pid + ":" + j); commit(); };
1127
+ r2.append(nx, sep, ny, rmv); plist.appendChild(r2);
1128
+ });
1129
+ const addPt = document.createElement("button"); addPt.className = "pill"; addPt.textContent = "+ Point";
1130
+ addPt.onclick = () => { pushHist(); pts.push([5, 5]); a.points = pts.slice(); select("artist:" + pid + ":" + j); commit(); };
1131
+ body.appendChild(plist); body.appendChild(addPt);
934
1132
  }
935
1133
  else if (a.type === "arrow") {
936
- body.appendChild(slider("From X", P("x0"), a.x0, -10, 20, 0.2));
937
- body.appendChild(slider("From Y", P("y0"), a.y0, -10, 10, 0.2));
938
- body.appendChild(slider("To X", P("x1"), a.x1, -10, 20, 0.2));
939
- body.appendChild(slider("To Y", P("y1"), a.y1, -10, 10, 0.2));
1134
+ body.appendChild(slider("From X", P("x0"), a.x0, -10, 20, 0.2, null, true));
1135
+ body.appendChild(slider("From Y", P("y0"), a.y0, -10, 10, 0.2, null, true));
1136
+ body.appendChild(slider("To X", P("x1"), a.x1, -10, 20, 0.2, null, true));
1137
+ body.appendChild(slider("To Y", P("y1"), a.y1, -10, 10, 0.2, null, true));
940
1138
  body.appendChild(color("Color", P("color"), a.color || "#000"));
941
1139
  body.appendChild(slider("Width", P("lw"), a.lw ?? 1.5, 0.3, 4, 0.1));
942
1140
  }
@@ -950,10 +1148,11 @@ function buildFigartistInspector(body, j) {
950
1148
  const fa = state.spec.figure_artists[j];
951
1149
  const P = (...k) => ["figure_artists", j, ...k];
952
1150
  if (fa.type === "text") {
953
- body.appendChild(text("Text", P("text"), fa.text));
1151
+ body.appendChild(text("Text", P("text"), fa.text, true));
954
1152
  body.appendChild(slider("X", P("x"), fa.x, 0, 1, 0.01));
955
1153
  body.appendChild(slider("Y", P("y"), fa.y, 0, 1, 0.01));
956
1154
  body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 12, 6, 26, 0.5));
1155
+ body.appendChild(slider("Rotation (deg)", P("rotation"), fa.rotation ?? 0, -180, 180, 1));
957
1156
  body.appendChild(color("Color", P("color"), fa.color || "#000"));
958
1157
  } else if (fa.type === "legend") {
959
1158
  body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 10, 6, 20, 0.5));
@@ -967,25 +1166,29 @@ function buildFigartistInspector(body, j) {
967
1166
  const tin = document.createElement("input"); tin.type = "text"; tin.value = lb; tin.style.flex = "2";
968
1167
  tin.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); fa.labels[i] = tin.value; scheduleDraft(); });
969
1168
  tin.addEventListener("change", () => { commitSnapshot(); commit(); });
970
- const mk = document.createElement("select"); mk.className = "ctl-sel"; mk.style.cssText = "width:auto;flex:0 0 auto;padding:4px 6px";
1169
+ const mk = document.createElement("select"); mk.className = "ctl-sel"; mk.setAttribute("aria-label", "Entry marker"); mk.style.cssText = "width:auto;flex:0 0 auto;padding:4px 6px";
971
1170
  for (const [v, l] of [["", "— line"], ["o", "o dot"], ["^", "^ tri"], ["s", "s sq"], ["D", "D dia"]]) { const o = document.createElement("option"); o.value = v; o.textContent = l; mk.appendChild(o); }
972
1171
  mk.value = (fa.handles[i] && fa.handles[i].marker) || "";
973
1172
  mk.addEventListener("change", () => { pushHist(); fa.handles = fa.handles || []; fa.handles[i] = fa.handles[i] || {}; fa.handles[i].marker = mk.value; fa.handles[i].ms = mk.value ? 3 : 0; commit(); });
974
- const ls = document.createElement("select"); ls.className = "ctl-sel"; ls.style.cssText = "width:auto;flex:0 0 auto;padding:4px 6px";
1173
+ const ls = document.createElement("select"); ls.className = "ctl-sel"; ls.setAttribute("aria-label", "Entry line style"); ls.style.cssText = "width:auto;flex:0 0 auto;padding:4px 6px";
975
1174
  for (const [v, l] of [["-", "solid"], ["dotted", "dotted"], ["dashed", "dashed"]]) { const o = document.createElement("option"); o.value = v; o.textContent = l; ls.appendChild(o); }
976
1175
  ls.value = (fa.handles[i] && fa.handles[i].ls) || "-";
977
1176
  ls.addEventListener("change", () => { pushHist(); fa.handles = fa.handles || []; fa.handles[i] = fa.handles[i] || {}; fa.handles[i].ls = ls.value; commit(); });
1177
+ const ci = document.createElement("input"); ci.type = "color"; ci.title = "Entry color"; const _hc = (fa.handles[i] && fa.handles[i].color) || ""; ci.value = /^#[0-9a-f]{6}$/i.test(_hc) ? _hc : "#b03a2e"; ci.style.cssText = "width:26px;height:22px;padding:1px;border:1px solid var(--line);border-radius:5px;background:var(--input-bg);flex:0 0 auto";
1178
+ ci.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); fa.handles[i] = fa.handles[i] || {}; fa.handles[i].color = ci.value; scheduleDraft(); });
1179
+ ci.addEventListener("change", () => { commitSnapshot(); commit(); });
978
1180
  const rm = document.createElement("span"); rm.textContent = "×"; rm.style.cssText = "cursor:pointer;color:var(--faint);padding:0 6px;font-size:15px";
979
1181
  rm.onclick = () => { pushHist(); fa.labels.splice(i, 1); (fa.handles || []).splice(i, 1); select("figartist:" + j); commit(); };
980
- row.append(tin, mk, ls, rm); elist.appendChild(row);
1182
+ row.append(tin, ci, mk, ls, rm); elist.appendChild(row);
981
1183
  });
982
1184
  body.appendChild(elist);
983
1185
  const addE = document.createElement("button"); addE.className = "pill"; addE.textContent = "+ Entry";
984
1186
  addE.onclick = () => { pushHist(); fa.labels = fa.labels || []; fa.handles = fa.handles || []; fa.labels.push("entry"); fa.handles.push({ color: "#b03a2e", lw: 1.5 }); select("figartist:" + j); commit(); };
985
1187
  body.appendChild(addE);
986
1188
  } else if (fa.type === "suptitle") {
987
- body.appendChild(text("Text", P("text"), fa.text));
1189
+ body.appendChild(text("Text", P("text"), fa.text, true));
988
1190
  body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 14, 6, 26, 0.5));
1191
+ body.appendChild(slider("Rotation (deg)", P("rotation"), fa.rotation ?? 0, -180, 180, 1));
989
1192
  body.appendChild(color("Color", P("color"), fa.color || "#000"));
990
1193
  }
991
1194
  body.appendChild(ctlHead(""));
@@ -996,7 +1199,7 @@ function buildFigartistInspector(body, j) {
996
1199
 
997
1200
  function errorbarSelect(p) {
998
1201
  const w = document.createElement("div"); w.className = "ctl"; w.appendChild(ctlHead("Error bars (±)"));
999
- const sel = document.createElement("select"); sel.className = "ctl-sel";
1202
+ const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", "Error bars");
1000
1203
  const none = document.createElement("option"); none.value = ""; none.textContent = "(none)"; sel.appendChild(none);
1001
1204
  for (const s of state.signals) { const o = document.createElement("option"); o.value = s.ref; o.textContent = s.label; sel.appendChild(o); }
1002
1205
  sel.value = p.data.errorbar ? p.data.errorbar.ref : "";
@@ -1060,7 +1263,7 @@ function buildArtistList(body, pid) {
1060
1263
  /* ---------------- control builders ---------------- */
1061
1264
  function ctlHead(t) { const h = document.createElement("div"); h.className = "section"; h.textContent = t; return h; }
1062
1265
  function hint(t) { const h = document.createElement("div"); h.className = "hint"; h.innerHTML = t; return h; }
1063
- function slider(label, path, val, min, max, step, afterChange) {
1266
+ function slider(label, path, val, min, max, step, afterChange, noClamp) {
1064
1267
  const w = document.createElement("div"); w.className = "ctl";
1065
1268
  const head = document.createElement("div"); head.className = "ctl-head";
1066
1269
  const lab = document.createElement("label"); lab.textContent = label;
@@ -1071,7 +1274,7 @@ function slider(label, path, val, min, max, step, afterChange) {
1071
1274
  const vv = document.createElement("input"); vv.type = "text"; vv.className = "val num-in";
1072
1275
  vv.inputMode = "decimal"; vv.spellcheck = false; vv.setAttribute("aria-label", label + " value");
1073
1276
  head.append(lab, vv); w.appendChild(head);
1074
- const inp = document.createElement("input"); inp.type = "range"; inp.min = min; inp.max = max; inp.step = step;
1277
+ const inp = document.createElement("input"); inp.type = "range"; inp.min = min; inp.max = max; inp.step = step; inp.setAttribute("aria-label", label + " (slider)");
1075
1278
  let cur = val;
1076
1279
  const paint = () => { if (document.activeElement !== vv) vv.value = fmt(cur, step); inp.value = cur; };
1077
1280
  paint();
@@ -1085,7 +1288,9 @@ function slider(label, path, val, min, max, step, afterChange) {
1085
1288
  function commitVal() {
1086
1289
  const v = parseFloat(vv.value);
1087
1290
  if (isNaN(v)) { paint(); return; } // not a number: keep the last good value
1088
- const cv = Math.max(min, Math.min(max, v)); // clamp to the slider's range
1291
+ // Data-coordinate fields (noClamp) take the typed value as authoritative; the
1292
+ // range only bounds coarse dragging. Everything else clamps to the slider range.
1293
+ const cv = noClamp ? v : Math.max(min, Math.min(max, v));
1089
1294
  if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec);
1090
1295
  cur = cv; setPath(state.spec, path, cv); inp.value = cv; vv.value = fmt(cv, step);
1091
1296
  commitSnapshot(); if (afterChange) afterChange(); commit();
@@ -1097,38 +1302,67 @@ function slider(label, path, val, min, max, step, afterChange) {
1097
1302
  });
1098
1303
  w.appendChild(inp); return w;
1099
1304
  }
1100
- function text(label, path, val) {
1305
+ // Esc in a half-edited text field reverts to the pre-edit snapshot (not a commit).
1306
+ function wireEscRevert(el) {
1307
+ el.addEventListener("keydown", (e) => {
1308
+ if (e.key === "Escape" && state.sliderSnapshot) {
1309
+ e.preventDefault(); e.stopPropagation();
1310
+ state.spec = state.sliderSnapshot; state.sliderSnapshot = null; updateHist(); commit();
1311
+ }
1312
+ });
1313
+ }
1314
+ function text(label, path, val, multiline) {
1101
1315
  const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
1102
- const inp = document.createElement("input"); inp.type = "text"; inp.value = val == null ? "" : val;
1316
+ const inp = document.createElement(multiline ? "textarea" : "input");
1317
+ if (multiline) { inp.rows = 2; inp.className = "ctl-area"; } else { inp.type = "text"; }
1318
+ inp.value = val == null ? "" : val; inp.placeholder = "Text… (LaTeX like $x_0$ works)"; inp.spellcheck = false; inp.setAttribute("aria-label", label);
1103
1319
  inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, inp.value); scheduleDraft(); });
1104
1320
  inp.addEventListener("change", () => { commitSnapshot(); commit(); });
1321
+ wireEscRevert(inp);
1105
1322
  w.appendChild(inp); return w;
1106
1323
  }
1107
1324
  function ticksText(label, path, val) {
1108
1325
  const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
1109
- const inp = document.createElement("input"); inp.type = "text"; inp.value = Array.isArray(val) ? val.join(", ") : "";
1326
+ const inp = document.createElement("input"); inp.type = "text"; inp.value = Array.isArray(val) ? val.join(", ") : ""; inp.placeholder = "e.g. 0, 5, 10";
1110
1327
  const parse = () => { const t = inp.value.trim(); return t ? t.split(",").map((s) => parseFloat(s.trim())).filter((n) => !isNaN(n)) : "auto"; };
1111
1328
  inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); });
1112
1329
  inp.addEventListener("change", () => { commitSnapshot(); commit(); });
1330
+ wireEscRevert(inp);
1331
+ w.appendChild(inp); return w;
1332
+ }
1333
+ // Comma-separated STRING list (custom tick labels; LaTeX allowed, blank = none).
1334
+ function labelsText(label, path, val) {
1335
+ const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
1336
+ const inp = document.createElement("input"); inp.type = "text"; inp.value = Array.isArray(val) ? val.join(", ") : ""; inp.placeholder = "e.g. a, b, c (LaTeX ok)"; inp.spellcheck = false;
1337
+ const parse = () => { const t = inp.value.trim(); return t ? t.split(",").map((s) => s.trim()) : null; };
1338
+ inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); });
1339
+ inp.addEventListener("change", () => { commitSnapshot(); commit(); });
1340
+ wireEscRevert(inp);
1113
1341
  w.appendChild(inp); return w;
1114
1342
  }
1115
1343
  function num2(label, path, val) {
1116
1344
  const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
1117
1345
  const row = document.createElement("div"); row.className = "num-row";
1118
- const a = document.createElement("input"), b = document.createElement("input"), sep = document.createElement("span"); sep.className = "sep"; sep.textContent = "–"; a.type = b.type = "text";
1346
+ const a = document.createElement("input"), b = document.createElement("input"), sep = document.createElement("span"); sep.className = "sep"; sep.textContent = "–"; a.type = b.type = "text"; a.placeholder = "min"; b.placeholder = "max";
1119
1347
  a.value = val ? val[0] : ""; b.value = val ? val[1] : "";
1120
1348
  const parse = () => { const x = parseFloat(a.value), y = parseFloat(b.value); return (a.value && b.value && !isNaN(x) && !isNaN(y)) ? [x, y] : null; };
1121
1349
  a.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); }); b.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); });
1122
1350
  a.addEventListener("change", () => { commitSnapshot(); commit(); }); b.addEventListener("change", () => { commitSnapshot(); commit(); });
1351
+ wireEscRevert(a); wireEscRevert(b);
1123
1352
  row.append(a, sep, b); w.appendChild(row); return w;
1124
1353
  }
1125
1354
  function color(label, path, val) {
1126
1355
  const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
1127
1356
  const row = document.createElement("div"); row.className = "color-row";
1128
- const inp = document.createElement("input"); inp.type = "color"; inp.value = val || "#000000";
1129
- const hex = document.createElement("span"); hex.className = "color-hex"; hex.textContent = val || "#000000";
1130
- inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, inp.value); hex.textContent = inp.value; scheduleDraft(); });
1357
+ const isHex = (v) => /^#[0-9a-f]{6}$/i.test(v || "");
1358
+ const inp = document.createElement("input"); inp.type = "color"; inp.value = isHex(val) ? val : "#000000"; inp.setAttribute("aria-label", label + " (color picker)");
1359
+ const hex = document.createElement("input"); hex.type = "text"; hex.className = "color-hex"; hex.value = val || "#000000"; hex.spellcheck = false; hex.placeholder = "#rrggbb or tab:red"; hex.setAttribute("aria-label", label + " (hex or name)");
1360
+ const apply = (v) => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, v); if (isHex(v)) inp.value = v; scheduleDraft(); };
1361
+ inp.addEventListener("input", () => { apply(inp.value); hex.value = inp.value; });
1131
1362
  inp.addEventListener("change", () => { commitSnapshot(); commit(); });
1363
+ hex.addEventListener("input", () => { apply(hex.value); });
1364
+ hex.addEventListener("change", () => { commitSnapshot(); commit(); });
1365
+ wireEscRevert(hex);
1132
1366
  row.append(inp, hex); w.appendChild(row); return w;
1133
1367
  }
1134
1368
  function check(label, path, val) {
@@ -1145,20 +1379,20 @@ function checkInline(label, path, val) {
1145
1379
  }
1146
1380
  function selControl(label, path, val, options) {
1147
1381
  const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
1148
- const sel = document.createElement("select"); sel.className = "ctl-sel";
1382
+ const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", label);
1149
1383
  for (const [v, l] of options) { const o = document.createElement("option"); o.value = v; o.textContent = l; if (v === val) o.selected = true; sel.appendChild(o); }
1150
1384
  sel.addEventListener("change", () => { pushHist(); setPath(state.spec, path, sel.value); commit(); });
1151
1385
  w.appendChild(sel); return w;
1152
1386
  }
1153
1387
  function sigSelect(label, path, val, allowNone) {
1154
1388
  const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
1155
- const sel = document.createElement("select"); sel.className = "ctl-sel";
1389
+ const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", label);
1156
1390
  if (allowNone) { const o = document.createElement("option"); o.value = ""; o.textContent = "(none)"; if (!val) o.selected = true; sel.appendChild(o); }
1157
1391
  for (const s of state.signals) { const o = document.createElement("option"); o.value = s.ref; o.textContent = s.label + (s.ndim > 1 ? " (" + s.shape.join("×") + ")" : ""); if (s.ref === val) o.selected = true; sel.appendChild(o); }
1158
1392
  sel.addEventListener("change", () => { pushHist(); setPath(state.spec, path, sel.value || null); commit(); });
1159
1393
  w.appendChild(sel); return w;
1160
1394
  }
1161
- function fmt(v, step) { if (v == null) return "(default)"; return step < 1 ? String(Math.round(v * 100) / 100) : String(Math.round(v)); }
1395
+ function fmt(v, step) { if (v == null) return "(default)"; const dp = step >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(step) + 1e-9)); return String(Number(v.toFixed(dp))); }
1162
1396
 
1163
1397
  /* ---------------- zoom ---------------- */
1164
1398
  function applyTransform() {
@@ -1195,7 +1429,7 @@ function fitZoom() {
1195
1429
  async function openExport() {
1196
1430
  const { code } = await get(`/api/canvases/${state.slug}/code`);
1197
1431
  $("#codeBody").textContent = code; $("#codePath").textContent = (state.name || "canvas") + ".py";
1198
- $("#exportModal").classList.remove("hidden");
1432
+ openModal($("#exportModal"));
1199
1433
  }
1200
1434
 
1201
1435
  /* ---------------- 3-D view finder (double-click a 3-D plot) ---------------- */
@@ -1223,7 +1457,7 @@ async function openView3D(pid) {
1223
1457
  $("#v3e").value = v3d.elev; $("#v3a").value = v3d.azim; $("#v3d").value = v3d.dist;
1224
1458
  $("#v3invR").checked = v3d.invertR; $("#v3invZ").checked = v3d.invertZ;
1225
1459
  v3dVals();
1226
- $("#view3dModal").classList.remove("hidden");
1460
+ openModal($("#view3dModal"));
1227
1461
  v3dRender();
1228
1462
  }
1229
1463
  function v3dRender() {
@@ -1267,17 +1501,45 @@ function v3dWire() {
1267
1501
  pushHist();
1268
1502
  const p = state.spec.plots.find((x) => x.id === v3d.pid);
1269
1503
  p.style.elev = v3d.elev; p.style.azim = v3d.azim; p.style.dist = v3d.dist;
1270
- $("#view3dModal").classList.add("hidden");
1504
+ closeModal($("#view3dModal"));
1271
1505
  commit();
1272
1506
  };
1273
- const close = () => $("#view3dModal").classList.add("hidden");
1507
+ const close = () => closeModal($("#view3dModal"));
1274
1508
  $("#v3dCancel").onclick = close; $("#view3dClose").onclick = close;
1275
1509
  $("#view3dModal").onclick = (e) => { if (e.target === $("#view3dModal")) close(); };
1276
1510
  }
1277
1511
 
1278
1512
  /* ---------------- toast ---------------- */
1279
1513
  let toastTimer;
1280
- function toast(msg, isErr) { const t = $("#toast"); t.textContent = msg; t.style.background = isErr ? "var(--danger)" : "#1c2026"; t.classList.remove("hidden"); requestAnimationFrame(() => t.classList.add("show")); clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.classList.remove("show"); setTimeout(() => t.classList.add("hidden"), 200); }, 2200); }
1514
+ function toast(msg, isErr) { const t = $("#toast"); t.textContent = msg; t.style.background = isErr ? "var(--danger)" : "var(--toast-bg)"; t.classList.remove("hidden"); requestAnimationFrame(() => t.classList.add("show")); clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.classList.remove("show"); setTimeout(() => t.classList.add("hidden"), 200); }, isErr ? 4000 : 2200); }
1515
+
1516
+ /* ---------------- modal focus management ----------------
1517
+ Move focus into the dialog on open, trap Tab within it, and restore focus to
1518
+ the trigger on close (a11y: aria-modal="true" without a trap lets Tab escape). */
1519
+ let modalLastFocus = null;
1520
+ function _focusables(el) { return [...el.querySelectorAll("button, input, select, textarea")].filter((n) => !n.disabled && n.offsetParent !== null); }
1521
+ function openModal(el) { modalLastFocus = document.activeElement; el.classList.remove("hidden"); el._onTrap = (e) => { if (e.key !== "Tab") return; const f = _focusables(el); if (!f.length) return; const first = f[0], last = f[f.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; el.addEventListener("keydown", el._onTrap); const f = _focusables(el); if (f.length) f[0].focus(); }
1522
+ function closeModal(el) { el.classList.add("hidden"); if (el._onTrap) { el.removeEventListener("keydown", el._onTrap); el._onTrap = null; } if (modalLastFocus && modalLastFocus.focus) { try { modalLastFocus.focus(); } catch (_) {} } }
1523
+ // Styled confirm (replaces the native window.confirm) — resolves true/false.
1524
+ function confirmDialog(message, okLabel) {
1525
+ return new Promise((resolve) => {
1526
+ let box = document.getElementById("confirmModal");
1527
+ if (!box) {
1528
+ box = document.createElement("div"); box.id = "confirmModal"; box.className = "modal hidden"; box.setAttribute("role", "dialog"); box.setAttribute("aria-modal", "true");
1529
+ box.innerHTML = `<div class="modal-card"><h3>Confirm</h3><p class="confirm-msg"></p><div class="modal-actions modal-actions-right"><button class="textbtn confirm-cancel">Cancel</button><button class="primary confirm-ok"></button></div></div>`;
1530
+ document.body.appendChild(box);
1531
+ }
1532
+ box.querySelector(".confirm-msg").textContent = message;
1533
+ const ok = box.querySelector(".confirm-ok"), cancel = box.querySelector(".confirm-cancel");
1534
+ ok.textContent = okLabel || "Confirm";
1535
+ let settled = false;
1536
+ const done = (v) => { if (settled) return; settled = true; box.classList.add("hidden"); box.onclick = null; ok.onclick = cancel.onclick = null; resolve(v); };
1537
+ ok.onclick = () => done(true);
1538
+ cancel.onclick = () => done(false);
1539
+ box.onclick = (e) => { if (e.target === box) done(false); };
1540
+ openModal(box);
1541
+ });
1542
+ }
1281
1543
 
1282
1544
  /* ---------------- boot ---------------- */
1283
1545
  async function boot() {
@@ -1289,6 +1551,7 @@ async function boot() {
1289
1551
  doc = await get(`/api/canvases/${slug}`);
1290
1552
  } catch (e) { return showLoadError(e); }
1291
1553
  state.name = doc.name;
1554
+ document.title = "Fig Foundry — " + state.name;
1292
1555
  state.spec = doc.spec;
1293
1556
  state.dataset = doc.spec.dataset || null;
1294
1557
  let sigRes;
@@ -1311,11 +1574,32 @@ function showLoadError(e) {
1311
1574
  }
1312
1575
  function wireChrome() {
1313
1576
  v3dWire();
1577
+ // The key handler accepts ⌘ on macOS (metaKey); make the visible labels match.
1578
+ const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
1579
+ const MOD = isMac ? "⌘" : "Ctrl";
1580
+ $("#undoBtn").title = "Undo (" + MOD + "+Z)";
1581
+ $("#redoBtn").title = "Redo (" + MOD + "Shift+Z)";
1314
1582
  $("#themeBtn").onclick = cycleTheme;
1583
+ // Help / shortcuts modal
1584
+ const helpRows = [
1585
+ [MOD + "+Z", "Undo"], [MOD + "+Shift+Z", "Redo"],
1586
+ [MOD + "+A", "Select all"], [MOD + "+C / V / X", "Copy / paste / cut"], [MOD + "+D", "Duplicate"],
1587
+ ["Delete / Backspace", "Delete selection"], ["Esc", "Deselect / close dialog"],
1588
+ ["Arrow keys", "Nudge (Shift = bigger)"], ["Shift+click", "Add to / remove from selection"],
1589
+ ["Drag empty space", "Marquee select"], ["Space+drag / middle-drag", "Pan"],
1590
+ ["Scroll", "Zoom"], ["Double-click a 3-D plot", "Open the viewfinder"],
1591
+ ];
1592
+ $("#helpList").innerHTML = helpRows.map(([k, d]) => `<span class="k">${k}</span><span class="d">${d}</span>`).join("");
1593
+ const helpModal = $("#helpModal");
1594
+ $("#helpBtn").onclick = () => openModal(helpModal);
1595
+ $("#helpClose").onclick = () => closeModal(helpModal);
1596
+ $("#helpOk").onclick = () => closeModal(helpModal);
1597
+ helpModal.onclick = (e) => { if (e.target === helpModal) closeModal(helpModal); };
1315
1598
  $("#backBtn").onclick = () => { location.href = "/"; };
1316
1599
  $("#figName").addEventListener("change", async () => {
1317
1600
  state.name = $("#figName").value.trim() || "Untitled";
1318
1601
  $("#figName").value = state.name;
1602
+ document.title = "Fig Foundry — " + state.name;
1319
1603
  await api(`/api/canvases/${state.slug}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: state.name }) });
1320
1604
  toast("Renamed to " + state.name);
1321
1605
  });
@@ -1325,7 +1609,7 @@ function wireChrome() {
1325
1609
  location.href = "/canvas?slug=" + r.slug;
1326
1610
  };
1327
1611
  $("#deleteBtn").onclick = async () => {
1328
- if (!confirm('Delete "' + state.name + '"? This cannot be undone.')) return;
1612
+ if (!(await confirmDialog('Delete "' + state.name + '"? This cannot be undone.', "Delete"))) return;
1329
1613
  await api(`/api/canvases/${state.slug}`, { method: "DELETE" });
1330
1614
  location.href = "/";
1331
1615
  };
@@ -1334,58 +1618,78 @@ function wireChrome() {
1334
1618
  $("#addPlotBtn").onclick = () => { pushHist(); const sg = state.signals.filter((s) => s.ndim === 1); const x = sg.find((s) => s.name === "x") || sg[0]; const y = sg.find((s) => s !== x) || sg[0]; const p = _blankPlot(nextPlotId(), [0.35, 0.35, 0.3, 0.3], x ? x.ref : null, y ? y.ref : null); p.type = "line"; state.spec.plots.push(p); select("plot:" + p.id); commit(); };
1335
1619
  $("#loadDataBtn").onclick = (e) => { e.stopPropagation(); loadDataMenu(e.clientX, e.clientY); };
1336
1620
  $("#exportBtn").onclick = openExport;
1337
- $("#modalClose").onclick = () => $("#exportModal").classList.add("hidden");
1338
- $("#exportModal").onclick = (e) => { if (e.target === $("#exportModal")) $("#exportModal").classList.add("hidden"); };
1621
+ $("#modalClose").onclick = () => closeModal($("#exportModal"));
1622
+ $("#exportModal").onclick = (e) => { if (e.target === $("#exportModal")) closeModal($("#exportModal")); };
1339
1623
  $("#copyCode").onclick = async () => { try { await navigator.clipboard.writeText($("#codeBody").textContent); toast("Code copied"); } catch (e) { toast("Copy failed", true); } };
1340
1624
  $("#downloadCode").onclick = () => { location.href = `/api/canvases/${state.slug}/download`; toast("Downloading " + (state.name || "canvas") + ".py"); };
1341
1625
  $("#dlPng").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=png`; };
1342
1626
  $("#dlSvg").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=svg`; };
1343
1627
  $("#dlPdf").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=pdf`; };
1344
- let pan = null, spaceDown = false;
1628
+ let pan = null, spaceDown = false, marquee = null, marqueeEl = null;
1345
1629
  stage.addEventListener("wheel", (e) => { if (!state.render) return; e.preventDefault(); setZoom(state.zoom * (e.deltaY < 0 ? 1.12 : 1 / 1.12), e.clientX, e.clientY); }, { passive: false });
1346
1630
  stage.addEventListener("pointerdown", (e) => {
1347
1631
  const empty = e.target === stage || e.target === stageInner || e.target === figImg || e.target === overlay;
1348
- if (e.button === 1 || (e.button === 0 && (empty || spaceDown))) {
1632
+ if (e.button === 1 || (e.button === 0 && spaceDown)) {
1349
1633
  e.preventDefault();
1350
1634
  stage.setPointerCapture(e.pointerId); // pointerup is delivered even if released outside the window
1351
- pan = { x: e.clientX, y: e.clientY, px: state.pan.x, py: state.pan.y, clickEmpty: e.button === 0 && empty && !spaceDown };
1635
+ pan = { x: e.clientX, y: e.clientY, px: state.pan.x, py: state.pan.y };
1636
+ } else if (e.button === 0 && empty && !spaceDown) {
1637
+ e.preventDefault();
1638
+ stage.setPointerCapture(e.pointerId);
1639
+ marquee = { x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY, base: e.shiftKey ? new Set(state.selection) : new Set() };
1640
+ if (!marqueeEl) { marqueeEl = document.createElement("div"); marqueeEl.className = "marquee"; stage.appendChild(marqueeEl); }
1641
+ marqueeEl.style.display = "block";
1642
+ }
1643
+ });
1644
+ window.addEventListener("pointermove", (e) => {
1645
+ if (pan) { state.pan.x = pan.px + (e.clientX - pan.x); state.pan.y = pan.py + (e.clientY - pan.y); applyTransform(); }
1646
+ if (marquee) {
1647
+ marquee.x1 = e.clientX; marquee.y1 = e.clientY;
1648
+ const sr = stage.getBoundingClientRect();
1649
+ marqueeEl.style.left = (Math.min(marquee.x0, marquee.x1) - sr.left) + "px";
1650
+ marqueeEl.style.top = (Math.min(marquee.y0, marquee.y1) - sr.top) + "px";
1651
+ marqueeEl.style.width = Math.abs(marquee.x1 - marquee.x0) + "px";
1652
+ marqueeEl.style.height = Math.abs(marquee.y1 - marquee.y0) + "px";
1352
1653
  }
1353
1654
  });
1354
- window.addEventListener("pointermove", (e) => { if (!pan) return; state.pan.x = pan.px + (e.clientX - pan.x); state.pan.y = pan.py + (e.clientY - pan.y); applyTransform(); });
1355
- window.addEventListener("pointerup", (e) => { if (pan && pan.clickEmpty && Math.abs(e.clientX - pan.x) < 3 && Math.abs(e.clientY - pan.y) < 3) select(null); pan = null; });
1356
- window.addEventListener("pointercancel", () => { pan = null; });
1655
+ window.addEventListener("pointerup", (e) => {
1656
+ if (pan) pan = null;
1657
+ if (marquee) {
1658
+ const small = Math.abs(e.clientX - marquee.x0) < 3 && Math.abs(e.clientY - marquee.y0) < 3;
1659
+ if (small) { if (!marquee.base.size) select(null); }
1660
+ else marqueeSelect(marquee.x0, marquee.y0, e.clientX, e.clientY, marquee.base);
1661
+ if (marqueeEl) marqueeEl.style.display = "none";
1662
+ marquee = null;
1663
+ }
1664
+ });
1665
+ window.addEventListener("pointercancel", () => { pan = null; if (marqueeEl) marqueeEl.style.display = "none"; marquee = null; });
1357
1666
  window.addEventListener("keydown", (e) => { if (e.code === "Space" && !/INPUT|SELECT|TEXTAREA/.test(e.target.tagName || "")) { spaceDown = true; stage.style.cursor = "grab"; } });
1358
1667
  window.addEventListener("keyup", (e) => { if (e.code === "Space") { spaceDown = false; stage.style.cursor = ""; } });
1359
1668
  document.addEventListener("keydown", (e) => {
1360
1669
  const exportOpen = !$("#exportModal").classList.contains("hidden");
1361
1670
  const v3dOpen = !$("#view3dModal").classList.contains("hidden");
1671
+ const helpOpen = !$("#helpModal").classList.contains("hidden");
1362
1672
  // Escape closes a modal first (works even when a slider inside it has focus)
1363
- if (e.key === "Escape" && (exportOpen || v3dOpen)) { if (exportOpen) $("#exportModal").classList.add("hidden"); else $("#view3dModal").classList.add("hidden"); return; }
1673
+ if (e.key === "Escape" && (exportOpen || v3dOpen || helpOpen)) { if (exportOpen) closeModal($("#exportModal")); else if (v3dOpen) closeModal($("#view3dModal")); else closeModal($("#helpModal")); return; }
1364
1674
  const tgt = e.target;
1365
- if (tgt.isContentEditable || tgt.tagName === "INPUT" || tgt.tagName === "SELECT" || tgt.tagName === "TEXTAREA") { if (tgt.tagName === "INPUT" && tgt.type === "text" && e.key === "Escape") tgt.blur(); return; }
1675
+ // Only text-like fields own their keys (typing, native Ctrl+C, Esc-revert). A focused
1676
+ // range/checkbox/color/select must still let modifier chords, Delete and Escape through
1677
+ // to the global handlers (previously every INPUT swallowed Ctrl+Z).
1678
+ const textLike = tgt.isContentEditable || tgt.tagName === "TEXTAREA" || (tgt.tagName === "INPUT" && /text|number|search|email|tel|url|password/.test(tgt.type));
1679
+ if (textLike) return;
1680
+ if ((tgt.tagName === "INPUT" || tgt.tagName === "SELECT") && !e.ctrlKey && !e.metaKey && !["Delete", "Backspace", "Escape"].includes(e.key)) return; // plain keys (arrows) stay native on a focused control
1366
1681
  if (e.key === "Escape") { select(null); return; }
1367
1682
  if (exportOpen || v3dOpen) return; // a modal is up: global shortcuts must not edit the figure behind it
1368
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { e.preventDefault(); e.shiftKey ? redo() : undo(); }
1369
- else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") { e.preventDefault(); redo(); }
1683
+ const mod = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();
1684
+ if (mod && k === "z") { e.preventDefault(); e.shiftKey ? redo() : undo(); }
1685
+ else if (mod && k === "y") { e.preventDefault(); redo(); }
1686
+ else if (mod && k === "a") { e.preventDefault(); state.selection = new Set([...state.spec.plots.map((p) => "plot:" + p.id), ...state.spec.figure_artists.map((_, j) => "figartist:" + j)]); state.selected = state.selection.size ? [...state.selection].pop() : null; refreshSelection(); buildInspector(); }
1687
+ else if (mod && k === "c") { e.preventDefault(); copySelection(); }
1688
+ else if (mod && k === "v") { e.preventDefault(); pasteClipboard(); }
1689
+ else if (mod && k === "x") { e.preventDefault(); copySelection(); deleteSelection(); }
1690
+ else if (mod && k === "d") { e.preventDefault(); copySelection(); pasteClipboard(); }
1370
1691
  else if (e.key.startsWith("Arrow") && state.selected) { e.preventDefault(); const s = e.shiftKey ? 0.05 : 0.005; if (e.key === "ArrowLeft") nudgeSelected(-s, 0); else if (e.key === "ArrowRight") nudgeSelected(s, 0); else if (e.key === "ArrowUp") nudgeSelected(0, s); else if (e.key === "ArrowDown") nudgeSelected(0, -s); }
1371
- else if ((e.key === "Delete" || e.key === "Backspace") && state.selected) {
1372
- e.preventDefault(); pushHist();
1373
- const ids = state.selection.size ? [...state.selection] : [state.selected];
1374
- const plotIds = new Set(ids.filter((id) => id.startsWith("plot:")).map((id) => id.slice(5)));
1375
- if (plotIds.size) state.spec.plots = state.spec.plots.filter((p) => !plotIds.has(p.id));
1376
- const artDel = new Map(), lineDel = new Map(), figDel = [];
1377
- for (const id of ids) {
1378
- if (id.startsWith("artist:")) { const [, pid, j] = id.split(":"); if (!artDel.has(pid)) artDel.set(pid, []); artDel.get(pid).push(+j); }
1379
- else if (id.startsWith("line:")) { const [, pid, j] = id.split(":"); if (!lineDel.has(pid)) lineDel.set(pid, []); lineDel.get(pid).push(+j); }
1380
- else if (id.startsWith("figartist:")) figDel.push(+id.slice(10));
1381
- else if (id.startsWith("title:")) { const p = state.spec.plots.find((x) => x.id === id.slice(6)); if (p) p.title.visible = false; }
1382
- }
1383
- // splice high-to-low so deleting several items at once doesn't shift the other indices
1384
- for (const [pid, js] of artDel) { const p = state.spec.plots.find((x) => x.id === pid); if (p) js.sort((a, b) => b - a).forEach((j) => p.artists.splice(j, 1)); }
1385
- for (const [pid, js] of lineDel) { const p = state.spec.plots.find((x) => x.id === pid); if (p && p.extra_lines) js.sort((a, b) => b - a).forEach((j) => p.extra_lines.splice(j, 1)); }
1386
- figDel.sort((a, b) => b - a).forEach((j) => state.spec.figure_artists.splice(j, 1));
1387
- select(null); commit();
1388
- }
1692
+ else if ((e.key === "Delete" || e.key === "Backspace") && (state.selected || state.selection.size)) { e.preventDefault(); deleteSelection(); }
1389
1693
  });
1390
1694
  // settle (full-quality render + save) the instant an arrow key is released
1391
1695
  document.addEventListener("keyup", (e) => {
@@ -16,6 +16,7 @@
16
16
  })();
17
17
  </script>
18
18
  <title>Fig Foundry</title>
19
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='2' y='2' width='60' height='60' rx='15' fill='%232f6690'/%3E%3Cpolyline points='16,45 27,31 35,39 49,20' fill='none' stroke='%23fff' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
19
20
  <link rel="stylesheet" href="/static/style.css">
20
21
  <link rel="stylesheet" href="/static/canvas.css">
21
22
  </head>
@@ -7,6 +7,7 @@
7
7
  artwork stays publication-clean no matter what the UI chrome looks like.
8
8
  ========================================================================== */
9
9
  :root {
10
+ color-scheme: light;
10
11
  /* surfaces */
11
12
  --bg: #eef0f3;
12
13
  --panel: #ffffff;
@@ -17,7 +18,7 @@
17
18
  /* text */
18
19
  --ink: #1c2026;
19
20
  --muted: #58616d;
20
- --faint: #79828e;
21
+ --faint: #5d6673;
21
22
  /* lines */
22
23
  --line: #e6e8ec;
23
24
  --line-strong: #d3d8de;
@@ -50,6 +51,7 @@
50
51
  }
51
52
 
52
53
  [data-theme="dark"] {
54
+ color-scheme: dark;
53
55
  --bg: #0f1115;
54
56
  --panel: #171a21;
55
57
  --panel-2: #1d212a;
@@ -285,6 +287,13 @@ input[type="text"] {
285
287
  transition: border-color .12s, box-shadow .12s;
286
288
  }
287
289
  input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
290
+ textarea.ctl-area {
291
+ width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
292
+ font-size: 13px; line-height: 1.35; color: var(--ink); background: var(--input-bg); outline: none;
293
+ font-family: inherit; resize: vertical; min-height: 42px;
294
+ transition: border-color .12s, box-shadow .12s;
295
+ }
296
+ textarea.ctl-area:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
288
297
  .color-row { display: flex; align-items: center; gap: 10px; }
289
298
  input[type="color"] {
290
299
  width: 38px; height: 30px; border: 1px solid var(--line); border-radius: 7px;
@@ -347,3 +356,10 @@ input[type="color"] {
347
356
  ::-webkit-scrollbar { width: 11px; height: 11px; }
348
357
  ::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 6px; border: 3px solid transparent; background-clip: content-box; }
349
358
  ::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); border: 3px solid transparent; background-clip: content-box; }
359
+
360
+ /* responsive topbar: wrap + compact below ~1100px so the Export button never clips */
361
+ @media (max-width: 1100px) {
362
+ .topbar { flex-wrap: wrap; height: auto; padding: 8px 12px; row-gap: 8px; }
363
+ .fig-name { flex: 1 1 120px; max-width: 170px; }
364
+ #zoomLabel { display: none; }
365
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "figfoundry"
3
- version = "0.2.1"
3
+ version = "0.3.1"
4
4
  description = "WYSIWYG matplotlib figure composer: load data (.npz/.pkl), drag signals onto a canvas to build plots, and export clean reproducible code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -336,7 +336,7 @@ wheels = [
336
336
 
337
337
  [[package]]
338
338
  name = "figfoundry"
339
- version = "0.1.0"
339
+ version = "0.2.1"
340
340
  source = { editable = "." }
341
341
  dependencies = [
342
342
  { name = "fastapi" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes