@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.
- package/package.json +11 -3
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/atoms/Button.jsx +17 -6
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/OverlayGlassPanel.jsx +40 -0
- package/src/atoms/PriceDisplay.jsx +34 -0
- package/src/atoms/ProsePreview.jsx +53 -0
- package/src/atoms/RotaryDial.jsx +150 -0
- package/src/atoms/SearchInput.jsx +108 -0
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -0
- package/src/hooks/cssVar.js +53 -0
- package/src/hooks/useAxisAnimation.js +91 -0
- package/src/hooks/usePrefersReducedMotion.js +21 -0
- package/src/hooks/useTilt.js +49 -0
- package/src/index.js +66 -1
- package/src/molecules/AlignmentGrid.jsx +53 -0
- package/src/molecules/ArticleCard.jsx +178 -0
- package/src/molecules/CardFeatureItem.jsx +130 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/SelectionOverlay.jsx +108 -0
- package/src/molecules/ShapeDropdown.jsx +92 -0
- package/src/molecules/ShellDrawer.jsx +169 -0
- package/src/molecules/ShellSearchOverlay.jsx +177 -0
- package/src/molecules/SpecList.jsx +30 -0
- package/src/molecules/SpectrumControls.jsx +504 -0
- package/src/molecules/SwatchControls.jsx +217 -0
- package/src/molecules/TabsRow.jsx +87 -0
- package/src/molecules/VideoBlock.jsx +86 -0
- package/src/molecules/WorkListItem.jsx +83 -0
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
- package/src/organisms/ArticleHeader.jsx +95 -0
- package/src/organisms/AsciiCursor.jsx +526 -0
- package/src/organisms/BentoCard.jsx +187 -0
- package/src/organisms/Canvas.jsx +299 -0
- package/src/organisms/ColorLoader.jsx +155 -0
- package/src/organisms/CtaGlobal.jsx +67 -0
- package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
- package/src/organisms/EditorShell.jsx +111 -0
- package/src/organisms/ErrorBoundary.jsx +70 -0
- package/src/organisms/FeatureSplit.jsx +82 -0
- package/src/organisms/FeaturedCarousel.jsx +258 -0
- package/src/organisms/FeaturesCardSection.jsx +90 -0
- package/src/organisms/FullBleedHero.jsx +111 -0
- package/src/organisms/GalleryCarousel.jsx +83 -0
- package/src/organisms/LoaderOverlay.jsx +30 -0
- package/src/organisms/MediaViewer.jsx +95 -0
- package/src/organisms/NewsletterBand.jsx +122 -0
- package/src/organisms/ParallaxShelf.jsx +141 -0
- package/src/organisms/PortableTextRenderer.jsx +115 -0
- package/src/organisms/ProductDetailLayout.jsx +189 -0
- package/src/organisms/ScrollDriftGallery.jsx +214 -0
- package/src/organisms/SpectrumGrid.jsx +90 -0
- package/src/organisms/StackHero.jsx +83 -0
- package/src/organisms/WorkCard.jsx +120 -0
- package/src/organisms/WorkViewToggle.jsx +170 -0
- package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
- package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
- package/src/organisms/foundry/TypefaceHero.jsx +107 -0
- package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
- package/src/organisms/foundry/VariableFontSection.jsx +158 -0
- package/src/organisms/foundry/glyphData.js +30 -0
- 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
|
+
}
|