partforge 0.87.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,14 +4,182 @@
4
4
  // STAGE, not document.body, so it lives in .pf-stage's positioning context and
5
5
  // behaves under the narrow-pane layout. While visible it owns all pointer
6
6
  // events, which is what freezes orbit controls during annotation — no viewer
7
- // changes needed. Strokes render dark-core-over-light-halo so ink reads on
8
- // both themes and any model color.
7
+ // changes needed. Elements render dark-core-over-light-halo so ink reads on
8
+ // both themes and any model color; a tool-driven overlay (handles, guides,
9
+ // labels, ...) is drawn on top in chrome colors, and is excluded from export.
9
10
  import { runCleanupSteps } from "../teardown.js";
11
+ import { visibleRuns, handlesOf, INK_COLORS } from "./elements.js";
10
12
 
11
- const CORE_COLOR = "#d92d20";
12
13
  const HALO_COLOR = "rgba(255, 255, 255, 0.85)";
13
14
  const HALO_RATIO = 2.2; // halo pass width relative to the core width
14
15
 
16
+ // Stage space -> pixels. Stage y ranges over [0,1] and spans the bitmap
17
+ // height; stage x is pre-scaled by the viewport aspect (bitmap width =
18
+ // aspect x height by construction, see elements.js), so the same factor
19
+ // (target.height) maps both axes.
20
+ const mapper = (target) => {
21
+ const s = target.height;
22
+ return (p) => [p.x * s, p.y * s];
23
+ };
24
+
25
+ // One halo-or-core pass over every element's visible runs. Shared by the
26
+ // live draw and the elements-only export path.
27
+ function strokePass(target, ctx, elements, widthScale, colorOf) {
28
+ const short = Math.min(target.width, target.height);
29
+ ctx.lineCap = "round";
30
+ ctx.lineJoin = "round";
31
+ const toPx = mapper(target);
32
+ for (const el of elements) {
33
+ const w = el.width * short * widthScale;
34
+ ctx.strokeStyle = colorOf(el);
35
+ ctx.fillStyle = ctx.strokeStyle;
36
+ ctx.lineWidth = w;
37
+ for (const run of visibleRuns(el)) {
38
+ if (run.length === 1) {
39
+ const [x, y] = toPx(run[0]);
40
+ ctx.beginPath();
41
+ ctx.arc(x, y, w / 2, 0, Math.PI * 2);
42
+ ctx.fill();
43
+ continue;
44
+ }
45
+ ctx.beginPath();
46
+ run.forEach((p, i) => {
47
+ const [x, y] = toPx(p);
48
+ if (i === 0) ctx.moveTo(x, y);
49
+ else ctx.lineTo(x, y);
50
+ });
51
+ ctx.stroke();
52
+ }
53
+ }
54
+ }
55
+
56
+ // Elements only — halo pass then core pass. This is both the export path
57
+ // (toDataUrl draws only this into a scratch canvas) and the base of the live
58
+ // draw.
59
+ function drawElements(ctx, target, elements) {
60
+ ctx.clearRect(0, 0, target.width, target.height);
61
+ strokePass(target, ctx, elements, HALO_RATIO, () => HALO_COLOR);
62
+ strokePass(target, ctx, elements, 1, (el) => INK_COLORS[el.color]);
63
+ }
64
+
65
+ // Chrome (accent/surface/text) colors for overlay adornments, resolved from
66
+ // CSS custom properties on the canvas so the ink layer follows the host's
67
+ // theme. Falls back to literal defaults — required in bare test environments
68
+ // where getComputedStyle may be absent or return unset custom properties.
69
+ const chromeColor = (canvas, name, fallback) => {
70
+ try {
71
+ const v = globalThis.getComputedStyle?.(canvas)?.getPropertyValue(name)?.trim();
72
+ return v || fallback;
73
+ } catch {
74
+ return fallback;
75
+ }
76
+ };
77
+
78
+ const chromeColors = (canvas) => ({
79
+ accent: chromeColor(canvas, "--pf-accent", "#3f7bf0"),
80
+ surface: chromeColor(canvas, "--pf-surface", "#ffffff"),
81
+ text: chromeColor(canvas, "--pf-text", "#111111"),
82
+ });
83
+
84
+ function drawGlow(ctx, target, glowEl, accent) {
85
+ if (!glowEl) return;
86
+ const short = Math.min(target.width, target.height);
87
+ const toPx = mapper(target);
88
+ const w = glowEl.width * short * HALO_RATIO * 1.6;
89
+ ctx.save();
90
+ ctx.globalAlpha = 0.35;
91
+ ctx.strokeStyle = accent;
92
+ ctx.fillStyle = accent;
93
+ ctx.lineCap = "round";
94
+ ctx.lineJoin = "round";
95
+ ctx.lineWidth = w;
96
+ for (const run of visibleRuns(glowEl)) {
97
+ if (run.length === 1) {
98
+ const [x, y] = toPx(run[0]);
99
+ ctx.beginPath();
100
+ ctx.arc(x, y, w / 2, 0, Math.PI * 2);
101
+ ctx.fill();
102
+ continue;
103
+ }
104
+ ctx.beginPath();
105
+ run.forEach((p, i) => {
106
+ const [x, y] = toPx(p);
107
+ if (i === 0) ctx.moveTo(x, y);
108
+ else ctx.lineTo(x, y);
109
+ });
110
+ ctx.stroke();
111
+ }
112
+ ctx.restore();
113
+ }
114
+
115
+ // Handles/labels/glyph geometry and the chrome stroke widths below are
116
+ // specified as CSS pixels, but the bitmap they draw into is device pixels
117
+ // (CSS size × dpr, see resize()) — the same distinction the ink strokes
118
+ // already get for free via el.width being a fraction of the (dpr-scaled)
119
+ // short edge. Every literal constant here is multiplied by `dpr` (the
120
+ // current bitmap dpr, threaded in from draw()) so this chrome renders at its
121
+ // intended CSS size instead of shrinking to half that on a dpr-2 display.
122
+ function drawHandles(ctx, target, handlesEl, chrome, dpr) {
123
+ if (!handlesEl) return;
124
+ const toPx = mapper(target);
125
+ const HS = 7 * dpr;
126
+ ctx.fillStyle = chrome.surface;
127
+ ctx.strokeStyle = chrome.accent;
128
+ ctx.lineWidth = 1.5 * dpr;
129
+ for (const h of handlesOf(handlesEl)) {
130
+ const [x, y] = toPx(h);
131
+ ctx.fillRect(x - HS / 2, y - HS / 2, HS, HS);
132
+ ctx.strokeRect(x - HS / 2, y - HS / 2, HS, HS);
133
+ }
134
+ }
135
+
136
+ function drawGuide(ctx, target, guide, chrome, dpr) {
137
+ if (!guide) return;
138
+ const toPx = mapper(target);
139
+ ctx.strokeStyle = chrome.accent;
140
+ ctx.lineWidth = 1 * dpr;
141
+ ctx.setLineDash([4 * dpr, 3 * dpr]);
142
+ if (guide.kind === "rect") {
143
+ // w/h are stage-space extents, not a stroke width — map them with the
144
+ // same factor as points (target.height), not the short-edge convention
145
+ // strokePass/drawGlow use for line width.
146
+ const [x, y] = toPx({ x: guide.cx - guide.w / 2, y: guide.cy - guide.h / 2 });
147
+ const [x2, y2] = toPx({ x: guide.cx + guide.w / 2, y: guide.cy + guide.h / 2 });
148
+ ctx.strokeRect(x, y, x2 - x, y2 - y);
149
+ } else if (guide.kind === "cross") {
150
+ const [x, y] = toPx({ x: guide.cx, y: guide.cy });
151
+ const arm = 5 * dpr;
152
+ ctx.beginPath();
153
+ ctx.moveTo(x - arm, y);
154
+ ctx.lineTo(x + arm, y);
155
+ ctx.moveTo(x, y - arm);
156
+ ctx.lineTo(x, y + arm);
157
+ ctx.stroke();
158
+ }
159
+ ctx.setLineDash([]);
160
+ }
161
+
162
+ function drawLabel(ctx, target, label, chrome, dpr) {
163
+ if (!label) return;
164
+ const toPx = mapper(target);
165
+ const [x, y] = toPx(label);
166
+ ctx.font = `${10 * dpr}px monospace`;
167
+ const metrics = ctx.measureText(label.text);
168
+ const padX = 4 * dpr;
169
+ const padY = 3 * dpr;
170
+ const boxW = (metrics?.width || 0) + padX * 2;
171
+ const boxH = 10 * dpr + padY * 2;
172
+ // Offset the plate clear of the pointer: the label anchors at the dragged
173
+ // corner/handle, i.e. under the cursor, so 16px right + 12px up keeps it
174
+ // outside both the arrow cursor's body and the crosshair's arms.
175
+ const bx = x + 16 * dpr;
176
+ const by = y - 12 * dpr - boxH;
177
+ ctx.fillStyle = chrome.surface;
178
+ ctx.fillRect(bx, by, boxW, boxH);
179
+ ctx.fillStyle = chrome.accent;
180
+ ctx.fillText(label.text, bx + padX, by + boxH - padY);
181
+ }
182
+
15
183
  export function createInkCanvas(stage, {
16
184
  getContext2d = (canvas) => canvas.getContext("2d"),
17
185
  createCanvas = () => document.createElement("canvas"),
@@ -21,52 +189,39 @@ export function createInkCanvas(stage, {
21
189
  canvas.hidden = true;
22
190
  stage.appendChild(canvas);
23
191
  const ctx = getContext2d(canvas);
24
- let strokes = [];
25
-
26
- // Strokes are normalized, so a pass is written against whatever bitmap it is
27
- // handed the live canvas, or the scratch one toDataUrl uses to bound an
28
- // export. Width scales with the target's short edge for the same reason.
29
- function drawPass(target, targetCtx, color, widthScale) {
30
- targetCtx.strokeStyle = color;
31
- targetCtx.fillStyle = color;
32
- targetCtx.lineCap = "round";
33
- targetCtx.lineJoin = "round";
34
- const short = Math.min(target.width, target.height);
35
- for (const stroke of strokes) {
36
- const w = stroke.width * short * widthScale;
37
- if (stroke.points.length === 1) {
38
- const [nx, ny] = stroke.points[0];
39
- targetCtx.beginPath();
40
- targetCtx.arc(nx * target.width, ny * target.height, w / 2, 0, Math.PI * 2);
41
- targetCtx.fill();
42
- continue;
43
- }
44
- targetCtx.lineWidth = w;
45
- targetCtx.beginPath();
46
- stroke.points.forEach(([nx, ny], i) => {
47
- const x = nx * target.width;
48
- const y = ny * target.height;
49
- if (i === 0) targetCtx.moveTo(x, y);
50
- else targetCtx.lineTo(x, y);
51
- });
52
- targetCtx.stroke();
53
- }
54
- }
55
-
56
- function drawInto(targetCtx, target) {
57
- targetCtx.clearRect(0, 0, target.width, target.height);
58
- drawPass(target, targetCtx, HALO_COLOR, HALO_RATIO);
59
- drawPass(target, targetCtx, CORE_COLOR, 1);
60
- }
192
+ let scene = { elements: [], overlay: {} };
193
+ // The bitmap's current dpr, set by resize()/size() below and read by draw()
194
+ // to scale the overlay chrome's fixed-CSS-pixel constants (handles, label,
195
+ // chrome line widths) up to device pixels. Defaults to 1 so a
196
+ // draw() before the first resize() (there isn't one on this path, but
197
+ // belt-and-suspenders) doesn't under/over-scale.
198
+ let currentDpr = 1;
61
199
 
200
+ // Live draw order: glow -> all halos -> all cores -> handles -> guide ->
201
+ // label. Glow renders before the elements' own halo/core passes so it reads
202
+ // as a soft field behind the ink, not on top of it. Pointer-followers (the
203
+ // eraser ring, the rotate glyph) are deliberately NOT drawn here: anything
204
+ // that must track the cursor per-mousemove would force a full-canvas redraw
205
+ // per event — they are CSS cursors instead (app.css), rendered by the
206
+ // compositor at zero canvas cost.
62
207
  function draw() {
63
208
  if (!ctx) return;
64
- drawInto(ctx, canvas);
209
+ const overlay = scene.overlay || {};
210
+ const chrome = chromeColors(canvas);
211
+ const dpr = currentDpr;
212
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
213
+ drawGlow(ctx, canvas, overlay.glowEl, chrome.accent);
214
+ strokePass(canvas, ctx, scene.elements, HALO_RATIO, () => HALO_COLOR);
215
+ strokePass(canvas, ctx, scene.elements, 1, (el) => INK_COLORS[el.color]);
216
+ drawHandles(ctx, canvas, overlay.handlesEl, chrome, dpr);
217
+ drawGuide(ctx, canvas, overlay.guide, chrome, dpr);
218
+ drawLabel(ctx, canvas, overlay.label, chrome, dpr);
65
219
  }
66
220
 
67
221
  function resize() {
68
222
  const rect = stage.getBoundingClientRect();
69
223
  const dpr = globalThis.devicePixelRatio || 1;
224
+ currentDpr = dpr;
70
225
  const width = Math.max(1, Math.round(rect.width * dpr));
71
226
  const height = Math.max(1, Math.round(rect.height * dpr));
72
227
  if (canvas.width !== width || canvas.height !== height) {
@@ -77,11 +232,11 @@ export function createInkCanvas(stage, {
77
232
  }
78
233
 
79
234
  // The viewer's own ResizeObserver is internal (viewer.js exposes no resize
80
- // hook), so the overlay runs its own — ink is normalized, so a resize is
81
- // just a re-rasterize at the new bitmap size. Skip it while hidden: the
82
- // stage keeps resizing (rail drags, window resizes) whether or not
83
- // annotate mode is on, and re-rasterizing an invisible canvas is wasted
84
- // work; show() already calls resize() so nothing is missed on re-entry.
235
+ // hook), so the overlay runs its own — a resize is just a re-rasterize at
236
+ // the new bitmap size. Skip it while hidden: the stage keeps resizing (rail
237
+ // drags, window resizes) whether or not annotate mode is on, and
238
+ // re-rasterizing an invisible canvas is wasted work; show() already calls
239
+ // resize() so nothing is missed on re-entry.
85
240
  const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
86
241
  if (canvas.hidden) return;
87
242
  resize();
@@ -93,27 +248,29 @@ export function createInkCanvas(stage, {
93
248
  element: canvas,
94
249
  show() { canvas.hidden = false; resize(); },
95
250
  hide() { canvas.hidden = true; },
96
- setStrokes(next) { strokes = next; draw(); },
97
- // The ink layer as a transparent PNG. `maxEdge` bounds the exported
98
- // bitmap: the live canvas is stage-sized × devicePixelRatio, so on a large
99
- // hi-DPI display it runs to several thousand pixels a side, and a PNG that
100
- // big is both slow to encode and large enough that a host with a payload
101
- // ceiling would have to drop it losing the drawing while keeping the
102
- // picture of the model, which is the one outcome worse than failing. Above
103
- // the bound the strokes are re-rasterized into a scratch canvas rather than
104
- // resampled, so thin ink stays crisp instead of turning to mush. Under it
105
- // (the ordinary case) nothing is copied and the live canvas exports
106
- // directly.
251
+ setScene(next) { scene = next; draw(); },
252
+ // The ink layer as a transparent PNG, elements only overlay adornments
253
+ // (handles, guides, labels, the eraser ring, ...) are tool chrome, not
254
+ // part of the drawing, and must never reach the export. Since the live
255
+ // canvas now carries that overlay, export always re-rasterizes elements
256
+ // into a scratch canvas rather than reading the live bitmap directly.
257
+ // `maxEdge` bounds the exported bitmap: the live canvas is stage-sized ×
258
+ // devicePixelRatio, so on a large hi-DPI display it runs to several
259
+ // thousand pixels a side, and a PNG that big is both slow to encode and
260
+ // large enough that a host with a payload ceiling would have to drop it —
261
+ // losing the drawing while keeping the picture of the model, which is the
262
+ // one outcome worse than failing. Above the bound the elements are
263
+ // re-rasterized at the scaled-down size rather than resampled, so thin
264
+ // ink stays crisp instead of turning to mush.
107
265
  toDataUrl({ maxEdge } = {}) {
108
266
  const long = Math.max(canvas.width, canvas.height);
109
- if (!maxEdge || long <= maxEdge) return canvas.toDataURL("image/png");
110
- const scale = maxEdge / long;
267
+ const scale = maxEdge && long > maxEdge ? maxEdge / long : 1;
111
268
  const scratch = createCanvas();
112
269
  scratch.width = Math.max(1, Math.round(canvas.width * scale));
113
270
  scratch.height = Math.max(1, Math.round(canvas.height * scale));
114
271
  const scratchCtx = getContext2d(scratch);
115
272
  if (!scratchCtx) return canvas.toDataURL("image/png");
116
- drawInto(scratchCtx, scratch);
273
+ drawElements(scratchCtx, scratch, scene.elements);
117
274
  return scratch.toDataURL("image/png");
118
275
  },
119
276
  size: () => ({ width: canvas.width, height: canvas.height, dpr: globalThis.devicePixelRatio || 1 }),
@@ -0,0 +1,202 @@
1
+ // Sketch-mode toolbar: the top-centre pill that OWNS the mode while sketch
2
+ // mode is on (annotate-controls.js's viewbar pencil+actions is hidden for the
3
+ // duration — mount toggles both). A direct sibling of annotate-controls.js:
4
+ // same mode-object-owns-behavior split, same tooltip/cleanup idioms, same
5
+ // `send: "host"` contract for a host that draws its own send affordance.
6
+ //
7
+ // Two DOM nodes are appended to the stage: the toolbar pill itself and a
8
+ // `.pf-sketch-hint` sibling (not a child — see the CSS, which positions it
9
+ // independently below the pill) carrying a one-line usage hint for the
10
+ // active tool.
11
+ import { attachButtonTooltips } from "../tooltip.js";
12
+ import { runCleanupSteps } from "../teardown.js";
13
+ import { INK_COLORS } from "./elements.js";
14
+
15
+ // pen: the same pencil glyph as annotate-controls.js's toggle button.
16
+ const PEN_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>`;
17
+ const LINE_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 19 19 5"/></svg>`;
18
+ const RECT_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="1"/></svg>`;
19
+ const ELLIPSE_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="12" rx="9" ry="6.5"/></svg>`;
20
+ // hand: lucide "hand" glyph.
21
+ const HAND_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>`;
22
+ // eraser: lucide "eraser" glyph.
23
+ const ERASER_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>`;
24
+ // undo: lucide "undo-2" glyph.
25
+ const UNDO_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>`;
26
+ // clear: lucide "trash-2" glyph.
27
+ const CLEAR_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>`;
28
+ // close: lucide "x" glyph. The toolbar's own exit affordance — see its
29
+ // wiring below for why the mode needs one now that #viewbar (and the pencil
30
+ // toggle that used to sit on it) is hidden for the duration of sketch mode.
31
+ const CLOSE_ICON = `<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`;
32
+
33
+ const TOOLS = [
34
+ { tool: "pen", icon: PEN_ICON, label: "Pen", hint: "drag to draw" },
35
+ { tool: "line", icon: LINE_ICON, label: "Line", hint: "drag endpoint to endpoint · snaps to 0/45/90° · shift forces" },
36
+ { tool: "rect", icon: RECT_ICON, label: "Rectangle", hint: "drag corner to corner · snaps to square · shift forces" },
37
+ { tool: "ellipse", icon: ELLIPSE_ICON, label: "Ellipse", hint: "drag corner to corner · snaps to circle · shift forces" },
38
+ { tool: "hand", icon: HAND_ICON, label: "Move", hint: "drag a shape to move it · handles resize · just outside rotates" },
39
+ { tool: "eraser", icon: ERASER_ICON, label: "Eraser", hint: "scrub to erase" },
40
+ ];
41
+
42
+ const COLOR_LABELS = { red: "Red ink", blue: "Blue ink", green: "Green ink" };
43
+
44
+ function makeButton({ className = "", dataset = {}, title, ariaLabel, html }) {
45
+ const button = document.createElement("button");
46
+ button.type = "button";
47
+ if (className) button.className = className;
48
+ for (const [key, value] of Object.entries(dataset)) button.dataset[key] = value;
49
+ if (html !== undefined) button.innerHTML = html;
50
+ if (title) button.title = title;
51
+ button.setAttribute("aria-label", ariaLabel ?? title ?? "");
52
+ return button;
53
+ }
54
+
55
+ function makeSeparator() {
56
+ const sep = document.createElement("span");
57
+ sep.className = "sep";
58
+ sep.setAttribute("aria-hidden", "true");
59
+ return sep;
60
+ }
61
+
62
+ export function attachSketchToolbar(mode, { stage, tooltip, send = "viewbar" } = {}) {
63
+ const element = document.createElement("div");
64
+ element.className = "pf-sketch-toolbar";
65
+ element.setAttribute("role", "toolbar");
66
+ element.hidden = true;
67
+
68
+ const hint = document.createElement("div");
69
+ hint.className = "pf-sketch-hint";
70
+ hint.hidden = true;
71
+
72
+ const toolButtons = TOOLS.map(({ tool, icon, label }) => makeButton({
73
+ dataset: { tool },
74
+ title: label,
75
+ html: icon,
76
+ }));
77
+ for (const button of toolButtons) button.setAttribute("aria-pressed", "false");
78
+ element.append(...toolButtons, makeSeparator());
79
+
80
+ const swatchButtons = Object.keys(INK_COLORS).map((color) => {
81
+ const button = makeButton({
82
+ className: "pf-swatch",
83
+ dataset: { color },
84
+ title: COLOR_LABELS[color] ?? color,
85
+ });
86
+ button.style.setProperty("--sw", INK_COLORS[color]);
87
+ return button;
88
+ });
89
+ element.append(...swatchButtons, makeSeparator());
90
+
91
+ const undoButton = makeButton({ dataset: { action: "undo" }, title: "Undo", html: UNDO_ICON });
92
+ const clearButton = makeButton({ dataset: { action: "clear" }, title: "Clear", html: CLEAR_ICON });
93
+ element.append(undoButton, clearButton);
94
+
95
+ let sendButton = null;
96
+ if (send !== "host") {
97
+ sendButton = makeButton({ dataset: { action: "send" }, title: "Send" });
98
+ sendButton.textContent = "Send";
99
+ element.append(sendButton);
100
+ }
101
+
102
+ // Close: the toolbar's own exit affordance, always the LAST button — it
103
+ // must outlive send's presence/absence so the mode is exitable in-UI
104
+ // regardless of the `send` variant. Never disabled (unlike undo/clear/
105
+ // send, which gate on stroke count): exiting has to work with an empty
106
+ // sketch. annotate-mode.js's document-capture Escape listener covers the
107
+ // keyboard path; this covers pointer/touch, including mobile, which has no
108
+ // Escape key at all.
109
+ const closeButton = makeButton({ dataset: { action: "close" }, title: "Exit sketch", html: CLOSE_ICON });
110
+ element.append(closeButton);
111
+
112
+ stage.append(element, hint);
113
+
114
+ const allButtons = [...toolButtons, ...swatchButtons, undoButton, clearButton, sendButton, closeButton].filter(Boolean);
115
+ const tooltipBinding = tooltip
116
+ ? attachButtonTooltips(tooltip, allButtons.map((btn) => ({ element: btn })))
117
+ : null;
118
+
119
+ function syncTools() {
120
+ const activeTool = mode.tool();
121
+ for (const button of toolButtons) {
122
+ const on = button.dataset.tool === activeTool;
123
+ button.classList.toggle("on", on);
124
+ button.setAttribute("aria-pressed", String(on));
125
+ }
126
+ const activeColor = mode.color();
127
+ for (const button of swatchButtons) {
128
+ button.classList.toggle("on", button.dataset.color === activeColor);
129
+ }
130
+ const entry = TOOLS.find((t) => t.tool === activeTool);
131
+ hint.textContent = entry?.hint ?? "";
132
+ }
133
+
134
+ function syncDisabled() {
135
+ const empty = mode.strokeCount() === 0;
136
+ undoButton.disabled = !mode.canUndo();
137
+ clearButton.disabled = empty;
138
+ if (sendButton) sendButton.disabled = empty;
139
+ tooltipBinding?.sync();
140
+ }
141
+
142
+ function syncVisibility() {
143
+ const on = mode.isEnabled();
144
+ element.hidden = !on;
145
+ hint.hidden = !on;
146
+ }
147
+
148
+ function sync() {
149
+ syncTools();
150
+ syncDisabled();
151
+ syncVisibility();
152
+ }
153
+
154
+ const onUndo = () => mode.undo();
155
+ const onClear = () => mode.clear();
156
+ const onSendClick = () => mode.send();
157
+ const onClose = () => mode.setEnabled(false);
158
+
159
+ const toolHandlers = toolButtons.map((button) => {
160
+ const handler = () => mode.setTool(button.dataset.tool);
161
+ button.addEventListener("click", handler);
162
+ return { button, handler };
163
+ });
164
+ const colorHandlers = swatchButtons.map((button) => {
165
+ const handler = () => mode.setColor(button.dataset.color);
166
+ button.addEventListener("click", handler);
167
+ return { button, handler };
168
+ });
169
+ undoButton.addEventListener("click", onUndo);
170
+ clearButton.addEventListener("click", onClear);
171
+ sendButton?.addEventListener("click", onSendClick);
172
+ closeButton.addEventListener("click", onClose);
173
+
174
+ const offTool = mode.onToolChange(sync);
175
+ const offInk = mode.onInkChange(sync);
176
+ const offMode = mode.onModeChange(sync);
177
+
178
+ sync();
179
+
180
+ let detached = false;
181
+ return {
182
+ element,
183
+ detach() {
184
+ if (detached) return;
185
+ detached = true;
186
+ runCleanupSteps([
187
+ offTool,
188
+ offInk,
189
+ offMode,
190
+ ...toolHandlers.map(({ button, handler }) => () => button.removeEventListener("click", handler)),
191
+ ...colorHandlers.map(({ button, handler }) => () => button.removeEventListener("click", handler)),
192
+ () => undoButton.removeEventListener("click", onUndo),
193
+ () => clearButton.removeEventListener("click", onClear),
194
+ () => sendButton?.removeEventListener("click", onSendClick),
195
+ () => closeButton.removeEventListener("click", onClose),
196
+ () => tooltipBinding?.detach(),
197
+ () => element.remove(),
198
+ () => hint.remove(),
199
+ ], "sketch toolbar cleanup failed");
200
+ },
201
+ };
202
+ }
@@ -278,7 +278,7 @@ button.action:disabled { opacity: .5; cursor: default; }
278
278
  /* keyboard focus ring shared across the panel's interactive controls */
279
279
  .seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
280
280
  button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible,
281
- .pf-viewcube-toggle:focus-visible, .pf-float-rail-toggle:focus-visible {
281
+ .pf-viewcube-toggle:focus-visible, .pf-float-rail-toggle:focus-visible, .pf-sketch-toolbar button:focus-visible {
282
282
  outline: none;
283
283
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
284
284
  }
@@ -318,6 +318,10 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
318
318
  border-radius: var(--pf-radius-pill);
319
319
  box-shadow: var(--pf-shadow-float);
320
320
  }
321
+ /* Sketch mode hides the whole bar while it owns the stage (mount.js) — the
322
+ display: flex above is author-origin and beats the UA's [hidden] rule, the
323
+ same trap the buttons' own [hidden] rule below already guards against. */
324
+ #viewbar[hidden] { display: none; }
321
325
  #viewbar:not(.pf-float-viewbar) { position: fixed; top: 12px; right: 12px; z-index: 15; }
322
326
  #viewbar button {
323
327
  width: 34px; height: 34px; border: 0; border-radius: var(--pf-radius-control);
@@ -337,25 +341,68 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
337
341
  #viewbar .pf-measure-actions { display: flex; gap: 4px; }
338
342
  #viewbar .pf-measure-actions[hidden] { display: none; }
339
343
  #viewbar .pf-measure-actions button { width: auto; min-width: 56px; padding: 0 8px; }
340
- #viewbar .pf-annotate-actions { display: flex; gap: 4px; }
341
- #viewbar .pf-annotate-actions[hidden] { display: none; }
342
- #viewbar .pf-annotate-actions button { width: auto; min-width: 56px; padding: 0 8px; }
343
- /* Annotate's actions row has three buttons (Undo/Clear/Send) against
344
- cutaway's/measure's two, so it is the first to overflow the stage's left
345
- edge as the viewport narrows — the full-size pill (5 icon buttons + this
346
- row, ~374px) already exceeds a 375-390px phone's usable width (viewport
347
- minus the stage's 12px margins on both sides) before the shared 360px
348
- rule below ever engages. Shrink only this row here; the icon buttons and
349
- the other two action rows still have room down to 360px. */
350
- @media (max-width: 430px) {
351
- #viewbar .pf-annotate-actions { gap: 3px; }
352
- #viewbar .pf-annotate-actions button { min-width: 40px; padding: 0 5px; font-size: 11px; }
353
- }
354
344
  #viewbar button:disabled { opacity: .38; cursor: not-allowed; }
355
345
  #viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
356
346
  #viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
357
347
  #viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
358
348
 
349
+ /* Sketch-mode toolbar: the #viewbar pill idiom, top-centre, owning the mode
350
+ while the viewbar itself is hidden (mount toggles both).
351
+
352
+ `left: 50%` is a shrink-to-fit trap for an absolutely positioned element:
353
+ its auto width shrink-wraps against the space between `left` and the
354
+ containing block's right edge, which here is only HALF the stage, not the
355
+ whole stage — so the row wraps (stranding the close button on its own
356
+ centred second line, on top of .pf-sketch-hint) well before it actually
357
+ needs to. `width: max-content` opts back into sizing to the row's
358
+ preferred (unwrapped) width; `max-width` below still caps that on stages
359
+ too narrow for the full row, so genuine wrapping on narrow stages still
360
+ works. */
361
+ .pf-sketch-toolbar {
362
+ position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
363
+ display: flex; align-items: center; gap: 4px; padding: 4px;
364
+ background: var(--pf-surface); border: 1px solid var(--pf-border);
365
+ border-radius: var(--pf-radius-pill); box-shadow: var(--pf-shadow-float);
366
+ z-index: 20; width: max-content; max-width: calc(100% - 16px); flex-wrap: wrap; justify-content: center;
367
+ }
368
+ .pf-sketch-toolbar[hidden] { display: none; }
369
+ .pf-sketch-toolbar .sep { width: 1px; align-self: stretch; margin: 4px 2px; background: var(--pf-border); }
370
+ .pf-sketch-toolbar button {
371
+ width: 34px; height: 34px; border: 0; border-radius: var(--pf-radius-control);
372
+ background: transparent; color: var(--pf-muted-2); cursor: pointer;
373
+ display: flex; align-items: center; justify-content: center;
374
+ }
375
+ .pf-sketch-toolbar button[data-action="send"] { width: auto; min-width: 56px; padding: 0 10px; }
376
+ .pf-sketch-toolbar button:hover { color: var(--pf-text-2); background: var(--pf-surface-2); }
377
+ .pf-sketch-toolbar button:disabled { opacity: .35; cursor: default; background: transparent; color: var(--pf-muted); }
378
+ .pf-sketch-toolbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
379
+ .pf-sketch-toolbar .pf-swatch { width: 26px; height: 26px; margin: 4px 1px; border-radius: 50%; }
380
+ .pf-sketch-toolbar .pf-swatch::before {
381
+ content: ""; width: 14px; height: 14px; border-radius: 50%;
382
+ background: var(--sw); box-shadow: 0 0 0 2px color-mix(in oklab, var(--sw) 25%, transparent);
383
+ }
384
+ .pf-sketch-toolbar .pf-swatch.on { background: var(--pf-surface-2); }
385
+ .pf-sketch-toolbar .pf-swatch.on::before { box-shadow: 0 0 0 2.5px var(--pf-bg), 0 0 0 4.5px var(--sw); }
386
+ .pf-sketch-hint {
387
+ position: absolute; top: 58px; left: 50%; transform: translateX(-50%);
388
+ font-family: var(--pf-mono); font-size: 10px; letter-spacing: .04em;
389
+ color: var(--pf-hint); z-index: 19; pointer-events: none; white-space: nowrap;
390
+ }
391
+ .pf-sketch-hint[hidden] { display: none; }
392
+ /* hand-tool cursors on the ink canvas */
393
+ .pf-ink-canvas.hand { cursor: default; }
394
+ .pf-ink-canvas.hand.over { cursor: grab; }
395
+ .pf-ink-canvas.hand.handle { cursor: crosshair; }
396
+ /* Pointer-followers are CSS cursors, not canvas drawings: anything that must
397
+ track the pointer per-mousemove would force a full-canvas redraw per event
398
+ (annotate-mode used to do exactly that for the rotate glyph and eraser
399
+ ring, and it read as lag). The compositor renders cursors for free. */
400
+ .pf-ink-canvas.hand.rotate { cursor: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2224%22 height=%2224%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cg stroke=%22%23000%22 stroke-width=%224.5%22 opacity=%22.55%22%3E%3Cpath d=%22M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8%22/%3E%3Cpath d=%22M21 3v5h-5%22/%3E%3C/g%3E%3Cg stroke=%22%23fff%22 stroke-width=%222%22%3E%3Cpath d=%22M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8%22/%3E%3Cpath d=%22M21 3v5h-5%22/%3E%3C/g%3E%3C/svg%3E") 12 12, alias; }
401
+ .pf-ink-canvas.hand.dragging { cursor: grabbing; }
402
+ /* The ring's 16px radius mirrors annotate-mode.js's ERASER_PX — keep them
403
+ in step so the cursor shows the true brush footprint. */
404
+ .pf-ink-canvas.erasing { cursor: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2236%22 height=%2236%22 viewBox=%220 0 36 36%22 fill=%22none%22%3E%3Ccircle cx=%2218%22 cy=%2218%22 r=%2216%22 stroke=%22%23000%22 stroke-width=%223.5%22 opacity=%22.55%22/%3E%3Ccircle cx=%2218%22 cy=%2218%22 r=%2216%22 stroke=%22%23fff%22 stroke-width=%221.5%22/%3E%3C/svg%3E") 18 18, crosshair; }
405
+
359
406
  /* ---- the rail toggle: a bare floating icon at the stage's top right --------
360
407
  APPEARANCE only (placement lives in chrome.css, per the split at the top of
361
408
  this file). Through 2026-08-20 this was the last button in #viewbar's pill;
@@ -482,12 +529,8 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
482
529
  @media (max-width: 360px) {
483
530
  #viewbar { gap: 3px; }
484
531
  #viewbar button { width: 30px; height: 30px; font-size: 13px; }
485
- #viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions, #viewbar .pf-annotate-actions { gap: 3px; }
486
- #viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button, #viewbar .pf-annotate-actions button { min-width: 44px; padding: 0 6px; }
487
- /* Annotate's three-button row (Undo/Clear/Send) is wider than cutaway's or
488
- measure's two-button rows at the shared size above, so it still clips the
489
- bar's left edge at 320px — shrink it further than the shared rule. */
490
- #viewbar .pf-annotate-actions button { min-width: 38px; padding: 0 4px; font-size: 10px; }
532
+ #viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions { gap: 3px; }
533
+ #viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button { min-width: 44px; padding: 0 6px; }
491
534
  }
492
535
 
493
536
  /* ---- measurement mode -----------------------------------------------------
@@ -469,8 +469,10 @@
469
469
 
470
470
  /* ---- annotation ink layer: a transparent 2D canvas over the viewer --------
471
471
  Shown only while annotation mode is on. It deliberately owns pointer events
472
- while visible — that is what freezes orbit/pan/zoom during drawing. Below
473
- the viewbar (z 15) so Undo/Clear/Send stay clickable. */
472
+ while visible — that is what freezes orbit/pan/zoom during drawing. #viewbar
473
+ itself is hidden for the duration (mount.js toggles both), replaced by
474
+ .pf-sketch-toolbar (app.css, z 20) which now holds Undo/Clear/Send; this
475
+ layer sits below that at z 10 so the toolbar stays clickable over it. */
474
476
  .pf-ink-canvas {
475
477
  position: absolute;
476
478
  inset: 0;