@kolkrabbi/kol-component 0.183.0 → 0.185.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.183.0",
3
+ "version": "0.185.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -59,7 +59,14 @@ const RADIUS_CLASSES = {
59
59
  full: 'rounded-full',
60
60
  }
61
61
 
62
- const HALO_SHADOW = '0 0 0 1px #000, 0 0 0 2px #505050'
62
+ /* The halo ring is THEME-AWARE. It was the literal `0 0 0 1px #000, 0 0 0 2px
63
+ * #505050` carried in from the macOS port, which put a pure-black ring on a
64
+ * rgb(250,250,250) page in light theme — measured by kol-fxr on the swatch
65
+ * chips (`editor-set-is-behind-its-source`, 2026-09-03). These are the two
66
+ * tokens its own SwatchControls draws, and in dark they resolve to ≈ the
67
+ * port's original values, so the look the variant was named for is unchanged
68
+ * where it was correct. */
69
+ const HALO_SHADOW = '0 0 0 1px var(--kol-surface-primary), 0 0 0 2px var(--kol-fg-32)'
63
70
 
64
71
  export default function ColorSwatch({
65
72
  hex,
@@ -1,3 +1,6 @@
1
+ import { useContext } from 'react'
2
+ import { CanvasZoomContext } from '../hooks/canvasZoom.js'
3
+
1
4
  /* taxonomy-ok: presentational transform-chrome overlay. It nests no KOL
2
5
  * component (pure inline-styled squares + label), so by the letter of the
3
6
  * molecule test it reads as an atom — but the lobby spec places it as a
@@ -7,23 +10,40 @@
7
10
  /**
8
11
  * SelectionOverlay — pure transform chrome for a selected box.
9
12
  *
10
- * Renders a dashed outline, 8 named resize handles, and a `W × H` dimension
11
- * label, all positioned in the **same 1080-virtual coordinate space** the
12
- * target lives in (pairs with Canvas's scale layer — place it as a sibling of
13
- * the box inside the same scale layer). Each handle carries a
14
- * `data-handle="NW|N|NE|E|SE|S|SW|W"` attribute so a parent's pointer router
15
- * can start the right resize mode. No interaction logic of its own — the drag
16
- * math lives in the consumer, which reads `e.target.dataset.handle`.
13
+ * Renders a dashed outline, 8 named resize handles, a rotate handle and a
14
+ * `W × H` dimension label, all positioned in the **same 1080-virtual
15
+ * coordinate space** the target lives in (pairs with Canvas's scale layer —
16
+ * place it as a sibling of the box inside the same scale layer). Each handle
17
+ * carries a `data-handle="NW|N|NE|E|SE|S|SW|W|ROT"` attribute so a parent's
18
+ * pointer router can start the right drag mode. No interaction logic of its
19
+ * own — the drag math lives in the consumer, which reads
20
+ * `e.target.dataset.handle`.
17
21
  *
18
- * Ported from the brand editor with the `layer` model reduced to a flat `box`
22
+ * Ported from kol-fxr's editor with the `layer` model reduced to a flat `box`
19
23
  * (per lobby spec): renders nothing when there's no positional box.
20
24
  *
21
- * @param {{x:number,y:number,w:number,h:number}} box virtual-coord position + size; null/x==null → renders nothing
25
+ * ZOOM COMPENSATION IS THE POINT (restored 2026-09-03,
26
+ * `editor-set-is-behind-its-source`). The chrome renders in virtual px INSIDE
27
+ * the canvas's zoomed transform, so every screen-constant dimension — handle
28
+ * size, outline width, the rotate handle's offset, the label — divides by the
29
+ * live zoom from `CanvasZoomContext`. The first port hardcoded `1px` / `10px`
30
+ * / `marginTop: 6`, so at 3× the handles drew 30px and the label ballooned;
31
+ * kol-fxr measured it and reverted the adoption. Outside a `PanZoomViewport`
32
+ * the context is 1 and every division is a no-op, so a static canvas is
33
+ * unaffected.
34
+ *
35
+ * The label also counter-SCALES rather than just re-sizing: `scale(1/zoom)`
36
+ * with a top-left origin keeps its padding, radius and letter-spacing
37
+ * screen-constant too, which a font-size alone does not.
38
+ *
39
+ * @param {{x:number,y:number,w:number,h:number,rotation?:number}} box virtual-coord position + size; null/x==null → renders nothing. `rotation` in degrees turns the chrome with the box about its centre
22
40
  * @param {boolean} showHandles render the 8 resize handles (default true)
41
+ * @param {boolean} showRotate render the rotate handle (default: follows `showHandles`) — independent because a path hides the resize handles, node-edit owning their geometry, and still rotates
23
42
  * @param {boolean} showLabel render the `W × H` dimension label (default true)
24
- * @param {number} handleSize handle square size in virtual px (default 10)
43
+ * @param {number} handleSize handle square size in virtual px BEFORE zoom compensation (default 10)
25
44
  * @param {string} accentColor outline + handle + label color (default var(--kol-accent-primary))
26
45
  * @param {Function} labelFormatter (box) => string — dimension readout (default `${round(w)} × ${round(h)}`)
46
+ * @param {string} rotateTitle tooltip on the rotate handle (default 'Rotate')
27
47
  */
28
48
  const HANDLE_DIRS = [
29
49
  { dir: 'NW', cursor: 'nwse-resize', x: 0, y: 0 },
@@ -36,17 +56,35 @@ const HANDLE_DIRS = [
36
56
  { dir: 'W', cursor: 'ew-resize', x: 0, y: 0.5 },
37
57
  ]
38
58
 
59
+ /* The rotate handle's float above the top edge, in virtual px before zoom
60
+ * compensation — fxr's number. */
61
+ const ROTATE_OFFSET = 22
62
+
39
63
  export default function SelectionOverlay({
40
64
  box,
41
65
  showHandles = true,
66
+ showRotate,
42
67
  showLabel = true,
43
68
  handleSize = 10,
44
69
  accentColor = 'var(--kol-accent-primary)',
45
70
  labelFormatter = (b) => `${Math.round(b.w)} × ${Math.round(b.h)}`,
71
+ rotateTitle = 'Rotate',
46
72
  }) {
73
+ /* Chrome renders in virtual px inside the zoomed transform — divide by zoom
74
+ * so handles / outline / label stay screen-constant at any zoom. 1 outside a
75
+ * PanZoomViewport, which makes every division below a no-op. */
76
+ const zoom = useContext(CanvasZoomContext)
77
+
47
78
  if (!box || box.x == null) return null /* no positional box → no chrome */
48
79
 
49
80
  const { x, y, w, h } = box
81
+ const size = handleSize / zoom
82
+ const hairline = 1 / zoom
83
+ const rotate = showRotate ?? showHandles
84
+ /* `rotation` may arrive as a BINDING OBJECT on an animated prop; chrome uses
85
+ * the base 0 rather than throwing on `${{…}}deg` — editing chrome over
86
+ * animated transforms is a consumer-side v1 limitation, and fxr's guard. */
87
+ const rot = typeof box.rotation === 'number' ? box.rotation : 0
50
88
 
51
89
  return (
52
90
  <div
@@ -54,6 +92,9 @@ export default function SelectionOverlay({
54
92
  position: 'absolute',
55
93
  left: x, top: y,
56
94
  width: w, height: h,
95
+ /* the chrome rotates WITH the box (centre origin) so the wireframe and
96
+ * the handles hug the actually-rendered box, not its unrotated slot */
97
+ transform: rot ? `rotate(${rot}deg)` : undefined,
57
98
  pointerEvents: 'none',
58
99
  zIndex: 100,
59
100
  }}
@@ -61,22 +102,43 @@ export default function SelectionOverlay({
61
102
  <div
62
103
  style={{
63
104
  position: 'absolute', inset: 0,
64
- outline: `1px dashed ${accentColor}`,
105
+ outline: `${hairline}px dashed ${accentColor}`,
65
106
  outlineOffset: 0,
66
107
  }}
67
108
  />
109
+ {/* rotate handle — a circle floating above the top edge; a drag rotates
110
+ * the box about its centre. Independent of the resize handles: a path
111
+ * hides those (node-edit owns their geometry) and still rotates. */}
112
+ {rotate && (
113
+ <div
114
+ data-handle="ROT"
115
+ title={rotateTitle}
116
+ style={{
117
+ position: 'absolute',
118
+ left: `calc(50% - ${size / 2}px)`,
119
+ top: -(ROTATE_OFFSET / zoom),
120
+ width: size,
121
+ height: size,
122
+ borderRadius: '50%',
123
+ background: 'white',
124
+ border: `${hairline}px solid ${accentColor}`,
125
+ cursor: 'grab',
126
+ pointerEvents: 'auto',
127
+ }}
128
+ />
129
+ )}
68
130
  {showHandles && HANDLE_DIRS.map(({ dir, cursor, x: hx, y: hy }) => (
69
131
  <div
70
132
  key={dir}
71
133
  data-handle={dir}
72
134
  style={{
73
135
  position: 'absolute',
74
- left: `calc(${hx * 100}% - ${handleSize / 2}px)`,
75
- top: `calc(${hy * 100}% - ${handleSize / 2}px)`,
76
- width: handleSize,
77
- height: handleSize,
136
+ left: `calc(${hx * 100}% - ${size / 2}px)`,
137
+ top: `calc(${hy * 100}% - ${size / 2}px)`,
138
+ width: size,
139
+ height: size,
78
140
  background: 'white',
79
- border: `1px solid ${accentColor}`,
141
+ border: `${hairline}px solid ${accentColor}`,
80
142
  cursor,
81
143
  pointerEvents: 'auto',
82
144
  }}
@@ -88,7 +150,11 @@ export default function SelectionOverlay({
88
150
  position: 'absolute',
89
151
  left: 0,
90
152
  top: '100%',
91
- marginTop: 6,
153
+ marginTop: 6 / zoom,
154
+ /* counter-scale, not just a smaller font: padding, radius and
155
+ * tracking have to stay screen-constant too */
156
+ transform: `scale(${1 / zoom})`,
157
+ transformOrigin: 'top left',
92
158
  fontFamily: 'var(--kol-font-family-mono)',
93
159
  fontSize: 10,
94
160
  letterSpacing: '0.04em',
@@ -0,0 +1,19 @@
1
+ import { createContext } from 'react'
2
+
3
+ /**
4
+ * CanvasZoomContext — the canvas viewport's live zoom factor.
5
+ *
6
+ * Lives in `src/hooks` rather than beside `Canvas` because BOTH tiers need it:
7
+ * the organism publishes it (`PanZoomViewport`) and the atoms consume it
8
+ * (`SelectionOverlay` divides every screen-constant dimension by it). An atom
9
+ * importing `../organisms/Canvas.jsx` is an upward import and the taxonomy
10
+ * gate is right to refuse it — so the shared value moves down to the tier
11
+ * neither side owns, the same reason `glyphLadders.js` sits here.
12
+ *
13
+ * Defaults to 1, so a consumer reads it unconditionally and a canvas without a
14
+ * pan-zoom viewport turns every `/ zoom` into a no-op.
15
+ *
16
+ * `@kolkrabbi/kol-component` exports it from the barrel as `CanvasZoomContext`,
17
+ * and `Canvas.jsx` re-exports it under the same name it always had.
18
+ */
19
+ export const CanvasZoomContext = createContext(1)
package/src/index.js CHANGED
@@ -111,7 +111,11 @@ export { default as TabsRow } from './molecules/TabsRow.jsx'
111
111
  /* monorepo sets (P6–P10) — organism members. Foundry members live in the
112
112
  standalone @kolkrabbi/kol-foundry package (with the type-specimen kit +
113
113
  live-font effects moved there 2026-07-09) — never re-exported here. */
114
- export { default as Canvas, CanvasFrame, PanViewport, CANVAS_VIRTUAL_W, DEFAULT_ASPECTS, CANVAS_DEFAULTS } from './organisms/Canvas.jsx'
114
+ /* `CanvasZoomContext` and `PanZoomViewport` are the load-bearing pair for an
115
+ * editor: the viewport publishes the zoom, every piece of editing chrome reads
116
+ * it. Absent from this barrel until 0.185.0, which is why the first port of
117
+ * this set could not keep its chrome screen-constant. */
118
+ export { default as Canvas, CanvasFrame, PanViewport, PanZoomViewport, CanvasZoomContext, useFps, CANVAS_VIRTUAL_W, DEFAULT_ASPECTS, CANVAS_DEFAULTS } from './organisms/Canvas.jsx'
115
119
  export { default as EditorShell } from './utilities/EditorShell.jsx'
116
120
  export { default as GalleryCarousel } from './organisms/GalleryCarousel.jsx'
117
121
  export { default as AsciiCursor } from './utilities/AsciiCursor.jsx'
@@ -235,7 +235,14 @@ export default function ContentCard({
235
235
  * the trailing edge, both bottom-aligned so the buttons sit on the last line
236
236
  * of copy rather than floating beside the title. */
237
237
  const hasPlate = hasText || actions != null
238
- const framed = box.border != null || box.bg != null
238
+ /* FRAMED follows the EFFECTIVE fill, not the variant's. `bg` (0.183.0) let a
239
+ * consumer ground an unframed variant, and this line still read the table —
240
+ * so `article` + `bg` painted the ground but kept `framed` false, which
241
+ * dropped the card's own `overflow-hidden rounded-*` AND left `mediaRadius`
242
+ * true: a rounded still floating inside a square grey card, plate corners
243
+ * square (kol-client-hrafn, screenshot-confirmed on 0.183.0, fixed 0.183.1).
244
+ * One prop is not done until every derivation reads it. */
245
+ const framed = box.border != null || (bg ?? box.bg) != null
239
246
 
240
247
  const textNode = hasPlate ? (
241
248
  <div
@@ -1,4 +1,5 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
2
+ import { CanvasZoomContext } from '../hooks/canvasZoom.js'
2
3
 
3
4
  /**
4
5
  * Canvas — the editor's aspect-ratio stage.
@@ -13,17 +14,32 @@ import { useEffect, useRef, useState } from 'react'
13
14
  * shares it, so a consumer's drag math converts screen deltas to virtual by
14
15
  * dividing by the live scale (frameWidth / 1080).
15
16
  *
16
- * Three parts ship from this file: `CanvasFrame` (the bare scale layer),
17
- * `Canvas` (the letterbox, default export), and `PanViewport` (Space+drag pan).
18
- * Export all three so a consumer can place a bare frame or compose their own
19
- * viewport.
17
+ * Ships from this file: `CanvasZoomContext`, `CanvasFrame` (the bare scale
18
+ * layer), `Canvas` (the letterbox, default export), `PanZoomViewport` (pan +
19
+ * zoom + rulers + guides), `PanViewport` (pan only) and `useFps`.
20
20
  *
21
- * Ported from the brand editor with three app-couplings dropped (per lobby
21
+ * Ported from kol-fxr's editor with three app-couplings dropped (per lobby
22
22
  * spec): the static `ASPECTS` table → an `aspects` prop (+ DEFAULT_ASPECTS);
23
23
  * the `#0E0E11` dark bg default → `transparent` (let the theme own the stage);
24
24
  * and the hardwired `kol-grid-bg` grid → a `backdrop` slot the consumer fills.
25
+ *
26
+ * ZOOM CAME BACK 2026-09-03 (`editor-set-is-behind-its-source`, kol-fxr). The
27
+ * first port took Space-and-drag pan only and left behind wheel zoom, the zoom
28
+ * clamps, `zoomAt`, the rulers, the guides and — load-bearing —
29
+ * `CanvasZoomContext`, which every piece of editing chrome reads to stay
30
+ * screen-constant. fxr bumped to current, ran the adoption and reverted:
31
+ * without the context its `SelectionOverlay` drew 30px handles at 3×. A
32
+ * pan-only viewport is not this component, it is a third of it.
25
33
  */
26
34
 
35
+ /* The viewport's live zoom factor — editing chrome divides its virtual-px
36
+ * dimensions by it to stay screen-constant. Defined in `hooks/canvasZoom.js`
37
+ * because the atoms consume it and an atom may not import an organism (the
38
+ * taxonomy gate, correctly); re-exported here under the name it has always
39
+ * had, so `import { CanvasZoomContext } from '@kolkrabbi/kol-component'` and
40
+ * every existing deep import keep working. */
41
+ export { CanvasZoomContext }
42
+
27
43
  /* Fixed virtual canvas width — children render in this pixel space and the
28
44
  * outer rect scales to fit the viewport via CSS transform. Height is derived
29
45
  * from the active aspect ratio. Load-bearing: the coordinate contract. */
@@ -95,6 +111,11 @@ export function CanvasFrame({
95
111
  return (
96
112
  <div
97
113
  ref={rectRef}
114
+ /* The rulers and the guides locate the frame by this attribute and read
115
+ * its on-screen rect, which already folds in the letterbox, the fit
116
+ * scale and the pan/zoom transform — so `screen = left + virtual*pxPer`
117
+ * holds at any zoom with no second coordinate system. */
118
+ data-canvas-frame
98
119
  className="relative w-full"
99
120
  style={{
100
121
  aspectRatio: ratio,
@@ -159,8 +180,15 @@ export function CanvasFrame({
159
180
  * @param {string} bgColor frame background fill
160
181
  * @param {string} guideColor guide border + label color
161
182
  * @param {'center'|'start'} align vertical placement in the letterbox
162
- * @param {boolean} panEnabled wrap in a Space+drag PanViewport
183
+ * @param {boolean} panEnabled wrap in a PanZoomViewport — Space+drag pan, wheel/pinch zoom, rulers, guides
163
184
  * @param {ReactNode} backdrop node placed behind the frame in the pan viewport (e.g. a grid)
185
+ * @param {number} gutter px breathing room the letterbox leaves around the frame (default 48)
186
+ * @param {'contain'|'cover'} fit `cover` overflows the viewport instead of letterboxing — a display-side crop; the composition is untouched
187
+ * @param {boolean} rulers draw the virtual-px rulers (default true, `panEnabled` only)
188
+ * @param {{h: number[], v: number[]}} guides ruler-guide positions in VIRTUAL px; with `setGuides`, the guides layer renders and is draggable
189
+ * @param {Function} setGuides updater for `guides` — the viewport owns no guide state, the consumer does
190
+ * @param {boolean} guidesInteractive let guides be grabbed and created (default true)
191
+ * @param {Function} onSpaceTap fires when Space was pressed and released WITHOUT panning — a tap, not a drag. fxr binds its transport play/pause here; unset, a tap does nothing
164
192
  * @param {ReactNode} children rendered in the 1080-virtual scale layer
165
193
  */
166
194
  export default function Canvas({
@@ -172,18 +200,30 @@ export default function Canvas({
172
200
  align = 'center',
173
201
  panEnabled = false,
174
202
  backdrop,
203
+ gutter = 48,
204
+ fit = 'contain',
205
+ rulers = true,
206
+ guides,
207
+ setGuides,
208
+ guidesInteractive = true,
209
+ onSpaceTap,
175
210
  children,
176
211
  }) {
177
212
  const { ratio } = resolveAspect(aspect, customRatio, aspects)
178
213
 
214
+ /* fit='cover': the frame overflows the viewport instead of letterboxing —
215
+ * a display-side crop (the composition itself is untouched). */
179
216
  const letterbox = (
180
217
  <div
181
- className={`flex ${align === 'start' ? 'items-start' : 'items-center'} justify-center w-full h-full`}
218
+ className={`flex ${align === 'start' ? 'items-start' : 'items-center'} justify-center w-full h-full ${fit === 'cover' ? 'overflow-hidden' : ''}`}
182
219
  style={{ containerType: 'size' }}
183
220
  >
184
221
  <div
222
+ className="shrink-0"
185
223
  style={{
186
- width: `min(calc(100cqw - 48px), calc((100cqh - 48px) * ${ratio}))`,
224
+ width: fit === 'cover'
225
+ ? `max(100cqw, calc(100cqh * ${ratio}))`
226
+ : `min(calc(100cqw - ${gutter}px), calc((100cqh - ${gutter}px) * ${ratio}))`,
187
227
  }}
188
228
  >
189
229
  <CanvasFrame
@@ -200,11 +240,27 @@ export default function Canvas({
200
240
  )
201
241
 
202
242
  if (!panEnabled) return letterbox
203
- return <PanViewport backdrop={backdrop}>{letterbox}</PanViewport>
243
+ return (
244
+ <PanZoomViewport
245
+ backdrop={backdrop}
246
+ showRulers={rulers}
247
+ guides={guides}
248
+ setGuides={setGuides}
249
+ guidesInteractive={guidesInteractive}
250
+ onSpaceTap={onSpaceTap}
251
+ >
252
+ {letterbox}
253
+ </PanZoomViewport>
254
+ )
204
255
  }
205
256
 
206
257
  /**
207
- * PanViewport — Space + drag pan wrapper.
258
+ * PanViewport — Space + drag pan wrapper. **Pan only, no zoom.**
259
+ *
260
+ * Kept for a consumer composing its own viewport, and because it is what
261
+ * shipped. An editor wants `PanZoomViewport` (below): this one publishes no
262
+ * `CanvasZoomContext`, so editing chrome inside it cannot stay
263
+ * screen-constant, which is the defect kol-fxr measured on 2026-09-03.
208
264
  *
209
265
  * Hold Space → cursor `grab`. Mousedown while held → drag-pan, cursor
210
266
  * `grabbing`. The pan transform applies to the child div that holds the frame;
@@ -297,3 +353,632 @@ export function PanViewport({ backdrop, children }) {
297
353
  </div>
298
354
  )
299
355
  }
356
+
357
+ /* ── Pan + zoom ────────────────────────────────────────────────────────────
358
+ * Ported verbatim from kol-fxr `src/editor/shell/Canvas.jsx` (2026-09-03,
359
+ * `editor-set-is-behind-its-source`). Two app couplings became seams: the
360
+ * Space-tap `transport.toggle()` is now `onSpaceTap`, and the hardwired
361
+ * `.kol-grid-bg` div is the `backdrop` node this package already took. The
362
+ * clamps, the anchor math, the wheel handling and the chip layout are the
363
+ * source's — fxr is the reference for this set. */
364
+
365
+ const ZOOM_MIN = 0.1
366
+ const ZOOM_MAX = 8
367
+
368
+ /* Anchor a zoom change at a screen point (sx, sy relative to the viewport
369
+ * top-left) so the content under the cursor stays put. Transform is
370
+ * `translate(x,y) scale(zoom)` with origin 0,0, so screen = p*zoom + pan;
371
+ * inverting for a fixed p gives the new pan below. */
372
+ function zoomAt(v, factor, sx, sy) {
373
+ const z2 = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, v.zoom * factor))
374
+ return {
375
+ zoom: z2,
376
+ x: sx - (sx - v.x) * (z2 / v.zoom),
377
+ y: sy - (sy - v.y) * (z2 / v.zoom),
378
+ }
379
+ }
380
+
381
+ /* useFps(enabled) — live framerate, measured only while `enabled` (the RAF
382
+ * idles when off); returns frames per second, updated twice a second.
383
+ * Exported because a consumer's own stage corner renders the same chip.
384
+ *
385
+ * A plain block comment on purpose: `@param` belongs to a destructured props
386
+ * signature, and the props gate pairs a `/** … *\/` block with the next such
387
+ * export — a JSDoc'd positional hook hands its `@param` to the component
388
+ * below it, which is exactly the false positive it reported on this file. */
389
+ export function useFps(enabled) {
390
+ const [fps, setFps] = useState(0)
391
+ useEffect(() => {
392
+ if (!enabled) return
393
+ let raf, frames = 0, last = performance.now()
394
+ const loop = (now) => {
395
+ frames++
396
+ if (now - last >= 500) {
397
+ setFps(Math.round((frames * 1000) / (now - last)))
398
+ frames = 0
399
+ last = now
400
+ }
401
+ raf = requestAnimationFrame(loop)
402
+ }
403
+ raf = requestAnimationFrame(loop)
404
+ return () => cancelAnimationFrame(raf)
405
+ }, [enabled])
406
+ return fps
407
+ }
408
+
409
+ function isTypingTarget(el) {
410
+ if (!el) return false
411
+ const tag = el.tagName
412
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable
413
+ }
414
+
415
+ /**
416
+ * PanZoomViewport — infinite-canvas viewport (pan + zoom), and the one that
417
+ * publishes `CanvasZoomContext`.
418
+ *
419
+ * Pan: hold Space + drag (cursor grab/grabbing), or two-finger trackpad
420
+ * scroll. Zoom: Cmd/Ctrl + wheel or trackpad pinch, anchored at the pointer;
421
+ * Cmd+0 resets, Cmd+= / Cmd+- step-zoom at the viewport center. A single
422
+ * `translate() scale()` on the transform layer carries both, so a consumer's
423
+ * screen→virtual math needs no zoom awareness. Pointer events on the
424
+ * transform layer disable while Space is held so layer mousedowns don't fire
425
+ * mid-pan. `f` toggles an fps chip beside the zoom readout.
426
+ *
427
+ * @param {ReactNode} backdrop placed behind the frame (grid / dark bg); the consumer owns its sizing — oversize it so panning never reveals an edge
428
+ * @param {boolean} showRulers draw the virtual-px rulers (default true)
429
+ * @param {{h: number[], v: number[]}} guides guide positions in VIRTUAL px
430
+ * @param {Function} setGuides updater for `guides`; without both, no guides layer renders
431
+ * @param {boolean} guidesInteractive allow grab + create (default true)
432
+ * @param {Function} onSpaceTap fires on a Space press released without panning
433
+ * @param {ReactNode} children the letterboxed frame
434
+ */
435
+ export function PanZoomViewport({
436
+ children,
437
+ backdrop,
438
+ showRulers = true,
439
+ guides,
440
+ setGuides,
441
+ guidesInteractive = true,
442
+ onSpaceTap,
443
+ }) {
444
+ const containerRef = useRef(null)
445
+ const [spaceHeld, setSpaceHeld] = useState(false)
446
+ /* Space tap vs Space+drag: the ref records whether a pan drag consumed this
447
+ * Space press (set on pan mousedown), so keyup can tell them apart. */
448
+ const spacePannedRef = useRef(false)
449
+ const [dragging, setDragging] = useState(false)
450
+ const [view, setView] = useState({ zoom: 1, x: 0, y: 0 })
451
+ const dragStart = useRef(null)
452
+ const [showFps, setShowFps] = useState(false)
453
+ const fps = useFps(showFps)
454
+ /* Ref mirror so the keyup listener calls the latest callback without
455
+ * rebinding the window listeners on every consumer re-render. */
456
+ const spaceTapRef = useRef(onSpaceTap)
457
+ spaceTapRef.current = onSpaceTap
458
+
459
+ /* Space toggles pan mode; Cmd+0 / Cmd+= / Cmd+- drive zoom from the
460
+ * keyboard (centered on the viewport). Skipped while typing in a field. */
461
+ useEffect(() => {
462
+ const isInputTarget = (el) =>
463
+ el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
464
+ const onKeyDown = (e) => {
465
+ if (isInputTarget(e.target)) return
466
+ if (e.code === 'Space') {
467
+ e.preventDefault()
468
+ if (!e.repeat) spacePannedRef.current = false
469
+ setSpaceHeld(true)
470
+ return
471
+ }
472
+ if (e.metaKey || e.ctrlKey) {
473
+ if (e.key === '0') {
474
+ e.preventDefault()
475
+ setView({ zoom: 1, x: 0, y: 0 })
476
+ } else if (e.key === '=' || e.key === '+') {
477
+ e.preventDefault()
478
+ const r = containerRef.current?.getBoundingClientRect()
479
+ setView((v) => zoomAt(v, 1.2, (r?.width ?? 0) / 2, (r?.height ?? 0) / 2))
480
+ } else if (e.key === '-') {
481
+ e.preventDefault()
482
+ const r = containerRef.current?.getBoundingClientRect()
483
+ setView((v) => zoomAt(v, 1 / 1.2, (r?.width ?? 0) / 2, (r?.height ?? 0) / 2))
484
+ }
485
+ }
486
+ }
487
+ const onKeyUp = (e) => {
488
+ if (e.code === 'Space') {
489
+ setSpaceHeld(false)
490
+ setDragging(false)
491
+ dragStart.current = null
492
+ /* No pan happened → this was a tap. fxr binds play/pause here; the
493
+ * same input guard as keydown so typing a space in a field is not a
494
+ * tap. Unset, a tap does nothing. */
495
+ if (!spacePannedRef.current && !isInputTarget(e.target)) spaceTapRef.current?.()
496
+ }
497
+ }
498
+ window.addEventListener('keydown', onKeyDown)
499
+ window.addEventListener('keyup', onKeyUp)
500
+ return () => {
501
+ window.removeEventListener('keydown', onKeyDown)
502
+ window.removeEventListener('keyup', onKeyUp)
503
+ }
504
+ }, [])
505
+
506
+ useEffect(() => {
507
+ if (!dragging) return
508
+ const onMove = (e) => {
509
+ if (!dragStart.current) return
510
+ const { x: sx, y: sy } = dragStart.current
511
+ setView((v) => ({ ...v, x: e.clientX - sx, y: e.clientY - sy }))
512
+ }
513
+ const onUp = () => {
514
+ setDragging(false)
515
+ dragStart.current = null
516
+ }
517
+ window.addEventListener('mousemove', onMove)
518
+ window.addEventListener('mouseup', onUp)
519
+ return () => {
520
+ window.removeEventListener('mousemove', onMove)
521
+ window.removeEventListener('mouseup', onUp)
522
+ }
523
+ }, [dragging])
524
+
525
+ /* Wheel: Cmd/Ctrl+wheel or trackpad pinch (ctrlKey) → zoom at pointer;
526
+ * plain two-finger scroll → pan. Native non-passive listener so we can
527
+ * preventDefault the browser's page-zoom / scroll. */
528
+ useEffect(() => {
529
+ const node = containerRef.current
530
+ if (!node) return
531
+ const onWheel = (e) => {
532
+ e.preventDefault()
533
+ const rect = node.getBoundingClientRect()
534
+ const sx = e.clientX - rect.left
535
+ const sy = e.clientY - rect.top
536
+ if (e.ctrlKey || e.metaKey) {
537
+ setView((v) => zoomAt(v, Math.exp(-e.deltaY * 0.0015), sx, sy))
538
+ } else {
539
+ setView((v) => ({ ...v, x: v.x - e.deltaX, y: v.y - e.deltaY }))
540
+ }
541
+ }
542
+ node.addEventListener('wheel', onWheel, { passive: false })
543
+ return () => node.removeEventListener('wheel', onWheel)
544
+ }, [])
545
+
546
+ /* A consumer's zoom TOOL announces clicks as `kol:zoom-at` (client coords +
547
+ * factor) rather than reaching into this state — anchored zoom at the
548
+ * pointer. Same idiom as `kol:guide-drag-start` below. */
549
+ useEffect(() => {
550
+ const onZoomEvt = (e) => {
551
+ const node = containerRef.current
552
+ if (!node) return
553
+ const { clientX, clientY, factor } = e.detail
554
+ const rect = node.getBoundingClientRect()
555
+ setView((v) => zoomAt(v, factor, clientX - rect.left, clientY - rect.top))
556
+ }
557
+ window.addEventListener('kol:zoom-at', onZoomEvt)
558
+ return () => window.removeEventListener('kol:zoom-at', onZoomEvt)
559
+ }, [])
560
+
561
+ /* `f` toggles the fps chip (measured only while shown). Guarded against
562
+ * typing targets so text fields don't flip it. */
563
+ useEffect(() => {
564
+ const onKey = (e) => {
565
+ if (e.key !== 'f' && e.key !== 'F') return
566
+ if (e.metaKey || e.ctrlKey || e.altKey) return
567
+ if (isTypingTarget(e.target)) return
568
+ setShowFps((v) => !v)
569
+ }
570
+ window.addEventListener('keydown', onKey)
571
+ return () => window.removeEventListener('keydown', onKey)
572
+ }, [])
573
+
574
+ const onMouseDown = (e) => {
575
+ if (!spaceHeld) return
576
+ e.preventDefault()
577
+ spacePannedRef.current = true
578
+ dragStart.current = { x: e.clientX - view.x, y: e.clientY - view.y }
579
+ setDragging(true)
580
+ }
581
+
582
+ /* Only override the cursor while actively panning (Space-held / dragging).
583
+ * At rest, leave it unset so consumers above can apply their own cursor
584
+ * (a tool-driven cursor) and have it visible across the backdrop AND the
585
+ * canvas frame, not just the frame area. */
586
+ const cursor = dragging ? 'grabbing' : spaceHeld ? 'grab' : undefined
587
+ const atRest = view.zoom === 1 && view.x === 0 && view.y === 0
588
+
589
+ return (
590
+ <CanvasZoomContext.Provider value={view.zoom}>
591
+ <div
592
+ ref={containerRef}
593
+ className="relative w-full h-full overflow-hidden select-none"
594
+ style={cursor ? { cursor } : undefined}
595
+ onMouseDown={onMouseDown}
596
+ >
597
+ {/* No transition on the transform: editing chrome (selection
598
+ wireframe, path nodes) sizes itself by 1/zoom from React state,
599
+ which updates instantly — an eased CSS transform lags behind and
600
+ makes the chrome visibly pop. Instant zoom keeps chrome, rulers and
601
+ stage in the same frame. */}
602
+ <div
603
+ className="absolute inset-0"
604
+ style={{
605
+ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
606
+ transformOrigin: '0 0',
607
+ pointerEvents: spaceHeld ? 'none' : 'auto',
608
+ }}
609
+ >
610
+ {backdrop}
611
+ <div className="relative w-full h-full">{children}</div>
612
+ </div>
613
+
614
+ {/* Ruler guides — viewport-level so each line spans the whole visible
615
+ canvas area (readable against the rulers), under the rulers, above
616
+ the canvas content. */}
617
+ {guides && setGuides && (
618
+ <CanvasGuides
619
+ containerRef={containerRef}
620
+ view={view}
621
+ guides={guides}
622
+ setGuides={setGuides}
623
+ interactive={guidesInteractive && !spaceHeld}
624
+ />
625
+ )}
626
+
627
+ {showRulers && <CanvasRuler containerRef={containerRef} view={view} disabled={spaceHeld} />}
628
+
629
+ {/* Zoom % + fps — matching chips. Zoom (click resets to 100% /
630
+ centered) first, fps to its right, shown while `f` toggles it. */}
631
+ <div className="absolute bottom-3 right-3 z-[3] flex items-center gap-2">
632
+ <button
633
+ type="button"
634
+ onClick={() => setView({ zoom: 1, x: 0, y: 0 })}
635
+ className="px-2 py-1 rounded border border-fg-08 bg-surface-secondary kol-mono-12 text-emphasis tabular-nums"
636
+ style={{ opacity: atRest && !showFps ? 0.55 : 1 }}
637
+ title="Reset zoom (⌘0)"
638
+ >
639
+ {Math.round(view.zoom * 100)}%
640
+ </button>
641
+ {showFps && (
642
+ <span
643
+ className="px-2 py-1 rounded border border-fg-08 bg-surface-secondary kol-mono-12 text-emphasis tabular-nums"
644
+ title="Framerate — press F to hide"
645
+ >
646
+ {fps} fps
647
+ </span>
648
+ )}
649
+ </div>
650
+ </div>
651
+ </CanvasZoomContext.Provider>
652
+ )
653
+ }
654
+
655
+ /* ── Rulers + guides ─────────────────────────────────────────────────────── */
656
+
657
+ const RULER = 18 /* px thickness of each ruler bar */
658
+ const RULER_STEPS = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000]
659
+
660
+ /* Smallest 1-2-5 virtual step whose on-screen spacing clears `target` px, so
661
+ * labels never crowd regardless of zoom. */
662
+ function niceStep(pxPer, target = 80) {
663
+ for (const s of RULER_STEPS) if (s * pxPer >= target) return s
664
+ return RULER_STEPS[RULER_STEPS.length - 1]
665
+ }
666
+
667
+ /* Virtual ticks visible across [0, spanScreen], given where virtual-0 sits on
668
+ * screen (originScreen) and the screen-px-per-virtual-px scale. */
669
+ function ticksFor(originScreen, pxPer, spanScreen, step) {
670
+ const vMin = (0 - originScreen) / pxPer
671
+ const vMax = (spanScreen - originScreen) / pxPer
672
+ const first = Math.ceil(vMin / step) * step
673
+ const out = []
674
+ for (let v = first; v <= vMax; v += step) out.push({ v: Math.round(v), s: originScreen + v * pxPer })
675
+ return out
676
+ }
677
+
678
+ /* Frame geometry inside the viewport — locates the tagged
679
+ * `[data-canvas-frame]` and reads its on-screen rect (which already folds in
680
+ * the letterbox, fit-scale, and the pan/zoom transform) relative to the
681
+ * container, so `screen = left/top + virtual * pxPer` holds at any zoom with
682
+ * no separate math. `vh` is the frame's height in virtual px (for clamping
683
+ * horizontal guides). Re-measures on every `view` change and on container
684
+ * resize. Shared by CanvasRuler and CanvasGuides so ruler labels and guide
685
+ * lines can never disagree. */
686
+ function useFrameGeom(containerRef, view) {
687
+ const [geom, setGeom] = useState(null)
688
+
689
+ const measure = useCallback(() => {
690
+ const el = containerRef.current
691
+ if (!el) return
692
+ const frame = el.querySelector('[data-canvas-frame]')
693
+ const crect = el.getBoundingClientRect()
694
+ if (!frame || crect.width === 0) { setGeom(null); return }
695
+ const frect = frame.getBoundingClientRect()
696
+ const pxPer = frect.width / CANVAS_VIRTUAL_W
697
+ setGeom({
698
+ left: frect.left - crect.left,
699
+ top: frect.top - crect.top,
700
+ pxPer,
701
+ vh: pxPer > 0 ? frect.height / pxPer : 0,
702
+ cw: crect.width,
703
+ ch: crect.height,
704
+ })
705
+ }, [containerRef])
706
+
707
+ /* A zoom that ANIMATES its transform (a consumer's eased step-zoom) would
708
+ * have a single measure read the pre-animation rect, and the labels would
709
+ * lag the whole tween. Re-measure per animation frame until the frame rect
710
+ * stops moving (2 stable frames); an instant zoom settles immediately,
711
+ * costing a couple of no-op frames. */
712
+ useLayoutEffect(() => {
713
+ measure()
714
+ let raf
715
+ let prevKey
716
+ let stable = 0
717
+ const tick = () => {
718
+ const frame = containerRef.current?.querySelector('[data-canvas-frame]')
719
+ if (!frame) return
720
+ const r = frame.getBoundingClientRect()
721
+ const key = `${r.left}|${r.top}|${r.width}`
722
+ if (key !== prevKey) {
723
+ prevKey = key
724
+ stable = 0
725
+ measure()
726
+ } else if (++stable >= 2) {
727
+ return
728
+ }
729
+ raf = requestAnimationFrame(tick)
730
+ }
731
+ raf = requestAnimationFrame(tick)
732
+ return () => cancelAnimationFrame(raf)
733
+ }, [measure, view, containerRef])
734
+
735
+ useEffect(() => {
736
+ const el = containerRef.current
737
+ if (!el) return
738
+ const ro = new ResizeObserver(measure)
739
+ ro.observe(el)
740
+ return () => ro.disconnect()
741
+ }, [measure, containerRef])
742
+
743
+ return geom
744
+ }
745
+
746
+ /**
747
+ * CanvasRuler — top + left rulers in virtual-canvas px, mapped through the
748
+ * measured frame geometry (see useFrameGeom).
749
+ *
750
+ * Dragging off a ruler starts a new guide: the ruler only ANNOUNCES the
751
+ * gesture via a `kol:guide-drag-start` CustomEvent (same idiom as
752
+ * `kol:zoom-at`) — CanvasGuides owns the guide drag, and the positions live in
753
+ * the consumer's state. Canvases without a guides layer no-op. `disabled`
754
+ * (Space-held pan) lets the pointerdown bubble to the pan handler instead.
755
+ */
756
+ function CanvasRuler({ containerRef, view, disabled = false }) {
757
+ const geom = useFrameGeom(containerRef, view)
758
+
759
+ const startGuideDrag = (axis) => (e) => {
760
+ if (disabled || e.button !== 0) return
761
+ e.preventDefault()
762
+ window.dispatchEvent(new CustomEvent('kol:guide-drag-start', {
763
+ detail: { axis, clientX: e.clientX, clientY: e.clientY },
764
+ }))
765
+ }
766
+
767
+ if (!geom || geom.pxPer <= 0) return null
768
+ const step = niceStep(geom.pxPer)
769
+ const hTicks = ticksFor(geom.left, geom.pxPer, geom.cw, step)
770
+ const vTicks = ticksFor(geom.top, geom.pxPer, geom.ch, step)
771
+
772
+ /* Ruler chrome rides the themed fg ramp so it flips with light/dark: an 8%
773
+ * bar with fg ticks/labels, all naturally contrast-correct in both themes —
774
+ * no hardcoded greys. */
775
+ const tickColor = 'var(--kol-fg-48)'
776
+ const textColor = 'var(--kol-fg-64)'
777
+ const barBg = 'var(--kol-fg-08)'
778
+ const borderColor = 'var(--kol-fg-16)'
779
+ const labelStyle = { fontFamily: 'var(--kol-font-family-mono)', fontSize: 9 }
780
+
781
+ return (
782
+ <>
783
+ <svg width="100%" height={RULER}
784
+ onPointerDown={startGuideDrag('h')}
785
+ style={{ position: 'absolute', top: 0, left: 0, zIndex: 4, cursor: 'row-resize' }}>
786
+ <rect x={0} y={0} width="100%" height={RULER} fill={barBg} />
787
+ {hTicks.map(({ v, s }) => (
788
+ <g key={v}>
789
+ <line x1={s} y1={RULER - 5} x2={s} y2={RULER} stroke={tickColor} strokeWidth={1} />
790
+ <text x={s + 2} y={9} fill={textColor} style={labelStyle}>{v}</text>
791
+ </g>
792
+ ))}
793
+ <line x1={0} y1={RULER - 0.5} x2="100%" y2={RULER - 0.5} stroke={borderColor} strokeWidth={1} />
794
+ </svg>
795
+ <svg width={RULER} height="100%"
796
+ onPointerDown={startGuideDrag('v')}
797
+ style={{ position: 'absolute', top: 0, left: 0, zIndex: 4, cursor: 'col-resize' }}>
798
+ <rect x={0} y={0} width={RULER} height="100%" fill={barBg} />
799
+ {vTicks.map(({ v, s }) => (
800
+ <g key={v}>
801
+ <line x1={RULER - 5} y1={s} x2={RULER} y2={s} stroke={tickColor} strokeWidth={1} />
802
+ <text x={9} y={s - 2} fill={textColor} style={labelStyle}
803
+ transform={`rotate(-90 9 ${s - 2})`}>{v}</text>
804
+ </g>
805
+ ))}
806
+ <line x1={RULER - 0.5} y1={0} x2={RULER - 0.5} y2="100%" stroke={borderColor} strokeWidth={1} />
807
+ </svg>
808
+ <div style={{ position: 'absolute', top: 0, left: 0, width: RULER, height: RULER, background: barBg, borderRight: `1px solid ${borderColor}`, borderBottom: `1px solid ${borderColor}`, zIndex: 4, pointerEvents: 'none' }} />
809
+ </>
810
+ )
811
+ }
812
+
813
+ /* A ruler guide — 1px accent line spanning the full viewport (guides read
814
+ * against the rulers, not just the frame). Pointer events live on a slop
815
+ * wrapper around the line so the grab zone stays ~5px; everything is screen px
816
+ * at the viewport level, so no zoom compensation is needed here. */
817
+ function GuideLine({ axis, screenPos, interactive, onGrab }) {
818
+ const slop = 5
819
+ const h = axis === 'h'
820
+ return (
821
+ <div
822
+ onPointerDown={interactive ? onGrab : undefined}
823
+ /* Stop the compat mousedown from reaching handlers underneath (pan /
824
+ * click-away) in browsers that fire it despite the canceled
825
+ * pointerdown. */
826
+ onMouseDown={interactive ? (e) => e.stopPropagation() : undefined}
827
+ style={{
828
+ position: 'absolute',
829
+ ...(h
830
+ ? { left: 0, top: screenPos - slop, width: '100%', height: slop * 2 + 1, cursor: 'row-resize' }
831
+ : { left: screenPos - slop, top: 0, width: slop * 2 + 1, height: '100%', cursor: 'col-resize' }),
832
+ pointerEvents: interactive ? 'auto' : 'none',
833
+ }}
834
+ >
835
+ <div
836
+ style={{
837
+ position: 'absolute',
838
+ ...(h
839
+ ? { left: 0, top: slop, width: '100%', height: 1 }
840
+ : { left: slop, top: 0, width: 1, height: '100%' }),
841
+ background: 'var(--kol-accent-primary)',
842
+ }}
843
+ />
844
+ </div>
845
+ )
846
+ }
847
+
848
+ /**
849
+ * CanvasGuides — ruler guides rendered at the viewport level so each line
850
+ * spans the entire visible canvas area instead of clipping to the letterbox
851
+ * frame. Positions are stored in virtual canvas px and threaded down as props
852
+ * — the viewport is chrome and owns no guide state; the screen mapping is the
853
+ * same frame-rect geometry the rulers use: screen = frameLeft/Top + virtual *
854
+ * pxPer.
855
+ *
856
+ * Interaction:
857
+ * • grab a line (±5px slop) to move it — row/col-resize cursors
858
+ * • drag off a ruler to create (CanvasRuler announces the gesture via the
859
+ * `kol:guide-drag-start` CustomEvent; this layer owns the drag)
860
+ * • release at virtual pos < 0 (back over the source ruler, or past the
861
+ * frame edge toward it) deletes instead of committing
862
+ *
863
+ * Drags use window-level POINTER events — the ruler cancels its pointerdown,
864
+ * which suppresses the whole compatibility mouse-event stream for the
865
+ * interaction, so mousemove/mouseup would never fire.
866
+ */
867
+ function CanvasGuides({ containerRef, view, guides, setGuides, interactive }) {
868
+ const geom = useFrameGeom(containerRef, view)
869
+ /* Ref mirror so the drag listeners read fresh geometry without rebinding. */
870
+ const geomRef = useRef(null)
871
+ geomRef.current = geom
872
+ const [guideDrag, setGuideDrag] = useState(null) /* { axis:'h'|'v', index:number|null, pos:number } | null */
873
+
874
+ /* client coords → virtual canvas px, via the measured frame geometry. */
875
+ const toVirtual = useCallback((clientX, clientY) => {
876
+ const el = containerRef.current
877
+ const g = geomRef.current
878
+ if (!el || !g || g.pxPer <= 0) return { vx: 0, vy: 0 }
879
+ const crect = el.getBoundingClientRect()
880
+ return {
881
+ vx: (clientX - crect.left - g.left) / g.pxPer,
882
+ vy: (clientY - crect.top - g.top) / g.pxPer,
883
+ }
884
+ }, [containerRef])
885
+
886
+ /* A pointerdown on a ruler dispatches kol:guide-drag-start (see
887
+ * CanvasRuler); this opens a new-guide drag at the pointer. */
888
+ useEffect(() => {
889
+ const onStart = (e) => {
890
+ const { axis, clientX, clientY } = e.detail
891
+ const { vx, vy } = toVirtual(clientX, clientY)
892
+ setGuideDrag({ axis, index: null, pos: Math.round(axis === 'h' ? vy : vx) })
893
+ }
894
+ window.addEventListener('kol:guide-drag-start', onStart)
895
+ return () => window.removeEventListener('kol:guide-drag-start', onStart)
896
+ }, [toVirtual])
897
+
898
+ /* Window-level listeners while a guide drag is live. Commit on pointerup:
899
+ * append (new) or move (existing); pos < 0 deletes / discards. Positions
900
+ * clamp to the far canvas edge and round to whole virtual px. */
901
+ useEffect(() => {
902
+ if (!guideDrag) return
903
+ const posFrom = (e) => {
904
+ const { vx, vy } = toVirtual(e.clientX, e.clientY)
905
+ return Math.round(guideDrag.axis === 'h' ? vy : vx)
906
+ }
907
+ const onMove = (e) => setGuideDrag((d) => d && { ...d, pos: posFrom(e) })
908
+ const onUp = (e) => {
909
+ const pos = posFrom(e)
910
+ const { axis, index } = guideDrag
911
+ const max = axis === 'h' ? Math.round(geomRef.current?.vh ?? 0) : CANVAS_VIRTUAL_W
912
+ setGuides((g) => {
913
+ const arr = [...g[axis]]
914
+ if (pos < 0) {
915
+ if (index != null) arr.splice(index, 1) /* dropped on the ruler → delete */
916
+ } else if (index == null) {
917
+ arr.push(Math.min(pos, max))
918
+ } else {
919
+ arr[index] = Math.min(pos, max)
920
+ }
921
+ return { ...g, [axis]: arr }
922
+ })
923
+ setGuideDrag(null)
924
+ }
925
+ window.addEventListener('pointermove', onMove)
926
+ window.addEventListener('pointerup', onUp)
927
+ return () => {
928
+ window.removeEventListener('pointermove', onMove)
929
+ window.removeEventListener('pointerup', onUp)
930
+ }
931
+ }, [guideDrag, toVirtual, setGuides])
932
+
933
+ if (!geom || geom.pxPer <= 0) return null
934
+ const screenFor = (axis, pos) =>
935
+ axis === 'h' ? geom.top + pos * geom.pxPer : geom.left + pos * geom.pxPer
936
+ const canGrab = interactive && !guideDrag
937
+ const grab = (axis, index, pos) => (e) => {
938
+ if (e.button !== 0) return
939
+ e.stopPropagation()
940
+ e.preventDefault()
941
+ setGuideDrag({ axis, index, pos })
942
+ }
943
+
944
+ return (
945
+ /* z-[3]: under the rulers (zIndex 4), above the transform layer (canvas
946
+ content). pointer-events-none wrapper — only the slop zones re-enable. */
947
+ <div className="absolute inset-0 pointer-events-none z-[3]">
948
+ {guides.h.map((y, i) => (
949
+ guideDrag?.axis === 'h' && guideDrag.index === i ? null : (
950
+ <GuideLine
951
+ key={`gh-${i}`} axis="h" screenPos={screenFor('h', y)}
952
+ interactive={canGrab} onGrab={grab('h', i, y)}
953
+ />
954
+ )
955
+ ))}
956
+ {guides.v.map((x, i) => (
957
+ guideDrag?.axis === 'v' && guideDrag.index === i ? null : (
958
+ <GuideLine
959
+ key={`gv-${i}`} axis="v" screenPos={screenFor('v', x)}
960
+ interactive={canGrab} onGrab={grab('v', i, x)}
961
+ />
962
+ )
963
+ ))}
964
+ {guideDrag && (
965
+ <>
966
+ <GuideLine
967
+ axis={guideDrag.axis}
968
+ screenPos={screenFor(guideDrag.axis, guideDrag.pos)}
969
+ interactive={false}
970
+ />
971
+ {/* Full-viewport cursor shield — keeps the row/col-resize cursor
972
+ while the pointer roams outside the dragged line's slop zone. */}
973
+ <div
974
+ className="absolute inset-0"
975
+ style={{
976
+ cursor: guideDrag.axis === 'h' ? 'row-resize' : 'col-resize',
977
+ pointerEvents: 'auto',
978
+ }}
979
+ />
980
+ </>
981
+ )}
982
+ </div>
983
+ )
984
+ }
@@ -427,7 +427,7 @@ export function WheelTriangle({ hue, sat, val, onChangeHue, onChangeSV }) {
427
427
  xmlns="http://www.w3.org/1999/xhtml"
428
428
  style={{
429
429
  width: '100%', height: '100%',
430
- background: `conic-gradient(from 0deg,
430
+ background: `conic-gradient(from 90deg,
431
431
  hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%),
432
432
  hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%),
433
433
  hsl(360,100%,50%))`,