@kolkrabbi/kol-component 0.128.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 +2 -2
- package/src/atoms/Input.jsx +1 -1
- package/src/atoms/ViewToggle.jsx +4 -4
- package/src/index.js +2 -0
- package/src/molecules/AudioSheet.jsx +4 -4
- package/src/molecules/ColorInputRow.jsx +1 -1
- package/src/molecules/Dropdown.jsx +1 -1
- package/src/molecules/MediaCard.jsx +4 -4
- package/src/molecules/PlaybackBar.jsx +2 -2
- package/src/molecules/TiltBento.jsx +4 -4
- package/src/organisms/MediaLibraryPages.jsx +1 -1
- package/src/organisms/SettingsPanel.jsx +11 -5
- package/src/organisms/sectionSurface.js +1 -1
- package/src/utilities/QuadrantSync.jsx +588 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "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",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
39
|
+
"@kolkrabbi/kol-icons": "^0.25.0"
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
42
42
|
"src",
|
package/src/atoms/Input.jsx
CHANGED
|
@@ -9,7 +9,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
9
9
|
* variant="outline" — bordered, transparent bg — THE secondary
|
|
10
10
|
* treatment (2026-07-08 chrome law: one
|
|
11
11
|
* secondary, always subordinate to filled)
|
|
12
|
-
* tone="inverse" — the dark chip (`fg-
|
|
12
|
+
* tone="inverse" — the dark chip (`fg-ab-24`) for a washed plane
|
|
13
13
|
* (ControlToneInverse, 2026-08-27); same prop on ViewToggle · Dropdown
|
|
14
14
|
* variant="ghost" — legacy alias, resolves to outline
|
|
15
15
|
* variant="property" — the Figma property field (PropertyField,
|
package/src/atoms/ViewToggle.jsx
CHANGED
|
@@ -7,7 +7,7 @@ import { toneClass } from '../utilities/tone.js'
|
|
|
7
7
|
* variant="text" — segmented bare buttons; active uses kol-control--filled,
|
|
8
8
|
* inactive is bare text-meta with text-emphasis on hover.
|
|
9
9
|
* variant="icon" — bordered chip-row container holding square icon
|
|
10
|
-
* buttons. Active uses bg-fg-
|
|
10
|
+
* buttons. Active uses bg-fg-ab-24.
|
|
11
11
|
* variant="single" — single button binary toggle. Click flips between the
|
|
12
12
|
* two `options` values. The button shows the *current*
|
|
13
13
|
* option's label; active = on (kol-control--filled),
|
|
@@ -28,7 +28,7 @@ import { toneClass } from '../utilities/tone.js'
|
|
|
28
28
|
* is the active … it would fit better on the light grey"): on a washed plane
|
|
29
29
|
* (`pageWash`, `fg-04`) the default grey well reads as a second plate, so the
|
|
30
30
|
* icon variant's two values swap — the well takes the dark chip
|
|
31
|
-
* (`fg-
|
|
31
|
+
* (`fg-ab-24`), the active chip the `fg-16` ink wash (user, on the live
|
|
32
32
|
* page: "just use bg-fg-16"), the inactive hover a lighter wash. Theme rules on `.kol-tone-inverse`
|
|
33
33
|
* (kol-theme ≥0.78.0); the same prop on `Dropdown` and `Input`.
|
|
34
34
|
*/
|
|
@@ -84,10 +84,10 @@ const ViewToggle = ({
|
|
|
84
84
|
const buttonClasses = (isActive) => {
|
|
85
85
|
if (isIconVariant) {
|
|
86
86
|
// Inset "well" pattern — container is bg-fg-04 (slight lift from page),
|
|
87
|
-
// active button uses bg-fg-
|
|
87
|
+
// active button uses bg-fg-ab-24 (theme-invariant black, always
|
|
88
88
|
// darkens regardless of theme) so it reads as recessed/pressed.
|
|
89
89
|
return `inline-flex items-center justify-center p-1.5 rounded transition-colors text-emphasis cursor-pointer ${
|
|
90
|
-
isActive ? 'bg-fg-
|
|
90
|
+
isActive ? 'bg-fg-ab-24' : 'hover:bg-fg-ab-08'
|
|
91
91
|
}`
|
|
92
92
|
}
|
|
93
93
|
/* Active = filled chip. Inactive = bare-text on the shell base — no
|
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'
|
|
@@ -22,7 +22,7 @@ function useCover(src) {
|
|
|
22
22
|
*
|
|
23
23
|
* cover the same as VideoSheet — the artwork is the frame (`w-[min(78vh,100vw_-_10rem)]`,
|
|
24
24
|
* square), the bar floating over its bottom edge (inset 16, radius 12)
|
|
25
|
-
* sheet the QuickTime window — a dark plate (`bg-fg-
|
|
25
|
+
* sheet the QuickTime window — a dark plate (`bg-fg-ab-88`, `min(100vw − 10rem, 1000px)`),
|
|
26
26
|
* the cover square left, `Time: mm:ss` beside it, the bar flush along the bottom
|
|
27
27
|
*
|
|
28
28
|
* Artwork = the file's embedded ID3 `APIC` cover (`readCover`); no cover → a light
|
|
@@ -39,14 +39,14 @@ export default function AudioSheet({ src, onDuration, variant = 'cover' }) {
|
|
|
39
39
|
const audio = <audio ref={ref} src={src} autoPlay preload="metadata" {...handlers} />
|
|
40
40
|
const art = cover
|
|
41
41
|
? <img src={cover} alt="" className="w-full h-full object-cover" />
|
|
42
|
-
: <div className="w-full h-full flex items-center justify-center bg-
|
|
42
|
+
: <div className="w-full h-full flex items-center justify-center bg-ab-white text-fg-ab-32"><Icon name="music-note" size={64} /></div>
|
|
43
43
|
|
|
44
44
|
if (variant === 'sheet') return (
|
|
45
|
-
<div className="relative w-[min(100vw_-_10rem,1000px)] rounded overflow-hidden bg-fg-
|
|
45
|
+
<div className="relative w-[min(100vw_-_10rem,1000px)] rounded overflow-hidden bg-fg-ab-88">
|
|
46
46
|
{audio}
|
|
47
47
|
<div className="flex items-center gap-16 p-10 pb-24">
|
|
48
48
|
<div className="w-[min(50vh,380px)] aspect-square shrink-0 overflow-hidden">{art}</div>
|
|
49
|
-
<span className="kol-mono-16 text-
|
|
49
|
+
<span className="kol-mono-16 text-ab-white"><span className="opacity-48">Time: </span><b className="opacity-80">{clock(bar.duration)}</b></span>
|
|
50
50
|
</div>
|
|
51
51
|
<PlaybackBar {...bar} place="left-0 right-0 bottom-0" />
|
|
52
52
|
</div>
|
|
@@ -116,7 +116,7 @@ export default function ColorInputRow({
|
|
|
116
116
|
{chip('stretch')}
|
|
117
117
|
<span
|
|
118
118
|
aria-hidden="true"
|
|
119
|
-
className={`absolute inset-0 inline-flex items-center justify-center rounded-[var(--kol-radius-xs)] transition-opacity bg-fg-
|
|
119
|
+
className={`absolute inset-0 inline-flex items-center justify-center rounded-[var(--kol-radius-xs)] transition-opacity bg-fg-ab-48 text-white pointer-events-none ${
|
|
120
120
|
locked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
121
121
|
}`}
|
|
122
122
|
>
|
|
@@ -17,7 +17,7 @@ import { indicatorSize } from '../hooks/glyphLadders.js'
|
|
|
17
17
|
* same fill (one piece: no border, no gap, hairline divider inside)
|
|
18
18
|
* variant="grey" — oq-12 filled trigger (opaque per the fill
|
|
19
19
|
* law); panel continues it
|
|
20
|
-
* tone="inverse" — the dark chip (`fg-
|
|
20
|
+
* tone="inverse" — the dark chip (`fg-ab-24`) for a washed
|
|
21
21
|
* plane, the panel continuing it (ControlToneInverse,
|
|
22
22
|
* kol-website 2026-08-27); same prop on ViewToggle · Input
|
|
23
23
|
* variant="outline" — bordered trigger; open panel carries the
|
|
@@ -27,7 +27,7 @@ export function SelectIndicator({ on = false }) {
|
|
|
27
27
|
style={
|
|
28
28
|
on
|
|
29
29
|
? { background: 'var(--kol-surface-on-primary)', borderColor: 'var(--kol-surface-on-primary)' }
|
|
30
|
-
: { background: 'var(--kol-fg-
|
|
30
|
+
: { background: 'var(--kol-fg-ab-16, rgba(0,0,0,0.15))', borderColor: 'var(--kol-fg-ab-48, rgba(0,0,0,0.4))' }
|
|
31
31
|
}
|
|
32
32
|
aria-hidden="true"
|
|
33
33
|
>
|
|
@@ -76,7 +76,7 @@ export default function MediaCard({
|
|
|
76
76
|
{selectMode ? (
|
|
77
77
|
<span
|
|
78
78
|
className="kol-frame-control kol-frame-control--top-left rounded p-1"
|
|
79
|
-
style={{ background: 'var(--kol-fg-
|
|
79
|
+
style={{ background: 'var(--kol-fg-ab-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
|
|
80
80
|
>
|
|
81
81
|
<SelectIndicator on={selected} />
|
|
82
82
|
</span>
|
|
@@ -85,8 +85,8 @@ export default function MediaCard({
|
|
|
85
85
|
href={downloadHref}
|
|
86
86
|
aria-label="Download"
|
|
87
87
|
title="Download"
|
|
88
|
-
className="kol-frame-control inline-flex items-center justify-center w-8 h-8 rounded text-emphasis hover:bg-fg-
|
|
89
|
-
style={{ background: 'var(--kol-fg-
|
|
88
|
+
className="kol-frame-control inline-flex items-center justify-center w-8 h-8 rounded text-emphasis hover:bg-fg-ab-24 transition-colors"
|
|
89
|
+
style={{ background: 'var(--kol-fg-ab-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
|
|
90
90
|
onClick={(e) => e.stopPropagation()}
|
|
91
91
|
>
|
|
92
92
|
<Icon name="download" size={16} />
|
|
@@ -13,7 +13,7 @@ const RATES = [1, 1.5, 2]
|
|
|
13
13
|
/**
|
|
14
14
|
* PlaybackBar — the QuickTime bar, ruled against the reference (PlaybackBarAndAudioSheet,
|
|
15
15
|
* kol-r2b2 2026-08-27; supersedes 0.107.0's strip): a frosted strip over media —
|
|
16
|
-
* `bg-fg-
|
|
16
|
+
* `bg-fg-ab-48 backdrop-blur-xl`, **radius 12** (`rounded-xl`, the user's
|
|
17
17
|
* ruling on this surface — the 4px container law stands elsewhere), `h-16 px-8
|
|
18
18
|
* gap-7` — white glyphs whatever the theme (`.kol-playback-bar`, kol-theme ≥0.75.0:
|
|
19
19
|
* opacity .8, 1 on hover / focus-visible), the transport cluster (`skip-back-15` ·
|
|
@@ -44,7 +44,7 @@ export default function PlaybackBar({ playing, time, duration, rate = 1, onToggl
|
|
|
44
44
|
const pop = usePopover({ open, onOpenChange: setOpen, placement: 'top' })
|
|
45
45
|
const nextRate = RATES[(RATES.indexOf(rate) + 1) % RATES.length]
|
|
46
46
|
return (
|
|
47
|
-
<div className={`kol-playback-bar absolute h-16 px-8 flex items-center gap-7 bg-fg-
|
|
47
|
+
<div className={`kol-playback-bar absolute h-16 px-8 flex items-center gap-7 bg-fg-ab-48 backdrop-blur-xl text-ab-white ${place}`}>
|
|
48
48
|
<div className="flex items-center gap-5">
|
|
49
49
|
<IconFrame name="skip-back-15" variant="ghost" size="sm" onClick={() => onSeek(time - 15)} aria-label="Back 15 seconds" />
|
|
50
50
|
<IconFrame name={playing ? 'pause' : 'play'} variant="ghost" size="sm" onClick={onToggle} aria-label={playing ? 'Pause' : 'Play'} />
|
|
@@ -98,7 +98,7 @@ function Media({ src, poster, className }) {
|
|
|
98
98
|
* @param {boolean} alignRight right-align the card (ms-auto) vs fill (size-full)
|
|
99
99
|
* @param {boolean} enableTilt master tilt switch (default true)
|
|
100
100
|
* @param {string} titleClassName title classes — the family default is `kol-sans-heading-01
|
|
101
|
-
* text-
|
|
101
|
+
* text-ab-white` (confirmed 2026-08-27; the fork's was
|
|
102
102
|
* heading-02 uppercase — a consumer wanting that passes it)
|
|
103
103
|
* @param {string} contentClassName inner content-box classes
|
|
104
104
|
* @param {string} imageClassName media fit/position classes
|
|
@@ -118,7 +118,7 @@ export default function TiltBento({
|
|
|
118
118
|
overlayOpacity = 60,
|
|
119
119
|
alignRight = false,
|
|
120
120
|
enableTilt = true,
|
|
121
|
-
titleClassName = 'kol-sans-heading-01 text-
|
|
121
|
+
titleClassName = 'kol-sans-heading-01 text-ab-white',
|
|
122
122
|
contentClassName = 'max-w-[384px]',
|
|
123
123
|
imageClassName = 'object-cover object-center',
|
|
124
124
|
contentStackClassName = 'relative z-20 h-full flex flex-col justify-start items-start gap-4 p-6 md:p-8',
|
|
@@ -173,8 +173,8 @@ export default function TiltBento({
|
|
|
173
173
|
{/* kol-mono-14 — one rung above the mono-12 description, as the retired
|
|
174
174
|
* fork drew it (TiltBentoVoiceAndDefaults, kol-website 2026-08-27: this
|
|
175
175
|
* rode `kol-mono-text`, a class retired from the theme — zero rules emitted) */}
|
|
176
|
-
{subtitle && <p className={`kol-mono-14 text-
|
|
177
|
-
{description && <p className={`kol-mono-12 text-
|
|
176
|
+
{subtitle && <p className={`kol-mono-14 text-ab-white ${revealClass}`}>{subtitle}</p>}
|
|
177
|
+
{description && <p className={`kol-mono-12 text-ab-white pb-6 ${revealClass}`}>{description}</p>}
|
|
178
178
|
{bodyContent}
|
|
179
179
|
{href && (
|
|
180
180
|
<div className={revealClass}>
|
|
@@ -192,7 +192,7 @@ function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downl
|
|
|
192
192
|
if (!o) return null
|
|
193
193
|
const kind = kindOf(o)
|
|
194
194
|
const poster = kind === 'video' ? posterFor(o.key, keySet) : null
|
|
195
|
-
const ARROW = 'fixed top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded text-fg-48 hover:text-fg-default hover:bg-fg-
|
|
195
|
+
const ARROW = 'fixed top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded text-fg-48 hover:text-fg-default hover:bg-fg-ab-24 transition-colors'
|
|
196
196
|
return (
|
|
197
197
|
<FullscreenOverlay open onClose={onClose} closeButton={false}>
|
|
198
198
|
<button type="button" className={`${ARROW} left-6`} onClick={onPrev} disabled={files.length <= 1} aria-label="Previous"><Icon name="chevron-left" size={22} /></button>
|
|
@@ -150,10 +150,16 @@ export function LabeledControlSection({ label, divided = false, rowGap = 2, chil
|
|
|
150
150
|
/** SettingsChoice — the row's one-of-N control: the DS Dropdown, sm · primary
|
|
151
151
|
* (SettingsPanelEyebrowAndDropdowns — user: "put the toggles inside a dropdown,
|
|
152
152
|
* because it's super messy like it is"). Options are values or `{ value, label }`;
|
|
153
|
-
* width is the call site's (`className="w-40"`).
|
|
154
|
-
|
|
153
|
+
* width is the call site's (`className="w-40"`).
|
|
154
|
+
*
|
|
155
|
+
* `tone` · `size` · `variant` pass through (kol-fxr 2026-08-28): they were
|
|
156
|
+
* hardcoded, so a settings page ruled sunken could not be told — and kol-fxr
|
|
157
|
+
* rebuilt this same Dropdown in a local `ChoiceRow`, which is the exact
|
|
158
|
+
* duplication this component exists to prevent. A component that wraps another
|
|
159
|
+
* has to forward the wrapped one's seams or it becomes a wall. */
|
|
160
|
+
export function SettingsChoice({ options = [], value, onChange, ariaLabel, tone = 'default', size = 'sm', variant = 'primary', className = '' }) {
|
|
155
161
|
const opts = options.map((o) => (o != null && typeof o === 'object' ? o : { value: o, label: String(o) }))
|
|
156
|
-
return <Dropdown size=
|
|
162
|
+
return <Dropdown size={size} variant={variant} tone={tone} value={value} onChange={onChange} options={opts} className={`w-full ${className}`.trim()} aria-label={ariaLabel} />
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
/* THE CONTROL CHIP (SettingsPanelCompliance, user 2026-08-27: "WROOOONG" on the
|
|
@@ -173,13 +179,13 @@ export const chipCls = (on) => `${CHIP_CLS} ${on ? 'kol-control--filled' : 'text
|
|
|
173
179
|
* @param {Function} onToggle (value) => void
|
|
174
180
|
* @param {string} noun the trigger's noun (default 'kinds')
|
|
175
181
|
*/
|
|
176
|
-
export function SettingsMulti({ options = [], selected = [], onToggle, noun = 'kinds', className = '' }) {
|
|
182
|
+
export function SettingsMulti({ options = [], selected = [], onToggle, noun = 'kinds', tone = 'default', size = 'sm', className = '' }) {
|
|
177
183
|
const on = selected instanceof Set ? selected : new Set(selected)
|
|
178
184
|
const opts = [
|
|
179
185
|
{ value: '__summary', label: `${on.size} of ${options.length} ${noun}` },
|
|
180
186
|
...options.map((o) => ({ value: o.value, label: `${on.has(o.value) ? '✓ ' : ''}${o.label ?? String(o.value)}` })),
|
|
181
187
|
]
|
|
182
|
-
return <Dropdown size=
|
|
188
|
+
return <Dropdown size={size} variant="primary" tone={tone} value="__summary" options={opts} onChange={(v) => (v === '__summary' ? null : onToggle?.(v))} className={`w-full ${className}`.trim()} />
|
|
183
189
|
}
|
|
184
190
|
|
|
185
191
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* ONE surface prop across the section family (SectionBackgroundProp, kol-website
|
|
2
2
|
* 2026-08-27 — user: "ALL section family should pass bg as props"). A named
|
|
3
3
|
* surface, `none`, or a raw utility / token string for the odd case
|
|
4
|
-
* (`'bg-fg-
|
|
4
|
+
* (`'bg-fg-ab-16'`). Each section's default is what it painted before,
|
|
5
5
|
* so nothing moves on the bump. `theme` keeps scoping ink; this is the paint. */
|
|
6
6
|
export const SURFACES = {
|
|
7
7
|
primary: 'bg-surface-primary',
|
|
@@ -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
|
+
}
|