@kolkrabbi/kol-component 0.90.2 → 0.91.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.90.2",
3
+ "version": "0.91.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",
@@ -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,11 @@ 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
+ hero: { kicker: 'kol-helper-16 text-meta', title: 'kol-sans-display-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' },
67
73
  },
68
74
  work: {
69
75
  /* INVERSE ink — the card's plate is the drawer, `surface-inverse`. Leaving
@@ -111,6 +117,8 @@ const ORDER = {
111
117
  * form's own outer gap. A flat column gave every line the same gap, which
112
118
  * read as unrelated lines rather than a heading with its standfirst. */
113
119
  article: { card: ['tags', 'kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']], row: ['kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']] },
120
+ /* 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 */
121
+ articleHero: ['kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']],
114
122
  /* work CARD = the drawer's two lines: title, then one meta line.
115
123
  *
116
124
  * work ROW = WorkListItem, read off the live /work listing: a LEFT column of
@@ -155,7 +163,7 @@ const GAPS = {
155
163
  default: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-2)' },
156
164
  catalog: { card: 'var(--kol-spacing-1)', row: 'var(--kol-spacing-2)' },
157
165
  print: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-2)' },
158
- article: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-3)' },
166
+ article: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-3)', hero: 'var(--kol-spacing-3)' },
159
167
  work: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-4)' },
160
168
  typeface: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-6)' },
161
169
  }
@@ -169,7 +177,7 @@ export default function ContentText({
169
177
  className = '',
170
178
  }) {
171
179
  const ramp = RAMP[variant]?.[form] ?? RAMP.default[form] ?? RAMP.default.card
172
- const order = ORDER[variant]?.[form] ?? ORDER.default.card
180
+ const order = (form === 'hero' && ORDER[`${variant}Hero`]) || ORDER[variant]?.[form] || ORDER.default.card
173
181
  const values = { title, body, kicker, detail, date, size, meta, tags }
174
182
  const overrides = { title: titleClass, body: bodyClass, kicker: kickerClass, detail: detailClass, date: dateClass, size: sizeClass, meta: metaClass, tags: tagsClass }
175
183