@kolkrabbi/kol-component 0.129.0 → 0.130.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.129.0",
3
+ "version": "0.130.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",
package/src/index.js CHANGED
@@ -134,6 +134,8 @@ export { default as SectionCards } from './organisms/SectionCards.jsx'
134
134
  export { default as SectionCta, default as CtaGlobal } from './organisms/SectionCta.jsx'
135
135
  export { default as SectionFaq } from './organisms/SectionFaq.jsx'
136
136
  export { default as LoaderOverlay } from './utilities/LoaderOverlay.jsx'
137
+ /* dev chrome: name an element, agree on a cell, emit one sync line */
138
+ export { default as QuadrantSync } from './utilities/QuadrantSync.jsx'
137
139
  export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPicker, MediaBrowser } from './organisms/MediaLibrary.jsx'
138
140
  export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
139
141
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
@@ -0,0 +1,588 @@
1
+ /**
2
+ * QuadrantSync — the pinpoint overlay.
3
+ *
4
+ * Dev chrome for agreeing on WHICH element is being discussed before anyone
5
+ * edits it. Draws a chess-style grid on a named node and emits one sync line —
6
+ * `StageModuleGroup @ 1440w · e8 → h7` — that both sides restate. Born from a
7
+ * real failure: ten messages to move one button, none of them wrong about CSS,
8
+ * all of them about different elements.
9
+ *
10
+ * The rule that outranks every other: **the grid goes on what the USER named.**
11
+ * Never redirected to a child or a parent. The owner is REPORTED, never
12
+ * substituted — a grab-handle that moves a whole group lives at group level,
13
+ * and quietly re-pointing the grid at a child answers a question nobody asked.
14
+ *
15
+ * Mark nodes with `data-handle="Name"` on divs that already exist — never a
16
+ * wrapper added for the tool's benefit. Optionally `data-source="@kolkrabbi/…"`
17
+ * so design-system ownership is a fact at selection time, not a discovery
18
+ * mid-edit.
19
+ *
20
+ * A cell is a FRACTION of the named element, which is why `e8` means the same
21
+ * relative place at 1440w and 760w and a pixel offset never does.
22
+ *
23
+ * Measures and positions over nodes from a fixed layer; it never wraps a node,
24
+ * so grid position tracks the RENDERED layout rather than the tree. Renders
25
+ * nothing when `enabled` is false — dev-only by default.
26
+ *
27
+ * @param {Object} props
28
+ * @param {HTMLElement} props.root Subtree to hunt handles in (default document.body)
29
+ * @param {string} props.attr Attribute naming a node (default 'data-handle')
30
+ * @param {string} props.sourceAttr Attribute naming a node's origin (default 'data-source')
31
+ * @param {Array} props.bands Breakpoint bands; the live one is READ from the window
32
+ * @param {Object} props.defaultDivisions `{ cols, rows }` before `square` is applied
33
+ * @param {string} props.initialHandle Handle name to select on mount
34
+ * @param {Function} props.onSyncLine (line, parts) => void, fired when the line changes
35
+ * @param {string} props.handleColor Grid + outline color for the named node
36
+ * @param {string} props.pageColor Grid color for the page reference
37
+ * @param {boolean} props.enabled Render at all (default: off in production)
38
+ */
39
+
40
+ import { useCallback, useEffect, useRef, useState } from 'react'
41
+
42
+ /* ── coordinates ─────────────────────────────────────────────────────────
43
+ * Pure. No DOM writes. The invariant everything rests on: a cell is a
44
+ * fraction of the box, so the same relative point resolves to the same cell
45
+ * at any size.
46
+ */
47
+
48
+ const LETTERS = 'abcdefghijklmnopqrstuvwxyz'
49
+ export const MAX_COLS = 26
50
+ export const MAX_ROWS = 100
51
+ const SQUARE_TOLERANCE = 0.06
52
+
53
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))
54
+
55
+ /** `a1` is TOP-LEFT — screen convention, not chess's bottom-left. */
56
+ const cellLabel = (c, r) => LETTERS[c] + (r + 1)
57
+
58
+ const rectOf = (el) => {
59
+ const r = el.getBoundingClientRect()
60
+ return { left: r.left, top: r.top, width: r.width, height: r.height, right: r.right, bottom: r.bottom }
61
+ }
62
+
63
+ /** Which cell of `box` holds the viewport point. Out-of-box points clamp. */
64
+ function cellAt (box, px, py, cols, rows) {
65
+ return {
66
+ c: clamp(Math.floor(((px - box.left) / box.width) * cols), 0, cols - 1),
67
+ r: clamp(Math.floor(((py - box.top) / box.height) * rows), 0, rows - 1)
68
+ }
69
+ }
70
+
71
+ /** An element SPANS cells, it does not sit in one — `c1–f8`, or one label if it fits one cell. */
72
+ function spanOf (child, parent, cols, rows) {
73
+ const a = cellAt(parent, child.left + 1, child.top + 1, cols, rows)
74
+ const b = cellAt(parent, child.right - 1, child.bottom - 1, cols, rows)
75
+ const s = cellLabel(a.c, a.r)
76
+ const e = cellLabel(b.c, b.r)
77
+ return s === e ? s : `${s}–${e}`
78
+ }
79
+
80
+ function cellAspect (box, cols, rows) {
81
+ if (!box || !box.width || !box.height) return null
82
+ const cw = box.width / cols
83
+ const ch = box.height / rows
84
+ const ratio = cw / ch
85
+ return { cw, ch, ratio, isSquare: Math.abs(ratio - 1) <= SQUARE_TOLERANCE }
86
+ }
87
+
88
+ /**
89
+ * Derive the second axis so cells come out square on THIS box.
90
+ *
91
+ * One-shot on purpose. Recomputing on every reflow would silently change what
92
+ * a coordinate means, which is the confusion this whole thing exists to remove.
93
+ * After a reflow the cells stretch into rectangles — expected, and fine.
94
+ *
95
+ * The axis caps can make a naive first pass unusable on an extreme box (a 5.7:1
96
+ * strip at 8 columns wants 1.4 rows), so re-anchor on the other axis and derive
97
+ * back through the cap.
98
+ */
99
+ function squareDivisions (box, cols, rows) {
100
+ if (!box || !box.width || !box.height) return { cols, rows }
101
+ const ar = box.width / box.height
102
+ let c = clamp(cols, 1, MAX_COLS)
103
+ let r = Math.round(c / ar)
104
+
105
+ if (r < 2) {
106
+ r = 2
107
+ c = clamp(Math.round(r * ar), 1, MAX_COLS)
108
+ r = clamp(Math.round(c / ar), 1, MAX_ROWS)
109
+ } else if (r > MAX_ROWS) {
110
+ r = MAX_ROWS
111
+ c = clamp(Math.round(r * ar), 1, MAX_COLS)
112
+ r = clamp(Math.round(c / ar), 1, MAX_ROWS)
113
+ }
114
+ return { cols: c, rows: r }
115
+ }
116
+
117
+ /* ── the tree ─────────────────────────────────────────────────────────── */
118
+
119
+ /** Nearest ancestor carrying a handle. A REPORT, never a redirect. */
120
+ function ownerOf (el, attr) {
121
+ let p = el.parentElement
122
+ while (p && p !== document.body && !p.hasAttribute(attr)) p = p.parentElement
123
+ return p && p.hasAttribute(attr) ? p : null
124
+ }
125
+
126
+ function chainOf (el, attr) {
127
+ const names = []
128
+ let cur = ownerOf(el, attr)
129
+ while (cur) {
130
+ names.unshift(cur.getAttribute(attr))
131
+ cur = ownerOf(cur, attr)
132
+ }
133
+ return names
134
+ }
135
+
136
+ /** The owner's layout mode, and which axis it actually makes reachable. */
137
+ function layoutOf (el) {
138
+ const cs = getComputedStyle(el)
139
+ if (cs.display === 'flex' || cs.display === 'inline-flex') {
140
+ const column = cs.flexDirection.startsWith('column')
141
+ return { mode: `flex ${column ? 'column' : 'row'}`, axis: column ? 'y' : 'x' }
142
+ }
143
+ if (cs.display === 'grid' || cs.display === 'inline-grid') return { mode: 'grid', axis: 'xy' }
144
+ return { mode: cs.display, axis: 'y' }
145
+ }
146
+
147
+ /* A cell says WHERE, never what CSS to change. When a cell is unreachable in
148
+ * the owner's mode, saying so is the feature — a diff that cannot work is worse
149
+ * than an honest no. */
150
+ function reachability (layout) {
151
+ if (layout.axis === 'x') return 'Horizontal order reachable; vertical needs align/self.'
152
+ if (layout.axis === 'y') return "Vertical order reachable; horizontal needs the owner's layout mode changed."
153
+ return 'Both axes reachable via grid placement.'
154
+ }
155
+
156
+ /* ── breakpoint bands: read from the window, never chosen ─────────────── */
157
+
158
+ export const DEFAULT_BANDS = [
159
+ { name: 'Mobile', min: 0, max: 559 },
160
+ { name: 'Tablet', min: 560, max: 859 },
161
+ { name: 'Laptop', min: 860, max: 1279 },
162
+ { name: 'Desktop', min: 1280, max: Infinity }
163
+ ]
164
+
165
+ const bandFor = (w, bands) => bands.find((b) => w >= b.min && w <= b.max) || bands[bands.length - 1]
166
+
167
+ /* ── the deliverable ──────────────────────────────────────────────────── */
168
+
169
+ /** `StageModuleGroup @ 1440w · e8 → h7` */
170
+ function formatSyncLine (handle, width, from, to) {
171
+ let s = `${handle} @ ${width}w`
172
+ if (from) {
173
+ s += ` · ${from}`
174
+ if (to) s += ` → ${to}`
175
+ }
176
+ return s
177
+ }
178
+
179
+ const IS_PROD = typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production'
180
+
181
+ export default function QuadrantSync ({
182
+ root = null,
183
+ attr = 'data-handle',
184
+ sourceAttr = 'data-source',
185
+ bands = DEFAULT_BANDS,
186
+ defaultDivisions = { cols: 8, rows: 8 },
187
+ initialHandle = '',
188
+ onSyncLine,
189
+ handleColor = 'var(--kol-accent-primary)',
190
+ pageColor = 'var(--kol-fg-40, color-mix(in srgb, var(--kol-surface-on-primary) 40%, transparent))',
191
+ enabled = !IS_PROD
192
+ }) {
193
+ const [handle, setHandle] = useState(null)
194
+ const [cols, setCols] = useState(defaultDivisions.cols)
195
+ const [rows, setRows] = useState(defaultDivisions.rows)
196
+ const [fade, setFade] = useState(0.7)
197
+ const [showPage, setShowPage] = useState(false)
198
+ const [showHandle, setShowHandle] = useState(true)
199
+ const [showLabels, setShowLabels] = useState(true)
200
+ const [from, setFrom] = useState(null)
201
+ const [to, setTo] = useState(null)
202
+ const [settingsOpen, setSettingsOpen] = useState(false)
203
+ const [reportOpen, setReportOpen] = useState(false)
204
+ const [copied, setCopied] = useState(false)
205
+ const [width, setWidth] = useState(0)
206
+ const [, setTick] = useState(0)
207
+
208
+ const rootEl = root || (typeof document !== 'undefined' ? document.body : null)
209
+
210
+ /* re-measure whenever the world moves — the grid tracks the rendered layout */
211
+ useEffect(() => {
212
+ if (!enabled || typeof window === 'undefined') return undefined
213
+ const bump = () => {
214
+ setWidth(window.innerWidth)
215
+ setTick((t) => t + 1)
216
+ }
217
+ bump()
218
+ window.addEventListener('resize', bump)
219
+ window.addEventListener('scroll', bump, true)
220
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(bump) : null
221
+ if (ro && rootEl) ro.observe(rootEl)
222
+ return () => {
223
+ window.removeEventListener('resize', bump)
224
+ window.removeEventListener('scroll', bump, true)
225
+ if (ro) ro.disconnect()
226
+ }
227
+ }, [enabled, rootEl])
228
+
229
+ /* click-to-name */
230
+ useEffect(() => {
231
+ if (!enabled || !rootEl) return undefined
232
+ const onClick = (e) => {
233
+ const t = e.target
234
+ if (!t || !t.closest || t.closest('[data-quadrant-chrome]')) return
235
+ const el = t.closest(`[${attr}]`)
236
+ if (!el) return
237
+ e.preventDefault()
238
+ e.stopPropagation()
239
+ setHandle(el)
240
+ setFrom(null)
241
+ setTo(null)
242
+ }
243
+ rootEl.addEventListener('click', onClick, true)
244
+ return () => rootEl.removeEventListener('click', onClick, true)
245
+ }, [enabled, rootEl, attr])
246
+
247
+ useEffect(() => {
248
+ if (!enabled || !initialHandle || handle || typeof document === 'undefined') return
249
+ const el = document.querySelector(`[${attr}="${initialHandle}"]`)
250
+ if (el) setHandle(el)
251
+ }, [enabled, initialHandle, handle, attr])
252
+
253
+ /* outline the handle and its owner, restoring whatever was there before */
254
+ useEffect(() => {
255
+ if (!enabled) return undefined
256
+ const own = handle ? ownerOf(handle, attr) : null
257
+ const prev = []
258
+ if (handle) {
259
+ prev.push([handle, handle.style.outline, handle.style.outlineOffset])
260
+ handle.style.outline = `2px solid ${handleColor}`
261
+ handle.style.outlineOffset = '1px'
262
+ }
263
+ if (own) {
264
+ prev.push([own, own.style.outline, own.style.outlineOffset])
265
+ own.style.outline = `2px dashed ${pageColor}`
266
+ own.style.outlineOffset = '5px'
267
+ }
268
+ return () => prev.forEach(([el, o, off]) => {
269
+ el.style.outline = o
270
+ el.style.outlineOffset = off
271
+ })
272
+ }, [enabled, handle, attr, handleColor, pageColor])
273
+
274
+ const owner = handle && enabled ? ownerOf(handle, attr) : null
275
+ const handleBox = handle && enabled ? rectOf(handle) : null
276
+ const rootBox = rootEl && enabled ? rectOf(rootEl) : null
277
+ const aspect = cellAspect(handleBox, cols, rows)
278
+
279
+ const syncLine = handle ? formatSyncLine(handle.getAttribute(attr), width, from, to) : null
280
+
281
+ useEffect(() => {
282
+ if (syncLine && onSyncLine) {
283
+ onSyncLine(syncLine, { handle: handle && handle.getAttribute(attr), width, from, to })
284
+ }
285
+ // eslint-disable-next-line react-hooks/exhaustive-deps
286
+ }, [syncLine])
287
+
288
+ /** First click sets `from`, second sets `to`, third restarts. */
289
+ const choose = useCallback((id) => {
290
+ if (!from || to) { setFrom(id); setTo(null) } else if (id === from) setTo(null)
291
+ else setTo(id)
292
+ }, [from, to])
293
+
294
+ const applySquare = useCallback(() => {
295
+ if (!handleBox) return
296
+ const next = squareDivisions(handleBox, cols, rows)
297
+ setCols(next.cols)
298
+ setRows(next.rows)
299
+ }, [handleBox, cols, rows])
300
+
301
+ /* square once per newly-named element, so the grid STARTS square */
302
+ const squaredFor = useRef(null)
303
+ useEffect(() => {
304
+ if (!enabled || !handle) return
305
+ if (squaredFor.current === handle) return
306
+ squaredFor.current = handle
307
+ const next = squareDivisions(rectOf(handle), cols, rows)
308
+ setCols(next.cols)
309
+ setRows(next.rows)
310
+ // eslint-disable-next-line react-hooks/exhaustive-deps
311
+ }, [enabled, handle])
312
+
313
+ if (!enabled || typeof document === 'undefined') return null
314
+
315
+ const band = bandFor(width, bands)
316
+ const untreated = bands.filter((b) => b !== band).map((b) => b.name).join(' · ')
317
+ const labelsUsable = showLabels && cols <= 16 && rows <= 16
318
+ const anyGrid = showHandle || (showPage && handle !== rootEl)
319
+ const layout = owner ? layoutOf(owner) : null
320
+ const handleSource = handle ? handle.getAttribute(sourceAttr) || 'local' : null
321
+ const ownerSource = owner ? owner.getAttribute(sourceAttr) || 'local' : null
322
+ const BAR = 46
323
+
324
+ const renderGrid = (box, kind) => {
325
+ const live = kind === 'handle'
326
+ const cells = []
327
+ for (let r = 0; r < rows; r += 1) {
328
+ for (let c = 0; c < cols; c += 1) {
329
+ const id = cellLabel(c, r)
330
+ const picked = id === to ? 0.4 : id === from ? 0.22 : 0
331
+ cells.push(
332
+ <button
333
+ key={id}
334
+ type="button"
335
+ data-quadrant-chrome=""
336
+ aria-label={live ? `Cell ${id}` : undefined}
337
+ tabIndex={live ? 0 : -1}
338
+ onClick={live ? () => choose(id) : undefined}
339
+ className="absolute m-0 p-0 block text-left border-0"
340
+ style={{
341
+ left: `${(c / cols) * 100}%`,
342
+ top: `${(r / rows) * 100}%`,
343
+ width: `${100 / cols}%`,
344
+ height: `${100 / rows}%`,
345
+ borderRight: c === cols - 1 ? 'none' : '1px solid currentColor',
346
+ borderBottom: r === rows - 1 ? 'none' : '1px solid currentColor',
347
+ background: picked ? `color-mix(in srgb, currentColor ${picked * 100}%, transparent)` : 'transparent',
348
+ color: 'inherit',
349
+ cursor: live ? 'pointer' : 'default'
350
+ }}
351
+ >
352
+ {labelsUsable && (
353
+ <span
354
+ className="kol-helper-8 block"
355
+ style={{ padding: '2px 3px', fontWeight: picked ? 700 : 500 }}
356
+ >
357
+ {id}
358
+ </span>
359
+ )}
360
+ </button>
361
+ )
362
+ }
363
+ }
364
+ return (
365
+ <div
366
+ key={kind}
367
+ style={{
368
+ position: 'absolute',
369
+ left: box.left,
370
+ top: box.top,
371
+ width: box.width,
372
+ height: box.height,
373
+ color: live ? handleColor : pageColor,
374
+ opacity: live ? fade : fade * 0.32,
375
+ pointerEvents: live ? 'auto' : 'none'
376
+ }}
377
+ >
378
+ {live && <span style={{ position: 'absolute', inset: 0, border: '1px solid currentColor', pointerEvents: 'none' }} />}
379
+ {cells}
380
+ </div>
381
+ )
382
+ }
383
+
384
+ const q = (n, title, value, sub, subClass = 'text-fg-48') => (
385
+ <div>
386
+ <div className="kol-helper-8 text-fg-40" style={{ marginBottom: 5, letterSpacing: '0.12em' }}>
387
+ <span style={{ color: handleColor }}>{n}</span>{' '}{title.toUpperCase()}
388
+ </div>
389
+ <div className="kol-mono-12 text-fg-96" style={{ overflowWrap: 'anywhere' }}>{value}</div>
390
+ {sub ? <div className={`kol-mono-10 ${subClass}`} style={{ marginTop: 3, overflowWrap: 'anywhere' }}>{sub}</div> : null}
391
+ </div>
392
+ )
393
+
394
+ return (
395
+ <div data-quadrant-chrome="" className="kol-quadrant-sync">
396
+ {/* ── overlays ── */}
397
+ <div style={{ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 'var(--kol-z-nav, 1000)' }}>
398
+ {showPage && rootBox && handle !== rootEl ? renderGrid(rootBox, 'page') : null}
399
+ {showHandle && handleBox ? renderGrid(handleBox, 'handle') : null}
400
+ </div>
401
+
402
+ {/* ── settings: preferences only, never a report on the view ── */}
403
+ <aside
404
+ className="bg-surface-primary border border-fg-16"
405
+ style={{
406
+ position: 'fixed', top: 14, right: 14, width: 250, borderRadius: 4,
407
+ zIndex: 'var(--kol-z-tooltip, 300)'
408
+ }}
409
+ >
410
+ <div
411
+ className="flex items-center justify-between"
412
+ style={{ gap: 8, padding: '8px 10px', borderBottom: settingsOpen ? '1px solid var(--kol-border-default)' : 'none' }}
413
+ >
414
+ <span className="kol-helper-10 text-fg-40">GRID SETTINGS</span>
415
+ <button
416
+ type="button"
417
+ className="kol-helper-12 text-fg-64 border border-fg-16"
418
+ aria-expanded={settingsOpen}
419
+ aria-label={settingsOpen ? 'Close grid settings' : 'Open grid settings'}
420
+ onClick={() => setSettingsOpen((v) => !v)}
421
+ style={{ minWidth: 21, height: 19, borderRadius: 4, background: 'transparent', cursor: 'pointer' }}
422
+ >
423
+ {settingsOpen ? '–' : '+'}
424
+ </button>
425
+ </div>
426
+
427
+ {settingsOpen && (
428
+ <div className="flex flex-col" style={{ gap: 12, padding: '11px 10px 12px' }}>
429
+ <div>
430
+ <div className="kol-helper-8 text-fg-40" style={{ marginBottom: 6, letterSpacing: '0.11em' }}>DIVISIONS</div>
431
+ <div className="flex items-center" style={{ gap: 6 }}>
432
+ <input
433
+ type="number" min={1} max={MAX_COLS} value={cols} aria-label="Columns"
434
+ onChange={(e) => setCols(clamp(parseInt(e.target.value, 10) || 1, 1, MAX_COLS))}
435
+ className="kol-mono-12 text-fg-96 bg-fg-04 border border-fg-16"
436
+ style={{ width: 52, padding: '5px 6px', textAlign: 'center', borderRadius: 4 }}
437
+ />
438
+ <span className="kol-helper-10 text-fg-40">×</span>
439
+ <input
440
+ type="number" min={1} max={MAX_ROWS} value={rows} aria-label="Rows"
441
+ onChange={(e) => setRows(clamp(parseInt(e.target.value, 10) || 1, 1, MAX_ROWS))}
442
+ className="kol-mono-12 text-fg-96 bg-fg-04 border border-fg-16"
443
+ style={{ width: 52, padding: '5px 6px', textAlign: 'center', borderRadius: 4 }}
444
+ />
445
+ <button
446
+ type="button" onClick={applySquare}
447
+ title="Set divisions so cells come out square at the current size"
448
+ className="kol-helper-10 text-fg-96 border border-fg-16"
449
+ style={{ marginLeft: 'auto', padding: '5px 9px', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}
450
+ >
451
+ square
452
+ </button>
453
+ </div>
454
+ {aspect && (
455
+ <div className="kol-mono-10 text-fg-48" style={{ marginTop: 6 }}>
456
+ Cell{' '}
457
+ <span className="text-fg-96">{Math.round(aspect.cw)}×{Math.round(aspect.ch)}</span>{' · '}
458
+ {aspect.isSquare
459
+ ? <span style={{ color: 'var(--ui-success)' }}>square</span>
460
+ : `${aspect.ratio.toFixed(2)}:1`}
461
+ </div>
462
+ )}
463
+ </div>
464
+
465
+ <div>
466
+ <label className="kol-helper-8 text-fg-40 block" htmlFor="kq-fade" style={{ marginBottom: 6, letterSpacing: '0.11em' }}>FADE</label>
467
+ <input
468
+ id="kq-fade" type="range" min={15} max={100} value={Math.round(fade * 100)}
469
+ onChange={(e) => setFade(Number(e.target.value) / 100)}
470
+ style={{ width: '100%', accentColor: handleColor, display: 'block' }}
471
+ />
472
+ </div>
473
+
474
+ {/* page sits above handle — the page is the parent */}
475
+ <div className="flex flex-col" style={{ gap: 7 }}>
476
+ <label className="kol-mono-12 text-fg-96 flex items-center" style={{ gap: 7, cursor: 'pointer', userSelect: 'none' }}>
477
+ <input type="checkbox" checked={showPage} onChange={(e) => setShowPage(e.target.checked)} style={{ accentColor: handleColor, width: 12, height: 12, margin: 0 }} />
478
+ <span style={{ width: 7, height: 7, borderRadius: 2, background: pageColor, flex: 'none' }} />
479
+ Page grid
480
+ </label>
481
+ <label className="kol-mono-12 text-fg-96 flex items-center" style={{ gap: 7, cursor: 'pointer', userSelect: 'none' }}>
482
+ <input type="checkbox" checked={showHandle} onChange={(e) => setShowHandle(e.target.checked)} style={{ accentColor: handleColor, width: 12, height: 12, margin: 0 }} />
483
+ <span style={{ width: 7, height: 7, borderRadius: 2, background: handleColor, flex: 'none' }} />
484
+ Handle grid
485
+ </label>
486
+ <div style={{ borderTop: '1px solid var(--kol-border-default)', margin: '2px 0 1px' }} />
487
+ <label
488
+ className="kol-mono-12 text-fg-96 flex items-center"
489
+ style={{ gap: 7, cursor: anyGrid ? 'pointer' : 'not-allowed', userSelect: 'none', opacity: anyGrid ? 1 : 0.38 }}
490
+ >
491
+ <input type="checkbox" checked={showLabels} disabled={!anyGrid} onChange={(e) => setShowLabels(e.target.checked)} style={{ accentColor: handleColor, width: 12, height: 12, margin: 0 }} />
492
+ Cell labels
493
+ </label>
494
+ </div>
495
+ </div>
496
+ )}
497
+ </aside>
498
+
499
+ {/* ── report: what is in view. a banner, not a setting ── */}
500
+ {reportOpen && (
501
+ <div
502
+ className="bg-surface-secondary border-t border-fg-16"
503
+ style={{
504
+ position: 'fixed', left: 0, right: 0, bottom: BAR, padding: '14px 18px',
505
+ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 22,
506
+ zIndex: 'var(--kol-z-tooltip, 300)'
507
+ }}
508
+ >
509
+ {q(1, 'Looking at', handle ? handle.getAttribute(attr) : '—',
510
+ <>{width}w · <span className="text-fg-96">{band.name}</span><br />Untreated: {untreated}</>)}
511
+ {q(2, 'Lives in',
512
+ handle ? [...chainOf(handle, attr), handle.getAttribute(attr)].join(' › ') : '—',
513
+ handle && owner ? <>Spans <span className="text-fg-96">{owner.getAttribute(attr)}:{spanOf(handleBox, rectOf(owner), cols, rows)}</span></> : null)}
514
+ {q(3, 'Owned by',
515
+ handle
516
+ ? <span className="kol-helper-10" style={{ padding: '1px 6px', borderRadius: 4, background: handleSource === 'local' ? 'color-mix(in srgb, var(--ui-success) 20%, transparent)' : 'color-mix(in srgb, var(--ui-warning) 20%, transparent)', color: handleSource === 'local' ? 'var(--ui-success)' : 'var(--ui-warning)' }}>{handleSource}</span>
517
+ : '—',
518
+ ownerSource && ownerSource !== 'local'
519
+ ? <>Owner ships from <span style={{ color: 'var(--ui-warning)' }}>{ownerSource}</span> — local shim now, DS ticket with the diff</>
520
+ : owner ? <>Owner <span style={{ color: 'var(--ui-success)' }}>{owner.getAttribute(attr)}</span> is local — editable here</> : null)}
521
+ {q(4, 'Change via',
522
+ owner && layout ? `${owner.getAttribute(attr)} · ${layout.mode}` : handle ? 'page root' : '—',
523
+ layout ? reachability(layout) : handle ? 'Nothing above it decides its position.' : null)}
524
+ </div>
525
+ )}
526
+
527
+ {/* ── the deliverable ── */}
528
+ <div
529
+ className="bg-surface-primary border-t border-fg-16 flex items-center"
530
+ style={{ position: 'fixed', left: 0, right: 0, bottom: 0, height: BAR, padding: '0 16px', gap: 13, zIndex: 'var(--kol-z-tooltip, 300)' }}
531
+ >
532
+ <span className="kol-helper-8 text-fg-40" style={{ letterSpacing: '0.14em', flex: 'none' }}>SYNC LINE</span>
533
+ <div className="kol-mono-14 text-fg-96" style={{ flex: '1 1 auto', minWidth: 0, overflowX: 'auto', whiteSpace: 'nowrap' }}>
534
+ {handle ? (
535
+ <>
536
+ <span style={{ color: handleColor }}>{handle.getAttribute(attr)}</span>
537
+ <span className="text-fg-40"> @ </span>
538
+ <span className="text-fg-64">{width}w</span>
539
+ {from ? (
540
+ <>
541
+ <span className="text-fg-40"> · </span>{from}
542
+ {to
543
+ ? <><span className="text-fg-40"> → </span><span style={{ color: handleColor }}>{to}</span></>
544
+ : <span className="text-fg-40"> → pick a destination</span>}
545
+ </>
546
+ ) : showHandle ? <span className="text-fg-40"> · pick a cell</span> : null}
547
+ </>
548
+ ) : <span className="text-fg-40">Click an element to name it.</span>}
549
+ </div>
550
+ <div className="flex" style={{ gap: 6, flex: 'none' }}>
551
+ <button
552
+ type="button" aria-expanded={reportOpen} onClick={() => setReportOpen((v) => !v)}
553
+ className="kol-helper-12 border border-fg-16"
554
+ style={{ padding: '6px 12px', borderRadius: 4, background: 'transparent', cursor: 'pointer', color: reportOpen ? handleColor : 'var(--kol-surface-on-primary)' }}
555
+ >
556
+ Report
557
+ </button>
558
+ <button
559
+ type="button"
560
+ onClick={() => { setHandle(null); setFrom(null); setTo(null) }}
561
+ className="kol-helper-12 text-fg-96 border border-fg-16"
562
+ style={{ padding: '6px 12px', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}
563
+ >
564
+ Clear
565
+ </button>
566
+ <button
567
+ type="button" disabled={!syncLine}
568
+ onClick={() => {
569
+ if (!syncLine || !navigator.clipboard) return
570
+ navigator.clipboard.writeText(syncLine).catch(() => {}).then(() => {
571
+ setCopied(true)
572
+ window.setTimeout(() => setCopied(false), 1400)
573
+ })
574
+ }}
575
+ className="kol-helper-12"
576
+ style={{
577
+ padding: '6px 12px', borderRadius: 4, cursor: syncLine ? 'pointer' : 'default',
578
+ background: handleColor, color: 'var(--kol-accent-on-primary)',
579
+ border: `1px solid ${handleColor}`, opacity: syncLine ? 1 : 0.45
580
+ }}
581
+ >
582
+ {copied ? 'Copied' : 'Copy'}
583
+ </button>
584
+ </div>
585
+ </div>
586
+ </div>
587
+ )
588
+ }