@kolkrabbi/kol-component 0.2.0 → 0.4.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 (77) 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/QuantityInput.jsx +76 -114
  14. package/src/atoms/RotaryDial.jsx +150 -0
  15. package/src/atoms/SearchInput.jsx +108 -0
  16. package/src/atoms/TextPressure.jsx +331 -0
  17. package/src/atoms/TiltCard.jsx +127 -0
  18. package/src/atoms/TypeSample.jsx +50 -0
  19. package/src/atoms/TypeSpecCard.jsx +42 -0
  20. package/src/hooks/cssVar.js +53 -0
  21. package/src/hooks/useAxisAnimation.js +91 -0
  22. package/src/hooks/usePrefersReducedMotion.js +21 -0
  23. package/src/hooks/useTilt.js +49 -0
  24. package/src/index.js +66 -2
  25. package/src/molecules/AlignmentGrid.jsx +53 -0
  26. package/src/molecules/ArticleCard.jsx +178 -0
  27. package/src/molecules/CardFeatureItem.jsx +130 -0
  28. package/src/molecules/ColorInputRow.jsx +179 -0
  29. package/src/molecules/ColorRamp.jsx +114 -0
  30. package/src/molecules/FramedMediaBand.jsx +56 -0
  31. package/src/molecules/ImageBlock.jsx +34 -0
  32. package/src/molecules/MenuPopover.jsx +4 -27
  33. package/src/molecules/SelectionOverlay.jsx +108 -0
  34. package/src/molecules/ShapeDropdown.jsx +92 -0
  35. package/src/molecules/ShellDrawer.jsx +169 -0
  36. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  37. package/src/molecules/SpecList.jsx +30 -0
  38. package/src/molecules/SpectrumControls.jsx +504 -0
  39. package/src/molecules/SwatchControls.jsx +217 -0
  40. package/src/molecules/TabsRow.jsx +87 -0
  41. package/src/molecules/VideoBlock.jsx +86 -0
  42. package/src/molecules/WorkListItem.jsx +83 -0
  43. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  44. package/src/organisms/ArticleHeader.jsx +95 -0
  45. package/src/organisms/AsciiCursor.jsx +526 -0
  46. package/src/organisms/BentoCard.jsx +187 -0
  47. package/src/organisms/Canvas.jsx +299 -0
  48. package/src/organisms/ColorLoader.jsx +155 -0
  49. package/src/organisms/CtaGlobal.jsx +67 -0
  50. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  51. package/src/organisms/EditorShell.jsx +111 -0
  52. package/src/organisms/ErrorBoundary.jsx +70 -0
  53. package/src/organisms/FeatureSplit.jsx +82 -0
  54. package/src/organisms/FeaturedCarousel.jsx +258 -0
  55. package/src/organisms/FeaturesCardSection.jsx +90 -0
  56. package/src/organisms/FullBleedHero.jsx +111 -0
  57. package/src/organisms/GalleryCarousel.jsx +83 -0
  58. package/src/organisms/LoaderOverlay.jsx +30 -0
  59. package/src/organisms/MediaViewer.jsx +95 -0
  60. package/src/organisms/NewsletterBand.jsx +122 -0
  61. package/src/organisms/ParallaxShelf.jsx +141 -0
  62. package/src/organisms/PortableTextRenderer.jsx +115 -0
  63. package/src/organisms/ProductDetailLayout.jsx +189 -0
  64. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  65. package/src/organisms/SpectrumGrid.jsx +90 -0
  66. package/src/organisms/StackHero.jsx +83 -0
  67. package/src/organisms/WorkCard.jsx +120 -0
  68. package/src/organisms/WorkViewToggle.jsx +170 -0
  69. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  70. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  71. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  72. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  73. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  74. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  75. package/src/organisms/foundry/glyphData.js +30 -0
  76. package/src/organisms/foundry/index.js +21 -0
  77. package/src/atoms/QuantityStepper.jsx +0 -149
@@ -0,0 +1,91 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import usePrefersReducedMotion from './usePrefersReducedMotion.js'
3
+
4
+ /**
5
+ * useAxisAnimation — ping-pong rAF over a variable-font axis range.
6
+ *
7
+ * Owns a single numeric value that auto-oscillates between `min` and `max`
8
+ * (bouncing at each end) while `running` is true, then hands the value + a
9
+ * setter back to the caller so a slider can scrub it (scrub = call `setValue`,
10
+ * flip `running` off). This is the reusable core lifted out of the monorepo's
11
+ * VariableFontSection, whose auto-oscillator was inline and unguarded.
12
+ *
13
+ * Timing uses the `performance.now()` interval-accumulator pattern (the same
14
+ * shape as fontviewer's GlyphAnimator) rather than a `Date.now()` / `delta>16`
15
+ * throttle — the accumulator carries the sub-interval remainder so the step is
16
+ * frame-rate independent and doesn't jitter.
17
+ *
18
+ * Reduced-motion gate: when the user asks for reduced motion the loop never
19
+ * mounts and the hook returns its static default (`initial`, else the range
20
+ * midpoint) — the widget still renders and stays scrubbable, it just doesn't
21
+ * auto-play. `reduced` is returned so the caller can reflect it in its
22
+ * play/pause affordance.
23
+ *
24
+ * The value is axis-agnostic: the render boundary decides whether it feeds
25
+ * `fontWeight` (wght) or `font-variation-settings` (wdth/slnt/…).
26
+ *
27
+ * @param {Object} opts
28
+ * @param {number} opts.min - Lower bound of the oscillation + slider min (default 300).
29
+ * @param {number} opts.max - Upper bound of the oscillation + slider max (default 900).
30
+ * @param {number} opts.step - Units advanced per frame (default 2).
31
+ * @param {number} opts.fps - Target frame cadence for the accumulator (default 60).
32
+ * @param {boolean} opts.running - Whether the loop is active (default true). Gated by reduced-motion.
33
+ * @param {number} opts.initial - Starting / reduced-motion static value (default range midpoint).
34
+ * @returns {{ value: number, setValue: (v:number)=>void, reduced: boolean }}
35
+ */
36
+ export default function useAxisAnimation({
37
+ min = 300,
38
+ max = 900,
39
+ step = 2,
40
+ fps = 60,
41
+ running = true,
42
+ initial,
43
+ } = {}) {
44
+ const reduced = usePrefersReducedMotion()
45
+ const staticDefault = initial ?? Math.round((min + max) / 2)
46
+
47
+ const [value, setValueState] = useState(staticDefault)
48
+ const valueRef = useRef(staticDefault)
49
+ const dirRef = useRef(1)
50
+ const rafRef = useRef(null)
51
+
52
+ // External override (slider scrub). Keeps the ref in sync so a subsequent
53
+ // resume ping-pongs from where the user left it, not from a stale value.
54
+ const setValue = useCallback((v) => {
55
+ valueRef.current = v
56
+ setValueState(v)
57
+ }, [])
58
+
59
+ useEffect(() => {
60
+ if (reduced || !running) return
61
+
62
+ const interval = 1000 / fps
63
+ let last = performance.now()
64
+
65
+ const animate = (now) => {
66
+ const elapsed = now - last
67
+ if (elapsed >= interval) {
68
+ let next = valueRef.current + dirRef.current * step
69
+ if (next >= max) {
70
+ next = max
71
+ dirRef.current = -1
72
+ } else if (next <= min) {
73
+ next = min
74
+ dirRef.current = 1
75
+ }
76
+ valueRef.current = next
77
+ setValueState(next)
78
+ // Carry the sub-interval remainder so the cadence stays even.
79
+ last = now - (elapsed % interval)
80
+ }
81
+ rafRef.current = requestAnimationFrame(animate)
82
+ }
83
+
84
+ rafRef.current = requestAnimationFrame(animate)
85
+ return () => {
86
+ if (rafRef.current) cancelAnimationFrame(rafRef.current)
87
+ }
88
+ }, [reduced, running, min, max, step, fps])
89
+
90
+ return { value, setValue, reduced }
91
+ }
@@ -0,0 +1,21 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /**
4
+ * True when the user asks for reduced motion. The DS-wide motion gate:
5
+ * every animated/effect component checks this and renders its static
6
+ * form when true (the monorepo sources never did — added on recreate).
7
+ */
8
+ export default function usePrefersReducedMotion() {
9
+ const [reduced, setReduced] = useState(
10
+ () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches,
11
+ )
12
+
13
+ useEffect(() => {
14
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
15
+ const onChange = () => setReduced(mq.matches)
16
+ mq.addEventListener('change', onChange)
17
+ return () => mq.removeEventListener('change', onChange)
18
+ }, [])
19
+
20
+ return reduced
21
+ }
@@ -0,0 +1,49 @@
1
+ import { useRef } from 'react'
2
+ import { useMotionValue, useSpring, useTransform } from 'framer-motion'
3
+
4
+ /**
5
+ * Pointer-driven 3D tilt (framer-motion springs) — the ONE tilt hook.
6
+ * Ported from the monorepo's useBentoTiltMotion; TiltCard, BentoCard and
7
+ * friends all compose this instead of forking their own.
8
+ *
9
+ * Returns `{ ref, style, onMouseMove, onMouseLeave, motionValues }` —
10
+ * spread `ref`/handlers on a `motion.div` and pass `style` to it.
11
+ * `motionValues` exposes the raw springs for derived variants
12
+ * (e.g. TiltCard's `grounded` zone-snapping).
13
+ *
14
+ * Defaults are the design: tilt ±4°, spring 350/35, perspective 700,
15
+ * rest position center (0.5/0.5).
16
+ */
17
+ export default function useTilt({
18
+ magnitude = 4,
19
+ perspective = 700,
20
+ stiffness = 350,
21
+ damping = 35,
22
+ } = {}) {
23
+ const ref = useRef(null)
24
+ const mouseX = useMotionValue(0.5)
25
+ const mouseY = useMotionValue(0.5)
26
+
27
+ const rotateX = useSpring(useTransform(mouseY, [0, 1], [magnitude, -magnitude]), { stiffness, damping })
28
+ const rotateY = useSpring(useTransform(mouseX, [0, 1], [-magnitude, magnitude]), { stiffness, damping })
29
+
30
+ const onMouseMove = (e) => {
31
+ const rect = ref.current?.getBoundingClientRect()
32
+ if (!rect) return
33
+ mouseX.set((e.clientX - rect.left) / rect.width)
34
+ mouseY.set((e.clientY - rect.top) / rect.height)
35
+ }
36
+
37
+ const onMouseLeave = () => {
38
+ mouseX.set(0.5)
39
+ mouseY.set(0.5)
40
+ }
41
+
42
+ return {
43
+ ref,
44
+ style: { rotateX, rotateY, transformStyle: 'preserve-3d', transformPerspective: perspective },
45
+ onMouseMove,
46
+ onMouseLeave,
47
+ motionValues: { mouseX, mouseY, rotateX, rotateY },
48
+ }
49
+ }
package/src/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * lives in @kol/theme (kol-components-*.css).
7
7
  *
8
8
  * Placement follows the taxonomy rules in
9
- * docs/taxonomy/01-component-placement.md:
9
+ * docs/documentation/02-components/02-placement.md:
10
10
  * atom — nests no KOL component (kol-loader Icon/Graphic are
11
11
  * infrastructure, they don't count)
12
12
  * molecule — nests at least one KOL component
@@ -16,22 +16,33 @@
16
16
  */
17
17
 
18
18
  // atoms
19
+ export { default as AnimatedTitle } from './atoms/AnimatedTitle.jsx'
20
+ export { default as AssetGrid } from './atoms/AssetGrid.jsx'
19
21
  export { default as AssetPlaceholder } from './atoms/AssetPlaceholder.jsx'
20
22
  export { default as Avatar } from './atoms/Avatar.jsx'
21
23
  export { default as Badge } from './atoms/Badge.jsx'
22
24
  export { default as Button } from './atoms/Button.jsx'
23
25
  export { default as CopyButton } from './atoms/CopyButton.jsx'
26
+ export { default as CurveOverlay } from './atoms/CurveOverlay.jsx'
24
27
  export { default as Divider } from './atoms/Divider.jsx'
28
+ export { default as DocsToc } from './atoms/DocsToc.jsx'
25
29
  export { default as DropdownTagFilter } from './atoms/DropdownTagFilter.jsx'
30
+ export { default as EmptyState } from './atoms/EmptyState.jsx'
26
31
  export { default as ExitPreview } from './atoms/ExitPreview.jsx'
32
+ export { default as Figure } from './atoms/Figure.jsx'
27
33
  export { default as FullscreenOverlay } from './atoms/FullscreenOverlay.jsx'
34
+ export { default as HlsVideo } from './atoms/HlsVideo.jsx'
28
35
  export { default as Input } from './atoms/Input.jsx'
29
36
  export { default as Label } from './atoms/Label.jsx'
30
37
  export { default as LabeledControl } from './atoms/LabeledControl.jsx'
38
+ export { default as OverlayGlassPanel } from './atoms/OverlayGlassPanel.jsx'
31
39
  export { default as Pill } from './atoms/Pill.jsx'
32
40
  export { usePopover, PopoverPanel, Tooltip } from './atoms/Popover.jsx'
41
+ export { default as PriceDisplay } from './atoms/PriceDisplay.jsx'
42
+ export { default as ProsePreview } from './atoms/ProsePreview.jsx'
33
43
  export { default as QuantityInput } from './atoms/QuantityInput.jsx'
34
- export { default as QuantityStepper } from './atoms/QuantityStepper.jsx'
44
+ export { default as RotaryDial } from './atoms/RotaryDial.jsx'
45
+ export { default as SearchInput } from './atoms/SearchInput.jsx'
35
46
  export { default as Section } from './atoms/Section.jsx'
36
47
  export { default as SectionLabel } from './atoms/SectionLabel.jsx'
37
48
  export { default as SegmentedToggle } from './atoms/SegmentedToggle.jsx'
@@ -41,14 +52,29 @@ export { default as Textarea } from './atoms/Textarea.jsx'
41
52
  export { default as ToggleBracket } from './atoms/ToggleBracket.jsx'
42
53
  export { default as ToggleCheckbox } from './atoms/ToggleCheckbox.jsx'
43
54
  export { default as ToggleSwitch } from './atoms/ToggleSwitch.jsx'
55
+ export { default as TextPressure } from './atoms/TextPressure.jsx'
56
+ export { default as TiltCard } from './atoms/TiltCard.jsx'
44
57
  export { default as TransparentX } from './atoms/TransparentX.jsx'
58
+ export { default as TypeSample } from './atoms/TypeSample.jsx'
59
+ export { default as TypeSpecCard } from './atoms/TypeSpecCard.jsx'
45
60
  export { default as ViewToggle } from './atoms/ViewToggle.jsx'
46
61
 
47
62
  // molecules
48
63
  export { Accordion, AccordionPanel } from './molecules/Accordion.jsx'
64
+ /* monorepo sets (P6–P10) — molecule members */
65
+ export { default as AlignmentGrid } from './molecules/AlignmentGrid.jsx'
66
+ export { default as ArticleCard } from './molecules/ArticleCard.jsx'
67
+ export { default as ImageBlock } from './molecules/ImageBlock.jsx'
68
+ export { default as SelectionOverlay } from './molecules/SelectionOverlay.jsx'
69
+ export { default as VideoBlock, getEmbedUrl } from './molecules/VideoBlock.jsx'
70
+ export { default as WorkListItem } from './molecules/WorkListItem.jsx'
71
+ export { default as CardFeatureItem } from './molecules/CardFeatureItem.jsx'
49
72
  export { default as CodeBlock } from './molecules/CodeBlock.jsx'
73
+ export { default as ColorInputRow } from './molecules/ColorInputRow.jsx'
74
+ export { default as ColorRamp } from './molecules/ColorRamp.jsx'
50
75
  export { default as ColorSwatch } from './molecules/ColorSwatch.jsx'
51
76
  export { default as Dropdown } from './molecules/Dropdown.jsx'
77
+ export { default as FramedMediaBand } from './molecules/FramedMediaBand.jsx'
52
78
  export { default as Image } from './molecules/Image.jsx'
53
79
  export { default as MediaCard } from './molecules/MediaCard.jsx'
54
80
  export { default as MediaRow } from './molecules/MediaRow.jsx'
@@ -56,11 +82,45 @@ export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } fro
56
82
  export { MenuPopover } from './molecules/MenuPopover.jsx'
57
83
  export { ModalProvider, useModal } from './molecules/Modal.jsx'
58
84
  export { default as PropertyInput } from './molecules/PropertyInput.jsx'
85
+ export { default as ShapeDropdown } from './molecules/ShapeDropdown.jsx'
86
+ export { default as ShellDrawer } from './molecules/ShellDrawer.jsx'
87
+ export { default as ShellSearchOverlay } from './molecules/ShellSearchOverlay.jsx'
59
88
  export { default as Slider } from './molecules/Slider.jsx'
89
+ export { default as SpecList } from './molecules/SpecList.jsx'
90
+ export { default as SpectrumControls, HueStrip, SBSquare, WheelTriangle } from './molecules/SpectrumControls.jsx'
91
+ export { default as SwatchControls, SwatchStack, EyedropPick } from './molecules/SwatchControls.jsx'
92
+ export { default as TabsRow } from './molecules/TabsRow.jsx'
60
93
 
61
94
  // organisms
95
+ /* monorepo sets (P6–P10) — organism members. Foundry members are isolated
96
+ under the './foundry' subpath (opentype-heavy) — not re-exported here. */
97
+ export { default as ArticleHeader } from './organisms/ArticleHeader.jsx'
98
+ export { default as Canvas, CanvasFrame, PanViewport, CANVAS_VIRTUAL_W, DEFAULT_ASPECTS, CANVAS_DEFAULTS } from './organisms/Canvas.jsx'
99
+ export { default as DiagonalMarqueeRiver } from './organisms/DiagonalMarqueeRiver.jsx'
100
+ export { default as EditorShell } from './organisms/EditorShell.jsx'
101
+ export { default as GalleryCarousel } from './organisms/GalleryCarousel.jsx'
102
+ export { default as ParallaxShelf } from './organisms/ParallaxShelf.jsx'
103
+ export { default as PortableTextRenderer, slugify } from './organisms/PortableTextRenderer.jsx'
104
+ export { default as ProductDetailLayout } from './organisms/ProductDetailLayout.jsx'
105
+ export { default as ScrollDriftGallery } from './organisms/ScrollDriftGallery.jsx'
106
+ export { default as StackHero } from './organisms/StackHero.jsx'
107
+ export { default as WorkCard } from './organisms/WorkCard.jsx'
108
+ export { default as WorkViewToggle } from './organisms/WorkViewToggle.jsx'
109
+ export { default as AsciiCursor } from './organisms/AsciiCursor.jsx'
110
+ export { default as BentoCard } from './organisms/BentoCard.jsx'
62
111
  export { default as Carousel } from './organisms/Carousel.jsx'
112
+ export { default as ColorLoader } from './organisms/ColorLoader.jsx'
63
113
  export { default as ContentFilters } from './organisms/ContentFilters.jsx'
114
+ export { default as CtaGlobal } from './organisms/CtaGlobal.jsx'
115
+ export { default as ErrorBoundary } from './organisms/ErrorBoundary.jsx'
116
+ export { default as FeatureSplit } from './organisms/FeatureSplit.jsx'
117
+ export { default as FeaturedCarousel } from './organisms/FeaturedCarousel.jsx'
118
+ export { default as FeaturesCardSection } from './organisms/FeaturesCardSection.jsx'
119
+ export { default as FullBleedHero } from './organisms/FullBleedHero.jsx'
120
+ export { default as LoaderOverlay } from './organisms/LoaderOverlay.jsx'
121
+ export { default as MediaViewer } from './organisms/MediaViewer.jsx'
122
+ export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
123
+ export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
64
124
  export { default as Table } from './organisms/Table.jsx'
65
125
 
66
126
  // loaders (re-export — infrastructure, documented on /docs/loaders)
@@ -70,5 +130,9 @@ export { Icon } from '@kolkrabbi/kol-loader'
70
130
  export { default as Graphic, GRAPHICS } from './graphics/Graphic.jsx'
71
131
 
72
132
  // hooks
133
+ export { default as usePrefersReducedMotion } from './hooks/usePrefersReducedMotion.js'
73
134
  export { default as useReveal } from './hooks/useReveal.js'
74
135
  export { default as useScrollSpy } from './hooks/useScrollSpy.js'
136
+ export { default as useTilt } from './hooks/useTilt.js'
137
+ export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
138
+ export { resolveCssVar, resolveCssColor, isLight } from './hooks/cssVar.js'
@@ -0,0 +1,53 @@
1
+ import Button from '../atoms/Button.jsx'
2
+
3
+ /**
4
+ * AlignmentGrid — a six-cell alignment row: horizontal start/center/end then
5
+ * vertical start/center/end, each a quiet icon Button that emits an
6
+ * `(axis, mode)` alignment intent. Driven off a static config array (the
7
+ * default 6, overridable via `items`). Presentation only — the consumer wires
8
+ * `onAlign` to whatever "align these" means in its context (align a box to the
9
+ * canvas bounds, align a multi-selection's common bbox, …).
10
+ *
11
+ * Ported from the brand editor's AlignmentPanel with the store coupling
12
+ * dropped (per lobby spec): `useComposeState().alignSelected` → an `onAlign`
13
+ * prop; the hand-rolled `kol-btn-quiet` buttons → DS `Button` (quiet +
14
+ * iconOnly) so the DS owns the button atom; `EditorIcon` → the DS Icon
15
+ * (through Button).
16
+ *
17
+ * [icon-gap] The source's `align-h-{start,center,end}` / `align-v-{start,
18
+ * center,end}` glyph names are NOT in the loader. Remapped to the closest
19
+ * existing loader glyphs (stroke/layout): `align-horizontal-{left,center,
20
+ * right}` and `align-vertical-{top,center,bottom}`. Same six align marks,
21
+ * different names — no visual gap.
22
+ *
23
+ * @param {(axis:'h'|'v', mode:'start'|'center'|'end') => void} onAlign fired on cell click
24
+ * @param {Array} items [{ axis, mode, icon, title }] cells (default the standard 6)
25
+ */
26
+ const ALIGN_BUTTONS = [
27
+ { axis: 'h', mode: 'start', icon: 'align-horizontal-left', title: 'Align left' },
28
+ { axis: 'h', mode: 'center', icon: 'align-horizontal-center', title: 'Align horizontal center' },
29
+ { axis: 'h', mode: 'end', icon: 'align-horizontal-right', title: 'Align right' },
30
+ { axis: 'v', mode: 'start', icon: 'align-vertical-top', title: 'Align top' },
31
+ { axis: 'v', mode: 'center', icon: 'align-vertical-center', title: 'Align vertical center' },
32
+ { axis: 'v', mode: 'end', icon: 'align-vertical-bottom', title: 'Align bottom' },
33
+ ]
34
+
35
+ export default function AlignmentGrid({ onAlign, items = ALIGN_BUTTONS }) {
36
+ return (
37
+ <div className="grid grid-cols-6 gap-1">
38
+ {items.map((b) => (
39
+ <Button
40
+ key={`${b.axis}-${b.mode}`}
41
+ quiet
42
+ iconOnly={b.icon}
43
+ iconSize={16}
44
+ onClick={() => onAlign?.(b.axis, b.mode)}
45
+ title={b.title}
46
+ aria-label={b.title}
47
+ className="w-full"
48
+ style={{ height: 28, padding: 6 }}
49
+ />
50
+ ))}
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,178 @@
1
+ import Pill from '../atoms/Pill.jsx'
2
+ import Image from './Image.jsx'
3
+
4
+ /**
5
+ * Optional link wrapper — the one place routing lives. `http*`/`mailto` hrefs
6
+ * open in a new tab; any other href renders a plain same-tab anchor with an
7
+ * `onNavigate(event)` seam an SPA consumer intercepts (preventDefault + its
8
+ * router). No `href` → the bare content, no anchor.
9
+ */
10
+ function CardLink({ href, onNavigate, className, children }) {
11
+ if (!href) return <div className={className}>{children}</div>
12
+ const isExternal = href.startsWith('http') || href.startsWith('mailto')
13
+ return (
14
+ <a
15
+ href={href}
16
+ onClick={isExternal ? undefined : onNavigate}
17
+ target={isExternal ? '_blank' : undefined}
18
+ rel={isExternal ? 'noreferrer noopener' : undefined}
19
+ className={className}
20
+ >
21
+ {children}
22
+ </a>
23
+ )
24
+ }
25
+
26
+ /** Cover-fit thumbnail (or fg-token placeholder box when no src). */
27
+ function Thumb({ src, alt }) {
28
+ if (!src) return null
29
+ return (
30
+ <Image
31
+ src={src}
32
+ alt={alt}
33
+ className="object-cover"
34
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
35
+ />
36
+ )
37
+ }
38
+
39
+ /**
40
+ * ArticleCard — one blog-card family with three sizes (`default` / `hero` /
41
+ * `mini`), collapsing what were three byte-identical duplicate components. All
42
+ * three share the flat view-model (title, excerpt, thumbnail, tags, meta) and
43
+ * the link seam; `size` picks the layout.
44
+ *
45
+ * - **default** — grid tile: thumbnail (landscape/portrait) over tag Pills, a
46
+ * mono title, an excerpt, and a `date • readingTime` meta Pill.
47
+ * - **hero** — index masthead: an optional header row (`label` + `meta`),
48
+ * a big 16/9 image that zooms on hover, then kicker → title → summary.
49
+ * - **mini** — sidebar row: a fixed 120×120 thumbnail beside title → summary →
50
+ * meta.
51
+ *
52
+ * De-Sanitized flat props, no router import. Placeholder thumbnails use
53
+ * fg-opacity tokens (theme-aware — no `.dark` style injection). Every string
54
+ * is authored at the call site in its final case — no `text-transform`, no JS
55
+ * casing (the kicker/meta `uppercase` are presentational classes only).
56
+ *
57
+ * @param {'default'|'hero'|'mini'} size layout preset (default 'default')
58
+ * @param {string} title card title
59
+ * @param {string} excerpt summary/excerpt paragraph (alias: `summary`)
60
+ * @param {string} summary alias for `excerpt` (hero/mini call it this)
61
+ * @param {string} kicker hero-only eyebrow above the title
62
+ * @param {string} label hero header label (was the hardcoded "Featured")
63
+ * @param {string[]} meta hero header chips / mini meta line (joined ` • `)
64
+ * @param {string} date default meta — left of the `•`
65
+ * @param {string} readingTime default meta — right of the `•`
66
+ * @param {string[]} tags default: rendered as Pills; hero/mini: `data-tags` only
67
+ * @param {string} thumbnail image src (omit → fg-token placeholder)
68
+ * @param {'landscape'|'portrait'} aspect default thumbnail ratio (default 'landscape')
69
+ * @param {boolean} showHeader hero: show the label/meta header row (default true)
70
+ * @param {string} href link target; `http*`/`mailto` → new tab, else same-tab seam
71
+ * @param {Function} onNavigate (event) => void — same-tab click seam (SPA intercept)
72
+ * @param {string} className extra classes on the root
73
+ */
74
+ export default function ArticleCard({
75
+ size = 'default',
76
+ title,
77
+ excerpt,
78
+ summary,
79
+ kicker,
80
+ label,
81
+ meta,
82
+ date,
83
+ readingTime,
84
+ tags = [],
85
+ thumbnail,
86
+ aspect = 'landscape',
87
+ showHeader = true,
88
+ href,
89
+ onNavigate,
90
+ className = '',
91
+ }) {
92
+ const body = excerpt ?? summary
93
+ const dataTags = tags?.length ? tags.join(' ') : undefined
94
+
95
+ if (size === 'hero') {
96
+ return (
97
+ <CardLink href={href} onNavigate={onNavigate} className={`block group ${className}`.trim()}>
98
+ <article className="w-full" data-tags={dataTags}>
99
+ {showHeader && (label || meta?.length) && (
100
+ <div className="flex justify-between items-center mb-4">
101
+ {label && <div className="kol-helper-14 text-fg-64">{label}</div>}
102
+ {meta?.length > 0 && (
103
+ <div className="flex gap-3 kol-helper-12 text-fg-48">
104
+ {meta.map((item, i) => <span key={i}>{item}</span>)}
105
+ </div>
106
+ )}
107
+ </div>
108
+ )}
109
+ <div className="aspect-[16/9] mb-4 overflow-hidden w-full bg-fg-04 border border-fg-08 hover:border-fg-16 rounded">
110
+ {thumbnail && (
111
+ <Image
112
+ src={thumbnail}
113
+ alt={title}
114
+ className="object-cover transition-transform duration-300 group-hover:scale-105"
115
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
116
+ />
117
+ )}
118
+ </div>
119
+ <div className="space-y-3">
120
+ {kicker && (
121
+ <div className="kol-helper-16 uppercase tracking-wide text-fg-64">{kicker}</div>
122
+ )}
123
+ <h2 className="kol-sans-heading-03 transition-opacity duration-200 group-hover:opacity-70 line-clamp-2">
124
+ {title}
125
+ </h2>
126
+ {body && <p className="kol-mono-14 text-fg-48 line-clamp-3">{body}</p>}
127
+ </div>
128
+ </article>
129
+ </CardLink>
130
+ )
131
+ }
132
+
133
+ if (size === 'mini') {
134
+ const metaText = Array.isArray(meta) ? meta.join(' • ') : meta
135
+ return (
136
+ <CardLink
137
+ href={href}
138
+ onNavigate={onNavigate}
139
+ className={`group flex gap-6 items-start transition-opacity hover:opacity-80 ${className}`.trim()}
140
+ >
141
+ <div className="flex-shrink-0 w-[120px] h-[120px] overflow-hidden rounded bg-fg-12" data-tags={dataTags}>
142
+ <Thumb src={thumbnail} alt={title} />
143
+ </div>
144
+ <div className="flex-1 min-w-0 flex flex-col gap-2.5">
145
+ <h4 className="kol-mono-14 line-clamp-2 transition-opacity group-hover:opacity-80">{title}</h4>
146
+ {body && <p className="kol-mono-14 text-fg-64 line-clamp-2">{body}</p>}
147
+ {metaText && <div className="kol-helper-12 text-fg-80 uppercase">{metaText}</div>}
148
+ </div>
149
+ </CardLink>
150
+ )
151
+ }
152
+
153
+ // size === 'default'
154
+ return (
155
+ <CardLink href={href} onNavigate={onNavigate} className={`block group cursor-pointer w-full max-w-full ${className}`.trim()}>
156
+ <article className="w-full max-w-full" data-tags={dataTags}>
157
+ <div
158
+ className="mb-4 overflow-hidden w-full rounded bg-fg-04 border border-fg-08"
159
+ style={{ aspectRatio: aspect === 'portrait' ? '3/4' : '16/9' }}
160
+ >
161
+ <Thumb src={thumbnail} alt={title} />
162
+ </div>
163
+ {tags.length > 0 && (
164
+ <div className="flex flex-wrap gap-2 mb-2">
165
+ {tags.map((tag, i) => (
166
+ <Pill key={i} variant="inverse" size="sm">{tag}</Pill>
167
+ ))}
168
+ </div>
169
+ )}
170
+ <h3 className="mb-2 kol-mono-20 transition-opacity duration-200 group-hover:opacity-70">{title}</h3>
171
+ {body && <p className="mb-2 kol-mono-14 text-fg-64">{body}</p>}
172
+ {(date || readingTime) && (
173
+ <Pill variant="subtle" size="sm">{[date, readingTime].filter(Boolean).join(' • ')}</Pill>
174
+ )}
175
+ </article>
176
+ </CardLink>
177
+ )
178
+ }
@@ -0,0 +1,130 @@
1
+ import { Icon } from '@kolkrabbi/kol-loader'
2
+
3
+ /* taxonomy-ok: nests only kol-loader's Icon (a package import the
4
+ * relative-import check can't see). */
5
+
6
+ /**
7
+ * CardFeatureItem — fixed-height feature card: a title + optional icon
8
+ * header, a flexible visual middle, and a mono description footer. The
9
+ * card's visual is polymorphic: an `.svg` URL string renders as a
10
+ * `mask-image` block tinted with `currentColor` (theme-correct line-art),
11
+ * any other string renders as a cover-fit `<img>`, a ReactNode renders
12
+ * as-is, and no visual falls back to a large 96px `Icon` (the header's
13
+ * `icon` name). The grid child of FeaturesCardSection.
14
+ *
15
+ * Optionally the whole card is a link. `http*` / `mailto` hrefs open in a
16
+ * new tab; any other href renders a plain same-tab anchor with an
17
+ * `onNavigate(event)` seam — an SPA consumer intercepts there
18
+ * (preventDefault + its router) instead of this component importing one.
19
+ *
20
+ * Title and description render exactly as authored — no casing transforms;
21
+ * author strings in their final case at the call site.
22
+ *
23
+ * @param {ReactNode} title header heading (kol-helper-16)
24
+ * @param {string} icon Icon name for the header (16px); also the 96px no-visual fallback
25
+ * @param {string|ReactNode} visual image URL, `.svg` mask URL, or inline node
26
+ * @param {ReactNode} description footer line (kol-mono-12, muted)
27
+ * @param {string} backgroundColor card background utility class
28
+ * @param {string} href link target; `http*`/`mailto` → new tab, else plain same-tab anchor
29
+ * @param {Function} onNavigate (event) => void — click seam on the same-tab anchor (SPA intercept)
30
+ * @param {'auto'|'9/6'|'10/6'|'16/9'|'1/1'} imageAspectRatio aspect class on the visual middle
31
+ * @param {string} imagePosition `<img>` object-position
32
+ */
33
+ export default function CardFeatureItem({
34
+ title,
35
+ icon,
36
+ visual,
37
+ description,
38
+ backgroundColor = 'bg-surface-primary',
39
+ href,
40
+ onNavigate,
41
+ imageAspectRatio = 'auto',
42
+ imagePosition = 'center',
43
+ }) {
44
+ const isSvgUrl = typeof visual === 'string' && visual.endsWith('.svg')
45
+
46
+ const aspectClasses = {
47
+ 'auto': '',
48
+ '9/6': 'aspect-[9/6]',
49
+ '10/6': 'aspect-[10/6]',
50
+ '16/9': 'aspect-video',
51
+ '1/1': 'aspect-square',
52
+ }
53
+ const aspectClass = aspectClasses[imageAspectRatio] || ''
54
+
55
+ const content = (
56
+ <>
57
+ <div className="w-full flex items-center justify-between gap-2">
58
+ <h3 className="kol-helper-16">{title}</h3>
59
+ {icon && <Icon name={icon} size={16} className="shrink-0" />}
60
+ </div>
61
+
62
+ <div className={`w-full flex-1 flex items-center justify-center overflow-hidden ${aspectClass}`.trim()}>
63
+ {visual ? (
64
+ typeof visual === 'string' ? (
65
+ isSvgUrl ? (
66
+ /* currentColor line-art: the SVG paints as a mask over bg-current */
67
+ <div
68
+ className="w-full h-full bg-current rounded"
69
+ style={{
70
+ maskImage: `url(${visual})`,
71
+ maskSize: 'contain',
72
+ maskRepeat: 'no-repeat',
73
+ maskPosition: 'center',
74
+ WebkitMaskImage: `url(${visual})`,
75
+ WebkitMaskSize: 'contain',
76
+ WebkitMaskRepeat: 'no-repeat',
77
+ WebkitMaskPosition: 'center',
78
+ }}
79
+ />
80
+ ) : (
81
+ <img
82
+ src={visual}
83
+ alt={typeof title === 'string' ? title : ''}
84
+ className="w-full h-full object-cover rounded"
85
+ style={{ objectPosition: imagePosition }}
86
+ />
87
+ )
88
+ ) : (
89
+ visual
90
+ )
91
+ ) : (
92
+ <Icon name={icon} size={96} />
93
+ )}
94
+ </div>
95
+
96
+ <p className="kol-mono-12 text-fg-48">{description}</p>
97
+ </>
98
+ )
99
+
100
+ const baseClasses = `w-full flex-1 h-[304px] md:h-72 p-4 md:p-6 gap-4 ${backgroundColor} rounded border border-fg-08 flex flex-col justify-between items-start overflow-hidden`
101
+
102
+ if (href) {
103
+ const isExternal = href.startsWith('http') || href.startsWith('mailto')
104
+
105
+ if (isExternal) {
106
+ return (
107
+ <a
108
+ href={href}
109
+ className={`${baseClasses} hover:border-fg-32 transition-colors duration-300`}
110
+ target="_blank"
111
+ rel="noreferrer noopener"
112
+ >
113
+ {content}
114
+ </a>
115
+ )
116
+ }
117
+
118
+ return (
119
+ <a
120
+ href={href}
121
+ onClick={onNavigate}
122
+ className={`${baseClasses} hover:border-fg-24 transition-colors duration-300`}
123
+ >
124
+ {content}
125
+ </a>
126
+ )
127
+ }
128
+
129
+ return <div className={baseClasses}>{content}</div>
130
+ }