@kolkrabbi/kol-component 0.90.2 → 0.91.1

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.90.2",
3
+ "version": "0.91.1",
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",
@@ -91,6 +91,8 @@ const MEDIA = {
91
91
 
92
92
  export default function ContentCard({
93
93
  variant = 'default',
94
+ hero = false,
95
+ label,
94
96
  pad,
95
97
  media,
96
98
  ratio,
@@ -110,10 +112,31 @@ export default function ContentCard({
110
112
  ...text
111
113
  }) {
112
114
  const box = BOX[variant] ?? BOX.default
115
+ /* HERO (2026-08-27 — the featured card riding a page's fold; ListingCard
116
+ * size="hero" had no ContentCard equivalent, which is why Stack still
117
+ * imported it): a header row above the media — `label` left, `meta` chips
118
+ * right — the media on the zoom's hero rung (1.02), the text on
119
+ * ContentText's `hero` form (display-03 title). article only; the row form
120
+ * has no hero. */
121
+ const isHero = hero && variant === 'article'
122
+ const { meta: heroMeta, ...textSlots } = text
123
+ if (!isHero) Object.assign(textSlots, { meta: text.meta })
124
+ if (isHero) delete textSlots.tags
125
+ const metaChips = isHero && heroMeta != null ? (Array.isArray(heroMeta) ? heroMeta : [heroMeta]) : null
126
+ const heroHeader = isHero && (label != null || metaChips?.length) ? (
127
+ <div className="kol-card-hero-header flex items-center justify-between" style={{ marginBottom: 'var(--kol-spacing-4)' }}>
128
+ {label != null && <div className="kol-helper-14 text-meta">{label}</div>}
129
+ {metaChips?.length > 0 && (
130
+ <div className="flex gap-3 kol-helper-12 text-meta">
131
+ {metaChips.map((item, i) => <span key={i}>{item}</span>)}
132
+ </div>
133
+ )}
134
+ </div>
135
+ ) : null
113
136
  const r = ratio ?? RATIOS[variant]
114
137
  const padding = pad ? `var(--kol-pad-card-${pad})` : box.pad
115
138
  /* image-only cards (print) pass no text slots — the empty plate must not render */
116
- const hasText = ['title', 'body', 'kicker', 'detail', 'date', 'size', 'meta', 'tags'].some((k) => text[k] != null)
139
+ const hasText = ['title', 'body', 'kicker', 'detail', 'date', 'size', 'meta', 'tags'].some((k) => textSlots[k] != null)
117
140
  /* `actions` sit IN THE TEXT PLATE, bottom-right (user ruling 2026-08-15:
118
141
  * *"space in text bottom right"*). Not stacked under the copy in their own
119
142
  * row — that grew the card — and not on the media, which was my call to make
@@ -138,7 +161,7 @@ export default function ContentCard({
138
161
  zIndex: box.layout === 'canvas' ? 1 : undefined,
139
162
  }}
140
163
  >
141
- {hasText && <ContentText variant={variant} form="card" {...text} />}
164
+ {hasText && <ContentText variant={variant} form={isHero ? 'hero' : 'card'} {...textSlots} />}
142
165
  {/* ABSOLUTE, not a flex sibling: the plate's height moves with the title
143
166
  * and the meta, so a laid-out stack would stretch or drift with it. The
144
167
  * inset reads the SAME pad token the plate uses, so the icons sit the
@@ -166,7 +189,7 @@ export default function ContentCard({
166
189
  frame: frame ?? MEDIA[variant]?.frame ?? false,
167
190
  ring: ring ?? MEDIA[variant]?.ring ?? false,
168
191
  borderHover: MEDIA[variant]?.borderHover ?? false,
169
- zoom: zoom ?? MEDIA[variant]?.zoom ?? false,
192
+ zoom: zoom ?? (isHero ? 'hero' : MEDIA[variant]?.zoom ?? false),
170
193
  }
171
194
 
172
195
  /* `control` — the in-frame control slot (user ruling 2026-08-15). One node,
@@ -181,6 +204,7 @@ export default function ContentCard({
181
204
  const body =
182
205
  box.layout === 'stack' ? (
183
206
  <>
207
+ {heroHeader}
184
208
  <div className="relative">
185
209
  <ContentMedia ratio={r} {...mediaProps}>{media}</ContentMedia>
186
210
  {controlNode}
@@ -241,6 +265,7 @@ export default function ContentCard({
241
265
  const hoverBg = HOVER[variant]
242
266
 
243
267
  const common = {
268
+ 'data-tags': isHero && Array.isArray(text.tags) && text.tags.length ? text.tags.join(' ') : undefined,
244
269
  className: `kol-card group flex ${expanded ? 'flex-row-reverse' : 'flex-col'} ${box.layout === 'drawer' ? 'relative overflow-hidden rounded-[var(--kol-radius-sm)]' : ''} ${framed ? 'overflow-hidden rounded-[var(--kol-radius-sm)]' : ''} ${box.border ? 'border' : ''} ${box.layout === 'canvas' ? 'relative' : ''} ${interactive ? 'cursor-pointer select-none' : ''} ${hoverBg && interactive ? 'kol-content-hover' : ''} ${className}`.trim(),
245
270
  style: {
246
271
  /* same reason as ContentRow: rest colours are PROPERTIES, because an
@@ -20,6 +20,7 @@
20
20
  * @param {ReactNode} kicker article only
21
21
  * @param {ReactNode} detail catalog · print
22
22
  * @param {ReactNode} date default · article · typeface
23
+ * @param {'card'|'row'|'hero'} form the block's form; `hero` is article's featured text (ContentCard `hero`)
23
24
  * @param {ReactNode} size default · article (file size / read length)
24
25
  * @param {ReactNode} meta work only
25
26
  * @param {number} gap inner line gap in px (defaults per variant/form)
@@ -64,6 +65,12 @@ const RAMP = {
64
65
  /* kol-content-title-dim: the title dims to 70% on card hover (StackCardHover, 2026-08-27 — ListingCard's move, kol-theme) */
65
66
  card: { kicker: 'kol-mono-12 text-meta', title: 'kol-sans-heading-03 text-emphasis kol-content-title-dim', body: 'kol-mono-14 text-body', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta', tags: 'flex flex-wrap gap-2' },
66
67
  row: { kicker: 'kol-mono-12 text-meta', title: 'kol-sans-heading-04 text-emphasis kol-content-title-dim', body: 'kol-mono-12 text-body', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta', tags: 'flex flex-wrap gap-2' },
68
+ /* HERO — the featured card riding a page's fold (ContentCard `hero`,
69
+ * 2026-08-27): ListingCard size="hero"'s text, read off Stack — kicker in
70
+ * the helper-16 voice, title on display-03 (Stack's ruled hero title),
71
+ * body mono-14. One emphasis (the title), everything else meta/body. */
72
+ /* UPPERCASE title, clamped 2, body clamped 2 — the shipped Stack hero, carried through (user 2026-08-27: "UPPERCASE THE FUCKING TITLE") */
73
+ hero: { kicker: 'kol-helper-16 text-meta', title: 'kol-sans-display-03 text-emphasis uppercase line-clamp-2 kol-content-title-dim', body: 'kol-mono-14 text-body line-clamp-2', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta', tags: 'flex flex-wrap gap-2' },
67
74
  },
68
75
  work: {
69
76
  /* INVERSE ink — the card's plate is the drawer, `surface-inverse`. Leaving
@@ -111,6 +118,8 @@ const ORDER = {
111
118
  * form's own outer gap. A flat column gave every line the same gap, which
112
119
  * read as unrelated lines rather than a heading with its standfirst. */
113
120
  article: { card: ['tags', 'kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']], row: ['kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']] },
121
+ /* the hero carries its tags as data (ListingCard hero: `data-tags` only) and its meta in the header row above the media — neither is a text line here */
122
+ articleHero: ['kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']],
114
123
  /* work CARD = the drawer's two lines: title, then one meta line.
115
124
  *
116
125
  * work ROW = WorkListItem, read off the live /work listing: a LEFT column of
@@ -155,7 +164,7 @@ const GAPS = {
155
164
  default: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-2)' },
156
165
  catalog: { card: 'var(--kol-spacing-1)', row: 'var(--kol-spacing-2)' },
157
166
  print: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-2)' },
158
- article: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-3)' },
167
+ article: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-3)', hero: 'var(--kol-spacing-3)' },
159
168
  work: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-4)' },
160
169
  typeface: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-6)' },
161
170
  }
@@ -169,7 +178,7 @@ export default function ContentText({
169
178
  className = '',
170
179
  }) {
171
180
  const ramp = RAMP[variant]?.[form] ?? RAMP.default[form] ?? RAMP.default.card
172
- const order = ORDER[variant]?.[form] ?? ORDER.default.card
181
+ const order = (form === 'hero' && ORDER[`${variant}Hero`]) || ORDER[variant]?.[form] || ORDER.default.card
173
182
  const values = { title, body, kicker, detail, date, size, meta, tags }
174
183
  const overrides = { title: titleClass, body: bodyClass, kicker: kickerClass, detail: detailClass, date: dateClass, size: sizeClass, meta: metaClass, tags: tagsClass }
175
184
 
@@ -22,6 +22,9 @@ import IconFrame from '../atoms/IconFrame.jsx'
22
22
  * @param {Function} props.renderItem — (filteredItems, viewMode, layout) => ReactNode
23
23
  * @param {Array} props.viewModeOptions — optional view mode options for the view strip
24
24
  * @param {string} props.defaultViewMode — default view mode (falls back to the FIRST option)
25
+ * @param {string} props.layout — controlled LIST/GRID value (kol-r2b2 2026-08-27: a consumer that persists
26
+ * layout per bucket needs the strip's value back; `defaultLayout` alone kept it internal)
27
+ * @param {Function} props.onLayoutChange — (layout) => void, fires on every strip click
25
28
  * @param {Function} props.onFilterChange — optional callback when filters change
26
29
  * @param {Array} props.mutuallyExclusiveFilters — filter keys that should be mutually exclusive
27
30
  * @param {Array} props.customFilterKeys — filter keys handled by renderItem, not by ContentFilters
@@ -60,6 +63,8 @@ const ContentFilters = ({
60
63
  defaultViewMode,
61
64
  layoutOptions,
62
65
  defaultLayout,
66
+ layout: layoutProp,
67
+ onLayoutChange,
63
68
  /* WHERE the LIST/GRID strip sits — the two arrangements kol-monitor and
64
69
  * kol-website each settled on, made interchangeable (user ruling 2026-08-15).
65
70
  *
@@ -115,7 +120,13 @@ const ContentFilters = ({
115
120
  const [isExpanded, setIsExpanded] = useState(false)
116
121
  const [internalViewMode, setInternalViewMode] = useState(defaultViewMode ?? viewModeOptions?.[0]?.value)
117
122
  const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
118
- const [layout, setLayout] = useState(defaultLayout ?? layoutOptions?.[0]?.value ?? 'grid')
123
+ /* Controlled when `layout` is passed, internal otherwise the same pair viewMode uses. */
124
+ const [internalLayout, setInternalLayout] = useState(defaultLayout ?? layoutOptions?.[0]?.value ?? 'grid')
125
+ const layout = layoutProp !== undefined ? layoutProp : internalLayout
126
+ const setLayout = (next) => {
127
+ setInternalLayout(next)
128
+ onLayoutChange?.(next)
129
+ }
119
130
  const [searchOpen, setSearchOpen] = useState(false)
120
131
  const [searchText, setSearchText] = useState('')
121
132