@kolkrabbi/kol-component 0.45.0 → 0.46.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/atoms/ActionButton.jsx +116 -0
- package/src/hooks/usePlaceholders.js +72 -0
- package/src/index.js +12 -1
- package/src/molecules/CodeBlock.jsx +2 -2
- package/src/molecules/ContentCard.jsx +140 -0
- package/src/molecules/ContentItem.jsx +14 -0
- package/src/molecules/ContentMedia.jsx +35 -0
- package/src/molecules/ContentRow.jsx +71 -0
- package/src/molecules/ContentText.jsx +138 -0
- package/src/molecules/CopyButton.jsx +40 -0
- package/src/molecules/EmptyState.jsx +14 -6
- package/src/molecules/MediaCard.jsx +2 -2
- package/src/molecules/Section.jsx +13 -2
- package/src/organisms/ContentCollection.jsx +56 -0
- package/src/organisms/ContentFilters.jsx +70 -28
- package/src/utilities/AssetPlaceholder.jsx +6 -1
- package/src/atoms/CopyButton.jsx +0 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.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",
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useRef, useState, useEffect } from 'react'
|
|
2
|
+
import { AnimatePresence, motion } from 'framer-motion'
|
|
3
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
|
+
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ActionButton — an icon control that CONFIRMS what it did (2026-08-15 user
|
|
8
|
+
* ruling: *"where is the 'code copied' or whatever message"*).
|
|
9
|
+
*
|
|
10
|
+
* The confirm-flip existed exactly once, welded to the clipboard inside
|
|
11
|
+
* CopyButton: fire, swap `copy`→`check` for 2s, flip the accessible label.
|
|
12
|
+
* Nothing else could reuse it, so every other in-frame control — download,
|
|
13
|
+
* select, anything a card puts over its media — had no way to acknowledge a
|
|
14
|
+
* click at all. This is that behaviour with the clipboard taken out of it;
|
|
15
|
+
* CopyButton is now a six-line wrapper around it and keeps its own API.
|
|
16
|
+
*
|
|
17
|
+
* The swap is ANIMATED, which the original was not — it hard-cut between two
|
|
18
|
+
* glyphs. framer-motion is already a declared peer of this package (TiltCard),
|
|
19
|
+
* so nothing new is installed. The curve is the house curve
|
|
20
|
+
* `cubic-bezier(0.16, 1, 0.3, 1)`, not `--kol-transition-base`'s generic
|
|
21
|
+
* material ease: the rest of the card family moves on the house curve and a
|
|
22
|
+
* control inside a card that eases differently reads as a foreign part.
|
|
23
|
+
*
|
|
24
|
+
* Chrome is `.kol-copy-btn` (kol-theme) — the DS's action-button look, which
|
|
25
|
+
* predates this component and is misnamed for it. Positioning stays the
|
|
26
|
+
* parent's job, exactly as before: pass `.kol-frame-control` to sit it in a
|
|
27
|
+
* card's corner, or nothing to leave it in flow.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} icon glyph at rest
|
|
30
|
+
* @param {string} confirmIcon glyph while confirming (default 'check')
|
|
31
|
+
* @param {string} label accessible label at rest
|
|
32
|
+
* @param {string} confirmLabel accessible label while confirming
|
|
33
|
+
* @param {Function} onAction (event) => void | Promise — awaited; the
|
|
34
|
+
* confirm only fires once it resolves
|
|
35
|
+
* @param {string} href renders an <a> instead of a <button>
|
|
36
|
+
* @param {number} hold ms to hold the confirm state (default 2000)
|
|
37
|
+
* @param {string} size sm | md | lg — the pinned square (28/32/36)
|
|
38
|
+
* and its SOLO glyph (16/20/24) move together,
|
|
39
|
+
* resolved from hooks/glyphLadders.js. This was
|
|
40
|
+
* a raw px number and defaulted to 16 in a 32px
|
|
41
|
+
* box: the sm glyph in the md square, which is
|
|
42
|
+
* the exact hand-transcription the ladders file
|
|
43
|
+
* exists to stop.
|
|
44
|
+
* @param {number} iconSize px override for the glyph only — the square
|
|
45
|
+
* never moves with it (2026-07-28 law)
|
|
46
|
+
*/
|
|
47
|
+
const HOUSE_EASE = [0.16, 1, 0.3, 1]
|
|
48
|
+
|
|
49
|
+
export default function ActionButton({
|
|
50
|
+
icon,
|
|
51
|
+
confirmIcon = 'check',
|
|
52
|
+
label,
|
|
53
|
+
confirmLabel,
|
|
54
|
+
onAction,
|
|
55
|
+
href,
|
|
56
|
+
hold = 2000,
|
|
57
|
+
size = 'md',
|
|
58
|
+
iconSize,
|
|
59
|
+
className = '',
|
|
60
|
+
...rest
|
|
61
|
+
}) {
|
|
62
|
+
const [done, setDone] = useState(false)
|
|
63
|
+
const timer = useRef(null)
|
|
64
|
+
|
|
65
|
+
/* the timer outlives the click — clear it if the control unmounts mid-hold,
|
|
66
|
+
* or React warns and the callback fires into a dead component */
|
|
67
|
+
useEffect(() => () => clearTimeout(timer.current), [])
|
|
68
|
+
|
|
69
|
+
const handle = async (event) => {
|
|
70
|
+
if (onAction) await onAction(event)
|
|
71
|
+
clearTimeout(timer.current)
|
|
72
|
+
setDone(true)
|
|
73
|
+
timer.current = setTimeout(() => setDone(false), hold)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const aria = (done && confirmLabel) || label
|
|
77
|
+
const cls = `kol-copy-btn kol-copy-btn-${size} ${className}`.trim()
|
|
78
|
+
const glyphPx = iconSize ?? glyphSize(size, true)
|
|
79
|
+
|
|
80
|
+
/* A HANDOFF, not a swap (user ruling 2026-08-15). `mode="wait"` held the box
|
|
81
|
+
* empty while the outgoing glyph finished, which reads as a blink at 32px.
|
|
82
|
+
* Both glyphs occupy the SAME grid cell and cross over each other: the old
|
|
83
|
+
* one lifts and fades as the new one rises into place. */
|
|
84
|
+
const glyph = (
|
|
85
|
+
<span className="inline-grid place-items-center" style={{ width: glyphPx, height: glyphPx }}>
|
|
86
|
+
<AnimatePresence initial={false}>
|
|
87
|
+
<motion.span
|
|
88
|
+
key={done ? 'confirm' : 'rest'}
|
|
89
|
+
className="inline-flex"
|
|
90
|
+
style={{ gridArea: '1 / 1' }}
|
|
91
|
+
initial={{ opacity: 0, scale: 0.6, y: 4 }}
|
|
92
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
93
|
+
exit={{ opacity: 0, scale: 0.6, y: -4 }}
|
|
94
|
+
transition={{ duration: 0.22, ease: HOUSE_EASE }}
|
|
95
|
+
>
|
|
96
|
+
<Icon name={done ? confirmIcon : icon} size={glyphPx} />
|
|
97
|
+
</motion.span>
|
|
98
|
+
</AnimatePresence>
|
|
99
|
+
</span>
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
/* The element follows the affordance — IconFrame's contract, same reasoning:
|
|
103
|
+
* a link must be a real <a> for middle-click, focus order and screen readers. */
|
|
104
|
+
if (href) {
|
|
105
|
+
return (
|
|
106
|
+
<a className={cls} href={href} onClick={handle} aria-label={aria} title={aria} {...rest}>
|
|
107
|
+
{glyph}
|
|
108
|
+
</a>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return (
|
|
112
|
+
<button type="button" className={cls} onClick={handle} aria-label={aria} title={aria} {...rest}>
|
|
113
|
+
{glyph}
|
|
114
|
+
</button>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* usePlaceholders — the ONE gate for placeholder / empty-state prose
|
|
5
|
+
* (GatedEmptyState, filed from kol-fxr 2026-08-15).
|
|
6
|
+
*
|
|
7
|
+
* The ticket's ruling, and the reason this is one concept rather than three:
|
|
8
|
+
* the filing repo had invented "helper text" vs "empty state" vs "hint" as
|
|
9
|
+
* separate ideas, and that is precisely what let the prose creep back — each
|
|
10
|
+
* kind had its own home and none had an off switch. There is one kind here,
|
|
11
|
+
* it is called a placeholder, and it is OFF until asked for.
|
|
12
|
+
*
|
|
13
|
+
* WHAT THE DS OWNS: the preference, its persistence, and the `.kol-placeholder`
|
|
14
|
+
* suppression rule in kol-utilities.css.
|
|
15
|
+
* WHAT THE CONSUMER OWNS: the keybind. A design system that grabs a global
|
|
16
|
+
* key collides with every app that already used it — kol-fxr's own `H` is
|
|
17
|
+
* already `toggle-visibility` in its editor keymap, which is exactly the
|
|
18
|
+
* collision the DS must not ship. Call `toggle` from whatever key you like.
|
|
19
|
+
*
|
|
20
|
+
* The hiding is CSS, not a render branch, so it covers a consumer's OWN prose
|
|
21
|
+
* the moment they put `.kol-placeholder` on it — not just <EmptyState gated>.
|
|
22
|
+
* One rule, no re-render, nothing to thread through a tree.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const STORAGE_KEY = 'kol-placeholders'
|
|
26
|
+
const ATTR = 'data-kol-placeholders'
|
|
27
|
+
|
|
28
|
+
const root = () => document.documentElement
|
|
29
|
+
|
|
30
|
+
/* Module-level subscribers: a settings checkbox and a keybind are usually two
|
|
31
|
+
* different components calling this hook, and per-component useState would let
|
|
32
|
+
* them disagree about a preference there is only one of. The CSS never
|
|
33
|
+
* desyncs (it reads the attribute), but the reported `shown` would. */
|
|
34
|
+
const listeners = new Set()
|
|
35
|
+
|
|
36
|
+
function read() {
|
|
37
|
+
try {
|
|
38
|
+
return localStorage.getItem(STORAGE_KEY) === 'on'
|
|
39
|
+
} catch {
|
|
40
|
+
return false // storage blocked → stay off, which is the default anyway
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function write(on) {
|
|
45
|
+
if (on) root().setAttribute(ATTR, '')
|
|
46
|
+
else root().removeAttribute(ATTR)
|
|
47
|
+
try {
|
|
48
|
+
localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off')
|
|
49
|
+
} catch { /* storage blocked — the attribute still holds for this session */ }
|
|
50
|
+
listeners.forEach((fn) => fn(on))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default function usePlaceholders() {
|
|
54
|
+
const [shown, setShown] = useState(false)
|
|
55
|
+
|
|
56
|
+
/* Boot from storage and stamp the attribute. Default OFF is the ruling, so
|
|
57
|
+
* an absent key and a blocked localStorage both land on the same answer. */
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const on = read()
|
|
60
|
+
if (on) root().setAttribute(ATTR, '')
|
|
61
|
+
setShown(on)
|
|
62
|
+
listeners.add(setShown)
|
|
63
|
+
return () => listeners.delete(setShown)
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
/* Read-then-flip off storage rather than state: a keybind handler bound once
|
|
67
|
+
* would otherwise close over the first render's value and toggle from stale. */
|
|
68
|
+
const toggle = useCallback(() => write(!read()), [])
|
|
69
|
+
const set = useCallback((on) => write(!!on), [])
|
|
70
|
+
|
|
71
|
+
return { shown, toggle, setShown: set }
|
|
72
|
+
}
|
package/src/index.js
CHANGED
|
@@ -22,7 +22,8 @@ export { default as AssetPlaceholder } from './utilities/AssetPlaceholder.jsx'
|
|
|
22
22
|
export { default as Avatar } from './atoms/Avatar.jsx'
|
|
23
23
|
export { default as Badge } from './atoms/Badge.jsx'
|
|
24
24
|
export { default as Button } from './atoms/Button.jsx'
|
|
25
|
-
export { default as
|
|
25
|
+
export { default as ActionButton } from './atoms/ActionButton.jsx'
|
|
26
|
+
export { default as CopyButton } from './molecules/CopyButton.jsx'
|
|
26
27
|
export { default as CurveOverlay } from './atoms/CurveOverlay.jsx'
|
|
27
28
|
export { default as Divider } from './atoms/Divider.jsx'
|
|
28
29
|
export { default as DocsToc } from './molecules/DocsToc.jsx'
|
|
@@ -79,6 +80,15 @@ export { default as FramedMediaBand } from './organisms/FramedMediaBand.jsx'
|
|
|
79
80
|
export { default as Image } from './atoms/Image.jsx'
|
|
80
81
|
export { default as MediaCard } from './molecules/MediaCard.jsx'
|
|
81
82
|
export { default as MediaRow } from './molecules/MediaRow.jsx'
|
|
83
|
+
|
|
84
|
+
/* content-card system (2026-08-15) — the ruled card/row family:
|
|
85
|
+
* docs/documentation/03-components/06-content-card-system.md */
|
|
86
|
+
export { default as ContentText } from './molecules/ContentText.jsx'
|
|
87
|
+
export { default as ContentMedia } from './molecules/ContentMedia.jsx'
|
|
88
|
+
export { default as ContentCard } from './molecules/ContentCard.jsx'
|
|
89
|
+
export { default as ContentRow } from './molecules/ContentRow.jsx'
|
|
90
|
+
export { default as ContentItem } from './molecules/ContentItem.jsx'
|
|
91
|
+
export { default as ContentCollection } from './organisms/ContentCollection.jsx'
|
|
82
92
|
export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
|
|
83
93
|
export { MenuPopover } from './molecules/MenuPopover.jsx'
|
|
84
94
|
export { ModalProvider, useModal } from './molecules/Modal.jsx'
|
|
@@ -148,6 +158,7 @@ export { default as useTilt } from './hooks/useTilt.js'
|
|
|
148
158
|
export { default as useCoarsePointer } from './hooks/useCoarsePointer.js'
|
|
149
159
|
export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
|
|
150
160
|
export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
|
|
161
|
+
export { default as usePlaceholders } from './hooks/usePlaceholders.js'
|
|
151
162
|
export { resolveCssVar, resolveCssColor, isLight } from './hooks/cssVar.js'
|
|
152
163
|
|
|
153
164
|
// color math (support module — HSL/hex conversion + harmony generation)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
|
2
2
|
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
|
3
|
-
import CopyButton from '
|
|
3
|
+
import CopyButton from './CopyButton.jsx'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* CodeBlock — REPLICATED from the elder reference
|
|
@@ -105,7 +105,7 @@ export default function CodeBlock({ children, code: codeProp, language: language
|
|
|
105
105
|
>
|
|
106
106
|
{code}
|
|
107
107
|
</SyntaxHighlighter>
|
|
108
|
-
<CopyButton text={code} className="kol-
|
|
108
|
+
<CopyButton text={code} className="kol-frame-control" />
|
|
109
109
|
</div>
|
|
110
110
|
</div>
|
|
111
111
|
)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import ContentMedia from './ContentMedia.jsx'
|
|
2
|
+
import ContentText from './ContentText.jsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ContentCard — the card form of the content-card system. Variants differ in
|
|
6
|
+
* text composition, media ratio, and the RULED box structure, all defaulted
|
|
7
|
+
* from the live review (06-content-card-system.md §2/§3):
|
|
8
|
+
*
|
|
9
|
+
* stack media (own ratio) on top, text plate below — default · article · work
|
|
10
|
+
* fill-card the CARD is the ratio frame; media fills the remainder above
|
|
11
|
+
* the plate — catalog · print (the A4 cards)
|
|
12
|
+
* canvas the card is the ratio frame; media fills it absolutely and the
|
|
13
|
+
* text plate floats on top — typeface (details over specimen)
|
|
14
|
+
*
|
|
15
|
+
* Padding spells the TOKENS (--kol-pad-card-{sm,md,lg} = 12/16/24), never a
|
|
16
|
+
* literal. Passing `pad` overrides the plate padding with one token step
|
|
17
|
+
* (named `pad`, not `size` — `size` is the ruled TEXT slot). Image-only cards
|
|
18
|
+
* (print) pass no text slots and render no plate. Exotic chrome (drawers,
|
|
19
|
+
* keylines, specimens) is consumer content through `media`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/* ruled defaults — defaults, not hardcodes; `ratio` stays overridable while
|
|
23
|
+
* the A4 question is open (06-content-card-system.md §4). */
|
|
24
|
+
const RATIOS = {
|
|
25
|
+
default: '1 / 1',
|
|
26
|
+
catalog: '1 / 1.41421',
|
|
27
|
+
print: '1 / 1.41421',
|
|
28
|
+
article: '16 / 9',
|
|
29
|
+
work: '3 / 4',
|
|
30
|
+
typeface: '1 / 1.41421',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* ruled box values per variant, verbatim from the §3 reference cards —
|
|
34
|
+
* paddings in tokens: pad-card-sm 12 · pad-card-md 16 · pad-card-lg 24 */
|
|
35
|
+
const BOX = {
|
|
36
|
+
default: { layout: 'stack', border: 'var(--kol-fg-12)', bg: 'var(--kol-fg-02)', pad: 'var(--kol-pad-card-sm)' },
|
|
37
|
+
catalog: { layout: 'fill-card', border: 'var(--kol-fg-04)', bg: 'var(--kol-fg-04)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true, plateBg: 'var(--kol-surface-primary)' },
|
|
38
|
+
print: { layout: 'fill-card', border: null, bg: 'var(--kol-surface-secondary)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true },
|
|
39
|
+
article: { layout: 'stack', border: null, bg: null, pad: '0', mediaGap: 'var(--kol-spacing-4)' },
|
|
40
|
+
work: { layout: 'stack', border: 'var(--kol-fg-04)', bg: null, pad: 'var(--kol-pad-card-md)' },
|
|
41
|
+
typeface: { layout: 'canvas', border: 'var(--kol-fg-08)', bg: 'var(--kol-surface-primary)', pad: 'var(--kol-pad-card-lg)' },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function ContentCard({
|
|
45
|
+
variant = 'default',
|
|
46
|
+
pad,
|
|
47
|
+
media,
|
|
48
|
+
ratio,
|
|
49
|
+
control,
|
|
50
|
+
actions,
|
|
51
|
+
selected = false,
|
|
52
|
+
onClick,
|
|
53
|
+
className = '',
|
|
54
|
+
...text
|
|
55
|
+
}) {
|
|
56
|
+
const box = BOX[variant] ?? BOX.default
|
|
57
|
+
const r = ratio ?? RATIOS[variant]
|
|
58
|
+
const padding = pad ? `var(--kol-pad-card-${pad})` : box.pad
|
|
59
|
+
/* image-only cards (print) pass no text slots — the empty plate must not render */
|
|
60
|
+
const hasText = ['title', 'body', 'kicker', 'detail', 'date', 'size', 'meta'].some((k) => text[k] != null)
|
|
61
|
+
/* `actions` — consumer content BELOW the text, in flow (2026-08-15 ruling).
|
|
62
|
+
* The other half of what MediaCard hardcodes: `control` sits OVER the media,
|
|
63
|
+
* this sits under the copy. Two positions, two slots, and a plate renders for
|
|
64
|
+
* either — an actions-only card is legal. */
|
|
65
|
+
const hasPlate = hasText || actions != null
|
|
66
|
+
const framed = box.border != null || box.bg != null
|
|
67
|
+
|
|
68
|
+
const textNode = hasPlate ? (
|
|
69
|
+
<div
|
|
70
|
+
style={{
|
|
71
|
+
padding,
|
|
72
|
+
marginTop: box.layout === 'stack' ? box.mediaGap : undefined,
|
|
73
|
+
borderTop: box.plateTop ? '1px solid var(--kol-fg-04)' : undefined,
|
|
74
|
+
background: box.plateBg,
|
|
75
|
+
position: box.layout === 'canvas' ? 'relative' : undefined,
|
|
76
|
+
zIndex: box.layout === 'canvas' ? 1 : undefined,
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
{hasText && <ContentText variant={variant} form="card" {...text} />}
|
|
80
|
+
{actions && <div style={{ marginTop: hasText ? 'var(--kol-spacing-2)' : undefined }}>{actions}</div>}
|
|
81
|
+
</div>
|
|
82
|
+
) : null
|
|
83
|
+
|
|
84
|
+
/* The CARD clips its own corners when it is framed, so the media must not
|
|
85
|
+
* round again — two radii on one edge is the visible double-round (user
|
|
86
|
+
* ruling 2026-08-15). Unframed variants (`article`) have nothing clipping
|
|
87
|
+
* them, so their media keeps its radius. The ROW is untouched: it does not
|
|
88
|
+
* clip, so ContentRow's thumb rounds as before. */
|
|
89
|
+
const mediaRadius = !framed
|
|
90
|
+
|
|
91
|
+
/* `control` — the in-frame control slot (user ruling 2026-08-15). One node,
|
|
92
|
+
* placed in the media frame's corner by `.kol-frame-control` (kol-theme).
|
|
93
|
+
* The CARD owns WHERE, the consumer owns WHAT: CopyButton, IconFrame with an
|
|
94
|
+
* href, a select indicator — the card knows none of them by name. This is
|
|
95
|
+
* what MediaCard hardcodes as a download link plus a select checkbox, and
|
|
96
|
+
* the reason its media library could not migrate onto ContentCard. */
|
|
97
|
+
const controlNode = control ? <div className="kol-frame-control">{control}</div> : null
|
|
98
|
+
|
|
99
|
+
const body =
|
|
100
|
+
box.layout === 'stack' ? (
|
|
101
|
+
<>
|
|
102
|
+
<div className="relative">
|
|
103
|
+
<ContentMedia ratio={r} radius={mediaRadius}>{media}</ContentMedia>
|
|
104
|
+
{controlNode}
|
|
105
|
+
</div>
|
|
106
|
+
{textNode}
|
|
107
|
+
</>
|
|
108
|
+
) : box.layout === 'fill-card' ? (
|
|
109
|
+
<>
|
|
110
|
+
<div className="flex-1 min-w-0 relative overflow-hidden">
|
|
111
|
+
<ContentMedia ratio={null} radius={mediaRadius}>{media}</ContentMedia>
|
|
112
|
+
{controlNode}
|
|
113
|
+
</div>
|
|
114
|
+
{textNode}
|
|
115
|
+
</>
|
|
116
|
+
) : (
|
|
117
|
+
/* canvas — media fills the frame, plate floats on top */
|
|
118
|
+
<>
|
|
119
|
+
<div className="absolute" style={{ inset: 0 }}>
|
|
120
|
+
<ContentMedia ratio={null} radius={mediaRadius}>{media}</ContentMedia>
|
|
121
|
+
{controlNode}
|
|
122
|
+
</div>
|
|
123
|
+
{textNode}
|
|
124
|
+
</>
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<article
|
|
129
|
+
onClick={onClick}
|
|
130
|
+
className={`flex flex-col ${framed ? 'overflow-hidden rounded-[var(--kol-radius-sm)]' : ''} ${box.border ? 'border' : ''} ${box.layout === 'canvas' ? 'relative' : ''} ${onClick ? 'cursor-pointer select-none' : ''} ${className}`.trim()}
|
|
131
|
+
style={{
|
|
132
|
+
borderColor: box.border ? (selected ? 'var(--kol-fg-64)' : box.border) : undefined,
|
|
133
|
+
background: box.bg ?? undefined,
|
|
134
|
+
aspectRatio: box.layout !== 'stack' ? r : undefined,
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{body}
|
|
138
|
+
</article>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import ContentCard from './ContentCard.jsx'
|
|
2
|
+
import ContentRow from './ContentRow.jsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ContentItem — the form switch the estate hand-wrote nine times
|
|
6
|
+
* (`layout === 'list' ? row : card`). One prop picks the form; everything
|
|
7
|
+
* else passes through to ContentCard / ContentRow unchanged, so a listing
|
|
8
|
+
* under a LIST/GRID toggle is one component with one prop flipped.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} form 'card' | 'row'
|
|
11
|
+
*/
|
|
12
|
+
export default function ContentItem({ form = 'card', ...rest }) {
|
|
13
|
+
return form === 'row' ? <ContentRow {...rest} /> : <ContentCard {...rest} />
|
|
14
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ContentMedia — the media slot of the content-card system.
|
|
5
|
+
*
|
|
6
|
+
* Ratio is the only knob (`fit` was cut 2026-08-15 — a card image covers,
|
|
7
|
+
* full stop). No children → AssetPlaceholder at the same ratio, so a card
|
|
8
|
+
* with no media is visibly flagged, never collapsed.
|
|
9
|
+
*
|
|
10
|
+
* `ratio` is a FREE prop (06-content-card-system.md §4 — the A4 question is
|
|
11
|
+
* open; nothing hardcodes a ratio). The ruled per-variant defaults live in
|
|
12
|
+
* ContentCard/ContentRow, not here.
|
|
13
|
+
*
|
|
14
|
+
* `radius` (2026-08-15 user ruling): OFF when the host frame already clips its
|
|
15
|
+
* own corners. A card that clips and a media slot that rounds are two radii on
|
|
16
|
+
* one edge — the visible double-round. ContentCard turns it off for its framed
|
|
17
|
+
* variants; the ROW keeps it, because a row does not clip.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} ratio CSS aspect-ratio, e.g. '1 / 1', '16 / 9', '1 / 1.41421'
|
|
20
|
+
* @param {boolean} radius round the media's own corners (default true)
|
|
21
|
+
* @param {ReactNode} children the real media; rendered object-cover
|
|
22
|
+
*/
|
|
23
|
+
export default function ContentMedia({ ratio = '1 / 1', radius = true, children, className = '' }) {
|
|
24
|
+
if (children == null) {
|
|
25
|
+
return <AssetPlaceholder radius={radius} aspectRatio={ratio ?? undefined} className={ratio == null ? `h-full ${className}` : className} />
|
|
26
|
+
}
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className={`relative w-full overflow-hidden ${radius ? 'rounded-[var(--kol-radius-sm)]' : ''} [&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>video]:h-full [&>video]:w-full [&>video]:object-cover ${ratio == null ? 'h-full' : ''} ${className}`.trim()}
|
|
30
|
+
style={ratio != null ? { aspectRatio: ratio } : undefined}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import ContentMedia from './ContentMedia.jsx'
|
|
2
|
+
import ContentText from './ContentText.jsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ContentRow — the row form of the content-card system: leading thumb (where
|
|
6
|
+
* the variant has one) beside the ruled text. Box values — thumb size, gap,
|
|
7
|
+
* padding, frame — default per variant to the RULED structures from the live
|
|
8
|
+
* review (06-content-card-system.md §2 boxes): default is a bare table-like
|
|
9
|
+
* line with a 48px thumb; catalog/print are framed between-headers with no
|
|
10
|
+
* thumb; article rides a 120px 16:9 thumb; work a framed 64px row; typeface a
|
|
11
|
+
* framed no-thumb block.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} variant default | catalog | print | article | work | typeface
|
|
14
|
+
* @param {ReactNode} media thumb content (omit → placeholder)
|
|
15
|
+
* @param {number} thumb thumb edge px — overrides the ruled default; 0 hides
|
|
16
|
+
* @param {string} ratio thumb aspect-ratio — overrides the ruled default
|
|
17
|
+
* @param {number} paddingY vertical padding px — overrides the ruled default
|
|
18
|
+
* @param {boolean} selected
|
|
19
|
+
* @param {Function} onClick
|
|
20
|
+
* text slots + *Class seams forwarded to ContentText.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/* ruled row boxes per variant (the §2 review) — paddings/gaps spell the
|
|
24
|
+
* --kol-spacing-* tokens (2=8 · 3=12 · 4=16 · 6=24), never a literal */
|
|
25
|
+
const S2 = 'var(--kol-spacing-2)', S3 = 'var(--kol-spacing-3)', S4 = 'var(--kol-spacing-4)', S6 = 'var(--kol-spacing-6)'
|
|
26
|
+
const BOX = {
|
|
27
|
+
default: { thumb: 48, ratio: '1 / 1', pad: `${S2} 0`, gap: S3, align: 'items-center', divider: true },
|
|
28
|
+
catalog: { thumb: 0, ratio: '1 / 1', pad: `${S2} ${S3}`, gap: S3, frame: 'var(--kol-fg-04)', bg: 'var(--kol-surface-secondary)', minH: 36, align: 'items-center' },
|
|
29
|
+
print: { thumb: 0, ratio: '1 / 1', pad: `${S2} ${S3}`, gap: S3, frame: 'var(--kol-fg-04)', bg: 'var(--kol-surface-secondary)', minH: 36, align: 'items-center' },
|
|
30
|
+
article: { thumb: 120, ratio: '16 / 9', pad: '0', gap: S6, align: 'items-start' },
|
|
31
|
+
work: { thumb: 64, ratio: '1 / 1', pad: S4, gap: S4, frame: 'var(--kol-fg-08)', bg: 'var(--kol-surface-secondary)', minH: 96, align: 'items-stretch' },
|
|
32
|
+
typeface: { thumb: 0, ratio: '1 / 1', pad: S6, gap: S6, frame: 'var(--kol-fg-08)', bg: 'var(--kol-surface-primary)', align: 'items-start' },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function ContentRow({
|
|
36
|
+
variant = 'default',
|
|
37
|
+
media,
|
|
38
|
+
thumb,
|
|
39
|
+
ratio,
|
|
40
|
+
paddingY,
|
|
41
|
+
actions,
|
|
42
|
+
selected = false,
|
|
43
|
+
onClick,
|
|
44
|
+
className = '',
|
|
45
|
+
...text
|
|
46
|
+
}) {
|
|
47
|
+
const box = BOX[variant] ?? BOX.default
|
|
48
|
+
const thumbPx = thumb ?? box.thumb
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
onClick={onClick}
|
|
52
|
+
className={`flex ${box.align} ${box.divider ? 'border-b border-fg-08' : ''} ${box.frame ? 'rounded-[var(--kol-radius-sm)] border' : ''} ${onClick ? 'cursor-pointer select-none' : ''} ${className}`.trim()}
|
|
53
|
+
style={{
|
|
54
|
+
gap: box.gap,
|
|
55
|
+
padding: paddingY != null ? `${paddingY}px 0` : box.pad,
|
|
56
|
+
minHeight: box.minH,
|
|
57
|
+
borderColor: box.frame || undefined,
|
|
58
|
+
background: selected ? 'var(--kol-fg-04)' : box.bg,
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{thumbPx > 0 && (
|
|
62
|
+
<div className="shrink-0" style={{ width: thumbPx }}>
|
|
63
|
+
<ContentMedia ratio={ratio ?? box.ratio}>{media}</ContentMedia>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
<ContentText variant={variant} form="row" className="flex-1" {...text} />
|
|
67
|
+
{/* trailing edge, never in the text flow — MediaRow's placement */}
|
|
68
|
+
{actions && <div className="shrink-0">{actions}</div>}
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContentText — the ruled text block of the content-card system.
|
|
3
|
+
*
|
|
4
|
+
* Renders the per-variant / per-form type ramp ruled 2026-08-15
|
|
5
|
+
* (docs/documentation/03-components/06-content-card-system.md §3). The laws:
|
|
6
|
+
* title is the ONLY slot that steps between card and row (size only, one
|
|
7
|
+
* family); body and meta are identical in both forms; helper-* only where a
|
|
8
|
+
* single line is guaranteed; ink is three roles (emphasis · body · meta).
|
|
9
|
+
*
|
|
10
|
+
* Every type class is a SEAM: pass `<slot>Class` to replace the ruled
|
|
11
|
+
* class+ink string whole (consumers on their own faces swap here). Passing
|
|
12
|
+
* nothing renders the ruled values.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} variant default | catalog | print | article | work | typeface
|
|
15
|
+
* @param {string} form card | row
|
|
16
|
+
* @param {ReactNode} title
|
|
17
|
+
* @param {ReactNode} body article · work · typeface
|
|
18
|
+
* @param {ReactNode} kicker article only
|
|
19
|
+
* @param {ReactNode} detail catalog · print
|
|
20
|
+
* @param {ReactNode} date default · article · typeface
|
|
21
|
+
* @param {ReactNode} size default · article (file size / read length)
|
|
22
|
+
* @param {ReactNode} meta work only
|
|
23
|
+
* @param {number} gap inner line gap in px (defaults per variant/form)
|
|
24
|
+
* @param {string} titleClass … kickerClass, bodyClass, detailClass,
|
|
25
|
+
* dateClass, sizeClass, metaClass — full class overrides
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/* [variant][form][slot] → 'type-class ink-role', verbatim from the ruled table */
|
|
29
|
+
const RAMP = {
|
|
30
|
+
default: {
|
|
31
|
+
card: { title: 'kol-helper-12 text-emphasis truncate', date: 'kol-helper-12 text-meta', size: 'kol-helper-12 text-body' },
|
|
32
|
+
row: { title: 'kol-helper-12 text-emphasis truncate', date: 'kol-helper-12 text-meta', size: 'kol-helper-12 text-body' },
|
|
33
|
+
},
|
|
34
|
+
catalog: {
|
|
35
|
+
card: { title: 'kol-mono-14 text-emphasis', detail: 'kol-mono-10 text-meta' },
|
|
36
|
+
row: { title: 'kol-mono-12 text-emphasis', detail: 'kol-mono-10 text-meta' },
|
|
37
|
+
},
|
|
38
|
+
print: {
|
|
39
|
+
card: { title: 'kol-mono-14 text-body', detail: 'kol-mono-10 text-meta' },
|
|
40
|
+
row: { title: 'kol-mono-10 text-body', detail: 'kol-mono-10 text-meta' },
|
|
41
|
+
},
|
|
42
|
+
article: {
|
|
43
|
+
card: { kicker: 'kol-helper-12 text-body', title: 'kol-sans-heading-03 text-emphasis', body: 'kol-mono-14 text-body', date: 'kol-helper-12 text-meta', size: 'kol-helper-12 text-body' },
|
|
44
|
+
row: { kicker: 'kol-helper-12 text-body', title: 'kol-sans-heading-05 text-emphasis', body: 'kol-mono-14 text-body', date: 'kol-helper-12 text-meta', size: 'kol-helper-12 text-body' },
|
|
45
|
+
},
|
|
46
|
+
work: {
|
|
47
|
+
card: { title: 'kol-sans-display-02 text-emphasis', body: 'kol-mono-14 text-body', meta: 'kol-mono-12 text-body' },
|
|
48
|
+
row: { title: 'kol-sans-display-03 text-emphasis', body: 'kol-mono-14 text-body', meta: 'kol-mono-12 text-body' },
|
|
49
|
+
},
|
|
50
|
+
typeface: {
|
|
51
|
+
card: { title: 'kol-mono-20 text-emphasis', body: 'kol-mono-14 text-body', date: 'kol-mono-12 text-body' },
|
|
52
|
+
row: { title: 'kol-mono-14 text-emphasis', body: 'kol-mono-14 text-body', date: 'kol-mono-12 text-body' },
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* render order per variant/form. Strings are slots; arrays are ONE line:
|
|
57
|
+
* ['group', …] = 16px baseline group · ['between', …] = header.between ·
|
|
58
|
+
* ['line', …] = one flex line, first slot flex-1 truncating, rest fixed.
|
|
59
|
+
* Directions are the RULED structures (06-content-card-system.md §2 boxes):
|
|
60
|
+
* default row is a single table-like line, catalog/print rows are between-
|
|
61
|
+
* headers. work row: line 2 stays the big line and carries the TITLE (fields
|
|
62
|
+
* were crossed in the shipped WorkListItem — ruled, do not re-derive). */
|
|
63
|
+
const ORDER = {
|
|
64
|
+
default: { card: ['title', ['group', 'date', 'size']], row: [['line', 'title', 'date', 'size']] },
|
|
65
|
+
catalog: { card: ['title', 'detail'], row: [['between', 'title', 'detail']] },
|
|
66
|
+
print: { card: ['title', 'detail'], row: [['between', 'title', 'detail']] },
|
|
67
|
+
article: { card: ['kicker', 'title', 'body', ['group', 'date', 'size']], row: ['kicker', 'title', 'body', ['group', 'date', 'size']] },
|
|
68
|
+
work: { card: ['title', 'body', 'meta'], row: ['body', 'title', 'meta'] },
|
|
69
|
+
typeface: { card: ['title', 'body', 'date'], row: [['between', 'title', 'date'], 'body'] },
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* inner line gap per variant/form, read from the shipped boxes — spelled in
|
|
73
|
+
* --kol-spacing-* tokens where the value sits on the scale (10px is the
|
|
74
|
+
* shipped article-row literal; the scale has no rung there) */
|
|
75
|
+
const GAPS = {
|
|
76
|
+
default: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-3)' },
|
|
77
|
+
catalog: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-2)' },
|
|
78
|
+
print: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-2)' },
|
|
79
|
+
article: { card: 'var(--kol-spacing-3)', row: '10px' },
|
|
80
|
+
work: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-3)' },
|
|
81
|
+
typeface: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-6)' },
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default function ContentText({
|
|
85
|
+
variant = 'default',
|
|
86
|
+
form = 'card',
|
|
87
|
+
title, body, kicker, detail, date, size, meta,
|
|
88
|
+
gap,
|
|
89
|
+
titleClass, bodyClass, kickerClass, detailClass, dateClass, sizeClass, metaClass,
|
|
90
|
+
className = '',
|
|
91
|
+
}) {
|
|
92
|
+
const ramp = RAMP[variant]?.[form] ?? RAMP.default[form] ?? RAMP.default.card
|
|
93
|
+
const order = ORDER[variant]?.[form] ?? ORDER.default.card
|
|
94
|
+
const values = { title, body, kicker, detail, date, size, meta }
|
|
95
|
+
const overrides = { title: titleClass, body: bodyClass, kicker: kickerClass, detail: detailClass, date: dateClass, size: sizeClass, meta: metaClass }
|
|
96
|
+
|
|
97
|
+
const line = (slot) =>
|
|
98
|
+
values[slot] == null ? null : (
|
|
99
|
+
<div key={slot} className={overrides[slot] ?? ramp[slot] ?? ''}>{values[slot]}</div>
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const nodes = order.map((entry, i) => {
|
|
103
|
+
if (typeof entry === 'string') return line(entry)
|
|
104
|
+
const [kind, ...slots] = entry
|
|
105
|
+
const parts = slots.map(line).filter(Boolean)
|
|
106
|
+
if (!parts.length) return null
|
|
107
|
+
if (kind === 'line') {
|
|
108
|
+
/* ruled (default row): title flex-1 truncate · date w-24 · size w-20,
|
|
109
|
+
* trailing fields right-aligned fixed columns so rows align in a list */
|
|
110
|
+
const W = [null, 96, 80]
|
|
111
|
+
return (
|
|
112
|
+
<div key={`line-${i}`} className="flex items-baseline min-w-0" style={{ gap: '12px' }}>
|
|
113
|
+
{parts.map((p, j) => (j === 0
|
|
114
|
+
? <div key={j} className="flex-1 min-w-0 truncate">{p}</div>
|
|
115
|
+
: <div key={j} className="shrink-0 text-right" style={{ width: W[j] ?? undefined }}>{p}</div>))}
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
key={`${kind}-${i}`}
|
|
122
|
+
className={`flex items-baseline ${kind === 'between' ? 'justify-between' : ''}`}
|
|
123
|
+
style={{ gap: 'var(--kol-spacing-4)' }}
|
|
124
|
+
>
|
|
125
|
+
{parts}
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
className={`flex min-w-0 flex-col ${className}`.trim()}
|
|
133
|
+
style={{ gap: typeof gap === 'number' ? `${gap}px` : gap ?? GAPS[variant]?.[form] ?? 'var(--kol-spacing-2)' }}
|
|
134
|
+
>
|
|
135
|
+
{nodes}
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import ActionButton from '../atoms/ActionButton.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CopyButton — THE copy-to-clipboard control (2026-08-09 user ruling): the
|
|
5
|
+
* 32×32 icon button — `copy` glyph flipping to `check` for 2s on copied,
|
|
6
|
+
* no text label. This is the button CodeBlock carried privately since the
|
|
7
|
+
* 2026-07-28 elder replication, promoted to the one shared atom; the old
|
|
8
|
+
* Copy/Copied label chip (one-off SVGs outside the icon set) is retired.
|
|
9
|
+
* Chrome comes from .kol-copy-btn (kol-theme); parents add their own
|
|
10
|
+
* positioning class (e.g. .kol-frame-control).
|
|
11
|
+
*
|
|
12
|
+
* The flip itself moved to ActionButton (2026-08-15) — it was the only
|
|
13
|
+
* confirm-feedback in the system and it was welded to the clipboard, so no
|
|
14
|
+
* other in-frame control could acknowledge a click. This is now clipboard
|
|
15
|
+
* behaviour plus that component; the public API is unchanged, and the swap
|
|
16
|
+
* gained an animation it never had.
|
|
17
|
+
*
|
|
18
|
+
* Props:
|
|
19
|
+
* text — string (or () => string) written to the clipboard
|
|
20
|
+
* className — extra classes (positioning etc.)
|
|
21
|
+
*/
|
|
22
|
+
export default function CopyButton({ text, className = '', ...props }) {
|
|
23
|
+
return (
|
|
24
|
+
<ActionButton
|
|
25
|
+
icon="copy"
|
|
26
|
+
confirmIcon="check"
|
|
27
|
+
label="Copy to clipboard"
|
|
28
|
+
confirmLabel="Copied"
|
|
29
|
+
className={className}
|
|
30
|
+
onAction={async () => {
|
|
31
|
+
try {
|
|
32
|
+
await navigator.clipboard.writeText(typeof text === 'function' ? text() : String(text ?? ''))
|
|
33
|
+
} catch {
|
|
34
|
+
/* clipboard blocked — silent */
|
|
35
|
+
}
|
|
36
|
+
}}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -5,14 +5,22 @@
|
|
|
5
5
|
* the placeholder name). All lines render as authored — no auto casing
|
|
6
6
|
* (the source's `uppercase` on the eyebrow was dropped per KOL rules).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* `gated` (GatedEmptyState, kol-fxr 2026-08-15) opts this instance into the
|
|
9
|
+
* app-wide placeholder switch — see usePlaceholders(). It is OPT-IN, not the
|
|
10
|
+
* default, on purpose: the ruling that placeholder prose defaults to hidden is
|
|
11
|
+
* the filing app's, and flipping it here would silently blank every surface
|
|
12
|
+
* already shipping an EmptyState. A consumer that wants the gate asks for it.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} eyebrow kicker line above the title
|
|
15
|
+
* @param {string} title headline
|
|
16
|
+
* @param {string} body optional supporting line
|
|
17
|
+
* @param {string} footer optional note above a top hairline
|
|
18
|
+
* @param {boolean} gated hide unless placeholders are switched on (default false)
|
|
19
|
+
* @param {string} className extra classes on the wrapper
|
|
12
20
|
*/
|
|
13
|
-
export default function EmptyState({ eyebrow, title, body, footer }) {
|
|
21
|
+
export default function EmptyState({ eyebrow, title, body, footer, gated = false, className = '' }) {
|
|
14
22
|
return (
|
|
15
|
-
<div>
|
|
23
|
+
<div className={`${gated ? 'kol-placeholder' : ''}${className ? ` ${className}` : ''}` || undefined}>
|
|
16
24
|
{/* helper (line-height 1) is single-line chrome ONLY — title and footer
|
|
17
25
|
* can wrap, so they ride the line-height-bearing kol-mono-* scale
|
|
18
26
|
* (the type-conform fault line; user, 2026-08-09). Eyebrow stays
|
|
@@ -69,7 +69,7 @@ export default function MediaCard({
|
|
|
69
69
|
{thumb}
|
|
70
70
|
{selectMode ? (
|
|
71
71
|
<span
|
|
72
|
-
className="
|
|
72
|
+
className="kol-frame-control kol-frame-control--top-left rounded p-1"
|
|
73
73
|
style={{ background: 'var(--kol-fg-absolute-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
|
|
74
74
|
>
|
|
75
75
|
<SelectIndicator on={selected} />
|
|
@@ -79,7 +79,7 @@ export default function MediaCard({
|
|
|
79
79
|
href={downloadHref}
|
|
80
80
|
aria-label="Download"
|
|
81
81
|
title="Download"
|
|
82
|
-
className="
|
|
82
|
+
className="kol-frame-control inline-flex items-center justify-center w-8 h-8 rounded text-emphasis hover:bg-fg-absolute-24 transition-colors"
|
|
83
83
|
style={{ background: 'var(--kol-fg-absolute-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
|
|
84
84
|
onClick={(e) => e.stopPropagation()}
|
|
85
85
|
>
|
|
@@ -3,10 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A small-caps label above a vertical content stack. Used across the editor
|
|
5
5
|
* inspector panels (palette / pattern / type modes): `<Section label="Aspect">…</Section>`.
|
|
6
|
+
*
|
|
7
|
+
* `divided` (InspectorSectionRhythm, 2026-08-15) adds the between-siblings
|
|
8
|
+
* hairline every rail consumer was retyping locally — the rule lives on the
|
|
9
|
+
* ADJACENT pair (`.kol-section--divided + .kol-section--divided`) in
|
|
10
|
+
* kol-components-molecules.css, so the first section in a stack never carries
|
|
11
|
+
* a stray top border. Set it on every section in the stack; a rail that mixes
|
|
12
|
+
* divided and plain sections divides only between the divided ones.
|
|
13
|
+
*
|
|
14
|
+
* ponytail: a `SectionStack` parent could own this instead of each child
|
|
15
|
+
* declaring it — that is the upgrade path if a consumer ever needs the stack
|
|
16
|
+
* to vary the rule per-gap. One prop is a smaller API than a new component.
|
|
6
17
|
*/
|
|
7
|
-
export default function Section({ label, children, className = '' }) {
|
|
18
|
+
export default function Section({ label, children, divided = false, className = '' }) {
|
|
8
19
|
return (
|
|
9
|
-
<div className={`flex flex-col gap-2 ${className}`}>
|
|
20
|
+
<div className={`flex flex-col gap-2${divided ? ' kol-section--divided' : ''} ${className}`}>
|
|
10
21
|
{label && (
|
|
11
22
|
<p className="kol-helper-10 tracking-widest text-meta">{label}</p>
|
|
12
23
|
)}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ContentCollection — the container half of the content-card system: the
|
|
5
|
+
* grid/list switch plus the motion that belongs to it. Animation lives in
|
|
6
|
+
* the wrapper — the only place it can (06-content-card-system.md §6);
|
|
7
|
+
* cards never animate themselves.
|
|
8
|
+
*
|
|
9
|
+
* Children are ContentItem / ContentCard / ContentRow (or anything). The
|
|
10
|
+
* collection owns the enter stagger, keyed to `form` so the switch re-runs
|
|
11
|
+
* it. Uses the house curve (--kol-ease-house).
|
|
12
|
+
*
|
|
13
|
+
* ponytail: form switch re-mounts with a stagger, no FLIP — add FLIP
|
|
14
|
+
* (measure → invert → play) when a consumer needs card↔row to tween.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} form 'grid' | 'list'
|
|
17
|
+
* @param {string} min grid track minimum (CSS length), grid form only
|
|
18
|
+
* @param {number} gap px between items
|
|
19
|
+
* @param {boolean} stagger enter animation on/off (reduced motion wins)
|
|
20
|
+
*/
|
|
21
|
+
export default function ContentCollection({
|
|
22
|
+
form = 'grid',
|
|
23
|
+
min = '12rem',
|
|
24
|
+
gap = 16,
|
|
25
|
+
stagger = true,
|
|
26
|
+
children,
|
|
27
|
+
className = '',
|
|
28
|
+
}) {
|
|
29
|
+
const reduced = usePrefersReducedMotion()
|
|
30
|
+
const animate = stagger && !reduced
|
|
31
|
+
const items = Array.isArray(children) ? children.flat() : [children]
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<ul
|
|
35
|
+
key={form}
|
|
36
|
+
className={`m-0 list-none p-0 ${className}`.trim()}
|
|
37
|
+
style={
|
|
38
|
+
form === 'grid'
|
|
39
|
+
? { display: 'grid', gridTemplateColumns: `repeat(auto-fill, minmax(${min}, 1fr))`, gap: `${gap}px` }
|
|
40
|
+
: { display: 'flex', flexDirection: 'column', gap: `${gap}px` }
|
|
41
|
+
}
|
|
42
|
+
>
|
|
43
|
+
{items.map((child, i) =>
|
|
44
|
+
child == null ? null : (
|
|
45
|
+
<li
|
|
46
|
+
key={child.key ?? i}
|
|
47
|
+
className={animate ? 'kol-collection-item' : undefined}
|
|
48
|
+
style={animate ? { animationDelay: `${i * 40}ms` } : undefined}
|
|
49
|
+
>
|
|
50
|
+
{child}
|
|
51
|
+
</li>
|
|
52
|
+
),
|
|
53
|
+
)}
|
|
54
|
+
</ul>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -24,6 +24,21 @@ import IconFrame from '../atoms/IconFrame.jsx'
|
|
|
24
24
|
* @param {Array} props.mutuallyExclusiveFilters — filter keys that should be mutually exclusive
|
|
25
25
|
* @param {Array} props.customFilterKeys — filter keys handled by renderItem, not by ContentFilters
|
|
26
26
|
* @param {ElementType} props.iconComponent — icon seam (defaults to DS Icon; needs `filter` + `search`)
|
|
27
|
+
*
|
|
28
|
+
* Look seams — all default to the shipped values, pass nothing and nothing changes:
|
|
29
|
+
* @param {string} props.titleClassName — header title type/ink
|
|
30
|
+
* @param {boolean} props.titleUppercase — cases the title (default false)
|
|
31
|
+
* @param {string} props.labelClassName — filter-group label type/ink
|
|
32
|
+
* @param {boolean} props.labelUppercase — cases the group label (default true)
|
|
33
|
+
* @param {string} props.tagVariant — filter chip variant ('primary' grey fill)
|
|
34
|
+
* @param {string} props.tagSize — filter chip size
|
|
35
|
+
* @param {string} props.tagActiveClassName — chip ink, selected
|
|
36
|
+
* @param {string} props.tagRestClassName — chip ink, unselected
|
|
37
|
+
* @param {string} props.viewClassName — RECENT/SAVED strip type
|
|
38
|
+
* @param {string} props.layoutClassName — LIST/GRID strip type
|
|
39
|
+
* @param {string} props.stripActiveClassName — strip ink, selected (both strips)
|
|
40
|
+
* @param {string} props.stripRestClassName — strip ink, unselected (both strips)
|
|
41
|
+
* @param {string} props.countClassName — the "N of N" type/ink
|
|
27
42
|
*/
|
|
28
43
|
const ContentFilters = ({
|
|
29
44
|
items,
|
|
@@ -46,6 +61,22 @@ const ContentFilters = ({
|
|
|
46
61
|
showCountOnlyWhenFiltering = false,
|
|
47
62
|
iconComponent,
|
|
48
63
|
className = '',
|
|
64
|
+
/* THE LOOK SEAMS. Every one defaults to what the component shipped, so
|
|
65
|
+
* passing nothing renders exactly as before — these exist because the
|
|
66
|
+
* hardcoded values were the wrong call for every consumer but one. */
|
|
67
|
+
titleClassName = 'kol-helper-14',
|
|
68
|
+
titleUppercase = false,
|
|
69
|
+
labelClassName = 'kol-helper-12 text-fg-96',
|
|
70
|
+
labelUppercase = true,
|
|
71
|
+
tagVariant = 'primary',
|
|
72
|
+
tagSize = 'sm',
|
|
73
|
+
tagActiveClassName = 'text-fg-96',
|
|
74
|
+
tagRestClassName = 'text-fg-48',
|
|
75
|
+
viewClassName = 'kol-helper-14',
|
|
76
|
+
layoutClassName = 'kol-helper-12',
|
|
77
|
+
stripActiveClassName = 'text-fg-96',
|
|
78
|
+
stripRestClassName = 'text-fg-32 hover:text-fg-48',
|
|
79
|
+
countClassName = 'kol-helper-12 text-fg-64',
|
|
49
80
|
}) => {
|
|
50
81
|
/* Icon seam — consumers on a local icon shelf pass their own component
|
|
51
82
|
* rather than being forced onto the DS set. Needs `filter` + `search`. */
|
|
@@ -119,13 +150,18 @@ const ContentFilters = ({
|
|
|
119
150
|
})
|
|
120
151
|
}, [items, activeFilters, customFilterKeys, searchText, searchKeys])
|
|
121
152
|
|
|
153
|
+
const showCount = !showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0
|
|
154
|
+
|
|
122
155
|
/* THE FILTER VALUE IS A TAG — the atom's whole reason to exist ("a Tag with
|
|
123
156
|
* no handler is a Pill wearing the wrong name"). Three defects lived here
|
|
124
157
|
* until 2026-08-15, all of them working around the atom instead of using it:
|
|
125
158
|
*
|
|
126
159
|
* variant="default" — not a declared variant; `VARIANTS[v] ?? primary`
|
|
127
|
-
* silently rendered the FILLED chip
|
|
128
|
-
* chip
|
|
160
|
+
* silently rendered the FILLED chip — which is what
|
|
161
|
+
* the chip should be. `primary` is now declared, not
|
|
162
|
+
* fallen through to. (An outlined `secondary` shipped
|
|
163
|
+
* 2026-08-15 and was ruled wrong the same day: the
|
|
164
|
+
* grey filled chip is the look.)
|
|
129
165
|
* className border-* — hand-rolled active state beside the atom's own
|
|
130
166
|
* `active` prop, which is what drives `.is-active`.
|
|
131
167
|
* <div onClick> — the handler on a wrapper, so Tag rendered a <span>
|
|
@@ -147,19 +183,19 @@ const ContentFilters = ({
|
|
|
147
183
|
* never what anyone looked at. No inline letter-spacing: the helper ramp
|
|
148
184
|
* carries its own, and the override the fork added was not in the
|
|
149
185
|
* rendered path either. */}
|
|
150
|
-
<h4 className=
|
|
186
|
+
<h4 className={labelClassName} style={labelUppercase ? { textTransform: 'uppercase' } : undefined}>
|
|
151
187
|
{group.label}
|
|
152
188
|
</h4>
|
|
153
189
|
<div className="flex flex-wrap gap-4">
|
|
154
190
|
{group.values.map((value) => (
|
|
155
191
|
<Tag
|
|
156
192
|
key={value}
|
|
157
|
-
size=
|
|
158
|
-
variant=
|
|
193
|
+
size={tagSize}
|
|
194
|
+
variant={tagVariant}
|
|
159
195
|
hash={false}
|
|
160
196
|
active={activeFilters.has(`${group.key}:${value}`)}
|
|
161
197
|
onClick={() => toggleFilter(group.key, value)}
|
|
162
|
-
className={activeFilters.has(`${group.key}:${value}`) ?
|
|
198
|
+
className={activeFilters.has(`${group.key}:${value}`) ? tagActiveClassName : tagRestClassName}
|
|
163
199
|
>
|
|
164
200
|
{value}
|
|
165
201
|
</Tag>
|
|
@@ -181,7 +217,7 @@ const ContentFilters = ({
|
|
|
181
217
|
* states"); the span here was the same defect that promoted it. */}
|
|
182
218
|
<h2 className="flex items-center gap-2">
|
|
183
219
|
{titleIcon && <IconFrame name={titleIcon} variant="secondary" size="md" />}
|
|
184
|
-
<span className=
|
|
220
|
+
<span className={titleClassName} style={titleUppercase ? { textTransform: 'uppercase' } : undefined}>{title}</span>
|
|
185
221
|
</h2>
|
|
186
222
|
<Divider variant="vertical" className="self-stretch py-1" />
|
|
187
223
|
<div className="flex items-center gap-1">
|
|
@@ -250,11 +286,6 @@ const ContentFilters = ({
|
|
|
250
286
|
</div>
|
|
251
287
|
|
|
252
288
|
<div className="flex items-center gap-8">
|
|
253
|
-
{(!showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0) && (
|
|
254
|
-
<span className="kol-helper-14 text-fg-64">
|
|
255
|
-
{filteredItems.length} of {totalCount}
|
|
256
|
-
</span>
|
|
257
|
-
)}
|
|
258
289
|
{/* RECENT / SAVED is the SAME STRIP as LIST / GRID, not a ViewToggle.
|
|
259
290
|
* Read off kol-monitor's original (_tmp/2026-08-15-shell-adoption/
|
|
260
291
|
* ContentFilters.jsx:225-232): inline spans, `kol-helper-14`,
|
|
@@ -270,7 +301,7 @@ const ContentFilters = ({
|
|
|
270
301
|
<span
|
|
271
302
|
key={opt.value}
|
|
272
303
|
onClick={() => handleViewModeChange(opt.value)}
|
|
273
|
-
className={
|
|
304
|
+
className={`${viewClassName} cursor-pointer select-none ${viewMode === opt.value ? stripActiveClassName : stripRestClassName}`}
|
|
274
305
|
style={{ textTransform: 'uppercase', letterSpacing: 1 }}
|
|
275
306
|
>
|
|
276
307
|
{opt.label}
|
|
@@ -292,7 +323,7 @@ const ContentFilters = ({
|
|
|
292
323
|
* the values hang beneath it. The strip previously rendered in its own
|
|
293
324
|
* row AFTER this block, which is why expanding a filter group pushed it
|
|
294
325
|
* down the page — the defect that started the whole ticket. */}
|
|
295
|
-
{(layoutOptions || isExpanded) && (
|
|
326
|
+
{(layoutOptions || isExpanded || showCount) && (
|
|
296
327
|
<div className="flex items-start justify-between gap-16 pb-4">
|
|
297
328
|
<div className="flex items-start gap-16">
|
|
298
329
|
{isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
|
|
@@ -307,20 +338,31 @@ const ContentFilters = ({
|
|
|
307
338
|
</button>
|
|
308
339
|
)}
|
|
309
340
|
</div>
|
|
310
|
-
{
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
341
|
+
{/* The count rides the LAYOUT row, not the header (user ruling
|
|
342
|
+
* 2026-08-15) — same `kol-helper-12` + 1px tracking as LIST/GRID
|
|
343
|
+
* beside it. Ink stays `text-fg-64`: it is static information, not
|
|
344
|
+
* a toggle, so it takes neither the 96 active nor the 32 rest. */}
|
|
345
|
+
<div className="flex items-center gap-4 flex-shrink-0">
|
|
346
|
+
{showCount && (
|
|
347
|
+
<span className={countClassName} style={{ letterSpacing: 1 }}>
|
|
348
|
+
{filteredItems.length} of {totalCount}
|
|
349
|
+
</span>
|
|
350
|
+
)}
|
|
351
|
+
{layoutOptions && (
|
|
352
|
+
<div className="flex items-center gap-4">
|
|
353
|
+
{layoutOptions.map((opt) => (
|
|
354
|
+
<span
|
|
355
|
+
key={opt.value}
|
|
356
|
+
onClick={() => setLayout(opt.value)}
|
|
357
|
+
className={`${layoutClassName} cursor-pointer select-none ${layout === opt.value ? stripActiveClassName : stripRestClassName}`}
|
|
358
|
+
style={{ letterSpacing: 1 }}
|
|
359
|
+
>
|
|
360
|
+
{opt.label}
|
|
361
|
+
</span>
|
|
362
|
+
))}
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
324
366
|
</div>
|
|
325
367
|
)}
|
|
326
368
|
|
|
@@ -6,17 +6,22 @@
|
|
|
6
6
|
* are visibly flagged rather than showing the browser's broken-image icon.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
/* `radius` (2026-08-15 user ruling): a placeholder inside a host that already
|
|
10
|
+
* CLIPS its own corners rounds twice — the visible double-round on the card
|
|
11
|
+
* form. The host turns it off; it stays on everywhere else, so nothing that
|
|
12
|
+
* renders a bare placeholder changes. */
|
|
9
13
|
export default function AssetPlaceholder({
|
|
10
14
|
category,
|
|
11
15
|
name,
|
|
12
16
|
aspectRatio = '16 / 9',
|
|
13
17
|
note = 'MISSING',
|
|
18
|
+
radius = true,
|
|
14
19
|
className = '',
|
|
15
20
|
}) {
|
|
16
21
|
const label = [category, name].filter(Boolean).join(' · ')
|
|
17
22
|
return (
|
|
18
23
|
<div
|
|
19
|
-
className={`kol-asset-placeholder flex flex-col items-center justify-center gap-[6px] w-full p-6 border border-dashed border-[var(--kol-fg-24)] rounded-[var(--kol-radius-sm)] bg-[var(--kol-fg-02)] text-fg-48 font-mono text-center box-border ${className}`.trim()}
|
|
24
|
+
className={`kol-asset-placeholder flex flex-col items-center justify-center gap-[6px] w-full p-6 border border-dashed border-[var(--kol-fg-24)] ${radius ? 'rounded-[var(--kol-radius-sm)]' : ''} bg-[var(--kol-fg-02)] text-fg-48 font-mono text-center box-border ${className}`.trim()}
|
|
20
25
|
style={{ aspectRatio }}
|
|
21
26
|
role="img"
|
|
22
27
|
aria-label={`${label || 'asset'} — ${note}`}
|
package/src/atoms/CopyButton.jsx
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* CopyButton — THE copy-to-clipboard control (2026-08-09 user ruling): the
|
|
6
|
-
* 32×32 icon button — `copy` glyph flipping to `check` for 2s on copied,
|
|
7
|
-
* no text label. This is the button CodeBlock carried privately since the
|
|
8
|
-
* 2026-07-28 elder replication, promoted to the one shared atom; the old
|
|
9
|
-
* Copy/Copied label chip (one-off SVGs outside the icon set) is retired.
|
|
10
|
-
* Chrome comes from .kol-copy-btn (kol-theme); parents add their own
|
|
11
|
-
* positioning class (e.g. CodeBlock's .kol-codeblock-copy).
|
|
12
|
-
*
|
|
13
|
-
* Props:
|
|
14
|
-
* text — string (or () => string) written to the clipboard
|
|
15
|
-
* className — extra classes (positioning etc.)
|
|
16
|
-
*/
|
|
17
|
-
export default function CopyButton({ text, className = '', ...props }) {
|
|
18
|
-
const [copied, setCopied] = useState(false)
|
|
19
|
-
|
|
20
|
-
const onCopy = async () => {
|
|
21
|
-
try {
|
|
22
|
-
await navigator.clipboard.writeText(typeof text === 'function' ? text() : String(text ?? ''))
|
|
23
|
-
setCopied(true)
|
|
24
|
-
setTimeout(() => setCopied(false), 2000)
|
|
25
|
-
} catch {
|
|
26
|
-
/* clipboard blocked — silent */
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return (
|
|
31
|
-
<button
|
|
32
|
-
type="button"
|
|
33
|
-
className={`kol-copy-btn ${className}`.trim()}
|
|
34
|
-
onClick={onCopy}
|
|
35
|
-
aria-label={copied ? 'Copied' : 'Copy to clipboard'}
|
|
36
|
-
title={copied ? 'Copied' : 'Copy'}
|
|
37
|
-
{...props}
|
|
38
|
-
>
|
|
39
|
-
<Icon name={copied ? 'check' : 'copy'} size={16} />
|
|
40
|
-
</button>
|
|
41
|
-
)
|
|
42
|
-
}
|