@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.45.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 CopyButton } from './atoms/CopyButton.jsx'
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 '../atoms/CopyButton.jsx'
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-codeblock-copy" />
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
- * @param {string} eyebrow kicker line above the title
9
- * @param {string} title headline
10
- * @param {string} body optional supporting line
11
- * @param {string} footer optional note above a top hairline
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="absolute top-3 left-3 rounded p-1"
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="absolute top-3 right-3 inline-flex items-center justify-center w-8 h-8 rounded text-emphasis hover:bg-fg-absolute-24 transition-colors"
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. The outlined
128
- * chip these want is `secondary`.
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="kol-helper-12 text-fg-96" style={{ textTransform: 'uppercase' }}>
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="sm"
158
- variant="secondary"
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}`) ? 'text-fg-96' : 'text-fg-48'}
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="kol-helper-16">{title}</span>
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={`kol-helper-14 cursor-pointer select-none ${viewMode === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
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
- {layoutOptions && (
311
- <div className="flex items-center gap-4 flex-shrink-0">
312
- {layoutOptions.map((opt) => (
313
- <span
314
- key={opt.value}
315
- onClick={() => setLayout(opt.value)}
316
- className={`kol-helper-12 cursor-pointer select-none ${layout === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
317
- style={{ letterSpacing: 1 }}
318
- >
319
- {opt.label}
320
- </span>
321
- ))}
322
- </div>
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}`}
@@ -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
- }