@kolkrabbi/kol-component 0.1.2 → 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 +12 -4
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/{primitives → atoms}/AssetPlaceholder.jsx +4 -2
- package/src/atoms/Avatar.jsx +4 -4
- package/src/atoms/Button.jsx +23 -9
- package/src/atoms/CopyButton.jsx +50 -0
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/{molecules → atoms}/DropdownTagFilter.jsx +2 -1
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/Input.jsx +7 -2
- 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/{molecules → atoms}/SegmentedToggle.jsx +33 -14
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/ToggleCheckbox.jsx +1 -1
- package/src/atoms/ToggleSwitch.jsx +1 -1
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -0
- package/src/{molecules → atoms}/ViewToggle.jsx +6 -3
- package/src/graphics/Graphic.jsx +56 -18
- package/src/graphics/graphicData.js +19 -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 +119 -39
- package/src/{primitives → molecules}/Accordion.jsx +4 -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/CodeBlock.jsx +11 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/{atoms → molecules}/ColorSwatch.jsx +1 -1
- package/src/molecules/Dropdown.jsx +3 -2
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/{primitives → molecules}/Image.jsx +1 -1
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/MediaCard.jsx +97 -0
- package/src/molecules/MediaRow.jsx +49 -0
- package/src/molecules/MenuItem.jsx +3 -2
- package/src/molecules/MenuPopover.jsx +11 -96
- 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/{atoms → molecules}/Slider.jsx +1 -1
- 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/{molecules → organisms}/ContentFilters.jsx +2 -2
- 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
- package/src/primitives/CodeBlock.jsx +0 -41
- /package/src/{molecules → atoms}/Badge.jsx +0 -0
- /package/src/{primitives → atoms}/ExitPreview.jsx +0 -0
- /package/src/{primitives → atoms}/FullscreenOverlay.jsx +0 -0
- /package/src/{molecules → atoms}/LabeledControl.jsx +0 -0
- /package/src/{molecules → atoms}/Pill.jsx +0 -0
- /package/src/{molecules → atoms}/Popover.jsx +0 -0
- /package/src/{molecules → atoms}/QuantityInput.jsx +0 -0
- /package/src/{molecules → atoms}/QuantityStepper.jsx +0 -0
- /package/src/{molecules → atoms}/Section.jsx +0 -0
- /package/src/{molecules → atoms}/SectionLabel.jsx +0 -0
- /package/src/{molecules → atoms}/Tag.jsx +0 -0
- /package/src/{molecules → atoms}/ToggleBracket.jsx +0 -0
- /package/src/{primitives → organisms}/Carousel.jsx +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CtaGlobal — editorial two-column contact/CTA band: a large display wordmark
|
|
3
|
+
* on the left (e.g. "/ CONNECT") and a right column of stacked
|
|
4
|
+
* label-over-value rows — a prompt row ("WORKING ON A PROJECT?" / "SEND A
|
|
5
|
+
* MESSAGE") and a contact row whose value is a `mailto:` link. The editorial
|
|
6
|
+
* closer of the CTA-band family (FoundryCTA is the simple centered tier).
|
|
7
|
+
* Columns sit side by side from `md` up and stack below — the source's
|
|
8
|
+
* no-breakpoint two-column was a bug fixed on recreation.
|
|
9
|
+
*
|
|
10
|
+
* Presentational — all copy arrives as props (the source hard-coded every
|
|
11
|
+
* string). The right column is internally a list of rows: the named
|
|
12
|
+
* `promptLabel`/`heading` and `contactLabel`/`email` conveniences cover the
|
|
13
|
+
* common case, `secondaryRows` slots extra rows between them. A row with an
|
|
14
|
+
* `href` renders an `<a>`; without, a static heading. Empty rows emit no
|
|
15
|
+
* nodes.
|
|
16
|
+
*
|
|
17
|
+
* Eyebrow, labels, and values render exactly as authored — the source's
|
|
18
|
+
* `uppercase` classes were dropped per KOL casing rules; author strings in
|
|
19
|
+
* their final case at the call site.
|
|
20
|
+
*
|
|
21
|
+
* @param {ReactNode} eyebrow left-column display wordmark (kol-sans-display-01)
|
|
22
|
+
* @param {ReactNode} promptLabel prompt-row label (kol-helper-16)
|
|
23
|
+
* @param {ReactNode} heading prompt-row value (kol-sans-heading-01)
|
|
24
|
+
* @param {ReactNode} contactLabel contact-row label (kol-helper-16)
|
|
25
|
+
* @param {string} email contact-row value + `mailto:` target; omit to drop the row
|
|
26
|
+
* @param {{label: ReactNode, value: ReactNode, href?: string}[]} secondaryRows extra right-column rows between prompt and contact
|
|
27
|
+
* @param {string} className extra classes on the section
|
|
28
|
+
*/
|
|
29
|
+
export default function CtaGlobal({
|
|
30
|
+
eyebrow,
|
|
31
|
+
promptLabel,
|
|
32
|
+
heading,
|
|
33
|
+
contactLabel,
|
|
34
|
+
email,
|
|
35
|
+
secondaryRows = [],
|
|
36
|
+
className = '',
|
|
37
|
+
}) {
|
|
38
|
+
const rows = [
|
|
39
|
+
...(promptLabel || heading ? [{ label: promptLabel, value: heading }] : []),
|
|
40
|
+
...secondaryRows,
|
|
41
|
+
...(email ? [{ label: contactLabel, value: email, href: `mailto:${email}` }] : []),
|
|
42
|
+
]
|
|
43
|
+
return (
|
|
44
|
+
<section className={`w-full bg-auto ${className}`.trim()}>
|
|
45
|
+
<div className="w-full py-10 flex flex-col md:flex-row items-start gap-12 overflow-hidden">
|
|
46
|
+
<div className="flex-1 self-stretch text-auto kol-sans-display-01">{eyebrow}</div>
|
|
47
|
+
<div className="flex-1 self-stretch pb-6 md:pt-32 flex flex-col justify-end items-start gap-12">
|
|
48
|
+
{rows.map((row, i) => (
|
|
49
|
+
<div key={i} className="self-stretch flex flex-col items-start gap-2">
|
|
50
|
+
{row.label && <div className="text-auto kol-helper-16">{row.label}</div>}
|
|
51
|
+
{row.href ? (
|
|
52
|
+
<a
|
|
53
|
+
href={row.href}
|
|
54
|
+
className="text-auto kol-sans-heading-01 hover:opacity-70 transition-opacity"
|
|
55
|
+
>
|
|
56
|
+
{row.value}
|
|
57
|
+
</a>
|
|
58
|
+
) : (
|
|
59
|
+
<div className="text-auto kol-sans-heading-01">{row.value}</div>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
import { gsap } from 'gsap'
|
|
3
|
+
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DiagonalMarqueeRiver — an auto-scrolling multi-column marquee "river":
|
|
7
|
+
* `items` are dealt round-robin into `colCount` vertical columns, each column
|
|
8
|
+
* runs an infinite constant-velocity GSAP y-tween at its own speed, and the
|
|
9
|
+
* whole block is rotated + scaled so the columns stream diagonally across a
|
|
10
|
+
* tall stage. An IntersectionObserver pauses every tween while the stage is
|
|
11
|
+
* off-screen. Data-agnostic — the card is a `renderItem` slot; the engine only
|
|
12
|
+
* measures scrollHeight and tweens y.
|
|
13
|
+
*
|
|
14
|
+
* Each column's list is rendered twice so the tween can travel exactly one set
|
|
15
|
+
* then repeat with an invisible wrap. On `prefers-reduced-motion` no GSAP runs
|
|
16
|
+
* and the columns render static. The tween set is rebuilt on container resize
|
|
17
|
+
* (a ResizeObserver remeasures scrollHeight — the source keyed only on the item
|
|
18
|
+
* list and drifted when the viewport changed).
|
|
19
|
+
*
|
|
20
|
+
* @param {any[]} items pool dealt round-robin into columns
|
|
21
|
+
* @param {(item:any,i:number)=>ReactNode} renderItem card slot per item
|
|
22
|
+
* @param {number} colCount number of columns (default 4)
|
|
23
|
+
* @param {number[]} columnSpeeds px/s per column (default [28,38,22,34], fallback 30)
|
|
24
|
+
* @param {number} gap vertical gap between cards and gap-between-columns, px (default 24)
|
|
25
|
+
* @param {string} height stage height (default '300vh')
|
|
26
|
+
* @param {number} rotate tilt of the block, deg (default -15)
|
|
27
|
+
* @param {number} scale oversize of the block (default 1.3)
|
|
28
|
+
* @param {boolean} shuffle one-time shuffle of `items` before dealing (default false)
|
|
29
|
+
*/
|
|
30
|
+
export default function DiagonalMarqueeRiver({
|
|
31
|
+
items = [],
|
|
32
|
+
renderItem,
|
|
33
|
+
colCount = 4,
|
|
34
|
+
columnSpeeds = [28, 38, 22, 34],
|
|
35
|
+
gap = 24,
|
|
36
|
+
height = '300vh',
|
|
37
|
+
rotate = -15,
|
|
38
|
+
scale = 1.3,
|
|
39
|
+
shuffle = false,
|
|
40
|
+
}) {
|
|
41
|
+
const containerRef = useRef(null)
|
|
42
|
+
const columnRefs = useRef([])
|
|
43
|
+
const tweensRef = useRef([])
|
|
44
|
+
const reducedMotion = usePrefersReducedMotion()
|
|
45
|
+
|
|
46
|
+
// Deal items round-robin into columns (optionally shuffled once).
|
|
47
|
+
const columns = useMemo(() => {
|
|
48
|
+
const pool = shuffle ? [...items].sort(() => Math.random() - 0.5) : items
|
|
49
|
+
const cols = Array.from({ length: colCount }, () => [])
|
|
50
|
+
pool.forEach((item, i) => cols[i % colCount].push(item))
|
|
51
|
+
return cols
|
|
52
|
+
}, [items, colCount, shuffle])
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (reducedMotion) return undefined
|
|
56
|
+
|
|
57
|
+
const build = () => {
|
|
58
|
+
const colEls = columnRefs.current.filter(Boolean)
|
|
59
|
+
tweensRef.current.forEach((tw) => tw.kill())
|
|
60
|
+
tweensRef.current = []
|
|
61
|
+
if (!colEls.length) return
|
|
62
|
+
|
|
63
|
+
colEls.forEach((colEl, i) => {
|
|
64
|
+
colEl.style.willChange = 'transform'
|
|
65
|
+
const singleSetHeight = colEl.scrollHeight / 2 // one copy of the doubled list
|
|
66
|
+
const speed = columnSpeeds[i] || 30
|
|
67
|
+
const initialOffset = -(singleSetHeight * ((i * 0.15) % 1)) // staggered per-column start
|
|
68
|
+
gsap.set(colEl, { y: initialOffset })
|
|
69
|
+
const tween = gsap.to(colEl, {
|
|
70
|
+
y: initialOffset - singleSetHeight, // travel exactly one set → seamless
|
|
71
|
+
duration: singleSetHeight / speed, // constant px/s regardless of column length
|
|
72
|
+
ease: 'none',
|
|
73
|
+
repeat: -1,
|
|
74
|
+
})
|
|
75
|
+
tweensRef.current.push(tween)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
build()
|
|
80
|
+
|
|
81
|
+
// Pause tweens while the stage is off-screen.
|
|
82
|
+
const io = new IntersectionObserver(
|
|
83
|
+
([entry]) => {
|
|
84
|
+
tweensRef.current.forEach((tw) => (entry.isIntersecting ? tw.resume() : tw.pause()))
|
|
85
|
+
},
|
|
86
|
+
{ threshold: 0 },
|
|
87
|
+
)
|
|
88
|
+
if (containerRef.current) io.observe(containerRef.current)
|
|
89
|
+
|
|
90
|
+
// [source-bug] resize-remeasure: source keyed only on the item list, so a
|
|
91
|
+
// viewport change left the tweens measuring a stale scrollHeight. Rebuild
|
|
92
|
+
// on container resize (debounced).
|
|
93
|
+
let raf = 0
|
|
94
|
+
const ro = new ResizeObserver(() => {
|
|
95
|
+
cancelAnimationFrame(raf)
|
|
96
|
+
raf = requestAnimationFrame(build)
|
|
97
|
+
})
|
|
98
|
+
if (containerRef.current) ro.observe(containerRef.current)
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
io.disconnect()
|
|
102
|
+
ro.disconnect()
|
|
103
|
+
cancelAnimationFrame(raf)
|
|
104
|
+
tweensRef.current.forEach((tw) => tw.kill())
|
|
105
|
+
tweensRef.current = []
|
|
106
|
+
}
|
|
107
|
+
}, [columns, columnSpeeds, reducedMotion])
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div ref={containerRef} className="w-full overflow-hidden" style={{ height }}>
|
|
111
|
+
<div
|
|
112
|
+
className="flex gap-6"
|
|
113
|
+
style={{
|
|
114
|
+
transform: `rotate(${rotate}deg) scale(${scale})`,
|
|
115
|
+
transformOrigin: 'center center',
|
|
116
|
+
height: '140%',
|
|
117
|
+
marginTop: '-20%',
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{columns.map((col, colIndex) => (
|
|
121
|
+
<div key={colIndex} className="flex-1 overflow-hidden">
|
|
122
|
+
<div
|
|
123
|
+
ref={(el) => {
|
|
124
|
+
columnRefs.current[colIndex] = el
|
|
125
|
+
}}
|
|
126
|
+
className="flex flex-col"
|
|
127
|
+
style={{ gap: `${gap}px` }}
|
|
128
|
+
>
|
|
129
|
+
{[...col, ...col].map((item, i) => (
|
|
130
|
+
<div key={i}>{renderItem?.(item, i)}</div>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import Divider from '../atoms/Divider.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EditorShell — the two-rail editor layout frame.
|
|
5
|
+
*
|
|
6
|
+
* ┌──────────────── topbar ──────────────────────┐
|
|
7
|
+
* ├────────┬──────────────────────────┬──────────┤
|
|
8
|
+
* │ left │ [canvasHeader] │ right │
|
|
9
|
+
* │ rail │ children (canvas) │ rail │
|
|
10
|
+
* └────────┴──────────────────────────┴──────────┘
|
|
11
|
+
*
|
|
12
|
+
* Fixed-width rails flank a fluid canvas column, all under an optional topbar.
|
|
13
|
+
* Hairlines between regions are composed from the DS `Divider` (vertical
|
|
14
|
+
* between rails/canvas, horizontal under the topbar and rail/canvas headers).
|
|
15
|
+
* Headers render only when their slot is filled, so an unused header
|
|
16
|
+
* contributes no border or gap (the source's `:empty` collapse, expressed as a
|
|
17
|
+
* conditional render instead of a CSS rule).
|
|
18
|
+
*
|
|
19
|
+
* Ported from the brand editor with the app couplings dropped (per lobby
|
|
20
|
+
* spec): the panel-registry + `panelsForSlot`/`SLOTS` indirection is replaced
|
|
21
|
+
* by plain ReactNode slots (`left` / `right` / `children` + optional headers,
|
|
22
|
+
* topbar, overlays); the `MenuTop` / `ShortcutsOverlay` imports become the
|
|
23
|
+
* `topbar` / `overlays` slots; the editor stylesheet import is gone (layout is
|
|
24
|
+
* Tailwind + composed Divider); the `#0E0E11` dark canvas is a `canvasBg` prop.
|
|
25
|
+
* `data-editor-keep-selection` stays as an opt-in click-away hook, not baked
|
|
26
|
+
* behavior.
|
|
27
|
+
*
|
|
28
|
+
* @param {ReactNode} topbar top bar spanning the full width (optional)
|
|
29
|
+
* @param {ReactNode} leftHeader left rail header (optional; renders a hairline when set)
|
|
30
|
+
* @param {ReactNode} left left rail body (scrolls independently)
|
|
31
|
+
* @param {ReactNode} canvasHeader sub-bar spanning only the canvas column, e.g. a tool palette (optional)
|
|
32
|
+
* @param {ReactNode} children the canvas region (fills the fluid column)
|
|
33
|
+
* @param {ReactNode} rightHeader right rail header (optional)
|
|
34
|
+
* @param {ReactNode} right right rail body (scrolls independently)
|
|
35
|
+
* @param {ReactNode} overlays floating overlays rendered above the frame (optional)
|
|
36
|
+
* @param {number} railWidth rail column width in px (default 320)
|
|
37
|
+
* @param {string} canvasBg canvas column background (default var(--kol-surface-primary))
|
|
38
|
+
* @param {string|number} height shell height (default '100dvh'; pass a bounded value to embed)
|
|
39
|
+
* @param {string} className extra classes merged onto the shell root
|
|
40
|
+
*/
|
|
41
|
+
export default function EditorShell({
|
|
42
|
+
topbar,
|
|
43
|
+
leftHeader,
|
|
44
|
+
left,
|
|
45
|
+
canvasHeader,
|
|
46
|
+
children,
|
|
47
|
+
rightHeader,
|
|
48
|
+
right,
|
|
49
|
+
overlays,
|
|
50
|
+
railWidth = 320,
|
|
51
|
+
canvasBg = 'var(--kol-surface-primary)',
|
|
52
|
+
height = '100dvh',
|
|
53
|
+
className = '',
|
|
54
|
+
}) {
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
data-editor-keep-selection
|
|
58
|
+
className={`flex flex-col overflow-hidden bg-surface-primary ${className}`.trim()}
|
|
59
|
+
style={{ height }}
|
|
60
|
+
>
|
|
61
|
+
{topbar && (
|
|
62
|
+
<>
|
|
63
|
+
<div className="shrink-0">{topbar}</div>
|
|
64
|
+
<Divider />
|
|
65
|
+
</>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
<div className="flex flex-1 min-h-0">
|
|
69
|
+
<Rail header={leftHeader} width={railWidth}>{left}</Rail>
|
|
70
|
+
<Divider variant="vertical" />
|
|
71
|
+
|
|
72
|
+
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
|
73
|
+
{canvasHeader && (
|
|
74
|
+
<>
|
|
75
|
+
<div className="shrink-0">{canvasHeader}</div>
|
|
76
|
+
<Divider />
|
|
77
|
+
</>
|
|
78
|
+
)}
|
|
79
|
+
<main
|
|
80
|
+
className="flex-1 min-h-0 select-none"
|
|
81
|
+
style={{ background: canvasBg }}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
</main>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<Divider variant="vertical" />
|
|
88
|
+
<Rail header={rightHeader} width={railWidth}>{right}</Rail>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{overlays}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Rail — fixed-width aside with an optional header (+ hairline) over a
|
|
97
|
+
* scrolling body. `min-h-0` lets the body's overflow scroll instead of
|
|
98
|
+
* stretching the whole shell. */
|
|
99
|
+
function Rail({ header, width, children }) {
|
|
100
|
+
return (
|
|
101
|
+
<aside className="flex flex-col min-h-0 shrink-0" style={{ width }}>
|
|
102
|
+
{header && (
|
|
103
|
+
<>
|
|
104
|
+
<div className="shrink-0">{header}</div>
|
|
105
|
+
<Divider />
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
<div className="flex-1 min-h-0 overflow-y-auto">{children}</div>
|
|
109
|
+
</aside>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Component } from 'react'
|
|
2
|
+
import Button from '../atoms/Button.jsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ErrorBoundary — class error boundary (boundaries can't be hooks). Catches
|
|
6
|
+
* render errors in its subtree and swaps in a centered full-screen fallback
|
|
7
|
+
* ("Something went wrong") with two recovery actions: retry in place and go
|
|
8
|
+
* home. A dev-only error/stack panel renders on Vite dev builds
|
|
9
|
+
* (`import.meta.env.DEV`) — prod users never see a stack. Wrap app regions
|
|
10
|
+
* so a single crash can't blank the whole app.
|
|
11
|
+
*
|
|
12
|
+
* @param {ReactNode} children the guarded subtree
|
|
13
|
+
* @param {string} homeHref "Go home" destination (default '/')
|
|
14
|
+
* @param {Function} fallback ({ error, errorInfo, reset }) => ReactNode — replaces the built-in fallback screen
|
|
15
|
+
* @param {Function} onReset called after "Try again" clears the error state
|
|
16
|
+
*/
|
|
17
|
+
export default class ErrorBoundary extends Component {
|
|
18
|
+
state = { hasError: false, error: null, errorInfo: null }
|
|
19
|
+
|
|
20
|
+
static getDerivedStateFromError() {
|
|
21
|
+
return { hasError: true }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
componentDidCatch(error, errorInfo) {
|
|
25
|
+
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
|
26
|
+
this.setState({ error, errorInfo })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
handleReset = () => {
|
|
30
|
+
this.setState({ hasError: false, error: null, errorInfo: null })
|
|
31
|
+
this.props.onReset?.()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
render() {
|
|
35
|
+
if (!this.state.hasError) return this.props.children
|
|
36
|
+
|
|
37
|
+
const { error, errorInfo } = this.state
|
|
38
|
+
if (this.props.fallback) {
|
|
39
|
+
return this.props.fallback({ error, errorInfo, reset: this.handleReset })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="min-h-screen w-full flex items-center justify-center px-8 bg-surface-primary">
|
|
44
|
+
<div className="max-w-[600px] space-y-8 text-center">
|
|
45
|
+
<div className="space-y-4">
|
|
46
|
+
<h1 className="kol-sans-display-02">Something went wrong</h1>
|
|
47
|
+
<p className="kol-mono-16 opacity-70">
|
|
48
|
+
We encountered an unexpected error. This has been logged and we'll look into it.
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
{import.meta.env.DEV && error && (
|
|
53
|
+
<div className="text-left p-6 rounded overflow-auto max-h-[300px] bg-surface-secondary">
|
|
54
|
+
<p className="kol-mono-12 font-bold mb-2">Error Details (dev only):</p>
|
|
55
|
+
<pre className="kol-mono-12 whitespace-pre-wrap break-all">
|
|
56
|
+
{error.toString()}
|
|
57
|
+
{errorInfo?.componentStack}
|
|
58
|
+
</pre>
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
<div className="flex gap-4 justify-center flex-wrap">
|
|
63
|
+
<Button variant="primary" onClick={this.handleReset}>Try again</Button>
|
|
64
|
+
<Button variant="secondary" href={this.props.homeHref ?? '/'}>Go home</Button>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FeatureSplit — editorial media-and-text split section: a text column
|
|
3
|
+
* (kicker / display pull / lede, then a stats strip OR a CTA row) beside a
|
|
4
|
+
* media column (image/video with an optional gradient veil + caption). Two
|
|
5
|
+
* columns at/above 901px, single stacked column below. Emits
|
|
6
|
+
* kol-feature-split* classes (CSS in @kol/theme).
|
|
7
|
+
*
|
|
8
|
+
* Presentational — every piece of content arrives as a slot. `meta` and
|
|
9
|
+
* `ctas` are mutually exclusive by intent (pick one); `ctas` is
|
|
10
|
+
* conventionally a row of KOL Buttons (composition dependency — nothing is
|
|
11
|
+
* imported here). The media column renders only when `media` is passed, and
|
|
12
|
+
* `caption` gates both the gradient veil and the caption element, so omitted
|
|
13
|
+
* slots emit no empty nodes.
|
|
14
|
+
*
|
|
15
|
+
* Kicker / meta labels / caption render exactly as authored — the source's
|
|
16
|
+
* `text-transform: uppercase` was dropped per KOL casing rules; author those
|
|
17
|
+
* strings in their final case at the call site.
|
|
18
|
+
*
|
|
19
|
+
* @param {ReactNode} kicker mono eyebrow above the title (accent color)
|
|
20
|
+
* @param {ReactNode} title display pull headline; `<em>` renders as the italic accent
|
|
21
|
+
* @param {ReactNode} body lede paragraph
|
|
22
|
+
* @param {{num: ReactNode, label: string}[]} meta stats strip (mutually exclusive with `ctas`)
|
|
23
|
+
* @param {ReactNode} ctas button row (mutually exclusive with `meta`)
|
|
24
|
+
* @param {ReactNode} media image/video for the visual column; omit to hide it
|
|
25
|
+
* @param {ReactNode} caption mono caption + gradient veil over the media
|
|
26
|
+
* @param {string} bgImage URL for an inline cover background on the section
|
|
27
|
+
* @param {boolean} fullBleed span the full viewport width (100vw breakout)
|
|
28
|
+
* @param {string} className extra classes on the section
|
|
29
|
+
* @param {string} innerClassName extra classes on the grid wrapper
|
|
30
|
+
* @param {string} columnClassName extra classes on the text column
|
|
31
|
+
*/
|
|
32
|
+
export default function FeatureSplit({
|
|
33
|
+
kicker,
|
|
34
|
+
title,
|
|
35
|
+
body,
|
|
36
|
+
meta,
|
|
37
|
+
ctas,
|
|
38
|
+
media,
|
|
39
|
+
caption,
|
|
40
|
+
bgImage,
|
|
41
|
+
fullBleed = false,
|
|
42
|
+
className = '',
|
|
43
|
+
innerClassName = '',
|
|
44
|
+
columnClassName = '',
|
|
45
|
+
}) {
|
|
46
|
+
const sectionStyle = bgImage
|
|
47
|
+
? { backgroundImage: `url(${bgImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
|
48
|
+
: undefined
|
|
49
|
+
const bleed = fullBleed ? 'w-screen ml-[calc(50%-50vw)]' : ''
|
|
50
|
+
return (
|
|
51
|
+
<section
|
|
52
|
+
className={`kol-feature-split px-5 py-16 md:px-8 md:py-24 lg:px-14 lg:py-32 ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}
|
|
53
|
+
style={sectionStyle}
|
|
54
|
+
>
|
|
55
|
+
<div className={`max-w-[1200px] mx-auto grid grid-cols-1 min-[901px]:grid-cols-2 items-center gap-[clamp(48px,6vw,96px)] ${innerClassName}`.trim()}>
|
|
56
|
+
<div className={`flex flex-col gap-4 max-w-[640px] ${columnClassName}`.trim()}>
|
|
57
|
+
{kicker && <span className="kol-feature-split-kicker">{kicker}</span>}
|
|
58
|
+
{title && <h1 className="kol-feature-split-pull">{title}</h1>}
|
|
59
|
+
{body && <p className="kol-feature-split-body">{body}</p>}
|
|
60
|
+
{meta && meta.length > 0 && (
|
|
61
|
+
<div className="kol-feature-split-meta flex flex-wrap gap-y-7 gap-x-12 pt-4">
|
|
62
|
+
{meta.map((m) => (
|
|
63
|
+
<div key={m.label} className="flex flex-col gap-0.5">
|
|
64
|
+
<span className="kol-feature-split-meta-num">{m.num}</span>
|
|
65
|
+
<span className="kol-feature-split-meta-label">{m.label}</span>
|
|
66
|
+
</div>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
{ctas && <div className="flex flex-wrap gap-4 pt-2">{ctas}</div>}
|
|
71
|
+
</div>
|
|
72
|
+
{media && (
|
|
73
|
+
<div className="kol-feature-split-visual relative aspect-[4/5] rounded-[4px] overflow-hidden">
|
|
74
|
+
{media}
|
|
75
|
+
{caption && <div className="kol-feature-split-visual-veil" aria-hidden="true" />}
|
|
76
|
+
{caption && <span className="kol-feature-split-visual-caption">{caption}</span>}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
</section>
|
|
81
|
+
)
|
|
82
|
+
}
|