@kolkrabbi/kol-component 0.109.0 → 0.111.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.109.0",
3
+ "version": "0.111.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",
@@ -3,8 +3,8 @@ import { useMotionValue, useSpring, useTransform } from 'framer-motion'
3
3
 
4
4
  /**
5
5
  * Pointer-driven 3D tilt (framer-motion springs) — the ONE tilt hook.
6
- * Ported from the monorepo's useBentoTiltMotion; TiltCard, BentoCard and
7
- * friends all compose this instead of forking their own.
6
+ * Ported from the monorepo's useBentoTiltMotion; the Tilt family — TiltCard,
7
+ * TiltBento composes this instead of forking their own.
8
8
  *
9
9
  * Returns `{ ref, style, onMouseMove, onMouseLeave, motionValues }` —
10
10
  * spread `ref`/handlers on a `motion.div` and pass `style` to it.
package/src/index.js CHANGED
@@ -116,7 +116,7 @@ export { default as Canvas, CanvasFrame, PanViewport, CANVAS_VIRTUAL_W, DEFAULT_
116
116
  export { default as EditorShell } from './utilities/EditorShell.jsx'
117
117
  export { default as GalleryCarousel } from './organisms/GalleryCarousel.jsx'
118
118
  export { default as AsciiCursor } from './utilities/AsciiCursor.jsx'
119
- export { default as BentoCard } from './molecules/BentoCard.jsx'
119
+ export { default as TiltBento, default as BentoCard } from './molecules/TiltBento.jsx'
120
120
  export { default as Carousel } from './molecules/Carousel.jsx'
121
121
  /* EmblaNav — THE prev/next pair. Exported so a consumer building its own embla
122
122
  * stage reaches for it instead of re-typing the button markup, which is how the
@@ -1,187 +1,6 @@
1
- import { useEffect, useState } from 'react'
2
- import { motion } from 'framer-motion'
3
- import HlsVideo from '../atoms/HlsVideo.jsx'
4
- import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
5
- import Button from '../atoms/Button.jsx'
6
- import Image from '../atoms/Image.jsx'
7
- import useTilt from '../hooks/useTilt.js'
8
- import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
9
-
10
- /* taxonomy-ok: nests the same-file Media helper plus DS atoms/molecules
11
- * (HlsVideo, AssetPlaceholder, Button, Image) it composes — an organism. */
12
-
13
1
  /**
14
- * True on coarse-pointer (touch/no-hover) devices. Local to BentoCard, mirrors
15
- * TiltCard's copy; re-evaluates on device/orientation change via the
16
- * media-query change event (the monorepo source froze `useIsTouchDevice` in a
17
- * module-load const — fixed on recreate).
2
+ * @deprecated 2026-08-27 `BentoCard` is `TiltBento` under its old name (the
3
+ * Tilt family: `TiltCard` · `TiltBento` · `useTilt`). Alias kept; drops when no
4
+ * repo imports it (docs/operations/01-release/04-retirements.md).
18
5
  */
19
- function useCoarsePointer() {
20
- const [coarse, setCoarse] = useState(
21
- () => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches,
22
- )
23
-
24
- useEffect(() => {
25
- const mq = window.matchMedia('(pointer: coarse)')
26
- const onChange = () => setCoarse(mq.matches)
27
- mq.addEventListener('change', onChange)
28
- return () => mq.removeEventListener('change', onChange)
29
- }, [])
30
-
31
- return coarse
32
- }
33
-
34
- /**
35
- * Media — internal, NOT exported. Sniffs `src` by extension and renders the
36
- * right full-bleed media element: `.m3u8` → HlsVideo, `.mov|.mp4|.webm` →
37
- * autoplay <video>, any other src → the Image molecule (its own broken-asset
38
- * fallback), and no src → AssetPlaceholder. Positioning/fit arrive via
39
- * `className`; Image gets an inline height so its base `h-auto` can't beat the
40
- * `size-full` cover fill.
41
- */
42
- function Media({ src, poster, className }) {
43
- if (!src) return <AssetPlaceholder className={className} />
44
- if (/\.m3u8$/i.test(src)) return <HlsVideo src={src} poster={poster} className={className} />
45
- if (/\.(mov|mp4|webm)$/i.test(src)) {
46
- return (
47
- <video
48
- src={src}
49
- poster={poster}
50
- autoPlay
51
- muted
52
- loop
53
- playsInline
54
- preload="auto"
55
- className={className}
56
- />
57
- )
58
- }
59
- return <Image src={src} alt="" className={className} style={{ width: '100%', height: '100%' }} />
60
- }
61
-
62
- /**
63
- * BentoCard — media hover-card for grid/bento walls. Full-bleed auto-detected
64
- * media (HLS / video / image / placeholder) sits behind a content stack; on a
65
- * fine pointer, hover reveals a darkening scrim plus subtitle / description /
66
- * CTA over an always-visible title. No-hover (coarse-pointer) devices show
67
- * everything statically and drop the media's pointer capture.
68
- *
69
- * Motion is gated: the pointer-following 3D tilt (shared `useTilt` framer
70
- * springs — the monorepo's forked CSS `useBentoTilt` is gone) renders only on
71
- * a fine pointer with motion allowed; reduced-motion, coarse pointer, or
72
- * `enableTilt={false}` all fall back to a static card, and reduced-motion also
73
- * drops the reveal's opacity transition.
74
- *
75
- * Zero CMS coupling — flat props. The CTA is a DS Button link (`<a href>`, no
76
- * router import): `http*`/`mailto` open a new tab; any other href is a
77
- * same-tab anchor whose `onNavigate(event)` seam lets an SPA intercept
78
- * (preventDefault + its router) — wired capture-phase so it fires before the
79
- * default navigation. Title/subtitle/description render exactly as authored;
80
- * no casing transforms (author strings in their final case at the call site).
81
- *
82
- * @param {string} src media source; type auto-detected by extension
83
- * @param {string} poster poster frame for HLS/video
84
- * @param {ReactNode} title always-visible heading
85
- * @param {ReactNode} subtitle hover-revealed line
86
- * @param {ReactNode} description hover-revealed paragraph
87
- * @param {string} href CTA target; `http*`/`mailto` → new-tab anchor, else same-tab (onNavigate seam)
88
- * @param {Function} onNavigate (event) => void — same-tab CTA click seam (SPA intercept)
89
- * @param {ReactNode} buttonLabel CTA label (no default — author it)
90
- * @param {ReactNode} bodyContent extra content injected into the stack
91
- * @param {number} overlayOpacity scrim darkness % over the media, 0 disables (default 60)
92
- * @param {boolean} alignRight right-align the card (ms-auto) vs fill (size-full)
93
- * @param {boolean} enableTilt master tilt switch (default true)
94
- * @param {string} titleClassName title classes
95
- * @param {string} contentClassName inner content-box classes
96
- * @param {string} imageClassName media fit/position classes
97
- * @param {string} contentStackClassName stack layout classes
98
- * @param {string} className extra classes on the root
99
- */
100
- export default function BentoCard({
101
- src,
102
- poster,
103
- title,
104
- subtitle,
105
- description,
106
- href,
107
- onNavigate,
108
- buttonLabel,
109
- bodyContent = null,
110
- overlayOpacity = 60,
111
- alignRight = false,
112
- enableTilt = true,
113
- titleClassName = 'kol-sans-heading-01 text-absolute-white',
114
- contentClassName = 'max-w-[384px]',
115
- imageClassName = 'object-cover object-center',
116
- contentStackClassName = 'relative z-20 h-full flex flex-col justify-start items-start gap-4 p-6 md:p-8',
117
- className = '',
118
- ...rest
119
- }) {
120
- const reduced = usePrefersReducedMotion()
121
- const coarse = useCoarsePointer()
122
- const tilt = useTilt()
123
-
124
- const tiltOff = !enableTilt || reduced || coarse
125
- const Component = tiltOff ? 'div' : motion.div
126
- const rootStyle = {
127
- ...(tiltOff ? {} : tilt.style),
128
- backfaceVisibility: 'hidden',
129
- WebkitBackfaceVisibility: 'hidden',
130
- }
131
- const tiltHandlers = tiltOff
132
- ? {}
133
- : { ref: tilt.ref, onMouseMove: tilt.onMouseMove, onMouseLeave: tilt.onMouseLeave }
134
-
135
- // Reveal choreography. Coarse (no-hover) devices show everything statically;
136
- // fine pointers reveal on group-hover. The opacity transition is motion, so
137
- // reduced-motion drops it (content still reveals, just without the fade).
138
- const fade = reduced ? '' : 'transition-opacity duration-300'
139
- const revealClass = `${coarse ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} ${fade}`.trim()
140
-
141
- const mediaClass =
142
- `absolute left-0 top-0 size-full rounded overflow-hidden ${imageClassName} ${coarse ? 'pointer-events-none' : ''}`.trim()
143
-
144
- const isExternal = href && (href.startsWith('http') || href.startsWith('mailto'))
145
-
146
- return (
147
- <Component
148
- className={`relative group ${alignRight ? 'ms-auto' : 'size-full'} ${className}`.trim()}
149
- {...tiltHandlers}
150
- {...rest}
151
- style={{ ...rootStyle, ...rest.style }}
152
- >
153
- <Media src={src} poster={poster} className={mediaClass} />
154
-
155
- <div className="relative z-10 flex size-full h-full flex-col justify-start items-start text-auto">
156
- <div className={`relative z-10 ${contentClassName} w-full h-full self-stretch`}>
157
- {overlayOpacity > 0 && (
158
- <div
159
- className={`absolute -inset-1 rounded ${coarse ? 'opacity-60' : 'opacity-0 group-hover:opacity-100'} ${fade} pointer-events-none`.trim()}
160
- style={{ backgroundColor: `rgba(0, 0, 0, ${overlayOpacity / 100})` }}
161
- />
162
- )}
163
- <div className={contentStackClassName}>
164
- {title && <h3 className={titleClassName}>{title}</h3>}
165
- {subtitle && <p className={`kol-mono-text text-absolute-white ${revealClass}`}>{subtitle}</p>}
166
- {description && <p className={`kol-mono-12 text-absolute-white pb-6 ${revealClass}`}>{description}</p>}
167
- {bodyContent}
168
- {href && (
169
- <div className={revealClass}>
170
- <Button
171
- href={href}
172
- variant="primary"
173
- size="sm"
174
- {...(isExternal
175
- ? { target: '_blank', rel: 'noreferrer noopener' }
176
- : { onClickCapture: onNavigate })}
177
- >
178
- {buttonLabel}
179
- </Button>
180
- </div>
181
- )}
182
- </div>
183
- </div>
184
- </div>
185
- </Component>
186
- )
187
- }
6
+ export { default } from './TiltBento.jsx'
@@ -78,8 +78,12 @@ const FIT = {
78
78
  /* a consumer WRAPPER div fills the frame as an img/video does (ColumnBrowser round, 2026-08-27 — kol-r2b2's wrapped thumb fell back to the image's intrinsic size) */
79
79
  cover: '[&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>video]:h-full [&>video]:w-full [&>video]:object-cover [&>div]:h-full [&>div]:w-full',
80
80
  /* GridCard's previewFit, verbatim: `img { max-width: none; transform: scale(.5 | .3); transform-origin: top left }` */
81
- natural: '[&>img]:max-w-none [&>img]:scale-50 [&>img]:origin-top-left',
82
- compact: '[&>img]:max-w-none [&>img]:scale-[.3] [&>img]:origin-top-left',
81
+ /* the ANCHOR is the consumer's (ContentMediaFocusBinding, kol-monitor 2026-08-27 — user: "make it
82
+ * so that the focus can be set per repo"): `--kol-media-focus`, bound once on a consumer's :root,
83
+ * pins the fit AND the hover zoom (kol-theme `.kol-media-zoom`, same token) — the two share
84
+ * `transform-origin`. Unset = today's values: top left here, center on the zoom. */
85
+ natural: '[&>img]:max-w-none [&>img]:scale-50 [&>img]:origin-[var(--kol-media-focus,top_left)]',
86
+ compact: '[&>img]:max-w-none [&>img]:scale-[.3] [&>img]:origin-[var(--kol-media-focus,top_left)]',
83
87
  }
84
88
 
85
89
  /* the fade: the child <img> is cloned with loading="lazy" and `kol-media-fade`,
@@ -0,0 +1,191 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { motion } from 'framer-motion'
3
+ import HlsVideo from '../atoms/HlsVideo.jsx'
4
+ import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
5
+ import Button from '../atoms/Button.jsx'
6
+ import Image from '../atoms/Image.jsx'
7
+ import useTilt from '../hooks/useTilt.js'
8
+ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
9
+
10
+ /* taxonomy-ok: nests the same-file Media helper plus DS atoms/molecules
11
+ * (HlsVideo, AssetPlaceholder, Button, Image) it composes — an organism. */
12
+
13
+ /**
14
+ * True on coarse-pointer (touch/no-hover) devices. Local to TiltBento, mirrors
15
+ * TiltCard's copy; re-evaluates on device/orientation change via the
16
+ * media-query change event (the monorepo source froze `useIsTouchDevice` in a
17
+ * module-load const — fixed on recreate).
18
+ */
19
+ function useCoarsePointer() {
20
+ const [coarse, setCoarse] = useState(
21
+ () => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches,
22
+ )
23
+
24
+ useEffect(() => {
25
+ const mq = window.matchMedia('(pointer: coarse)')
26
+ const onChange = () => setCoarse(mq.matches)
27
+ mq.addEventListener('change', onChange)
28
+ return () => mq.removeEventListener('change', onChange)
29
+ }, [])
30
+
31
+ return coarse
32
+ }
33
+
34
+ /**
35
+ * Media — internal, NOT exported. Sniffs `src` by extension and renders the
36
+ * right full-bleed media element: `.m3u8` → HlsVideo, `.mov|.mp4|.webm` →
37
+ * autoplay <video>, any other src → the Image molecule (its own broken-asset
38
+ * fallback), and no src → AssetPlaceholder. Positioning/fit arrive via
39
+ * `className`; Image gets an inline height so its base `h-auto` can't beat the
40
+ * `size-full` cover fill.
41
+ */
42
+ function Media({ src, poster, className }) {
43
+ if (!src) return <AssetPlaceholder className={className} />
44
+ if (/\.m3u8$/i.test(src)) return <HlsVideo src={src} poster={poster} className={className} />
45
+ if (/\.(mov|mp4|webm)$/i.test(src)) {
46
+ return (
47
+ <video
48
+ src={src}
49
+ poster={poster}
50
+ autoPlay
51
+ muted
52
+ loop
53
+ playsInline
54
+ preload="auto"
55
+ className={className}
56
+ />
57
+ )
58
+ }
59
+ return <Image src={src} alt="" className={className} style={{ width: '100%', height: '100%' }} />
60
+ }
61
+
62
+ /**
63
+ * TiltBento — media hover-card for grid/bento walls, the Tilt family's composed
64
+ * tile (was `BentoCard` until 2026-08-27, user ruling: the three tilting things
65
+ * in the estate are ONE prefix family — `TiltCard` the bare frame, `TiltBento`
66
+ * this tile, `useTilt` the one hook; `BentoCard` is the alias on the retirement
67
+ * ledger). Full-bleed auto-detected
68
+ * media (HLS / video / image / placeholder) sits behind a content stack; on a
69
+ * fine pointer, hover reveals a darkening scrim plus subtitle / description /
70
+ * CTA over an always-visible title. No-hover (coarse-pointer) devices show
71
+ * everything statically and drop the media's pointer capture.
72
+ *
73
+ * Motion is gated: the pointer-following 3D tilt (shared `useTilt` framer
74
+ * springs — the monorepo's forked CSS `useBentoTilt` is gone) renders only on
75
+ * a fine pointer with motion allowed; reduced-motion, coarse pointer, or
76
+ * `enableTilt={false}` all fall back to a static card, and reduced-motion also
77
+ * drops the reveal's opacity transition.
78
+ *
79
+ * Zero CMS coupling — flat props. The CTA is a DS Button link (`<a href>`, no
80
+ * router import): `http*`/`mailto` open a new tab; any other href is a
81
+ * same-tab anchor whose `onNavigate(event)` seam lets an SPA intercept
82
+ * (preventDefault + its router) — wired capture-phase so it fires before the
83
+ * default navigation. Title/subtitle/description render exactly as authored;
84
+ * no casing transforms (author strings in their final case at the call site).
85
+ *
86
+ * @param {string} src media source; type auto-detected by extension
87
+ * @param {string} poster poster frame for HLS/video
88
+ * @param {ReactNode} title always-visible heading
89
+ * @param {ReactNode} subtitle hover-revealed line
90
+ * @param {ReactNode} description hover-revealed paragraph
91
+ * @param {string} href CTA target; `http*`/`mailto` → new-tab anchor, else same-tab (onNavigate seam)
92
+ * @param {Function} onNavigate (event) => void — same-tab CTA click seam (SPA intercept)
93
+ * @param {ReactNode} buttonLabel CTA label (no default — author it)
94
+ * @param {ReactNode} bodyContent extra content injected into the stack
95
+ * @param {number} overlayOpacity scrim darkness % over the media, 0 disables (default 60)
96
+ * @param {boolean} alignRight right-align the card (ms-auto) vs fill (size-full)
97
+ * @param {boolean} enableTilt master tilt switch (default true)
98
+ * @param {string} titleClassName title classes
99
+ * @param {string} contentClassName inner content-box classes
100
+ * @param {string} imageClassName media fit/position classes
101
+ * @param {string} contentStackClassName stack layout classes
102
+ * @param {string} className extra classes on the root
103
+ */
104
+ export default function TiltBento({
105
+ src,
106
+ poster,
107
+ title,
108
+ subtitle,
109
+ description,
110
+ href,
111
+ onNavigate,
112
+ buttonLabel,
113
+ bodyContent = null,
114
+ overlayOpacity = 60,
115
+ alignRight = false,
116
+ enableTilt = true,
117
+ titleClassName = 'kol-sans-heading-01 text-absolute-white',
118
+ contentClassName = 'max-w-[384px]',
119
+ imageClassName = 'object-cover object-center',
120
+ contentStackClassName = 'relative z-20 h-full flex flex-col justify-start items-start gap-4 p-6 md:p-8',
121
+ className = '',
122
+ ...rest
123
+ }) {
124
+ const reduced = usePrefersReducedMotion()
125
+ const coarse = useCoarsePointer()
126
+ const tilt = useTilt()
127
+
128
+ const tiltOff = !enableTilt || reduced || coarse
129
+ const Component = tiltOff ? 'div' : motion.div
130
+ const rootStyle = {
131
+ ...(tiltOff ? {} : tilt.style),
132
+ backfaceVisibility: 'hidden',
133
+ WebkitBackfaceVisibility: 'hidden',
134
+ }
135
+ const tiltHandlers = tiltOff
136
+ ? {}
137
+ : { ref: tilt.ref, onMouseMove: tilt.onMouseMove, onMouseLeave: tilt.onMouseLeave }
138
+
139
+ // Reveal choreography. Coarse (no-hover) devices show everything statically;
140
+ // fine pointers reveal on group-hover. The opacity transition is motion, so
141
+ // reduced-motion drops it (content still reveals, just without the fade).
142
+ const fade = reduced ? '' : 'transition-opacity duration-300'
143
+ const revealClass = `${coarse ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} ${fade}`.trim()
144
+
145
+ const mediaClass =
146
+ `absolute left-0 top-0 size-full rounded overflow-hidden ${imageClassName} ${coarse ? 'pointer-events-none' : ''}`.trim()
147
+
148
+ const isExternal = href && (href.startsWith('http') || href.startsWith('mailto'))
149
+
150
+ return (
151
+ <Component
152
+ className={`relative group ${alignRight ? 'ms-auto' : 'size-full'} ${className}`.trim()}
153
+ {...tiltHandlers}
154
+ {...rest}
155
+ style={{ ...rootStyle, ...rest.style }}
156
+ >
157
+ <Media src={src} poster={poster} className={mediaClass} />
158
+
159
+ <div className="relative z-10 flex size-full h-full flex-col justify-start items-start text-auto">
160
+ <div className={`relative z-10 ${contentClassName} w-full h-full self-stretch`}>
161
+ {overlayOpacity > 0 && (
162
+ <div
163
+ className={`absolute -inset-1 rounded ${coarse ? 'opacity-60' : 'opacity-0 group-hover:opacity-100'} ${fade} pointer-events-none`.trim()}
164
+ style={{ backgroundColor: `rgba(0, 0, 0, ${overlayOpacity / 100})` }}
165
+ />
166
+ )}
167
+ <div className={contentStackClassName}>
168
+ {title && <h3 className={titleClassName}>{title}</h3>}
169
+ {subtitle && <p className={`kol-mono-text text-absolute-white ${revealClass}`}>{subtitle}</p>}
170
+ {description && <p className={`kol-mono-12 text-absolute-white pb-6 ${revealClass}`}>{description}</p>}
171
+ {bodyContent}
172
+ {href && (
173
+ <div className={revealClass}>
174
+ <Button
175
+ href={href}
176
+ variant="primary"
177
+ size="sm"
178
+ {...(isExternal
179
+ ? { target: '_blank', rel: 'noreferrer noopener' }
180
+ : { onClickCapture: onNavigate })}
181
+ >
182
+ {buttonLabel}
183
+ </Button>
184
+ </div>
185
+ )}
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </Component>
190
+ )
191
+ }
@@ -42,8 +42,10 @@ import IconFrame from '../atoms/IconFrame.jsx'
42
42
  * @param {boolean} props.titleUppercase — cases the title (default false)
43
43
  * @param {string} props.labelClassName — filter-group label type/ink (default `kol-eyebrow text-fg-96`)
44
44
  * @param {boolean} props.labelUppercase — cases the group label (default true)
45
- * groups: `{ label, key, values, stack?, className?, wrapClassName? }` — THE FIRST GROUP HUGS ITS CHIPS, EVERY
46
- * GROUP AFTER IT FLOWS (user law 2026-08-27, by position, never by chip count); `stack` is the explicit
45
+ * groups: `{ label, key, values, stack?, className?, wrapClassName? }` — THE FIRST GROUP IS ONE CATALOG COLUMN
46
+ * WIDE (`(row 120px) / 6`, the `1fr` of `repeat(6, 1fr)` gap 24 a fraction, never a px; `.kol-filters-first`,
47
+ * kol-theme ≥0.73.0), EVERY GROUP AFTER IT FLOWS (user law 2026-08-27, by position, never by chip count —
48
+ * the hug's width overruled the same day: "nope not hug, fix a size"); `stack` is the explicit
47
49
  * override; `className` / `wrapClassName` are per-group seams
48
50
  * @param {string} props.tagVariant — filter chip variant ('primary' grey fill)
49
51
  * @param {string} props.tagSize — filter chip size
@@ -247,16 +249,22 @@ const ContentFilters = ({
247
249
  ) : null
248
250
 
249
251
  const renderFilterGroup = (group, index = 0) => {
250
- /* THE LAW (user ruling 2026-08-27, said "for the 10th time" — ContentFiltersFirstGroupHugs):
251
- * THE FIRST FILTER GROUP HUGS ITS CHIPS IT IS NARROW. EVERY GROUP AFTER IT
252
- * FLOWS ACROSS THE REST OF THE ROW. By POSITION, never by chip count. The
253
- * reference is kol-website /prints: CATEGORY hugs at the left, YEAR flows
254
- * beside it. Not equal columns (0.104.1, a misread reverted), not "short
255
- * groups stack" (0.101, one page's ruling reverted). `stack` stays the
256
- * explicit override; kol-fxr pins `stack: i === 0`. */
252
+ /* THE LAW (user ruling 2026-08-27, said "for the 10th time" — ContentFiltersFirstGroupHugs,
253
+ * its WIDTH overruled the same dayContentFiltersFirstGroupFixedWidth, kol-monitor: "nope
254
+ * not hug, fix a size if columns, maybe just use one?"): THE FIRST FILTER GROUP IS ONE
255
+ * CATALOG COLUMN WIDE — `.kol-filters-first` (kol-theme): `(100cqw 120px) / 6`, the `1fr`
256
+ * of the catalog's `repeat(6, 1fr)` gap 24, measured on the header row as a container so
257
+ * the count/strip beside the groups never narrows it. It sits over the first card; EVERY
258
+ * GROUP AFTER IT FLOWS across the rest of the row, starting over the second. By POSITION,
259
+ * never by chip count. A page without a 6-column catalog gets the same fraction of its row.
260
+ * Not a hug (0.104.3 — the column was 78px on one surface and 92 on the next), not equal
261
+ * columns (0.104.1), not "short groups stack" (0.101). `stack` stays the explicit override
262
+ * (`stack: false` on the first makes it flow); `group.className` still wins on width — the
263
+ * rule sits in the components layer. */
257
264
  const stacked = group.stack ?? index === 0
265
+ const first = index === 0 && stacked
258
266
  return (
259
- <div key={group.key} className={`flex flex-col gap-3 ${stacked ? 'shrink-0' : 'min-w-0 flex-1'} ${group.className ?? ''}`.trim()}>
267
+ <div key={group.key} className={`flex flex-col gap-3 ${first ? 'kol-filters-first' : stacked ? 'shrink-0' : 'min-w-0 flex-1'} ${group.className ?? ''}`.trim()}>
260
268
  {/* THE CATEGORY LABEL IS THE ACTIVE INK — `kol-helper-12` at `text-fg-96`,
261
269
  * the same full opacity a SELECTED layout item wears (user ruling
262
270
  * 2026-08-15: "TAGS and other categories are ACTIVE state full opacity").
@@ -445,7 +453,7 @@ const ContentFilters = ({
445
453
  * the whole row on `isExpanded` hid the strip until you opened filters,
446
454
  * which is not a state anyone would guess at. */}
447
455
  {(isExpanded || (layoutPlacement === 'below' && layoutStrip) || leadingActions || belowActions) && (
448
- <div className="flex items-start justify-between gap-16">
456
+ <div className="kol-filters-row flex items-start justify-between gap-16">
449
457
  <div className="flex min-w-0 flex-1 items-start gap-16">
450
458
  {leadingActions}
451
459
  {isExpanded && filterGroups.map((group, i) => renderFilterGroup(group, i))}