@kolkrabbi/kol-component 0.2.0 → 0.3.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.
Files changed (74) hide show
  1. package/package.json +11 -3
  2. package/src/atoms/AnimatedTitle.jsx +108 -0
  3. package/src/atoms/AssetGrid.jsx +29 -0
  4. package/src/atoms/Button.jsx +17 -6
  5. package/src/atoms/CurveOverlay.jsx +180 -0
  6. package/src/atoms/DocsToc.jsx +48 -0
  7. package/src/atoms/EmptyState.jsx +22 -0
  8. package/src/atoms/Figure.jsx +27 -0
  9. package/src/atoms/HlsVideo.jsx +56 -0
  10. package/src/atoms/OverlayGlassPanel.jsx +40 -0
  11. package/src/atoms/PriceDisplay.jsx +34 -0
  12. package/src/atoms/ProsePreview.jsx +53 -0
  13. package/src/atoms/RotaryDial.jsx +150 -0
  14. package/src/atoms/SearchInput.jsx +108 -0
  15. package/src/atoms/TextPressure.jsx +331 -0
  16. package/src/atoms/TiltCard.jsx +127 -0
  17. package/src/atoms/TypeSample.jsx +50 -0
  18. package/src/atoms/TypeSpecCard.jsx +42 -0
  19. package/src/hooks/cssVar.js +53 -0
  20. package/src/hooks/useAxisAnimation.js +91 -0
  21. package/src/hooks/usePrefersReducedMotion.js +21 -0
  22. package/src/hooks/useTilt.js +49 -0
  23. package/src/index.js +66 -1
  24. package/src/molecules/AlignmentGrid.jsx +53 -0
  25. package/src/molecules/ArticleCard.jsx +178 -0
  26. package/src/molecules/CardFeatureItem.jsx +130 -0
  27. package/src/molecules/ColorInputRow.jsx +179 -0
  28. package/src/molecules/ColorRamp.jsx +114 -0
  29. package/src/molecules/FramedMediaBand.jsx +56 -0
  30. package/src/molecules/ImageBlock.jsx +34 -0
  31. package/src/molecules/SelectionOverlay.jsx +108 -0
  32. package/src/molecules/ShapeDropdown.jsx +92 -0
  33. package/src/molecules/ShellDrawer.jsx +169 -0
  34. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  35. package/src/molecules/SpecList.jsx +30 -0
  36. package/src/molecules/SpectrumControls.jsx +504 -0
  37. package/src/molecules/SwatchControls.jsx +217 -0
  38. package/src/molecules/TabsRow.jsx +87 -0
  39. package/src/molecules/VideoBlock.jsx +86 -0
  40. package/src/molecules/WorkListItem.jsx +83 -0
  41. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  42. package/src/organisms/ArticleHeader.jsx +95 -0
  43. package/src/organisms/AsciiCursor.jsx +526 -0
  44. package/src/organisms/BentoCard.jsx +187 -0
  45. package/src/organisms/Canvas.jsx +299 -0
  46. package/src/organisms/ColorLoader.jsx +155 -0
  47. package/src/organisms/CtaGlobal.jsx +67 -0
  48. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  49. package/src/organisms/EditorShell.jsx +111 -0
  50. package/src/organisms/ErrorBoundary.jsx +70 -0
  51. package/src/organisms/FeatureSplit.jsx +82 -0
  52. package/src/organisms/FeaturedCarousel.jsx +258 -0
  53. package/src/organisms/FeaturesCardSection.jsx +90 -0
  54. package/src/organisms/FullBleedHero.jsx +111 -0
  55. package/src/organisms/GalleryCarousel.jsx +83 -0
  56. package/src/organisms/LoaderOverlay.jsx +30 -0
  57. package/src/organisms/MediaViewer.jsx +95 -0
  58. package/src/organisms/NewsletterBand.jsx +122 -0
  59. package/src/organisms/ParallaxShelf.jsx +141 -0
  60. package/src/organisms/PortableTextRenderer.jsx +115 -0
  61. package/src/organisms/ProductDetailLayout.jsx +189 -0
  62. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  63. package/src/organisms/SpectrumGrid.jsx +90 -0
  64. package/src/organisms/StackHero.jsx +83 -0
  65. package/src/organisms/WorkCard.jsx +120 -0
  66. package/src/organisms/WorkViewToggle.jsx +170 -0
  67. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  68. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  69. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  70. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  71. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  72. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  73. package/src/organisms/foundry/glyphData.js +30 -0
  74. package/src/organisms/foundry/index.js +21 -0
@@ -0,0 +1,217 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-loader'
3
+ import ColorSwatch from './ColorSwatch.jsx'
4
+
5
+ /* taxonomy-ok: the default composed control nests the same-file SwatchStack
6
+ * and EyedropPick (KOL named exports the relative-import check can't see);
7
+ * both collapse the source's internal FramedSwatch onto the sibling molecule
8
+ * ColorSwatch (a same-tier relative import). */
9
+
10
+ /*
11
+ * SwatchControls — the Photoshop-style top row of the colour panel, in two
12
+ * hand-tuned pieces plus the composed row. Hand-tuned visuals (fixed pixel
13
+ * slots, the overlap-by-DOM-order trick, the double-ring halo, the red-slash
14
+ * "none" marker). DO NOT refactor pieces to atoms — the look is intentional
15
+ * and matches the Ref design; promote each piece WHOLE.
16
+ *
17
+ * <SwatchControls ... /> (default export)
18
+ * — SwatchStack + EyedropPick laid out in a row.
19
+ *
20
+ * <SwatchStack fillColor strokeColor activePaint onSwap onClear />
21
+ * — overlapping fill + stroke paint chips (fill upper-left, stroke
22
+ * lower-right) with a swap arrow and a "set to none" marker. `activePaint`
23
+ * flips which chip renders SECOND, so the active paint wins the overlap.
24
+ * No z-index, no transform — DOM order alone stacks them.
25
+ *
26
+ * <EyedropPick sampleColor onPick disabled />
27
+ * — eyedropper icon button + a small sample chip of the sampled colour.
28
+ * The button is feature-gated: it is hidden entirely when the browser
29
+ * EyeDropper API is unavailable, and `disabled` dims it when supported
30
+ * but unusable. The actual EyeDropper call + canvas sampling live at the
31
+ * call site behind `onPick`.
32
+ *
33
+ * The paint chips and the sample chip collapse onto ColorSwatch's `halo`
34
+ * variant (`variant="halo"` supplies the exact tuned `0 0 0 1px #000,
35
+ * 0 0 0 2px #505050` double-ring); the source's redundant FramedSwatch is
36
+ * dropped. The swap / eyedrop glyphs route through the DS Icon.
37
+ */
38
+
39
+ /* Fixed chip slots inside the 44×44 stack — identical in both variants; only
40
+ * the render order changes. Positions live on absolutely-placed wrappers so
41
+ * ColorSwatch keeps its own `relative` root (its box-shadow halo needs it). */
42
+ const FILL_SLOT = 'left-[5px] top-[6px]'
43
+ const STROKE_SLOT = 'left-[15px] top-[16px]'
44
+
45
+ /* One 22px paint chip: a halo ColorSwatch pinned to its slot. Click → onSwap
46
+ * (either chip signals swap intent; the app decides fill↔active). */
47
+ function PaintChip({ id, label, color, slot, onSwap }) {
48
+ return (
49
+ <span className={`absolute ${slot}`}>
50
+ <ColorSwatch
51
+ hex={color}
52
+ size={22}
53
+ radius="full"
54
+ variant="halo"
55
+ hoverable={false}
56
+ onClick={onSwap}
57
+ data-id={id}
58
+ aria-label={label}
59
+ />
60
+ </span>
61
+ )
62
+ }
63
+
64
+ /**
65
+ * SwatchStack — the overlapping fill + stroke paint chips. `activePaint`
66
+ * selects which chip renders second (topmost in the overlap) purely via DOM
67
+ * order — no z-index, no transform. Clicking either chip, or the swap arrow,
68
+ * fires `onSwap`; the slash-circle marker fires `onClear`.
69
+ *
70
+ * @param {string} fillColor fill chip background (css color). Default '#FFFFFF'
71
+ * @param {string} strokeColor stroke chip background (css color). Default '#000000'
72
+ * @param {'fill'|'stroke'} activePaint which chip paints on top. Default 'fill'
73
+ * @param {Function} onSwap () => void — either chip or the swap arrow
74
+ * @param {Function} onClear () => void — the "set to none" marker
75
+ */
76
+ export function SwatchStack({
77
+ fillColor = '#FFFFFF',
78
+ strokeColor = '#000000',
79
+ activePaint = 'fill',
80
+ onSwap,
81
+ onClear,
82
+ }) {
83
+ const fill = <PaintChip key="fill" id="fill" label="Fill color" color={fillColor} slot={FILL_SLOT} onSwap={onSwap} />
84
+ const stroke = <PaintChip key="stroke" id="stroke" label="Stroke color" color={strokeColor} slot={STROKE_SLOT} onSwap={onSwap} />
85
+
86
+ /* Active paint is rendered SECOND so it paints on top in the overlap. */
87
+ const chips = activePaint === 'fill' ? [stroke, fill] : [fill, stroke]
88
+
89
+ return (
90
+ <div className="relative shrink-0 w-11 h-11">
91
+ {chips}
92
+ <SwapButton onSwap={onSwap} />
93
+ <NoneMarker onClear={onClear} />
94
+ </div>
95
+ )
96
+ }
97
+
98
+ /**
99
+ * EyedropPick — eyedropper icon button + a sample chip of the sampled colour.
100
+ * The button is hidden when the browser EyeDropper API is unavailable (no
101
+ * affordance for an action that can't run); when supported but unusable pass
102
+ * `disabled` to dim it. `onPick` is the app seam where EyeDropper + canvas
103
+ * sampling run.
104
+ *
105
+ * @param {string} sampleColor sample chip background (css color)
106
+ * @param {Function} onPick () => void — eyedropper button click
107
+ * @param {boolean} disabled dim + disable the button (default false)
108
+ */
109
+ export function EyedropPick({ sampleColor, onPick, disabled = false }) {
110
+ const supported = useEyeDropperSupported()
111
+
112
+ return (
113
+ <div className="flex items-start gap-1 shrink-0">
114
+ {supported && (
115
+ <button
116
+ type="button"
117
+ onClick={onPick}
118
+ disabled={disabled}
119
+ aria-label="Eyedropper"
120
+ title="Pick a color from the canvas"
121
+ className="inline-flex cursor-pointer text-fg hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed"
122
+ >
123
+ <Icon name="eyedrop" size={24} />
124
+ </button>
125
+ )}
126
+ <ColorSwatch
127
+ hex={sampleColor}
128
+ size={16}
129
+ radius="full"
130
+ variant="halo"
131
+ title="Sampled color"
132
+ className="mt-1"
133
+ />
134
+ </div>
135
+ )
136
+ }
137
+
138
+ /* ────────── Internal helpers (not exported) ────────── */
139
+
140
+ /* EyeDropper support, resolved after mount so SSR / first paint stays stable
141
+ * (starts false, flips true only where the API exists). */
142
+ function useEyeDropperSupported() {
143
+ const [supported, setSupported] = useState(false)
144
+ useEffect(() => {
145
+ setSupported(typeof window !== 'undefined' && 'EyeDropper' in window)
146
+ }, [])
147
+ return supported
148
+ }
149
+
150
+ /* Swap arrow, pinned top-right of the stack. Same intent as clicking a chip. */
151
+ function SwapButton({ onSwap }) {
152
+ return (
153
+ <button
154
+ type="button"
155
+ onClick={onSwap}
156
+ aria-label="Swap colors"
157
+ className="absolute left-[28px] top-0 inline-flex cursor-pointer text-fg hover:opacity-80"
158
+ >
159
+ <Icon name="swap" size={16} />
160
+ </button>
161
+ )
162
+ }
163
+
164
+ /* "Set to none" marker, pinned bottom-left of the stack. The red diagonal
165
+ * slash + token ring live in `.kol-swatch-none-marker` (chrome the JSX can't
166
+ * express — a two-stop gradient over a themed box-shadow ring). */
167
+ function NoneMarker({ onClear }) {
168
+ return (
169
+ <button
170
+ type="button"
171
+ onClick={onClear}
172
+ aria-label="Clear color"
173
+ className="kol-swatch-none-marker absolute left-[1px] top-[32px] w-2.5 h-2.5 rounded-full overflow-hidden cursor-pointer"
174
+ />
175
+ )
176
+ }
177
+
178
+ /**
179
+ * SwatchControls — the composed colour-panel top row: SwatchStack (fill /
180
+ * stroke paint chips + swap + none) alongside EyedropPick (eyedropper +
181
+ * sample chip). Fully controlled and store-free; the app owns the swap, clear,
182
+ * and pick handlers.
183
+ *
184
+ * @param {string} fillColor fill chip background (css color)
185
+ * @param {string} strokeColor stroke chip background (css color)
186
+ * @param {'fill'|'stroke'} activePaint which chip paints on top. Default 'fill'
187
+ * @param {Function} onSwap () => void — swap fill ↔ active
188
+ * @param {Function} onClear () => void — set active paint to none
189
+ * @param {string} sampleColor eyedropper sample chip background (css color)
190
+ * @param {Function} onPick () => void — invoke EyeDropper at the call site
191
+ * @param {boolean} eyedropDisabled dim + disable the eyedropper button
192
+ * @param {string} className extra classes on the row wrapper
193
+ */
194
+ export default function SwatchControls({
195
+ fillColor,
196
+ strokeColor,
197
+ activePaint = 'fill',
198
+ onSwap,
199
+ onClear,
200
+ sampleColor,
201
+ onPick,
202
+ eyedropDisabled = false,
203
+ className = '',
204
+ }) {
205
+ return (
206
+ <div className={`flex items-start gap-3 ${className}`}>
207
+ <SwatchStack
208
+ fillColor={fillColor}
209
+ strokeColor={strokeColor}
210
+ activePaint={activePaint}
211
+ onSwap={onSwap}
212
+ onClear={onClear}
213
+ />
214
+ <EyedropPick sampleColor={sampleColor} onPick={onPick} disabled={eyedropDisabled} />
215
+ </div>
216
+ )
217
+ }
@@ -0,0 +1,87 @@
1
+ import { useRef } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-loader'
3
+
4
+ /* taxonomy-ok: nests kol-loader's Icon */
5
+
6
+ /**
7
+ * TabsRow — labeled underline tab strip: text tabs where the active tab gets
8
+ * a 2px bottom underline and emphasis color; inactive tabs are muted and
9
+ * brighten on hover. Optional leading close button and trailing minimise
10
+ * chevron render only when their handlers are passed — no outer chrome, the
11
+ * consumer wraps it. Controlled: the parent owns `value`.
12
+ *
13
+ * Real tablist semantics: `role="tablist"`/`role="tab"` + `aria-selected`,
14
+ * roving tabindex, ArrowLeft/ArrowRight move focus and activate (wrapping).
15
+ * Labels render verbatim from `tabs` — no text-transform.
16
+ *
17
+ * @param {Array} tabs [{ id, label }] — tab items, authored at the call site
18
+ * @param {string} value id of the active tab (controlled)
19
+ * @param {Function} onChange (id) => void — fires on tab click or arrow-key move
20
+ * @param {Function} onClose () => void — show + wire the leading close button
21
+ * @param {Function} onMinimise () => void — show + wire the trailing minimise chevron
22
+ */
23
+ export default function TabsRow({ tabs = [], value, onChange, onClose, onMinimise }) {
24
+ const listRef = useRef(null)
25
+ const activeIdx = tabs.findIndex((t) => t.id === value)
26
+
27
+ const onKeyDown = (e) => {
28
+ if ((e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') || !tabs.length) return
29
+ e.preventDefault()
30
+ const dir = e.key === 'ArrowRight' ? 1 : -1
31
+ const next = ((activeIdx === -1 ? 0 : activeIdx) + dir + tabs.length) % tabs.length
32
+ onChange?.(tabs[next].id)
33
+ listRef.current?.querySelectorAll('[role="tab"]')[next]?.focus()
34
+ }
35
+
36
+ return (
37
+ <div className="flex items-stretch gap-4 px-3 h-10">
38
+ {onClose && (
39
+ <button
40
+ type="button"
41
+ onClick={onClose}
42
+ aria-label="Close"
43
+ className="text-meta hover:text-emphasis self-center"
44
+ style={{ lineHeight: 0 }}
45
+ >
46
+ <Icon name="close" size={12} />
47
+ </button>
48
+ )}
49
+
50
+ <div ref={listRef} role="tablist" onKeyDown={onKeyDown} className="flex items-stretch gap-4">
51
+ {tabs.map((t, i) => {
52
+ const isActive = t.id === value
53
+ return (
54
+ <button
55
+ key={t.id}
56
+ type="button"
57
+ role="tab"
58
+ aria-selected={isActive}
59
+ tabIndex={isActive || (activeIdx === -1 && i === 0) ? 0 : -1}
60
+ onClick={() => onChange?.(t.id)}
61
+ className={[
62
+ 'kol-mono-12 flex items-center cursor-pointer border-b-2',
63
+ isActive
64
+ ? 'text-emphasis border-fg'
65
+ : 'text-meta hover:text-emphasis border-transparent',
66
+ ].join(' ')}
67
+ >
68
+ {t.label}
69
+ </button>
70
+ )
71
+ })}
72
+ </div>
73
+
74
+ {onMinimise && (
75
+ <button
76
+ type="button"
77
+ onClick={onMinimise}
78
+ aria-label="Minimise"
79
+ className="ml-auto text-meta hover:text-emphasis self-center"
80
+ style={{ lineHeight: 0 }}
81
+ >
82
+ <Icon name="chevron-down" size={12} />
83
+ </button>
84
+ )}
85
+ </div>
86
+ )
87
+ }
@@ -0,0 +1,86 @@
1
+ import Figure from '../atoms/Figure.jsx'
2
+
3
+ /**
4
+ * getEmbedUrl — turn a YouTube/Vimeo share URL into its embeddable player URL.
5
+ * Kept verbatim from the source; it is the only real logic in this block.
6
+ * Returns null for anything it can't parse (→ the file player is used).
7
+ */
8
+ export function getEmbedUrl(url) {
9
+ if (!url) return null
10
+ const ytMatch = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
11
+ if (ytMatch) return `https://www.youtube.com/embed/${ytMatch[1]}`
12
+ const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
13
+ if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`
14
+ return null
15
+ }
16
+
17
+ /**
18
+ * VideoBlock — a captioned prose video: the DS Figure shell (optional label,
19
+ * aspect-locked bordered frame, optional figcaption) wrapping either an
20
+ * <iframe> embed (YouTube/Vimeo, auto-parsed from `url`) or a native <video>
21
+ * file player. Embed wins when `url` parses; otherwise the `file` player runs.
22
+ * The long-form sibling of ImageBlock — both compose the same Figure atom.
23
+ *
24
+ * De-Sanitized: flat props, no CMS `value` object. Renders nothing when
25
+ * neither an embed nor a file source resolves. Label/caption authored as-is.
26
+ *
27
+ * @param {string} url YouTube/Vimeo share URL → iframe embed
28
+ * @param {string} file direct video file URL → native <video>
29
+ * @param {string} poster poster image for the file player
30
+ * @param {string} label small mono label above the frame
31
+ * @param {string} caption figcaption below the frame
32
+ * @param {string} aspect CSS aspect-ratio for the frame (default '5/3')
33
+ * @param {boolean} autoplay autoplay the file player (forces muted)
34
+ * @param {boolean} muted mute the file player (forced true when autoplay)
35
+ * @param {boolean} controls show file-player controls (default true)
36
+ * @param {boolean} loop loop file playback
37
+ * @param {string} className extra classes on the <figure>
38
+ */
39
+ export default function VideoBlock({
40
+ url,
41
+ file,
42
+ poster,
43
+ label,
44
+ caption,
45
+ aspect = '5/3',
46
+ autoplay = false,
47
+ muted = false,
48
+ controls = true,
49
+ loop = false,
50
+ className = '',
51
+ }) {
52
+ const embedSrc = getEmbedUrl(url)
53
+ if (!embedSrc && !file) return null
54
+
55
+ const isMuted = autoplay ? true : muted
56
+
57
+ return (
58
+ <Figure label={label} caption={caption} aspect={aspect} className={className}>
59
+ {embedSrc ? (
60
+ <iframe
61
+ src={embedSrc}
62
+ title={label || caption || 'Embedded video'}
63
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
64
+ allowFullScreen
65
+ className="w-full h-full"
66
+ style={{ border: 0 }}
67
+ />
68
+ ) : (
69
+ <video
70
+ src={file}
71
+ poster={poster}
72
+ controls={controls}
73
+ controlsList="nodownload noplaybackrate noremoteplayback"
74
+ autoPlay={autoplay}
75
+ loop={loop}
76
+ muted={isMuted}
77
+ disablePictureInPicture
78
+ disableRemotePlayback
79
+ playsInline
80
+ className="w-full h-full object-cover"
81
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
82
+ />
83
+ )}
84
+ </Figure>
85
+ )
86
+ }
@@ -0,0 +1,83 @@
1
+ import Image from './Image.jsx'
2
+ import Tag from '../atoms/Tag.jsx'
3
+
4
+ /**
5
+ * WorkListItem — the list/row view of a `/work` project: the row twin of
6
+ * WorkCard. A horizontal card — a small square thumbnail on the left, then a
7
+ * content column split into a header row (title + tag chips on the left, type +
8
+ * year right-aligned) above a large display preview line (the project
9
+ * description). Hover raises a border; `active` persists that treatment for the
10
+ * last-hovered row so a parent list can mark one active.
11
+ *
12
+ * Shares WorkCard's flat project bag so a `/work` view can toggle grid ⇄ list
13
+ * as a render swap, not a data reshape. The whole row is an anchor (`href`) —
14
+ * the source row was not a link (its parent list owned navigation); as the
15
+ * list-view counterpart to WorkCard it is the click target itself. Thumbnail
16
+ * routes through DS Image (graceful missing-asset fallback); tags render as DS
17
+ * Tag chips. Router-agnostic via `onNavigate(href, event)`.
18
+ *
19
+ * Text casing: no text-transform (KOL rule) — the source's `uppercase` title
20
+ * and `capitalize` type are dropped; strings render verbatim as authored.
21
+ *
22
+ * @param {string} title header title
23
+ * @param {string} thumbnail thumbnail URL; the block is omitted when absent
24
+ * @param {string[]} tags rendered as Tag chips under the title
25
+ * @param {string} type right-column label
26
+ * @param {string|number} year right-column, below type
27
+ * @param {string} description large display preview line
28
+ * @param {string} href row navigation target
29
+ * @param {boolean} active persist the hover treatment (last-hovered row)
30
+ * @param {Function} onMouseEnter hover callback (parent tracks the active row)
31
+ * @param {Function} onNavigate (href, event) => void — anchor click seam for SPA routing
32
+ */
33
+ export default function WorkListItem({
34
+ title,
35
+ thumbnail,
36
+ tags,
37
+ type,
38
+ year,
39
+ description,
40
+ href,
41
+ active = false,
42
+ onMouseEnter,
43
+ onNavigate,
44
+ }) {
45
+ return (
46
+ <a
47
+ href={href}
48
+ onClick={onNavigate ? (e) => onNavigate(href, e) : undefined}
49
+ onMouseEnter={onMouseEnter}
50
+ className={`self-stretch min-h-24 md:min-h-40 p-4 md:p-6 rounded flex items-stretch gap-4 md:gap-6 mb-4 md:mb-6 overflow-hidden cursor-pointer transition-all duration-300 bg-surface-secondary border ${active ? 'border-fg-16' : 'border-transparent hover:border-fg-16'}`}
51
+ >
52
+ {thumbnail && (
53
+ <div className="shrink-0 w-16 h-16 md:w-28 md:h-28 rounded-[2px] overflow-hidden border border-fg-08">
54
+ <Image src={thumbnail} alt={title} category="work" name={title} className="w-full h-full object-cover" />
55
+ </div>
56
+ )}
57
+
58
+ <div className="flex flex-col justify-between min-w-0 flex-1 gap-3 md:gap-4">
59
+ <div className="flex justify-between items-start md:items-center gap-4">
60
+ <div className="flex flex-col gap-1 md:gap-2 min-w-0">
61
+ <div className="kol-mono-14 truncate">{title}</div>
62
+ {tags?.length > 0 && (
63
+ <div className="flex flex-wrap gap-1.5">
64
+ {tags.map((t) => (
65
+ <Tag key={t} size="sm" variant="naked" hash={false}>{t}</Tag>
66
+ ))}
67
+ </div>
68
+ )}
69
+ </div>
70
+
71
+ <div className="flex flex-col items-end gap-1 md:gap-2 shrink-0">
72
+ <span className="kol-mono-12 md:kol-mono-14">{type}</span>
73
+ <span className="kol-mono-12 text-fg-64">{year}</span>
74
+ </div>
75
+ </div>
76
+
77
+ <p className="kol-sans-heading-03 text-auto leading-tight whitespace-nowrap overflow-hidden text-ellipsis">
78
+ {description}
79
+ </p>
80
+ </div>
81
+ </a>
82
+ )
83
+ }
@@ -0,0 +1,89 @@
1
+ import { Icon } from '@kolkrabbi/kol-loader'
2
+ import Dropdown from '../Dropdown.jsx'
3
+ import Divider from '../../atoms/Divider.jsx'
4
+
5
+ /* taxonomy-ok: nests DS Dropdown + Divider (relative imports) plus kol-loader's
6
+ * Icon (a package import the relative-import check can't see). */
7
+
8
+ /**
9
+ * SpecimenSectionHeader — the shared header every type-specimen section mounts
10
+ * under. A title (with an optional inline icon) on the left, up to two
11
+ * controlled dropdowns (weight + style/axis) on the right, and a horizontal
12
+ * divider beneath. It is the connective tissue that makes the foundry sections
13
+ * read as one system, so it is built before the sections that consume it.
14
+ *
15
+ * Fully controlled and data-free: it holds no font data, only label/badge text,
16
+ * an icon name, and the two dropdowns' value/onChange pairs supplied by the
17
+ * parent section. Promoted from the monorepo's route-local `FoundrySection`;
18
+ * the `Foundry*` name was a route artifact, this name describes the role.
19
+ *
20
+ * Title source forks: `label` wins, else `badgeText`. `size` swaps the title
21
+ * type stop (`sm` → kol-mono-12, else kol-mono-16). Each dropdown is
22
+ * independently gated; the weight dropdown also requires a non-empty
23
+ * `weightOptions`.
24
+ *
25
+ * Text casing: title and option labels render verbatim as authored — no
26
+ * text-transform. Author "Roman" / "Italic" / "Character Set" in intended case.
27
+ *
28
+ * @param {Object} props
29
+ * @param {string} props.label - Title text (takes precedence over badgeText).
30
+ * @param {string} props.badgeText - Fallback title when label is unset.
31
+ * @param {string} props.icon - KOL Icon name shown after the title; omit to hide.
32
+ * @param {'sm'|'lg'} props.size - Title type stop (default 'lg').
33
+ * @param {string} props.selectedStyle - Controlled value of the style/axis dropdown.
34
+ * @param {Function} props.onStyleChange - Style dropdown change handler.
35
+ * @param {boolean} props.showDropdown - Render the style/axis dropdown (default true).
36
+ * @param {Array<{label,value}>} props.styleOptions - Style dropdown options (default Roman/Italic).
37
+ * @param {string} props.selectedWeight - Controlled value of the weight dropdown.
38
+ * @param {Function} props.onWeightChange - Weight dropdown change handler.
39
+ * @param {boolean} props.showWeightDropdown - Gate the weight dropdown (default true; also needs options).
40
+ * @param {Array<{label,value}>} props.weightOptions - Weight dropdown options (default []).
41
+ */
42
+ const SpecimenSectionHeader = ({
43
+ label,
44
+ badgeText,
45
+ icon,
46
+ size = 'lg',
47
+ selectedStyle,
48
+ onStyleChange,
49
+ showDropdown = true,
50
+ styleOptions = [
51
+ { label: 'Roman', value: 'roman' },
52
+ { label: 'Italic', value: 'italic' },
53
+ ],
54
+ selectedWeight,
55
+ onWeightChange,
56
+ showWeightDropdown = true,
57
+ weightOptions = [],
58
+ }) => {
59
+ const title = label || badgeText
60
+ // kol-mono-text-lg / kol-mono-sm-regular don't exist in the DS theme; the
61
+ // nearest mono stops are kol-mono-16 (lg) and kol-mono-12 (sm).
62
+ const titleClass = size === 'sm' ? 'kol-mono-12' : 'kol-mono-16'
63
+
64
+ return (
65
+ <div className="flex flex-col gap-[13px]">
66
+ <div className="w-full flex flex-row justify-between items-end gap-4">
67
+ {/* Left: title + optional icon */}
68
+ <div className="flex items-center gap-3 md:gap-4">
69
+ <span className={titleClass}>{title}</span>
70
+ {icon && <Icon name={icon} size={20} />}
71
+ </div>
72
+
73
+ {/* Right: controlled dropdowns */}
74
+ <div className="flex items-center gap-4">
75
+ {showWeightDropdown && weightOptions.length > 0 && (
76
+ <Dropdown options={weightOptions} value={selectedWeight} onChange={onWeightChange} />
77
+ )}
78
+ {showDropdown && (
79
+ <Dropdown options={styleOptions} value={selectedStyle} onChange={onStyleChange} />
80
+ )}
81
+ </div>
82
+ </div>
83
+
84
+ <Divider variant="horizontal" />
85
+ </div>
86
+ )
87
+ }
88
+
89
+ export default SpecimenSectionHeader
@@ -0,0 +1,95 @@
1
+ import Pill from '../atoms/Pill.jsx'
2
+ import Avatar from '../atoms/Avatar.jsx'
3
+ import Image from '../molecules/Image.jsx'
4
+
5
+ /**
6
+ * ArticleHeader — long-form article masthead: a row of tag Pills, a display
7
+ * title, an author cluster (DS Avatar + name + role), a `date • readingTime`
8
+ * meta line, an optional excerpt, and an optional hero image. Pure
9
+ * presentational composition of a flat prop bag.
10
+ *
11
+ * De-Sanitized: the four embedded Sanity image-URL builders and the
12
+ * `resolveImageUrl` unpacker are gone — `heroImage` is a plain resolved src
13
+ * string handed straight to DS Image (which owns its own missing-asset
14
+ * fallback). The author avatar is the DS Avatar atom (initials), not a
15
+ * hand-rolled `<img>` — so `authorInitial` replaces the Sanity `authorImage`.
16
+ * The app `reveal` entrance utility is dropped.
17
+ *
18
+ * Name and meta render uppercase via a presentational class only — strings are
19
+ * authored as-is at the call site, no JS casing.
20
+ *
21
+ * @param {string[]} tags Pill labels; row hidden when empty
22
+ * @param {string} title display title
23
+ * @param {string} authorName author name (rendered uppercase via class)
24
+ * @param {string} authorTitle author role/title line
25
+ * @param {string} authorInitial avatar glyph; defaults to authorName's first char
26
+ * @param {string} date publication date text
27
+ * @param {string} readingTime reading-time text (prefixed `• ` in the meta line)
28
+ * @param {string} excerpt lede paragraph; block hidden when falsy
29
+ * @param {string} heroImage resolved hero image src (omit → no hero block)
30
+ * @param {string} className extra classes on the <header>
31
+ */
32
+ export default function ArticleHeader({
33
+ tags = [],
34
+ title,
35
+ authorName,
36
+ authorTitle,
37
+ authorInitial,
38
+ date,
39
+ readingTime,
40
+ excerpt,
41
+ heroImage,
42
+ className = '',
43
+ }) {
44
+ const initial = authorInitial ?? (authorName ? authorName.trim().charAt(0) : '')
45
+
46
+ return (
47
+ <header className={`pb-12 ${className}`.trim()}>
48
+ <div className="max-w-[1400px] mx-auto flex flex-col">
49
+ {tags.length > 0 && (
50
+ <div className="flex flex-wrap items-center gap-2 pb-6">
51
+ {tags.map((tag, i) => (
52
+ <Pill key={i} variant="inverse" size="md">{tag}</Pill>
53
+ ))}
54
+ </div>
55
+ )}
56
+
57
+ <div className="flex flex-col gap-6">
58
+ <h1 className="kol-prose-title text-balance">{title}</h1>
59
+
60
+ <div className="flex flex-wrap items-center gap-6 text-fg-64 pb-3">
61
+ <div className="flex items-center gap-4">
62
+ <Avatar initial={initial} size="lg" />
63
+ <div className="flex flex-col">
64
+ <span className="kol-helper-12 uppercase text-fg-48">{authorName}</span>
65
+ <span className="kol-mono-12 text-fg-64">{authorTitle}</span>
66
+ </div>
67
+ </div>
68
+ <div className="flex items-center gap-3 kol-helper-12 uppercase text-fg-48">
69
+ <span>{date}</span>
70
+ {readingTime && <span>• {readingTime}</span>}
71
+ </div>
72
+ </div>
73
+
74
+ {excerpt && (
75
+ <div className="pb-4 w-full lg:w-[80%]">
76
+ <p className="kol-mono-14 text-fg-64">{excerpt}</p>
77
+ </div>
78
+ )}
79
+
80
+ {heroImage && (
81
+ <div className="rounded overflow-hidden border border-fg-08">
82
+ <Image
83
+ src={heroImage}
84
+ alt=""
85
+ loading="eager"
86
+ className="object-cover"
87
+ style={{ width: '100%', aspectRatio: '4/2', objectFit: 'cover' }}
88
+ />
89
+ </div>
90
+ )}
91
+ </div>
92
+ </div>
93
+ </header>
94
+ )
95
+ }