@kolkrabbi/kol-component 0.129.0 → 0.131.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 +1 -1
- package/src/index.js +2 -0
- package/src/molecules/ContentCard.jsx +47 -20
- package/src/molecules/ContentRow.jsx +43 -7
- package/src/molecules/ContentText.jsx +22 -23
- 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.131.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'
|
|
@@ -29,12 +29,11 @@ import ContentText from './ContentText.jsx'
|
|
|
29
29
|
/* ruled defaults — defaults, not hardcodes; `ratio` stays overridable while
|
|
30
30
|
* the A4 question is open (06-content-card-system.md §4). */
|
|
31
31
|
const RATIOS = {
|
|
32
|
-
|
|
32
|
+
file: '1 / 1',
|
|
33
33
|
catalog: '1 / 1.41421',
|
|
34
|
-
print: '1 / 1.41421',
|
|
35
34
|
article: '16 / 9',
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
showcase: '3 / 4',
|
|
36
|
+
showcaseCanvas: '1 / 1.41421',
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
/* ruled box values per variant, verbatim from the §3 reference cards —
|
|
@@ -43,24 +42,23 @@ const BOX = {
|
|
|
43
42
|
/* no border (ColumnBrowser round, user 2026-08-27: "I don't like the border, I feel
|
|
44
43
|
* like I've said that before" — the ListingCardThumbBorder ruling): the selected
|
|
45
44
|
* state reads from the checked ToggleCheckbox, not a fg-64 border */
|
|
46
|
-
|
|
45
|
+
file: { layout: 'stack', border: null, bg: 'var(--kol-fg-02)', pad: 'var(--kol-pad-card-sm)' },
|
|
47
46
|
/* THE FRAME READS BACKWARDS (CatalogCardFrameAndZoom, kol-website 2026-08-28 — user, on a 212-tile
|
|
48
47
|
* grid: no frame at rest; the old rest value is the hover): a wall of fg-04 frames is a grid of boxes,
|
|
49
48
|
* louder than what they hold. Rest `transparent` (the 1px stays, so the hover step never relayouts),
|
|
50
49
|
* hover fg-04. Was fg-04 → fg-16 (ShellHomeSystem, 2026-08-27). */
|
|
51
50
|
catalog: { layout: 'fill-card', border: 'transparent', bg: 'var(--kol-fg-04)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true, plateBg: 'var(--kol-surface-primary)', frameHover: 'var(--kol-fg-04)' },
|
|
52
51
|
/* flip: PrintGridCard's 3D turn on `isFlipped` → `selected` (ContentRowsAndPrintCard, 2026-08-27) */
|
|
53
|
-
print: { layout: 'fill-card', border: null, bg: 'var(--kol-surface-secondary)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true, flip: true },
|
|
54
52
|
article: { layout: 'stack', border: null, bg: null, pad: '0', mediaGap: 'var(--kol-spacing-4)' },
|
|
55
53
|
/* work is a DRAWER: image-only at rest, and on hover a light plate rises
|
|
56
54
|
* over the bottom of the artwork carrying the title + meta. This is the
|
|
57
55
|
* shipped WorkCard and it was wrong to reject it as "hiding the title" — a
|
|
58
56
|
* work shelf is a wall of images by design, and the caption is the reveal. */
|
|
59
|
-
|
|
57
|
+
showcase: { layout: 'drawer', border: 'var(--kol-fg-04)', bg: null, pad: 'var(--kol-pad-card-md)', padMd: 'var(--kol-pad-card-lg)' },
|
|
60
58
|
/* typeface's card is a FIXED 500px tall specimen board, not a ratio — the
|
|
61
59
|
* shipped item is `h-[500px]`, and a ratio re-crops the glyph at every
|
|
62
60
|
* column width, which is the one thing a specimen must not do. */
|
|
63
|
-
|
|
61
|
+
showcaseCanvas: { layout: 'canvas', border: 'var(--kol-fg-08)', bg: 'var(--kol-surface-primary)', pad: 'var(--kol-pad-card-lg)', height: 500 },
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
/* HOVER is a bg STEP on the opaque tier, per 05-control-chrome.md's state model
|
|
@@ -69,16 +67,15 @@ const BOX = {
|
|
|
69
67
|
* control vanishing. article is the exception the shipped card already set: it
|
|
70
68
|
* has no surface of its own to step, so it dims its title instead. */
|
|
71
69
|
const HOVER = {
|
|
72
|
-
|
|
70
|
+
file: 'var(--kol-oq-04)',
|
|
73
71
|
catalog: 'var(--kol-surface-tertiary)',
|
|
74
|
-
print: 'var(--kol-oq-04)',
|
|
75
72
|
/* article and work take NO surface hover, and that is a decision not a gap:
|
|
76
73
|
* article has no surface of its own (its media frame, when on, steps its
|
|
77
74
|
* border instead), and work's whole hover IS the drawer rising. A second wash under
|
|
78
75
|
* either would be two answers to one question. */
|
|
79
76
|
article: null,
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
showcase: null,
|
|
78
|
+
showcaseCanvas: 'var(--kol-surface-inverse)',
|
|
82
79
|
}
|
|
83
80
|
|
|
84
81
|
/* per-variant media treatment. `ring` sits OVER the artwork, `frame` UNDER it —
|
|
@@ -89,17 +86,39 @@ const MEDIA = {
|
|
|
89
86
|
/* zoom is for IMAGE-LED cards — where the artwork is the content and the
|
|
90
87
|
* card is a frame around it. A catalog tile whose preview is a diagram, or
|
|
91
88
|
* a default file card whose thumb is a 48px chip, gets nothing from it. */
|
|
92
|
-
/*
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
/* `print` FOLDED INTO `catalog` 2026-08-29 (user: "print doesnt need to exist
|
|
90
|
+
* all together, it can just use catalog"). Its two behaviours became PROPS,
|
|
91
|
+
* because a 3D turn and an image fade-in are things a card DOES, not kinds of
|
|
92
|
+
* content it holds: `flip` and `fade`. Its zoom is catalog's already (below).
|
|
93
|
+
* `ring` was removed the same day — it stays an opt-in on ContentMedia. */
|
|
94
|
+
showcase: { zoom: true },
|
|
95
95
|
/* frame OFF by default (ListingCardThumbBorder, user 2026-08-27: "I hate
|
|
96
96
|
* border — remove border"): the article thumb's hairline is opt-in —
|
|
97
97
|
* `frame` turns it on, and with it the fg-08 → fg-16 hover step */
|
|
98
98
|
article: { frame: false, borderHover: true, zoom: true },
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/* VARIANTS ARE CONTENT KINDS, NEVER PAGE NAMES (user ruling 2026-08-29:
|
|
102
|
+
* "naming the variants by their use location doesnt make sense, because print
|
|
103
|
+
* doesnt use print variant"). Six page-named variants became four kinds —
|
|
104
|
+
* file · catalog · article · showcase — and the old names alias onto them:
|
|
105
|
+
*
|
|
106
|
+
* default → file the fallback's name described its position
|
|
107
|
+
* print → catalog /prints renders `work` rows and a plateless
|
|
108
|
+
* catalog card; `flip` + `fade` became props
|
|
109
|
+
* work → showcase /work is a location; a work is a shown piece
|
|
110
|
+
* typeface → showcase + same card, full overlay instead of a drawer
|
|
111
|
+
* layout="canvas"
|
|
112
|
+
*
|
|
113
|
+
* `layout` is the discriminator inside `showcase` because `reveal` was already
|
|
114
|
+
* taken by the consumer's overlay NODE. Its values are the box vocabulary the
|
|
115
|
+
* component already spoke: drawer (default) · canvas. */
|
|
116
|
+
const ALIAS = { default: 'file', print: 'catalog', work: 'showcase', typeface: 'showcaseCanvas' }
|
|
117
|
+
const LAYOUT_KEY = { canvas: 'showcaseCanvas', drawer: 'showcase' }
|
|
118
|
+
|
|
101
119
|
export default function ContentCard({
|
|
102
|
-
variant = '
|
|
120
|
+
variant: variantProp = 'file',
|
|
121
|
+
layout,
|
|
103
122
|
hero = false,
|
|
104
123
|
label,
|
|
105
124
|
pad,
|
|
@@ -109,6 +128,9 @@ export default function ContentCard({
|
|
|
109
128
|
frame,
|
|
110
129
|
ring,
|
|
111
130
|
zoom,
|
|
131
|
+
/* `flip` + `fade` — behaviours, not variants (the print fold, 2026-08-29) */
|
|
132
|
+
flip,
|
|
133
|
+
fade,
|
|
112
134
|
/* plateRule (CatalogCardFrameAndZoom): the plate's top hairline — default the variant's (catalog and
|
|
113
135
|
* print draw it); `false` turns it off without an `!important` in a consumer sheet */
|
|
114
136
|
plateRule,
|
|
@@ -125,7 +147,12 @@ export default function ContentCard({
|
|
|
125
147
|
className = '',
|
|
126
148
|
...text
|
|
127
149
|
}) {
|
|
128
|
-
const
|
|
150
|
+
const aliased = ALIAS[variantProp] ?? variantProp
|
|
151
|
+
/* `layout` only re-keys inside the showcase family — it is not a general
|
|
152
|
+
* escape hatch that would let any variant borrow another's box. */
|
|
153
|
+
const variant = aliased.startsWith('showcase') && layout ? LAYOUT_KEY[layout] ?? aliased : aliased
|
|
154
|
+
const box = BOX[variant] ?? BOX.file
|
|
155
|
+
const doFlip = flip ?? box.flip
|
|
129
156
|
/* HERO (2026-08-27 — the featured card riding a page's fold; ListingCard
|
|
130
157
|
* size="hero" had no ContentCard equivalent, which is why Stack still
|
|
131
158
|
* imported it): a header row above the media — `label` left, `meta` chips
|
|
@@ -205,7 +232,7 @@ export default function ContentCard({
|
|
|
205
232
|
borderHover: MEDIA[variant]?.borderHover ?? false,
|
|
206
233
|
/* a catalog card with REAL media is image-led: it zooms (ShellHomeSystem) */
|
|
207
234
|
zoom: zoom ?? (isHero ? 'hero' : variant === 'catalog' ? media != null : MEDIA[variant]?.zoom ?? false),
|
|
208
|
-
fade: MEDIA[variant]?.fade ?? false,
|
|
235
|
+
fade: fade ?? MEDIA[variant]?.fade ?? false,
|
|
209
236
|
}
|
|
210
237
|
|
|
211
238
|
/* `control` — the in-frame control slot (user ruling 2026-08-15). One node,
|
|
@@ -236,12 +263,12 @@ export default function ContentCard({
|
|
|
236
263
|
</>
|
|
237
264
|
) : box.layout === 'fill-card' ? (
|
|
238
265
|
<>
|
|
239
|
-
<div className="flex-1 min-w-0 min-h-0 relative overflow-hidden" style={{ ...(expanded ? { flex: '0 0 50%' } : null), ...(
|
|
266
|
+
<div className="flex-1 min-w-0 min-h-0 relative overflow-hidden" style={{ ...(expanded ? { flex: '0 0 50%' } : null), ...(doFlip ? { perspective: '1000px' } : null) }}>
|
|
240
267
|
{/* the FLIP (print): PrintGridCard's turn, verbatim — preserve-3d,
|
|
241
268
|
* 0.4s ease-out, rotateY(180deg) while `selected`; the consumer's
|
|
242
269
|
* `onClick` reads the rect off `event.currentTarget` for its
|
|
243
270
|
* FLIP-transition (the seam was always reachable) */}
|
|
244
|
-
{
|
|
271
|
+
{doFlip ? (
|
|
245
272
|
<div className="h-full w-full" style={{ transformStyle: 'preserve-3d', backfaceVisibility: 'hidden', transition: 'transform 0.4s ease-out', transform: selected ? 'rotateY(180deg)' : 'rotateY(0deg)' }}>
|
|
246
273
|
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
247
274
|
</div>
|
|
@@ -10,12 +10,17 @@ import ContentText from './ContentText.jsx'
|
|
|
10
10
|
* thumb; article rides a 120px 16:9 thumb; work a framed 64px row; typeface a
|
|
11
11
|
* framed no-thumb block.
|
|
12
12
|
*
|
|
13
|
-
* @param {string} variant
|
|
13
|
+
* @param {string} variant file | catalog | print | article | work | typeface
|
|
14
|
+
* (`default` is an alias of `file`)
|
|
14
15
|
* @param {ReactNode} media thumb content (omit → placeholder)
|
|
15
16
|
* @param {number|'fill'} thumb thumb edge px — overrides the ruled default; 0 hides; `'fill'` = a square
|
|
16
17
|
* the height of the row's content, whatever the rung (WorkListingRowsAndFilters)
|
|
17
18
|
* @param {string} ratio thumb aspect-ratio — overrides the ruled default
|
|
18
19
|
* @param {number} paddingY vertical padding px — overrides the ruled default
|
|
20
|
+
* @param {{label: ReactNode, value: ReactNode}[]} specs label/value pairs on the
|
|
21
|
+
* row's trailing edge, independent of variant.
|
|
22
|
+
* Horizontal variants only — `typeface` is a column
|
|
23
|
+
* and takes a band through `footer` instead.
|
|
19
24
|
* @param {boolean} selected
|
|
20
25
|
* @param {Function} onClick
|
|
21
26
|
* text slots + *Class seams forwarded to ContentText.
|
|
@@ -25,12 +30,15 @@ import ContentText from './ContentText.jsx'
|
|
|
25
30
|
* --kol-spacing-* tokens (2=8 · 3=12 · 4=16 · 6=24), never a literal */
|
|
26
31
|
const S2 = 'var(--kol-spacing-2)', S3 = 'var(--kol-spacing-3)', S4 = 'var(--kol-spacing-4)', S6 = 'var(--kol-spacing-6)'
|
|
27
32
|
const BOX = {
|
|
28
|
-
|
|
33
|
+
/* NO hover wash (user 2026-08-29, on the variant-reference page: "makes a
|
|
34
|
+
* grey background, remove it"). The default row is a bare ruled line — a
|
|
35
|
+
* full-width grey band under it is heavier than the line itself. Scoped to
|
|
36
|
+
* `default`: catalog and print still carry the same `oq-04`, unruled. */
|
|
37
|
+
file: { thumb: 48, ratio: '1 / 1', pad: `${S2} 0`, gap: S3, align: 'items-center', divider: true },
|
|
29
38
|
/* catalog/print rows render AT 36px — the shipped GridCard list row is a
|
|
30
39
|
* fixed 36 and the Y padding was what pushed it past that. X padding stays;
|
|
31
40
|
* `minH` is now the whole height budget and the row centres inside it. */
|
|
32
41
|
catalog: { thumb: 0, ratio: '1 / 1', pad: `0 ${S3}`, gap: S3, frame: 'var(--kol-fg-04)', bg: 'var(--kol-surface-tertiary)', minH: 36, align: 'items-center', hover: 'var(--kol-oq-04)' },
|
|
33
|
-
print: { thumb: 0, ratio: '1 / 1', pad: `0 ${S3}`, gap: S3, frame: 'var(--kol-fg-04)', bg: 'var(--kol-surface-tertiary)', minH: 36, align: 'items-center', hover: 'var(--kol-oq-04)' },
|
|
34
42
|
/* ListingCard's row thumb is `bg-fg-12` bare — a tint, no border. */
|
|
35
43
|
/* article had NO hover of any kind — the only row in the family you could
|
|
36
44
|
* point at and get nothing back. It has no surface of its own, so it takes
|
|
@@ -44,22 +52,32 @@ const BOX = {
|
|
|
44
52
|
* the content height (136 in a 168 row) with no hairline, the frame steps
|
|
45
53
|
* transparent → fg-08 on hover ("0 → 16 is a big jump"). No md step: the rows
|
|
46
54
|
* were approved at their base values. */
|
|
47
|
-
|
|
55
|
+
showcase: { thumb: 'fill', ratio: '1 / 1', pad: S4, gap: S4, frame: 'transparent', frameHover: 'var(--kol-fg-08)', bg: 'var(--kol-surface-secondary)', minH: 168, align: 'items-stretch', thumbRadius: 'var(--kol-radius-xs)', thumbBorder: false },
|
|
48
56
|
/* typeface's row is a COLUMN, not a line: a header (name/styles left,
|
|
49
57
|
* classification/year right) with a full-width specimen band under it. The
|
|
50
58
|
* shipped item is `flex-col gap-6`, and forcing it into the horizontal
|
|
51
59
|
* thumb-beside-text shape is what turned its alphabet into a 160px thumb. */
|
|
52
|
-
|
|
60
|
+
showcaseCanvas: { thumb: 0, ratio: '1 / 1', pad: S6, gap: S6, column: true, frame: 'var(--kol-fg-08)', bg: 'transparent', minH: 160, align: 'items-start', hover: 'color-mix(in srgb, var(--kol-surface-on-primary) 1%, transparent)', frameHover: 'color-mix(in srgb, var(--kol-surface-on-primary) 24%, transparent)' },
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
/* `default` → `file` (user 2026-08-29). The variant was named after being the
|
|
64
|
+
* fallback, which described its position in the map rather than its content;
|
|
65
|
+
* every consumer of it is a file browser (r2b2 via MediaLibrary, the brand
|
|
66
|
+
* app's LibraryLocal + SlideDeckManager) and its slots are filename · date ·
|
|
67
|
+
* size. `default` keeps working as an alias — see 04-retirements.md. */
|
|
68
|
+
const ALIAS = { default: 'file', print: 'catalog', work: 'showcase', typeface: 'showcaseCanvas' }
|
|
69
|
+
const LAYOUT_KEY = { column: 'showcaseCanvas', line: 'showcase' }
|
|
70
|
+
|
|
55
71
|
export default function ContentRow({
|
|
56
|
-
variant = '
|
|
72
|
+
variant: variantProp = 'file',
|
|
73
|
+
layout,
|
|
57
74
|
media,
|
|
58
75
|
thumb,
|
|
59
76
|
ratio,
|
|
60
77
|
paddingY,
|
|
61
78
|
actions,
|
|
62
79
|
footer,
|
|
80
|
+
specs,
|
|
63
81
|
selected = false,
|
|
64
82
|
onClick,
|
|
65
83
|
href,
|
|
@@ -67,7 +85,9 @@ export default function ContentRow({
|
|
|
67
85
|
className = '',
|
|
68
86
|
...text
|
|
69
87
|
}) {
|
|
70
|
-
const
|
|
88
|
+
const aliased = ALIAS[variantProp] ?? variantProp
|
|
89
|
+
const variant = aliased.startsWith('showcase') && layout ? LAYOUT_KEY[layout] ?? aliased : aliased
|
|
90
|
+
const box = BOX[variant] ?? BOX.file
|
|
71
91
|
const thumbPx = thumb ?? box.thumb
|
|
72
92
|
const padY = (paddingY != null ? `${paddingY}px` : String(box.pad)).trim().split(/\s+/)[0]
|
|
73
93
|
|
|
@@ -133,6 +153,22 @@ export default function ContentRow({
|
|
|
133
153
|
</div>
|
|
134
154
|
)}
|
|
135
155
|
<ContentText variant={variant} form="row" className="flex-1" {...text} />
|
|
156
|
+
{/* `specs` rides the trailing edge on EVERY variant — year · material ·
|
|
157
|
+
* edition is a content difference, not a geometry one, and minting a
|
|
158
|
+
* seventh page-named box for it is the exact mistake §1 of the ticket is
|
|
159
|
+
* about (RowVariantNamesAndSpecs, 2026-08-29).
|
|
160
|
+
* ponytail: horizontal rows only — a column variant (`typeface`) has no
|
|
161
|
+
* right column to ride. Give it a band via `footer` instead. */}
|
|
162
|
+
{specs?.length > 0 && (
|
|
163
|
+
<dl className="kol-row-specs shrink-0">
|
|
164
|
+
{specs.map(({ label, value }, i) => (
|
|
165
|
+
<div key={label ?? i}>
|
|
166
|
+
<dt>{label}</dt>
|
|
167
|
+
<dd>{value}</dd>
|
|
168
|
+
</div>
|
|
169
|
+
))}
|
|
170
|
+
</dl>
|
|
171
|
+
)}
|
|
136
172
|
{/* trailing edge, never in the text flow — MediaRow's placement */}
|
|
137
173
|
{actions && <div className="shrink-0">{actions}</div>}
|
|
138
174
|
</>
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
|
|
48
48
|
/* [variant][form][slot] → 'type-class ink-role', verbatim from the ruled table */
|
|
49
49
|
const RAMP = {
|
|
50
|
-
|
|
50
|
+
file: {
|
|
51
51
|
card: { title: 'kol-sans-heading-04 text-emphasis truncate', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta' },
|
|
52
52
|
row: { title: 'kol-sans-heading-05 text-emphasis truncate', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta' },
|
|
53
53
|
},
|
|
@@ -57,12 +57,8 @@ const RAMP = {
|
|
|
57
57
|
card: { title: 'kol-mono-14 text-emphasis', detail: 'kol-mono-10 text-meta truncate' },
|
|
58
58
|
row: { title: 'kol-mono-12 text-emphasis', detail: 'kol-mono-10 text-meta' },
|
|
59
59
|
},
|
|
60
|
-
print
|
|
61
|
-
|
|
62
|
-
/* mono-12 + emphasis, matching catalog — print's row IS catalog's row, so
|
|
63
|
-
* the two must not disagree about their own type. */
|
|
64
|
-
row: { title: 'kol-mono-12 text-emphasis', detail: 'kol-mono-10 text-meta' },
|
|
65
|
-
},
|
|
60
|
+
/* `print` folded into `catalog` 2026-08-29 — its row was already catalog's,
|
|
61
|
+
* and its card differed only by `detail` not truncating. It aliases now. */
|
|
66
62
|
article: {
|
|
67
63
|
/* kol-content-title-dim: the title dims to 70% on card hover (StackCardHover, 2026-08-27 — ListingCard's move, kol-theme) */
|
|
68
64
|
card: { eyebrow: 'kol-mono-12 text-meta', title: 'kol-sans-heading-03 text-emphasis kol-content-title-dim', body: 'kol-mono-14 text-body', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta', tags: 'flex flex-wrap gap-2' },
|
|
@@ -75,7 +71,7 @@ const RAMP = {
|
|
|
75
71
|
* display-03 uppercase, clamp 2, dim on hover; body mono-14 fg-48 clamp 2. */
|
|
76
72
|
hero: { eyebrow: 'kol-card-kicker tracking-wide text-fg-64', title: 'kol-sans-display-03 uppercase line-clamp-2 kol-content-title-dim', body: 'kol-mono-14 text-fg-48 line-clamp-2', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta', tags: 'flex flex-wrap gap-2' },
|
|
77
73
|
},
|
|
78
|
-
|
|
74
|
+
showcase: {
|
|
79
75
|
/* INVERSE ink — the card's plate is the drawer, `surface-inverse`. Leaving
|
|
80
76
|
* these on `text-emphasis` painted light type on a light plate and the
|
|
81
77
|
* caption disappeared. */
|
|
@@ -95,7 +91,7 @@ const RAMP = {
|
|
|
95
91
|
* ink, the year kol-mono-12 fg-64. Body unchanged (the site passes its face). */
|
|
96
92
|
row: { title: 'kol-mono-14 uppercase text-emphasis truncate', body: 'kol-sans-heading-03 leading-tight text-emphasis truncate', meta: 'kol-mono-14 text-emphasis', date: 'kol-mono-12 text-fg-64', tags: 'flex flex-wrap items-center gap-1.5' },
|
|
97
93
|
},
|
|
98
|
-
|
|
94
|
+
showcaseCanvas: {
|
|
99
95
|
/* RULED ON SCREEN (TypefaceCardAndRow, kol-website 2026-08-27): name and
|
|
100
96
|
* classification are FULL ink, the year steps to 64; the card's title is the
|
|
101
97
|
* row's title string — one title voice for the typeface family. Ink only. */
|
|
@@ -122,9 +118,8 @@ const RAMP = {
|
|
|
122
118
|
* Directions are the RULED structures (06-content-card-system.md §2 boxes),
|
|
123
119
|
* read off the shipped components and the live pages they render on. */
|
|
124
120
|
const ORDER = {
|
|
125
|
-
|
|
121
|
+
file: { card: ['title', ['group', 'date', 'size']], row: ['title', ['group', 'date', 'size']] },
|
|
126
122
|
catalog: { card: ['title', 'detail'], row: [['between', 'title', 'detail']] },
|
|
127
|
-
print: { card: ['title', 'detail'], row: [['between', 'title', 'detail']] },
|
|
128
123
|
/* title + body are ONE block in BOTH forms — a `stack`, so they sit on the
|
|
129
124
|
* tight 4px internal gap while tags, kicker and the meta group keep the
|
|
130
125
|
* form's own outer gap. A flat column gave every line the same gap, which
|
|
@@ -142,12 +137,12 @@ const ORDER = {
|
|
|
142
137
|
/* WorkListItem's inner column is `justify-between` with the header row on
|
|
143
138
|
* top and the description BELOW IT, spanning the full width — not tucked
|
|
144
139
|
* inside the left column, which is what squeezed the big line. */
|
|
145
|
-
|
|
140
|
+
showcase: { card: ['title', 'meta'], row: [['between', ['stack', 'title', 'tags'], ['stack', 'meta', 'date']], 'body'] },
|
|
146
141
|
/* typeface row = ONE header line, both sides stacked: name over styles on the
|
|
147
142
|
* left, classification over year on the right. Verbatim from the shipped
|
|
148
143
|
* item, which had been flattened into title-left / date-right and lost the
|
|
149
144
|
* styles line into a body slot below. */
|
|
150
|
-
|
|
145
|
+
showcaseCanvas: { card: ['title', 'body'], row: [['between', ['stack', 'title', 'body'], ['stack', 'detail', 'date']]] },
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
/* inner line gap per variant/form, read from the shipped boxes and spelled in
|
|
@@ -163,26 +158,29 @@ const ORDER = {
|
|
|
163
158
|
* `self-stretch`, NOT `h-full` — the row carries `min-height`, never `height`,
|
|
164
159
|
* so `height: 100%` resolves against an indefinite parent, computes to auto,
|
|
165
160
|
* and shrink-wraps the column. Which is exactly what it did. */
|
|
166
|
-
const FILL = {
|
|
161
|
+
const FILL = { showcase: true }
|
|
167
162
|
|
|
168
163
|
/* gap INSIDE a ['stack', …] block. Defaults to the tight 4px pair article
|
|
169
164
|
* wants; work's header stack is the shipped gap-1 md:gap-2. */
|
|
170
165
|
const STACK = {
|
|
171
|
-
|
|
172
|
-
|
|
166
|
+
showcase: 'var(--kol-spacing-2)',
|
|
167
|
+
showcaseCanvas: 'var(--kol-spacing-2)',
|
|
173
168
|
}
|
|
174
169
|
|
|
175
170
|
const GAPS = {
|
|
176
|
-
|
|
171
|
+
file: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-2)' },
|
|
177
172
|
catalog: { card: 'var(--kol-spacing-1)', row: 'var(--kol-spacing-2)' },
|
|
178
|
-
print: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-2)' },
|
|
179
173
|
article: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-3)', hero: 'var(--kol-spacing-3)' },
|
|
180
|
-
|
|
181
|
-
|
|
174
|
+
showcase: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-4)' },
|
|
175
|
+
showcaseCanvas: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-6)' },
|
|
182
176
|
}
|
|
183
177
|
|
|
178
|
+
/* `default` → `file` (user 2026-08-29) — kept working as an alias here too,
|
|
179
|
+
* because ContentText is exported and a consumer can drive it directly. */
|
|
180
|
+
const ALIAS = { default: 'file', print: 'catalog', work: 'showcase', typeface: 'showcaseCanvas' }
|
|
181
|
+
|
|
184
182
|
export default function ContentText({
|
|
185
|
-
variant = '
|
|
183
|
+
variant: variantProp = 'file',
|
|
186
184
|
form = 'card',
|
|
187
185
|
title, body, eyebrow, kicker, detail, date, size, meta, tags,
|
|
188
186
|
gap, clamp,
|
|
@@ -192,8 +190,9 @@ export default function ContentText({
|
|
|
192
190
|
/* `kicker` / `kickerClass` = aliases of `eyebrow` / `eyebrowClass` (2026-08-27) */
|
|
193
191
|
eyebrow = eyebrow ?? kicker
|
|
194
192
|
eyebrowClass = eyebrowClass ?? kickerClass
|
|
195
|
-
const
|
|
196
|
-
const
|
|
193
|
+
const variant = ALIAS[variantProp] ?? variantProp
|
|
194
|
+
const ramp = RAMP[variant]?.[form] ?? RAMP.file[form] ?? RAMP.file.card
|
|
195
|
+
const order = (form === 'hero' && ORDER[`${variant}Hero`]) || ORDER[variant]?.[form] || ORDER.file.card
|
|
197
196
|
const values = { title, body, eyebrow, detail, date, size, meta, tags }
|
|
198
197
|
const overrides = { title: titleClass, body: bodyClass, eyebrow: eyebrowClass, detail: detailClass, date: dateClass, size: sizeClass, meta: metaClass, tags: tagsClass }
|
|
199
198
|
|
|
@@ -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
|
+
}
|