figfoundry 0.2.1__tar.gz → 0.3.0__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.0
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,75 @@ 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
+ const onMove = (ev) => {
256
+ let d = (Math.atan2(ev.clientY - cy0, ev.clientX - cx0) - startAng) * 180 / Math.PI;
257
+ if (ev.shiftKey) d = Math.round(d / 15) * 15;
258
+ let r = Math.round(rot0 + d); r = ((r % 360) + 360) % 360; if (r > 180) r -= 360;
259
+ tgt.set(r); movDiv.style.transform = `rotate(${r}deg)`; scheduleDraft();
260
+ };
261
+ const onUp = (ev) => { try { h.releasePointerCapture(ev.pointerId); } catch (_) {} document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onUp); movDiv.style.transform = ""; commitSnapshot(); commit(); };
262
+ document.addEventListener("pointermove", onMove); document.addEventListener("pointerup", onUp);
263
+ });
264
+ movDiv.appendChild(h);
265
+ }
199
266
 
200
267
  function refreshSelection() {
201
268
  overlay.querySelectorAll(".plotbox").forEach((d) => d.classList.toggle("selected", state.selection.has("plot:" + d.dataset.pid)));
202
269
  overlay.querySelectorAll(".mov").forEach((d) => d.classList.toggle("selected", state.selection.has(d.dataset.id)));
203
270
  document.querySelectorAll(".layer").forEach((l) => l.classList.toggle("active", l.dataset.id && state.selection.has(l.dataset.id)));
271
+ drawGroupBox();
272
+ }
273
+ // Dashed outline around a multi-selection so the group's extent is visible.
274
+ function drawGroupBox() {
275
+ let gb = document.getElementById("groupBox");
276
+ if (state.selection.size < 2) { if (gb) gb.remove(); return; }
277
+ const r = state.render; if (!r) return;
278
+ let l = 1, b = 1, rr = 0, tt = 0, any = false;
279
+ 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]); };
280
+ for (const id of state.selection) {
281
+ if (id.startsWith("plot:")) { const p = state.spec.plots.find((x) => x.id === id.slice(5)); if (p) absorb(p.rect); }
282
+ 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]); }
283
+ }
284
+ if (!any) return;
285
+ if (!gb) { gb = document.createElement("div"); gb.id = "groupBox"; overlay.appendChild(gb); }
286
+ gb.style.left = (l * 100) + "%"; gb.style.top = ((1 - tt) * 100) + "%";
287
+ gb.style.width = ((rr - l) * 100) + "%"; gb.style.height = ((tt - b) * 100) + "%";
288
+ gb.style.display = "block";
289
+ }
290
+ // Rubber-band select: everything whose bbox intersects the drag rect (in figure
291
+ // fractions). Shift adds to the current selection.
292
+ function marqueeSelect(x0, y0, x1, y1, base) {
293
+ const a = toFig(x0, y0), b = toFig(x1, y1);
294
+ const L = Math.min(a.fx, b.fx), R = Math.max(a.fx, b.fx);
295
+ const T = 1 - Math.min(a.fyTop, b.fyTop), B = 1 - Math.max(a.fyTop, b.fyTop);
296
+ const hit = (box) => box[0] < R && box[0] + box[2] > L && box[1] < T && box[1] + box[3] > B;
297
+ const sel = new Set(base);
298
+ for (const p of state.spec.plots) if (hit(p.rect)) sel.add("plot:" + p.id);
299
+ const r = state.render;
300
+ 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); }
301
+ state.selection = sel; state.selected = sel.size ? [...sel].pop() : null;
302
+ refreshSelection(); buildInspector();
204
303
  }
205
304
 
206
305
  /* ---------------- move / resize / drag ---------------- */
@@ -212,7 +311,7 @@ function makeMovable(el, id, mode) {
212
311
  const shift = e.shiftKey;
213
312
  const handle = (e.target.dataset && e.target.dataset.h) || null;
214
313
  const startF = toFig(e.clientX, e.clientY);
215
- let moved = false, onMove, onUp, sx = 0, sy = 0;
314
+ let moved = false, onMove, onUp, sx = 0, sy = 0, lastDxF = 0, lastDyF = 0, isGroup = false, groupDivs = [el];
216
315
  if (mode === "box") {
217
316
  const p = state.spec.plots.find((x) => x.id === id.slice(5));
218
317
  const rect0 = p.rect.slice();
@@ -224,6 +323,9 @@ function makeMovable(el, id, mode) {
224
323
  onMove = (ev) => {
225
324
  const F = toFig(ev.clientX, ev.clientY);
226
325
  const dxF = F.fx - startF.fx, dyBF = startF.fyTop - F.fyTop;
326
+ 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
327
+ if (!moved) pushHist(); // capture pre-drag state so a move/resize is undoable
328
+ moved = true;
227
329
  if (handle === null) {
228
330
  const rect = rect0.slice(); rect[0] += dxF; rect[1] += dyBF;
229
331
  const guides = snapRect(rect, others, r.img_w, r.img_h);
@@ -244,14 +346,31 @@ function makeMovable(el, id, mode) {
244
346
  Object.assign(el.style, rectToCss(rect));
245
347
  }
246
348
  scheduleDraft();
247
- moved = true;
248
349
  };
249
350
  } else {
250
351
  sx = e.clientX; sy = e.clientY;
352
+ const r0 = state.render;
353
+ const imgWpx = r0 ? r0.img_w * state.zoom : 1, imgHpx = r0 ? r0.img_h * state.zoom : 1;
354
+ const hs0 = r0 && r0.hotspots.find((h) => h.id === id);
355
+ 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;
356
+ const others = startBox ? snapModel(id) : [];
357
+ isGroup = state.selection.size > 1 && state.selection.has(id);
358
+ groupDivs = isGroup ? [...overlay.querySelectorAll(".mov")].filter((d) => state.selection.has(d.dataset.id)) : [el];
251
359
  onMove = (ev) => {
252
360
  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(); }
361
+ if (!moved && Math.abs(dx) < 3 && Math.abs(dy) < 3) return; // sub-3px = a click, not a drag
362
+ if (!moved) pushHist(); // capture pre-drag state so a move is undoable
363
+ moved = true;
364
+ let dxF = dx / imgWpx, dyF = -dy / imgHpx;
365
+ if (startBox) {
366
+ const box = startBox.slice(); box[0] += dxF; box[1] += dyF;
367
+ drawGuides(snapBoxPos(box, others, r0.img_w, r0.img_h));
368
+ lastDxF = box[0] - startBox[0]; lastDyF = box[1] - startBox[1]; // the snapped delta
369
+ } else { lastDxF = dxF; lastDyF = dyF; }
370
+ // el sits inside the zoomed stageInner, so its local px are screen px / zoom;
371
+ // a group drag moves every selected label by the same (snapped) delta.
372
+ groupDivs.forEach((d) => { d.style.transform = `translate(${lastDxF * imgWpx / state.zoom}px, ${-lastDyF * imgHpx / state.zoom}px)`; });
373
+ scheduleDraft();
255
374
  };
256
375
  }
257
376
  onUp = (ev) => {
@@ -259,9 +378,9 @@ function makeMovable(el, id, mode) {
259
378
  document.removeEventListener("pointerup", onUp);
260
379
  document.removeEventListener("pointercancel", onUp);
261
380
  drawGuides([]);
262
- el.style.transform = "";
381
+ groupDivs.forEach((d) => { d.style.transform = ""; });
263
382
  if (!moved) { select(id, shift); return; }
264
- if (mode !== "box") commitArtDrag(id, ev.clientX - sx, ev.clientY - sy);
383
+ if (mode !== "box") { const ids = isGroup ? [...state.selection] : [id]; for (const eid of ids) commitArtDrag(eid, lastDxF, lastDyF); }
265
384
  commit();
266
385
  };
267
386
  document.addEventListener("pointermove", onMove);
@@ -270,11 +389,9 @@ function makeMovable(el, id, mode) {
270
389
  });
271
390
  }
272
391
 
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;
392
+ function commitArtDrag(id, dxF, dyF) {
393
+ // Apply the drag DELTA (in figure fractions, already snapped) to the element's own
394
+ // coordinates, so the element lands exactly where the user dropped it.
278
395
  const r = state.spec;
279
396
  if (id.startsWith("title:")) {
280
397
  const pid = id.slice(6); const p = r.plots.find((x) => x.id === pid);
@@ -320,6 +437,47 @@ async function commit() {
320
437
  await doRender(false);
321
438
  }
322
439
 
440
+ /* ---------------- clipboard (copy / paste / cut / duplicate) ----------------
441
+ Copies top-level elements: plots (with their artists/lines) and figure_artists.
442
+ Signal refs are name-based, so a same-figure paste is always valid. */
443
+ function copySelection() {
444
+ const ids = state.selection.size ? [...state.selection] : (state.selected ? [state.selected] : []);
445
+ if (!ids.length) return;
446
+ const plotIds = new Set(ids.filter((i) => i.startsWith("plot:")).map((i) => i.slice(5)));
447
+ const figIdxs = ids.filter((i) => i.startsWith("figartist:")).map((i) => +i.slice(10));
448
+ const plots = state.spec.plots.filter((p) => plotIds.has(p.id)).map((p) => clone(p));
449
+ const figs = figIdxs.map((j) => clone(state.spec.figure_artists[j]));
450
+ if (!plots.length && !figs.length) return;
451
+ state.clipboard = { plots, figs };
452
+ toast("Copied " + (plots.length + figs.length) + " element" + (plots.length + figs.length > 1 ? "s" : ""));
453
+ }
454
+ function pasteClipboard() {
455
+ const cb = state.clipboard; if (!cb || (!cb.plots.length && !cb.figs.length)) return;
456
+ pushHist();
457
+ 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); }
458
+ 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); }
459
+ select(null); commit();
460
+ }
461
+ function deleteSelection() {
462
+ if (!state.selected && !state.selection.size) return;
463
+ pushHist();
464
+ const ids = state.selection.size ? [...state.selection] : [state.selected];
465
+ const plotIds = new Set(ids.filter((id) => id.startsWith("plot:")).map((id) => id.slice(5)));
466
+ if (plotIds.size) state.spec.plots = state.spec.plots.filter((p) => !plotIds.has(p.id));
467
+ const artDel = new Map(), lineDel = new Map(), figDel = [];
468
+ for (const id of ids) {
469
+ if (id.startsWith("artist:")) { const [, pid, j] = id.split(":"); if (!artDel.has(pid)) artDel.set(pid, []); artDel.get(pid).push(+j); }
470
+ else if (id.startsWith("line:")) { const [, pid, j] = id.split(":"); if (!lineDel.has(pid)) lineDel.set(pid, []); lineDel.get(pid).push(+j); }
471
+ else if (id.startsWith("figartist:")) figDel.push(+id.slice(10));
472
+ else if (id.startsWith("title:")) { const p = state.spec.plots.find((x) => x.id === id.slice(6)); if (p) p.title.visible = false; }
473
+ }
474
+ // splice high-to-low so deleting several items at once doesn't shift the other indices
475
+ 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)); }
476
+ 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)); }
477
+ figDel.sort((a, b) => b - a).forEach((j) => state.spec.figure_artists.splice(j, 1));
478
+ select(null); commit();
479
+ }
480
+
323
481
  let nudgePushed = false, nudgeTimer = null;
324
482
  function nudgeSelected(dx, dy) {
325
483
  const ids = state.selection.size ? [...state.selection] : (state.selected ? [state.selected] : []);
@@ -336,7 +494,16 @@ function nudgeOne(id, dx, dy) {
336
494
  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
495
  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
496
  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); } } }
497
+ else if (kind === "artist") { const p = state.spec.plots.find((x) => x.id === pid); const a = p && p.artists[j]; if (a) {
498
+ 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)); }
499
+ else if (a.type === "vline") a.x = r4(a.x + dx * 8);
500
+ else if (a.type === "hline") a.y = r4(a.y + dy * 8);
501
+ else if (a.type === "circle" || a.type === "ellipse") { a.x = r4(a.x + dx * 8); a.y = r4(a.y + dy * 8); }
502
+ 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)); }
503
+ else if (a.type === "vspan") { a.x0 = r4(a.x0 + dx * 8); a.x1 = r4(a.x1 + dx * 8); }
504
+ else if (a.type === "annotation") { a.xytext = [r4(a.xytext[0] + dx), r4(a.xytext[1] + dy)]; }
505
+ 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); }
506
+ } }
340
507
  }
341
508
 
342
509
  /* ---------------- signal drag -> create plot ---------------- */
@@ -520,6 +687,8 @@ function addPlotAt(fx, fyB, signal) {
520
687
  pushHist();
521
688
  const w = 0.3, h = 0.3;
522
689
  const rect = [r4(clamp(fx - w / 2, 0, 1 - w)), r4(clamp(fyB - h / 2, 0, 1 - h)), w, h];
690
+ const r = state.render || { img_w: 1000, img_h: 1000 };
691
+ snapRect(rect, state.spec.plots.map((p) => p.rect), r.img_w, r.img_h); // snap the new plot on drop
523
692
  const p = plotForSignal(signal, rect);
524
693
  state.spec.plots.push(p);
525
694
  select("plot:" + p.id);
@@ -701,7 +870,13 @@ function buildFigureElementsSection() {
701
870
  }
702
871
 
703
872
  function layoutAll(op, param) {
704
- const plots = state.spec.plots; if (!plots.length) return;
873
+ // Act on the selected plots when a multi-selection is active, else all plots.
874
+ let plots = state.spec.plots;
875
+ if (state.selection.size > 1) {
876
+ const selIds = new Set([...state.selection].filter((i) => i.startsWith("plot:")).map((i) => i.slice(5)));
877
+ if (selIds.size > 1) plots = plots.filter((p) => selIds.has(p.id));
878
+ }
879
+ if (!plots.length) return;
705
880
  const L = state.spec.layout || { margin: 0.05, gap: 0.04 };
706
881
  const rects = plots.map((p) => p.rect);
707
882
  pushHist();
@@ -788,7 +963,7 @@ function plotTypeSelect(pid) {
788
963
  const p = state.spec.plots[idx(pid)];
789
964
  const w = document.createElement("div"); w.className = "ctl";
790
965
  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";
966
+ const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", "Plot type");
792
967
  const groups = {};
793
968
  for (const [k, v] of Object.entries(PLOT_TYPES)) (groups[v.group] = groups[v.group] || []).push([k, v.label]);
794
969
  for (const gname of Object.keys(groups)) {
@@ -835,9 +1010,11 @@ function buildPlotInspector(body, pid) {
835
1010
  body.appendChild(ctlHead("Axes"));
836
1011
  body.appendChild(ticksText("X ticks (blank = auto)", ["plots", idx(pid), "axes", "xticks"], p.axes.xticks));
837
1012
  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));
1013
+ body.appendChild(labelsText("X tick labels (blank = auto)", ["plots", idx(pid), "axes", "xticklabels"], p.axes.xticklabels));
1014
+ body.appendChild(labelsText("Y tick labels (blank = auto)", ["plots", idx(pid), "axes", "yticklabels"], p.axes.yticklabels));
1015
+ body.appendChild(text("X label", ["plots", idx(pid), "axes", "xlabel"], p.axes.xlabel, true));
839
1016
  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));
1017
+ body.appendChild(text("Y label", ["plots", idx(pid), "axes", "ylabel"], p.axes.ylabel, true));
841
1018
  body.appendChild(check("Show Y label", ["plots", idx(pid), "axes", "ylabel_visible"], p.axes.ylabel_visible));
842
1019
  body.appendChild(hint("Labels and titles support LaTeX mathtext, e.g. <code>$x_0$</code>."));
843
1020
  body.appendChild(num2("X range (blank = auto)", ["plots", idx(pid), "axes", "xlim"], p.axes.xlim));
@@ -855,7 +1032,7 @@ function buildPlotInspector(body, pid) {
855
1032
  body.appendChild(check("Show y tick labels", ["plots", idx(pid), "axes", "labelleft"], p.axes.labelleft));
856
1033
  body.appendChild(ctlHead("Title"));
857
1034
  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));
1035
+ body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], p.title.text, true));
859
1036
  body.appendChild(slider("Title size", ["plots", idx(pid), "title", "fontsize"], p.title.fontsize, 6, 24, 0.5));
860
1037
  body.appendChild(selControl("Title weight", ["plots", idx(pid), "title", "weight"], p.title.weight, [["normal", "Normal"], ["bold", "Bold"]]));
861
1038
  body.appendChild(ctlHead("Artists"));
@@ -869,10 +1046,11 @@ const idx = (pid) => state.spec.plots.findIndex((p) => p.id === pid);
869
1046
 
870
1047
  function buildTitleInspector(body, pid) {
871
1048
  const t = state.spec.plots.find((x) => x.id === pid).title;
872
- body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], t.text));
1049
+ body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], t.text, true));
873
1050
  body.appendChild(slider("X (of panel)", ["plots", idx(pid), "title", "x"], t.x, -0.6, 1.6, 0.02));
874
1051
  body.appendChild(slider("Y (of panel)", ["plots", idx(pid), "title", "y"], t.y, -0.6, 1.6, 0.02));
875
1052
  body.appendChild(slider("Size (pt)", ["plots", idx(pid), "title", "fontsize"], t.fontsize, 6, 24, 0.5));
1053
+ body.appendChild(slider("Rotation (deg)", ["plots", idx(pid), "title", "rotation"], t.rotation ?? 0, -180, 180, 1));
876
1054
  body.appendChild(selControl("Weight", ["plots", idx(pid), "title", "weight"], t.weight, [["normal", "Normal"], ["bold", "Bold"]]));
877
1055
  body.appendChild(color("Color", ["plots", idx(pid), "title", "color"], t.color));
878
1056
  body.appendChild(hint("Drag the title on the canvas to reposition it freely."));
@@ -881,14 +1059,15 @@ function buildTitleInspector(body, pid) {
881
1059
  function buildArtistInspector(body, pid, j) {
882
1060
  const a = state.spec.plots.find((x) => x.id === pid).artists[j];
883
1061
  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")); }
1062
+ 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)); }
1063
+ 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)); }
1064
+ 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
1065
  else if (a.type === "annotation") {
888
- body.appendChild(text("Text", P("text"), a.text));
1066
+ body.appendChild(text("Text", P("text"), a.text, true));
889
1067
  body.appendChild(num2("Arrow points to (data x, y)", P("xy"), a.xy));
890
1068
  body.appendChild(num2("Label at (panel x, y)", P("xytext"), a.xytext));
891
1069
  body.appendChild(slider("Label size", P("fontsize"), a.fontsize ?? 10, 6, 20, 0.5));
1070
+ body.appendChild(slider("Rotation (deg)", P("rotation"), a.rotation ?? 0, -180, 180, 1));
892
1071
  body.appendChild(slider("Arrow width", P("lw"), a.lw ?? 1.2, 0.3, 4, 0.1));
893
1072
  body.appendChild(color("Color", P("color"), a.color || "#000"));
894
1073
  }
@@ -903,22 +1082,22 @@ function buildArtistInspector(body, pid, j) {
903
1082
  body.appendChild(slider("Edge width", P("lw"), a.lw ?? 1, 0, 4, 0.1));
904
1083
  }
905
1084
  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));
1085
+ body.appendChild(slider("X start (data)", P("x0"), a.x0, 0, 20, 0.5, null, true));
1086
+ body.appendChild(slider("X end (data)", P("x1"), a.x1, 0, 20, 0.5, null, true));
908
1087
  body.appendChild(color("Color", P("color"), a.color || "#b03a2e"));
909
1088
  body.appendChild(slider("Opacity", P("alpha"), a.alpha ?? 0.12, 0, 0.6, 0.01));
910
1089
  }
911
1090
  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));
1091
+ body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2, null, true));
1092
+ body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2, null, true));
914
1093
  body.appendChild(slider("Radius", P("r"), a.r ?? 1, 0.05, 10, 0.1));
915
1094
  body.appendChild(check("Filled", P("fill"), !!a.fill));
916
1095
  body.appendChild(color("Color", P("color"), a.color || "#000"));
917
1096
  body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1));
918
1097
  }
919
1098
  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));
1099
+ body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2, null, true));
1100
+ body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2, null, true));
922
1101
  body.appendChild(slider("Width", P("w"), a.w ?? 2, 0.1, 10, 0.1));
923
1102
  body.appendChild(slider("Height", P("h"), a.h ?? 1, 0.1, 10, 0.1));
924
1103
  body.appendChild(slider("Angle (deg)", P("angle"), a.angle ?? 0, -180, 180, 5));
@@ -930,13 +1109,29 @@ function buildArtistInspector(body, pid, j) {
930
1109
  body.appendChild(check("Filled", P("fill"), !!a.fill));
931
1110
  body.appendChild(color("Color", P("color"), a.color || "#000"));
932
1111
  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."));
1112
+ const pts = (a.points && a.points.length) ? a.points.slice() : [[4, 0], [6, 0], [5, 1]];
1113
+ body.appendChild(ctlHead("Points (data coords)"));
1114
+ const plist = document.createElement("div"); plist.className = "art-list";
1115
+ pts.forEach((pt, vi) => {
1116
+ const r2 = document.createElement("div"); r2.className = "num-row";
1117
+ const nx = document.createElement("input"), ny = document.createElement("input"), sep = document.createElement("span");
1118
+ sep.className = "sep"; sep.textContent = ","; nx.type = ny.type = "text"; nx.value = pt[0]; ny.value = pt[1];
1119
+ 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(); };
1120
+ nx.addEventListener("input", up); ny.addEventListener("input", up);
1121
+ nx.addEventListener("change", () => { commitSnapshot(); commit(); }); ny.addEventListener("change", () => { commitSnapshot(); commit(); });
1122
+ const rmv = document.createElement("span"); rmv.textContent = "×"; rmv.style.cssText = "cursor:pointer;color:var(--faint);padding:0 6px";
1123
+ rmv.onclick = () => { pushHist(); pts.splice(vi, 1); a.points = pts.slice(); select("artist:" + pid + ":" + j); commit(); };
1124
+ r2.append(nx, sep, ny, rmv); plist.appendChild(r2);
1125
+ });
1126
+ const addPt = document.createElement("button"); addPt.className = "pill"; addPt.textContent = "+ Point";
1127
+ addPt.onclick = () => { pushHist(); pts.push([5, 5]); a.points = pts.slice(); select("artist:" + pid + ":" + j); commit(); };
1128
+ body.appendChild(plist); body.appendChild(addPt);
934
1129
  }
935
1130
  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));
1131
+ body.appendChild(slider("From X", P("x0"), a.x0, -10, 20, 0.2, null, true));
1132
+ body.appendChild(slider("From Y", P("y0"), a.y0, -10, 10, 0.2, null, true));
1133
+ body.appendChild(slider("To X", P("x1"), a.x1, -10, 20, 0.2, null, true));
1134
+ body.appendChild(slider("To Y", P("y1"), a.y1, -10, 10, 0.2, null, true));
940
1135
  body.appendChild(color("Color", P("color"), a.color || "#000"));
941
1136
  body.appendChild(slider("Width", P("lw"), a.lw ?? 1.5, 0.3, 4, 0.1));
942
1137
  }
@@ -950,10 +1145,11 @@ function buildFigartistInspector(body, j) {
950
1145
  const fa = state.spec.figure_artists[j];
951
1146
  const P = (...k) => ["figure_artists", j, ...k];
952
1147
  if (fa.type === "text") {
953
- body.appendChild(text("Text", P("text"), fa.text));
1148
+ body.appendChild(text("Text", P("text"), fa.text, true));
954
1149
  body.appendChild(slider("X", P("x"), fa.x, 0, 1, 0.01));
955
1150
  body.appendChild(slider("Y", P("y"), fa.y, 0, 1, 0.01));
956
1151
  body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 12, 6, 26, 0.5));
1152
+ body.appendChild(slider("Rotation (deg)", P("rotation"), fa.rotation ?? 0, -180, 180, 1));
957
1153
  body.appendChild(color("Color", P("color"), fa.color || "#000"));
958
1154
  } else if (fa.type === "legend") {
959
1155
  body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 10, 6, 20, 0.5));
@@ -967,25 +1163,29 @@ function buildFigartistInspector(body, j) {
967
1163
  const tin = document.createElement("input"); tin.type = "text"; tin.value = lb; tin.style.flex = "2";
968
1164
  tin.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); fa.labels[i] = tin.value; scheduleDraft(); });
969
1165
  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";
1166
+ 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
1167
  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
1168
  mk.value = (fa.handles[i] && fa.handles[i].marker) || "";
973
1169
  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";
1170
+ 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
1171
  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
1172
  ls.value = (fa.handles[i] && fa.handles[i].ls) || "-";
977
1173
  ls.addEventListener("change", () => { pushHist(); fa.handles = fa.handles || []; fa.handles[i] = fa.handles[i] || {}; fa.handles[i].ls = ls.value; commit(); });
1174
+ 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";
1175
+ 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(); });
1176
+ ci.addEventListener("change", () => { commitSnapshot(); commit(); });
978
1177
  const rm = document.createElement("span"); rm.textContent = "×"; rm.style.cssText = "cursor:pointer;color:var(--faint);padding:0 6px;font-size:15px";
979
1178
  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);
1179
+ row.append(tin, ci, mk, ls, rm); elist.appendChild(row);
981
1180
  });
982
1181
  body.appendChild(elist);
983
1182
  const addE = document.createElement("button"); addE.className = "pill"; addE.textContent = "+ Entry";
984
1183
  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
1184
  body.appendChild(addE);
986
1185
  } else if (fa.type === "suptitle") {
987
- body.appendChild(text("Text", P("text"), fa.text));
1186
+ body.appendChild(text("Text", P("text"), fa.text, true));
988
1187
  body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 14, 6, 26, 0.5));
1188
+ body.appendChild(slider("Rotation (deg)", P("rotation"), fa.rotation ?? 0, -180, 180, 1));
989
1189
  body.appendChild(color("Color", P("color"), fa.color || "#000"));
990
1190
  }
991
1191
  body.appendChild(ctlHead(""));
@@ -996,7 +1196,7 @@ function buildFigartistInspector(body, j) {
996
1196
 
997
1197
  function errorbarSelect(p) {
998
1198
  const w = document.createElement("div"); w.className = "ctl"; w.appendChild(ctlHead("Error bars (±)"));
999
- const sel = document.createElement("select"); sel.className = "ctl-sel";
1199
+ const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", "Error bars");
1000
1200
  const none = document.createElement("option"); none.value = ""; none.textContent = "(none)"; sel.appendChild(none);
1001
1201
  for (const s of state.signals) { const o = document.createElement("option"); o.value = s.ref; o.textContent = s.label; sel.appendChild(o); }
1002
1202
  sel.value = p.data.errorbar ? p.data.errorbar.ref : "";
@@ -1060,7 +1260,7 @@ function buildArtistList(body, pid) {
1060
1260
  /* ---------------- control builders ---------------- */
1061
1261
  function ctlHead(t) { const h = document.createElement("div"); h.className = "section"; h.textContent = t; return h; }
1062
1262
  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) {
1263
+ function slider(label, path, val, min, max, step, afterChange, noClamp) {
1064
1264
  const w = document.createElement("div"); w.className = "ctl";
1065
1265
  const head = document.createElement("div"); head.className = "ctl-head";
1066
1266
  const lab = document.createElement("label"); lab.textContent = label;
@@ -1071,7 +1271,7 @@ function slider(label, path, val, min, max, step, afterChange) {
1071
1271
  const vv = document.createElement("input"); vv.type = "text"; vv.className = "val num-in";
1072
1272
  vv.inputMode = "decimal"; vv.spellcheck = false; vv.setAttribute("aria-label", label + " value");
1073
1273
  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;
1274
+ const inp = document.createElement("input"); inp.type = "range"; inp.min = min; inp.max = max; inp.step = step; inp.setAttribute("aria-label", label + " (slider)");
1075
1275
  let cur = val;
1076
1276
  const paint = () => { if (document.activeElement !== vv) vv.value = fmt(cur, step); inp.value = cur; };
1077
1277
  paint();
@@ -1085,7 +1285,9 @@ function slider(label, path, val, min, max, step, afterChange) {
1085
1285
  function commitVal() {
1086
1286
  const v = parseFloat(vv.value);
1087
1287
  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
1288
+ // Data-coordinate fields (noClamp) take the typed value as authoritative; the
1289
+ // range only bounds coarse dragging. Everything else clamps to the slider range.
1290
+ const cv = noClamp ? v : Math.max(min, Math.min(max, v));
1089
1291
  if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec);
1090
1292
  cur = cv; setPath(state.spec, path, cv); inp.value = cv; vv.value = fmt(cv, step);
1091
1293
  commitSnapshot(); if (afterChange) afterChange(); commit();
@@ -1097,38 +1299,67 @@ function slider(label, path, val, min, max, step, afterChange) {
1097
1299
  });
1098
1300
  w.appendChild(inp); return w;
1099
1301
  }
1100
- function text(label, path, val) {
1302
+ // Esc in a half-edited text field reverts to the pre-edit snapshot (not a commit).
1303
+ function wireEscRevert(el) {
1304
+ el.addEventListener("keydown", (e) => {
1305
+ if (e.key === "Escape" && state.sliderSnapshot) {
1306
+ e.preventDefault(); e.stopPropagation();
1307
+ state.spec = state.sliderSnapshot; state.sliderSnapshot = null; updateHist(); commit();
1308
+ }
1309
+ });
1310
+ }
1311
+ function text(label, path, val, multiline) {
1101
1312
  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;
1313
+ const inp = document.createElement(multiline ? "textarea" : "input");
1314
+ if (multiline) { inp.rows = 2; inp.className = "ctl-area"; } else { inp.type = "text"; }
1315
+ inp.value = val == null ? "" : val; inp.placeholder = "Text… (LaTeX like $x_0$ works)"; inp.spellcheck = false; inp.setAttribute("aria-label", label);
1103
1316
  inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, inp.value); scheduleDraft(); });
1104
1317
  inp.addEventListener("change", () => { commitSnapshot(); commit(); });
1318
+ wireEscRevert(inp);
1105
1319
  w.appendChild(inp); return w;
1106
1320
  }
1107
1321
  function ticksText(label, path, val) {
1108
1322
  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(", ") : "";
1323
+ const inp = document.createElement("input"); inp.type = "text"; inp.value = Array.isArray(val) ? val.join(", ") : ""; inp.placeholder = "e.g. 0, 5, 10";
1110
1324
  const parse = () => { const t = inp.value.trim(); return t ? t.split(",").map((s) => parseFloat(s.trim())).filter((n) => !isNaN(n)) : "auto"; };
1111
1325
  inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); });
1112
1326
  inp.addEventListener("change", () => { commitSnapshot(); commit(); });
1327
+ wireEscRevert(inp);
1328
+ w.appendChild(inp); return w;
1329
+ }
1330
+ // Comma-separated STRING list (custom tick labels; LaTeX allowed, blank = none).
1331
+ function labelsText(label, path, val) {
1332
+ 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);
1333
+ 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;
1334
+ const parse = () => { const t = inp.value.trim(); return t ? t.split(",").map((s) => s.trim()) : null; };
1335
+ inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); });
1336
+ inp.addEventListener("change", () => { commitSnapshot(); commit(); });
1337
+ wireEscRevert(inp);
1113
1338
  w.appendChild(inp); return w;
1114
1339
  }
1115
1340
  function num2(label, path, val) {
1116
1341
  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
1342
  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";
1343
+ 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
1344
  a.value = val ? val[0] : ""; b.value = val ? val[1] : "";
1120
1345
  const parse = () => { const x = parseFloat(a.value), y = parseFloat(b.value); return (a.value && b.value && !isNaN(x) && !isNaN(y)) ? [x, y] : null; };
1121
1346
  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
1347
  a.addEventListener("change", () => { commitSnapshot(); commit(); }); b.addEventListener("change", () => { commitSnapshot(); commit(); });
1348
+ wireEscRevert(a); wireEscRevert(b);
1123
1349
  row.append(a, sep, b); w.appendChild(row); return w;
1124
1350
  }
1125
1351
  function color(label, path, val) {
1126
1352
  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
1353
  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(); });
1354
+ const isHex = (v) => /^#[0-9a-f]{6}$/i.test(v || "");
1355
+ const inp = document.createElement("input"); inp.type = "color"; inp.value = isHex(val) ? val : "#000000"; inp.setAttribute("aria-label", label + " (color picker)");
1356
+ 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)");
1357
+ const apply = (v) => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, v); if (isHex(v)) inp.value = v; scheduleDraft(); };
1358
+ inp.addEventListener("input", () => { apply(inp.value); hex.value = inp.value; });
1131
1359
  inp.addEventListener("change", () => { commitSnapshot(); commit(); });
1360
+ hex.addEventListener("input", () => { apply(hex.value); });
1361
+ hex.addEventListener("change", () => { commitSnapshot(); commit(); });
1362
+ wireEscRevert(hex);
1132
1363
  row.append(inp, hex); w.appendChild(row); return w;
1133
1364
  }
1134
1365
  function check(label, path, val) {
@@ -1145,20 +1376,20 @@ function checkInline(label, path, val) {
1145
1376
  }
1146
1377
  function selControl(label, path, val, options) {
1147
1378
  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";
1379
+ const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", label);
1149
1380
  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
1381
  sel.addEventListener("change", () => { pushHist(); setPath(state.spec, path, sel.value); commit(); });
1151
1382
  w.appendChild(sel); return w;
1152
1383
  }
1153
1384
  function sigSelect(label, path, val, allowNone) {
1154
1385
  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";
1386
+ const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", label);
1156
1387
  if (allowNone) { const o = document.createElement("option"); o.value = ""; o.textContent = "(none)"; if (!val) o.selected = true; sel.appendChild(o); }
1157
1388
  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
1389
  sel.addEventListener("change", () => { pushHist(); setPath(state.spec, path, sel.value || null); commit(); });
1159
1390
  w.appendChild(sel); return w;
1160
1391
  }
1161
- function fmt(v, step) { if (v == null) return "(default)"; return step < 1 ? String(Math.round(v * 100) / 100) : String(Math.round(v)); }
1392
+ 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
1393
 
1163
1394
  /* ---------------- zoom ---------------- */
1164
1395
  function applyTransform() {
@@ -1195,7 +1426,7 @@ function fitZoom() {
1195
1426
  async function openExport() {
1196
1427
  const { code } = await get(`/api/canvases/${state.slug}/code`);
1197
1428
  $("#codeBody").textContent = code; $("#codePath").textContent = (state.name || "canvas") + ".py";
1198
- $("#exportModal").classList.remove("hidden");
1429
+ openModal($("#exportModal"));
1199
1430
  }
1200
1431
 
1201
1432
  /* ---------------- 3-D view finder (double-click a 3-D plot) ---------------- */
@@ -1223,7 +1454,7 @@ async function openView3D(pid) {
1223
1454
  $("#v3e").value = v3d.elev; $("#v3a").value = v3d.azim; $("#v3d").value = v3d.dist;
1224
1455
  $("#v3invR").checked = v3d.invertR; $("#v3invZ").checked = v3d.invertZ;
1225
1456
  v3dVals();
1226
- $("#view3dModal").classList.remove("hidden");
1457
+ openModal($("#view3dModal"));
1227
1458
  v3dRender();
1228
1459
  }
1229
1460
  function v3dRender() {
@@ -1267,17 +1498,45 @@ function v3dWire() {
1267
1498
  pushHist();
1268
1499
  const p = state.spec.plots.find((x) => x.id === v3d.pid);
1269
1500
  p.style.elev = v3d.elev; p.style.azim = v3d.azim; p.style.dist = v3d.dist;
1270
- $("#view3dModal").classList.add("hidden");
1501
+ closeModal($("#view3dModal"));
1271
1502
  commit();
1272
1503
  };
1273
- const close = () => $("#view3dModal").classList.add("hidden");
1504
+ const close = () => closeModal($("#view3dModal"));
1274
1505
  $("#v3dCancel").onclick = close; $("#view3dClose").onclick = close;
1275
1506
  $("#view3dModal").onclick = (e) => { if (e.target === $("#view3dModal")) close(); };
1276
1507
  }
1277
1508
 
1278
1509
  /* ---------------- toast ---------------- */
1279
1510
  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); }
1511
+ 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); }
1512
+
1513
+ /* ---------------- modal focus management ----------------
1514
+ Move focus into the dialog on open, trap Tab within it, and restore focus to
1515
+ the trigger on close (a11y: aria-modal="true" without a trap lets Tab escape). */
1516
+ let modalLastFocus = null;
1517
+ function _focusables(el) { return [...el.querySelectorAll("button, input, select, textarea")].filter((n) => !n.disabled && n.offsetParent !== null); }
1518
+ 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(); }
1519
+ 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 (_) {} } }
1520
+ // Styled confirm (replaces the native window.confirm) — resolves true/false.
1521
+ function confirmDialog(message, okLabel) {
1522
+ return new Promise((resolve) => {
1523
+ let box = document.getElementById("confirmModal");
1524
+ if (!box) {
1525
+ box = document.createElement("div"); box.id = "confirmModal"; box.className = "modal hidden"; box.setAttribute("role", "dialog"); box.setAttribute("aria-modal", "true");
1526
+ 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>`;
1527
+ document.body.appendChild(box);
1528
+ }
1529
+ box.querySelector(".confirm-msg").textContent = message;
1530
+ const ok = box.querySelector(".confirm-ok"), cancel = box.querySelector(".confirm-cancel");
1531
+ ok.textContent = okLabel || "Confirm";
1532
+ let settled = false;
1533
+ const done = (v) => { if (settled) return; settled = true; box.classList.add("hidden"); box.onclick = null; ok.onclick = cancel.onclick = null; resolve(v); };
1534
+ ok.onclick = () => done(true);
1535
+ cancel.onclick = () => done(false);
1536
+ box.onclick = (e) => { if (e.target === box) done(false); };
1537
+ openModal(box);
1538
+ });
1539
+ }
1281
1540
 
1282
1541
  /* ---------------- boot ---------------- */
1283
1542
  async function boot() {
@@ -1289,6 +1548,7 @@ async function boot() {
1289
1548
  doc = await get(`/api/canvases/${slug}`);
1290
1549
  } catch (e) { return showLoadError(e); }
1291
1550
  state.name = doc.name;
1551
+ document.title = "Fig Foundry — " + state.name;
1292
1552
  state.spec = doc.spec;
1293
1553
  state.dataset = doc.spec.dataset || null;
1294
1554
  let sigRes;
@@ -1311,11 +1571,32 @@ function showLoadError(e) {
1311
1571
  }
1312
1572
  function wireChrome() {
1313
1573
  v3dWire();
1574
+ // The key handler accepts ⌘ on macOS (metaKey); make the visible labels match.
1575
+ const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
1576
+ const MOD = isMac ? "⌘" : "Ctrl";
1577
+ $("#undoBtn").title = "Undo (" + MOD + "+Z)";
1578
+ $("#redoBtn").title = "Redo (" + MOD + "Shift+Z)";
1314
1579
  $("#themeBtn").onclick = cycleTheme;
1580
+ // Help / shortcuts modal
1581
+ const helpRows = [
1582
+ [MOD + "+Z", "Undo"], [MOD + "+Shift+Z", "Redo"],
1583
+ [MOD + "+A", "Select all"], [MOD + "+C / V / X", "Copy / paste / cut"], [MOD + "+D", "Duplicate"],
1584
+ ["Delete / Backspace", "Delete selection"], ["Esc", "Deselect / close dialog"],
1585
+ ["Arrow keys", "Nudge (Shift = bigger)"], ["Shift+click", "Add to / remove from selection"],
1586
+ ["Drag empty space", "Marquee select"], ["Space+drag / middle-drag", "Pan"],
1587
+ ["Scroll", "Zoom"], ["Double-click a 3-D plot", "Open the viewfinder"],
1588
+ ];
1589
+ $("#helpList").innerHTML = helpRows.map(([k, d]) => `<span class="k">${k}</span><span class="d">${d}</span>`).join("");
1590
+ const helpModal = $("#helpModal");
1591
+ $("#helpBtn").onclick = () => openModal(helpModal);
1592
+ $("#helpClose").onclick = () => closeModal(helpModal);
1593
+ $("#helpOk").onclick = () => closeModal(helpModal);
1594
+ helpModal.onclick = (e) => { if (e.target === helpModal) closeModal(helpModal); };
1315
1595
  $("#backBtn").onclick = () => { location.href = "/"; };
1316
1596
  $("#figName").addEventListener("change", async () => {
1317
1597
  state.name = $("#figName").value.trim() || "Untitled";
1318
1598
  $("#figName").value = state.name;
1599
+ document.title = "Fig Foundry — " + state.name;
1319
1600
  await api(`/api/canvases/${state.slug}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: state.name }) });
1320
1601
  toast("Renamed to " + state.name);
1321
1602
  });
@@ -1325,7 +1606,7 @@ function wireChrome() {
1325
1606
  location.href = "/canvas?slug=" + r.slug;
1326
1607
  };
1327
1608
  $("#deleteBtn").onclick = async () => {
1328
- if (!confirm('Delete "' + state.name + '"? This cannot be undone.')) return;
1609
+ if (!(await confirmDialog('Delete "' + state.name + '"? This cannot be undone.', "Delete"))) return;
1329
1610
  await api(`/api/canvases/${state.slug}`, { method: "DELETE" });
1330
1611
  location.href = "/";
1331
1612
  };
@@ -1334,58 +1615,78 @@ function wireChrome() {
1334
1615
  $("#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
1616
  $("#loadDataBtn").onclick = (e) => { e.stopPropagation(); loadDataMenu(e.clientX, e.clientY); };
1336
1617
  $("#exportBtn").onclick = openExport;
1337
- $("#modalClose").onclick = () => $("#exportModal").classList.add("hidden");
1338
- $("#exportModal").onclick = (e) => { if (e.target === $("#exportModal")) $("#exportModal").classList.add("hidden"); };
1618
+ $("#modalClose").onclick = () => closeModal($("#exportModal"));
1619
+ $("#exportModal").onclick = (e) => { if (e.target === $("#exportModal")) closeModal($("#exportModal")); };
1339
1620
  $("#copyCode").onclick = async () => { try { await navigator.clipboard.writeText($("#codeBody").textContent); toast("Code copied"); } catch (e) { toast("Copy failed", true); } };
1340
1621
  $("#downloadCode").onclick = () => { location.href = `/api/canvases/${state.slug}/download`; toast("Downloading " + (state.name || "canvas") + ".py"); };
1341
1622
  $("#dlPng").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=png`; };
1342
1623
  $("#dlSvg").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=svg`; };
1343
1624
  $("#dlPdf").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=pdf`; };
1344
- let pan = null, spaceDown = false;
1625
+ let pan = null, spaceDown = false, marquee = null, marqueeEl = null;
1345
1626
  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
1627
  stage.addEventListener("pointerdown", (e) => {
1347
1628
  const empty = e.target === stage || e.target === stageInner || e.target === figImg || e.target === overlay;
1348
- if (e.button === 1 || (e.button === 0 && (empty || spaceDown))) {
1629
+ if (e.button === 1 || (e.button === 0 && spaceDown)) {
1349
1630
  e.preventDefault();
1350
1631
  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 };
1632
+ pan = { x: e.clientX, y: e.clientY, px: state.pan.x, py: state.pan.y };
1633
+ } else if (e.button === 0 && empty && !spaceDown) {
1634
+ e.preventDefault();
1635
+ stage.setPointerCapture(e.pointerId);
1636
+ marquee = { x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY, base: e.shiftKey ? new Set(state.selection) : new Set() };
1637
+ if (!marqueeEl) { marqueeEl = document.createElement("div"); marqueeEl.className = "marquee"; stage.appendChild(marqueeEl); }
1638
+ marqueeEl.style.display = "block";
1639
+ }
1640
+ });
1641
+ window.addEventListener("pointermove", (e) => {
1642
+ if (pan) { state.pan.x = pan.px + (e.clientX - pan.x); state.pan.y = pan.py + (e.clientY - pan.y); applyTransform(); }
1643
+ if (marquee) {
1644
+ marquee.x1 = e.clientX; marquee.y1 = e.clientY;
1645
+ const sr = stage.getBoundingClientRect();
1646
+ marqueeEl.style.left = (Math.min(marquee.x0, marquee.x1) - sr.left) + "px";
1647
+ marqueeEl.style.top = (Math.min(marquee.y0, marquee.y1) - sr.top) + "px";
1648
+ marqueeEl.style.width = Math.abs(marquee.x1 - marquee.x0) + "px";
1649
+ marqueeEl.style.height = Math.abs(marquee.y1 - marquee.y0) + "px";
1352
1650
  }
1353
1651
  });
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; });
1652
+ window.addEventListener("pointerup", (e) => {
1653
+ if (pan) pan = null;
1654
+ if (marquee) {
1655
+ const small = Math.abs(e.clientX - marquee.x0) < 3 && Math.abs(e.clientY - marquee.y0) < 3;
1656
+ if (small) { if (!marquee.base.size) select(null); }
1657
+ else marqueeSelect(marquee.x0, marquee.y0, e.clientX, e.clientY, marquee.base);
1658
+ if (marqueeEl) marqueeEl.style.display = "none";
1659
+ marquee = null;
1660
+ }
1661
+ });
1662
+ window.addEventListener("pointercancel", () => { pan = null; if (marqueeEl) marqueeEl.style.display = "none"; marquee = null; });
1357
1663
  window.addEventListener("keydown", (e) => { if (e.code === "Space" && !/INPUT|SELECT|TEXTAREA/.test(e.target.tagName || "")) { spaceDown = true; stage.style.cursor = "grab"; } });
1358
1664
  window.addEventListener("keyup", (e) => { if (e.code === "Space") { spaceDown = false; stage.style.cursor = ""; } });
1359
1665
  document.addEventListener("keydown", (e) => {
1360
1666
  const exportOpen = !$("#exportModal").classList.contains("hidden");
1361
1667
  const v3dOpen = !$("#view3dModal").classList.contains("hidden");
1668
+ const helpOpen = !$("#helpModal").classList.contains("hidden");
1362
1669
  // 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; }
1670
+ if (e.key === "Escape" && (exportOpen || v3dOpen || helpOpen)) { if (exportOpen) closeModal($("#exportModal")); else if (v3dOpen) closeModal($("#view3dModal")); else closeModal($("#helpModal")); return; }
1364
1671
  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; }
1672
+ // Only text-like fields own their keys (typing, native Ctrl+C, Esc-revert). A focused
1673
+ // range/checkbox/color/select must still let modifier chords, Delete and Escape through
1674
+ // to the global handlers (previously every INPUT swallowed Ctrl+Z).
1675
+ const textLike = tgt.isContentEditable || tgt.tagName === "TEXTAREA" || (tgt.tagName === "INPUT" && /text|number|search|email|tel|url|password/.test(tgt.type));
1676
+ if (textLike) return;
1677
+ 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
1678
  if (e.key === "Escape") { select(null); return; }
1367
1679
  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(); }
1680
+ const mod = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();
1681
+ if (mod && k === "z") { e.preventDefault(); e.shiftKey ? redo() : undo(); }
1682
+ else if (mod && k === "y") { e.preventDefault(); redo(); }
1683
+ 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(); }
1684
+ else if (mod && k === "c") { e.preventDefault(); copySelection(); }
1685
+ else if (mod && k === "v") { e.preventDefault(); pasteClipboard(); }
1686
+ else if (mod && k === "x") { e.preventDefault(); copySelection(); deleteSelection(); }
1687
+ else if (mod && k === "d") { e.preventDefault(); copySelection(); pasteClipboard(); }
1370
1688
  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
- }
1689
+ else if ((e.key === "Delete" || e.key === "Backspace") && (state.selected || state.selection.size)) { e.preventDefault(); deleteSelection(); }
1389
1690
  });
1390
1691
  // settle (full-quality render + save) the instant an arrow key is released
1391
1692
  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.0"
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