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