@kolkrabbi/kol-component 0.149.0 → 0.150.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.149.0",
3
+ "version": "0.150.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",
package/src/index.js CHANGED
@@ -154,7 +154,7 @@ export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL, DEFAULT_KINDS, isSegmen
154
154
  export { nearestRatio, RATIOS } from './utilities/ratios.js'
155
155
  /* the grab gesture — shared so kol-shell's NavRail and kol-framework's
156
156
  * useDragResize wear ONE implementation (OneGrabGestureBothRails, 2026-08-30) */
157
- export { default as useGrabEdge } from './utilities/useGrabEdge.js'
157
+ export { default as useGrabEdge } from './hooks/useGrabEdge.js'
158
158
  export { default as markdownToHtml, inlineToHtml } from './utilities/markdownToHtml.js'
159
159
  export { default as RecordManager } from './organisms/RecordManager.jsx'
160
160
  export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
@@ -62,7 +62,16 @@ const BOX = {
62
62
  * the content height (136 in a 168 row) with no hairline, the frame steps
63
63
  * transparent → fg-08 on hover ("0 → 16 is a big jump"). No md step: the rows
64
64
  * were approved at their base values. */
65
- showcase: { thumb: 'fill', ratio: '1 / 1', pad: S4, gap: S4, frame: 'transparent', frameHover: 'var(--kol-fg-08)', bg: 'var(--kol-surface-secondary)', minH: 168, align: 'items-stretch', thumbRadius: 'var(--kol-radius-xs)', thumbBorder: false },
65
+ /* `heightSm` a FIXED rung below the md container, the floor above it
66
+ * (ContentRowShowcaseImageDrivenHeight, kol-website 2026-09-01, user ruling:
67
+ * the row's height is a function of the image, content fits inside it). On a
68
+ * 390 phone the narrow text column wrapped five tags to four rows and grew
69
+ * the row 62px past its floor, stranding the 136px thumb in dead space —
70
+ * 60px of ragged heights down one list. 168 is the floor's own number
71
+ * (136 thumb + 2×16 pad), so this is a change of KIND, not of value; the
72
+ * text has 136 to live in and the tags line goes single-row below md
73
+ * (ContentText's showcase ramp) so the cut lands on the chips, not mid-row. */
74
+ showcase: { thumb: 'fill', ratio: '1 / 1', pad: S4, gap: S4, frame: 'transparent', frameHover: 'var(--kol-fg-08)', bg: 'var(--kol-surface-secondary)', minH: 168, heightSm: 168, align: 'items-stretch', thumbRadius: 'var(--kol-radius-xs)', thumbBorder: false },
66
75
  /* ROSTER — a PICKABLE row (ContentRowRosterVariant, kol-chess 2026-08-31):
67
76
  * filled tile, no border, no divider, fixed height, square thumb, two
68
77
  * truncated lines that FILL the row rather than setting it.
@@ -171,6 +180,9 @@ export default function ContentRow({
171
180
  * so a consumer that wants a taller pick row gets one; what it cannot get is
172
181
  * a row whose height drifts with its own copy. */
173
182
  '--kol-row-h': box.height != null ? `${minHeight ?? box.height}px` : undefined,
183
+ /* the stacked case's fixed rung — `heightSm` variants only; `minHeight`
184
+ * still overrides, same contract as `height` above */
185
+ '--kol-row-h-sm': box.heightSm != null ? `${minHeight ?? box.heightSm}px` : undefined,
174
186
  /* `fill` = the rung minus the vertical padding — a definite number, not a
175
187
  * stretch (a min-height row has no definite cross size, so a stretched
176
188
  * aspect-ratio square resolved to the image's intrinsic width).
@@ -261,7 +273,7 @@ export default function ContentRow({
261
273
  )
262
274
 
263
275
  const common = {
264
- className: `kol-row group flex ${box.column ? 'flex-col' : ''} ${box.align} ${box.height != null ? 'kol-row--fixed' : ''} ${box.divider ? 'kol-row--divided' : ''} ${box.frame ? 'rounded-[var(--kol-radius-sm)] border' : ''} ${interactive ? 'cursor-pointer select-none' : ''} ${interactive && box.hover ? 'kol-content-hover' : ''} ${interactive && box.frameHover ? 'kol-content-hover-frame' : ''} ${className}`.trim(),
276
+ className: `kol-row group flex ${box.column ? 'flex-col' : ''} ${box.align} ${box.height != null ? 'kol-row--fixed' : ''} ${box.heightSm != null ? 'kol-row--fixed-sm' : ''} ${box.divider ? 'kol-row--divided' : ''} ${box.frame ? 'rounded-[var(--kol-radius-sm)] border' : ''} ${interactive ? 'cursor-pointer select-none' : ''} ${interactive && box.hover ? 'kol-content-hover' : ''} ${interactive && box.frameHover ? 'kol-content-hover-frame' : ''} ${className}`.trim(),
265
277
  style: vars,
266
278
  }
267
279
 
@@ -28,6 +28,8 @@
28
28
  * dateClass, sizeClass, metaClass — full class overrides
29
29
  */
30
30
 
31
+ import Tag from '../atoms/Tag'
32
+
31
33
  /* THE ROW INK LADDER — three rungs, and only three (2026-08-15).
32
34
  *
33
35
  * A work row was rendering its small title, its big description AND its type
@@ -89,7 +91,11 @@ const RAMP = {
89
91
  /* RULED ON /work beside the local WorkListItem (WorkListingRowsAndFilters,
90
92
  * 2026-08-27): the typeface row's voices — title + type kol-mono-14 full
91
93
  * ink, the year kol-mono-12 fg-64. Body unchanged (the site passes its face). */
92
- row: { title: 'kol-mono-14 uppercase text-emphasis truncate', body: 'kol-sans-heading-03 leading-tight text-emphasis truncate', meta: 'kol-mono-14 text-emphasis', date: 'kol-mono-12 text-fg-64', tags: 'flex flex-wrap items-center gap-1.5' },
94
+ /* tags go SINGLE-ROW below md (ContentRowShowcaseImageDrivenHeight,
95
+ * 2026-09-01): the row wears a fixed rung there, and a wrapping tag block
96
+ * was what grew it past the floor — the cut lands on the chips' right
97
+ * edge, not mid-row. */
98
+ row: { title: 'kol-mono-14 uppercase text-emphasis truncate', body: 'kol-sans-heading-03 leading-tight text-emphasis truncate', meta: 'kol-mono-14 text-emphasis', date: 'kol-mono-12 text-fg-64', tags: 'flex flex-wrap items-center gap-1.5 max-md:flex-nowrap max-md:overflow-hidden' },
93
99
  },
94
100
  /* ROSTER — a pickable row's two lines, both TRUNCATED (ContentRowRosterVariant,
95
101
  * kol-chess 2026-08-31). Written out rather than derived from `showcase`: the
@@ -222,9 +228,31 @@ export default function ContentText({
222
228
  * it overrode the class (user 2026-08-27: "I had put A HOVER OPACITY drop,
223
229
  * WHERE IS IT"). The hook is re-attached after the override. */
224
230
  const hook = (slot) => (overrides[slot] && /\bkol-content-title-dim\b/.test(ramp[slot] ?? '') ? ' kol-content-title-dim' : '')
231
+
232
+ /* An ARRAY-valued slot never renders as concatenated text
233
+ * (ContentTextTagsSlotRendersRawArray, 2026-09-01). React writes an array of
234
+ * strings as adjacent text nodes, and CSS folds contiguous text into ONE
235
+ * anonymous flex item — so the tags ramp's `gap-2` spaced nothing and the
236
+ * run-on string inherited the section's font. Strings in `tags` become the
237
+ * chip the estate's correct tag rows already wear (the tertiary Tag);
238
+ * elements a consumer pre-built pass through untouched. Any other slot's
239
+ * array (kol-website passes `meta={[date, readingTime]}`) gets one span per
240
+ * item plus the same flex seam, so the values separate instead of running on. */
241
+ const content = (slot) => {
242
+ const v = values[slot]
243
+ if (!Array.isArray(v)) return v
244
+ if (slot === 'tags')
245
+ return v.map((t, i) =>
246
+ typeof t === 'string' || typeof t === 'number' ? (
247
+ <Tag key={i} variant="tertiary" size="sm">{t}</Tag>
248
+ ) : t
249
+ )
250
+ return v.map((item, i) => <span key={i}>{item}</span>)
251
+ }
252
+ const spread = (slot) => (Array.isArray(values[slot]) && slot !== 'tags' ? ' flex flex-wrap gap-2' : '')
225
253
  const line = (slot) =>
226
254
  values[slot] == null ? null : (
227
- <div key={slot} className={`${overrides[slot] ?? ramp[slot] ?? ''}${extra(slot)}${hook(slot)}`.trim()}>{values[slot]}</div>
255
+ <div key={slot} className={`${overrides[slot] ?? ramp[slot] ?? ''}${extra(slot)}${hook(slot)}${spread(slot)}`.trim()}>{content(slot)}</div>
228
256
  )
229
257
 
230
258
  /* RECURSIVE (2026-08-15) — an entry inside a line/between/group may itself be
@@ -198,7 +198,9 @@ export default function SearchInput({
198
198
  }
199
199
  const shellCls = [
200
200
  bare
201
- ? 'flex w-full gap-2.5 px-4 py-3'
201
+ /* kol-control--bare: zero-chrome marker so the theme's coarse-pointer
202
+ * 16px floor covers this body plan too (OverlaySearchFieldZoomsIOS) */
203
+ ? 'kol-control--bare flex w-full gap-2.5 px-4 py-3'
202
204
  : `kol-control kol-control--${variant} kol-control-${size} gap-2${toneClass(tone) ? ' kol-tone-sunken' : ''}`,
203
205
  'items-center cursor-text',
204
206
  SIZE_TYPE[size],
@@ -141,10 +141,15 @@ export default function ShellDrawer({
141
141
  return createPortal(
142
142
  <>
143
143
  {backdrop && (
144
- <div
144
+ /* A BUTTON, not a div (OverlayScrimTapDismiss, 2026-09-01): iOS Safari
145
+ * does not bubble tap-clicks from non-interactive elements, so a div
146
+ * scrim's onClick never fires on a phone — the same line that broke
147
+ * the search overlay's dismiss. */
148
+ <button
149
+ type="button"
150
+ aria-label="Close"
145
151
  className={`fixed inset-0 z-[100] kol-overlay-scrim ${motionBackdrop}`}
146
152
  onClick={onClose}
147
- aria-hidden="true"
148
153
  />
149
154
  )}
150
155
  <div
@@ -319,7 +319,7 @@ const ContentFilters = ({
319
319
  * divider equally from both sides, so it read as a free-floating line
320
320
  * rather than the header's own baseline. */}
321
321
  <div className="flex items-center justify-between" style={{ marginBottom: 'var(--kol-spacing-3)' }}>
322
- <div className="flex items-center gap-6">
322
+ <div className="flex items-center gap-3 md:gap-6">
323
323
  {/* IconFrame, NOT a kol-btn span: this is decoration and clicks
324
324
  * nothing, so it must not wear a button's chrome. The atom exists
325
325
  * for exactly this (lobby ruling 2026-07-30 — "icons only, NO
@@ -331,8 +331,12 @@ const ContentFilters = ({
331
331
  * divider sat 24px from the title's text edge but 32 from the
332
332
  * icon glyphs (the frames carry 8px of air a side) and read pushed
333
333
  * toward the title; 16px on the title side balances it. The seam
334
- * `titleClassName` stays what it was. */}
335
- <span className={`${titleClassName} pr-4`} style={titleUppercase ? { textTransform: 'uppercase', letterSpacing: 1 } : undefined}>{title}</span>
334
+ * `titleClassName` stays what it was.
335
+ * MOBILE RUNG (ContentFiltersMobileGaps, 2026-09-01): the seam and
336
+ * the pad both halve below md — gap-3 + pr-2 keeps the same
337
+ * frame-air balance (12+8 glyph side · 12+8 title side) without
338
+ * spending 40px of a 390px viewport on one divider. */}
339
+ <span className={`${titleClassName} pr-2 md:pr-4`} style={titleUppercase ? { textTransform: 'uppercase', letterSpacing: 1 } : undefined}>{title}</span>
336
340
  </h2>
337
341
  <Divider variant="vertical" />
338
342
  <div className="flex items-center gap-1">
@@ -457,8 +461,8 @@ const ContentFilters = ({
457
461
  * the whole row on `isExpanded` hid the strip until you opened filters,
458
462
  * which is not a state anyone would guess at. */}
459
463
  {(isExpanded || (layoutPlacement === 'below' && layoutStrip) || leadingActions || belowActions) && (
460
- <div className="kol-filters-row flex items-start justify-between gap-16">
461
- <div className="flex min-w-0 flex-1 items-start gap-16">
464
+ <div className="kol-filters-row flex items-start justify-between gap-8 md:gap-16">
465
+ <div className="flex min-w-0 flex-1 items-start gap-8 md:gap-16">
462
466
  {leadingActions}
463
467
  {isExpanded && filterGroups.map((group, i) => renderFilterGroup(group, i))}
464
468
  {isExpanded && activeFilters.size > 0 && (
@@ -126,8 +126,14 @@ export default function SectionNewsletter({
126
126
  * at 390 the field and submit ran edge to edge against the band's own
127
127
  * boundary and read as breaking out of it. `px-5` is a floor the band owns.
128
128
  * Desktop does not move — the measure caps below the padded width, so the
129
- * inner block still centres at 80px from the band edge. */
130
- className={`kol-section-newsletter ${bleedClass(fullBleed)} flex flex-col justify-center px-5 sm:px-8 py-24 ${surfaceClass(background, theme ? 'primary' : 'none')} ${theme ? 'text-auto' : ''} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}
129
+ * inner block still centres at 80px from the band edge.
130
+ *
131
+ * `py-16 md:py-24` (SectionNewsletterMobileFoot, 2026-09-01): the flat
132
+ * `py-24` left 96px of empty band under the Subscribe button at 390 — a
133
+ * fifth of the card. 16/24 is the FAMILY's own mobile rung (SectionFaq,
134
+ * SectionSplit carry it already); this band was the outlier holding the
135
+ * desktop constant. Desktop unmoved. */
136
+ className={`kol-section-newsletter ${bleedClass(fullBleed)} flex flex-col justify-center px-5 sm:px-8 py-16 md:py-24 ${surfaceClass(background, theme ? 'primary' : 'none')} ${theme ? 'text-auto' : ''} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}
131
137
  >
132
138
  {/* the family's ONE cap — the shell's --kol-container-max ladder — and
133
139
  * inside it the lede's MEASURE on a wrapper (SectionNewsletterForm,
@@ -147,8 +147,18 @@ export default function SectionSplit({
147
147
  * phone with browser chrome sits in the 660–720 band, which is why
148
148
  * this was reported from a device four times and never reproduced
149
149
  * against a nominal 844-tall test. `max-w-full` alone only caps. */
150
- className={`kol-section-split-visual relative w-full max-w-full justify-self-center rounded-[var(--kol-radius-sm)] ${mediaClip ? 'overflow-hidden' : ''} ${mediaHover ? 'is-hoverable' : ''} ${mediaFirst ? 'order-1' : ''} ${centred ? 'max-w-[640px]' : ''}`.replace(/\s+/g, ' ').trim()}
151
- style={{ aspectRatio: ratio, height: 'calc(var(--kol-section-h, 60vh) - 2 * var(--kol-section-py, 4rem))' }}
150
+ /* THE RUNG STOPS AT THE STACK (SectionSplitVisualHeightRemainder,
151
+ * kol-website 2026-09-01). The bounded-frame height (rung 2×py,
152
+ * SectionSplitMediaBounded) is a two-column ruling: it holds where
153
+ * the text sits BESIDE the media. Stacked below 901px the text sits
154
+ * ABOVE it, the same calc handed the frame whatever the rung's
155
+ * arithmetic left (rung 40 at a 700-tall phone = 117px), and
156
+ * `overflow-hidden` clipped a 350px card to a letterbox strip —
157
+ * silently, which is why it was reported as a ProfileCard crop.
158
+ * Below 901 the frame is `w-full` + ratio: width decides, height
159
+ * follows, media never clips. ≥901 nothing moves. */
160
+ className={`kol-section-split-visual relative w-full max-w-full justify-self-center rounded-[var(--kol-radius-sm)] min-[901px]:h-[calc(var(--kol-section-h,60vh)_-_2*var(--kol-section-py,4rem))] ${mediaClip ? 'overflow-hidden' : ''} ${mediaHover ? 'is-hoverable' : ''} ${mediaFirst ? 'order-1' : ''} ${centred ? 'max-w-[640px]' : ''}`.replace(/\s+/g, ' ').trim()}
161
+ style={{ aspectRatio: ratio }}
152
162
  >
153
163
  {media}
154
164
  {caption && <div className="kol-section-split-visual-veil" aria-hidden="true" />}
@@ -132,10 +132,16 @@ export default function ShellSearchOverlay({
132
132
 
133
133
  return (
134
134
  <div className="fixed inset-0 flex items-start justify-center pt-[20vh]" style={{ zIndex: 'var(--kol-z-modal, 100)' }}>
135
- <div
135
+ {/* A BUTTON, not a div (OverlayScrimTapDismiss, 2026-09-01): iOS Safari
136
+ * does not bubble tap-clicks from non-interactive elements, so the div's
137
+ * onClick never fired on a phone and the only way out was the close
138
+ * control. A button always fires — and it ends the other half of the
139
+ * defect, an interactive target carrying `aria-hidden`. */}
140
+ <button
141
+ type="button"
142
+ aria-label="Close search"
136
143
  className="absolute inset-0 kol-overlay-scrim"
137
144
  onClick={onClose}
138
- aria-hidden="true"
139
145
  />
140
146
  <div
141
147
  ref={panelRef}
File without changes