@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,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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import CopyButton from '../atoms/CopyButton.jsx'
|
|
2
|
+
|
|
3
|
+
export default function CodeBlock({ children, language }) {
|
|
4
|
+
return (
|
|
5
|
+
<div className="kol-codeblock">
|
|
6
|
+
{language && <span className="kol-codeblock-lang">{language}</span>}
|
|
7
|
+
<CopyButton text={String(children)} className="kol-codeblock-copy" />
|
|
8
|
+
<pre><code>{children}</code></pre>
|
|
9
|
+
</div>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
3
|
+
import ColorSwatch from './ColorSwatch'
|
|
4
|
+
import Input from '../atoms/Input'
|
|
5
|
+
import LabeledControl from '../atoms/LabeledControl'
|
|
6
|
+
import { usePopover, PopoverPanel } from '../atoms/Popover'
|
|
7
|
+
|
|
8
|
+
const HEX6 = /^[0-9A-F]{6}$/
|
|
9
|
+
|
|
10
|
+
/** '#f0a' / 'F0A' / '#ff00aa' → 'FF00AA'; anything unparseable → null. */
|
|
11
|
+
function normalizeDigits(raw) {
|
|
12
|
+
if (typeof raw !== 'string') return null
|
|
13
|
+
let d = raw.replace(/^#/, '').trim().toUpperCase()
|
|
14
|
+
if (/^[0-9A-F]{3}$/.test(d)) d = d.replace(/./g, (c) => c + c)
|
|
15
|
+
return HEX6.test(d) ? d : null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ColorInputRow — swatch chip + `#` hex input row. The single merged form of
|
|
20
|
+
* the brand editor's ColorField (layer color assignment: palette-ref popover
|
|
21
|
+
* behind the swatch) and SwatchRow (palette-slot editing: trailing extras) —
|
|
22
|
+
* one core, optional trailing affordances instead of two overlapping
|
|
23
|
+
* molecules.
|
|
24
|
+
*
|
|
25
|
+
* The input keeps a local draft while typing and commits on blur/Enter:
|
|
26
|
+
* digits are filtered to hex as typed, shorthand `F0A` expands to `FF00AA`,
|
|
27
|
+
* and only a valid 6-digit hex fires `onChange('#RRGGBB')` — invalid drafts
|
|
28
|
+
* revert to the current value. Picking a palette-ref swatch fires the ref's
|
|
29
|
+
* (pre-resolved) hex and closes the popover.
|
|
30
|
+
*
|
|
31
|
+
* Composition only — no color math, no store coupling. Palette entries come
|
|
32
|
+
* in pre-resolved via `paletteRefs`; the app-side `resolveColor`/PALETTE_REFS
|
|
33
|
+
* and `tokenNameFor` seams stay in the consumer. SwatchRow's `edited` prop
|
|
34
|
+
* was dead in the source render and is dropped ([dead-prop] handled);
|
|
35
|
+
* `transparentTone` is an explicit prop, not inferred from the label string.
|
|
36
|
+
*
|
|
37
|
+
* @param {string|null} value current color, '#RRGGBB' (3-digit accepted, normalized); null → transparent "None" swatch
|
|
38
|
+
* @param {Function} onChange (hex: string) => void — normalized '#RRGGBB' on commit or palette pick
|
|
39
|
+
* @param {string} label optional label; when set the row wraps in LabeledControl
|
|
40
|
+
* @param {Array} paletteRefs [{ id, label, value }] pre-resolved entries → swatch becomes a popover trigger with a ref grid (omit for a plain preview chip)
|
|
41
|
+
* @param {Function} onRemove () => void — renders a trailing remove (×) button
|
|
42
|
+
* @param {ReactNode} trailing extra trailing affordances (lock toggle, token name, …)
|
|
43
|
+
* @param {number} swatchSize chip size in px (default 24)
|
|
44
|
+
* @param {string} transparentTone TransparentX stroke tone for the null-value swatch: 'warning' (default) | 'error' | 'info' | 'success'
|
|
45
|
+
* @param {string} inputVariant Input chrome: 'filled' (default) | 'ghost' | 'outline'
|
|
46
|
+
* @param {boolean} disabled dims the row, blocks pointer, aria-disabled
|
|
47
|
+
*/
|
|
48
|
+
export default function ColorInputRow({
|
|
49
|
+
value,
|
|
50
|
+
onChange,
|
|
51
|
+
label,
|
|
52
|
+
paletteRefs,
|
|
53
|
+
onRemove,
|
|
54
|
+
trailing,
|
|
55
|
+
swatchSize = 24,
|
|
56
|
+
transparentTone = 'warning',
|
|
57
|
+
inputVariant = 'filled',
|
|
58
|
+
disabled = false,
|
|
59
|
+
className = '',
|
|
60
|
+
}) {
|
|
61
|
+
const digits = normalizeDigits(value)
|
|
62
|
+
const hasRefs = Array.isArray(paletteRefs) && paletteRefs.length > 0
|
|
63
|
+
|
|
64
|
+
/* Local typing draft, re-synced whenever the controlled value changes
|
|
65
|
+
* (render-time reset — no effect, no flicker). */
|
|
66
|
+
const [draft, setDraft] = useState(digits ?? '')
|
|
67
|
+
const [prevValue, setPrevValue] = useState(value)
|
|
68
|
+
if (value !== prevValue) {
|
|
69
|
+
setPrevValue(value)
|
|
70
|
+
setDraft(normalizeDigits(value) ?? '')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const [open, setOpen] = useState(false)
|
|
74
|
+
const popover = usePopover({ open, onOpenChange: setOpen, placement: 'bottom-start', offset: 4 })
|
|
75
|
+
|
|
76
|
+
const commit = () => {
|
|
77
|
+
const next = normalizeDigits(draft)
|
|
78
|
+
if (next == null) {
|
|
79
|
+
setDraft(digits ?? '') // invalid or empty → revert, never emit
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
setDraft(next)
|
|
83
|
+
if (next !== digits) onChange?.('#' + next)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const pick = (ref) => {
|
|
87
|
+
onChange?.('#' + (normalizeDigits(ref.value) ?? ''))
|
|
88
|
+
setOpen(false)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Swatch subtitle for the trigger's aria-label: the matching ref's label
|
|
92
|
+
* when the value is a palette color, else the hex, else 'None'. */
|
|
93
|
+
const refLabel = hasRefs
|
|
94
|
+
? paletteRefs.find((r) => normalizeDigits(r.value) === digits)?.label
|
|
95
|
+
: undefined
|
|
96
|
+
const subtitle = digits == null ? 'None' : (refLabel ?? '#' + digits)
|
|
97
|
+
|
|
98
|
+
const swatch = (
|
|
99
|
+
<ColorSwatch
|
|
100
|
+
hex={digits ? '#' + digits : null}
|
|
101
|
+
size={hasRefs ? 'stretch' : swatchSize}
|
|
102
|
+
showTransparent={digits == null}
|
|
103
|
+
transparentTone={transparentTone}
|
|
104
|
+
hoverable={false}
|
|
105
|
+
/>
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const row = (
|
|
109
|
+
<div
|
|
110
|
+
className={`flex items-center gap-2 ${disabled ? 'opacity-30 pointer-events-none' : ''} ${className}`}
|
|
111
|
+
aria-disabled={disabled || undefined}
|
|
112
|
+
>
|
|
113
|
+
{hasRefs ? (
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
ref={popover.refs.setReference}
|
|
117
|
+
{...popover.getReferenceProps()}
|
|
118
|
+
aria-label={`${label ?? 'Color'}: ${subtitle}`}
|
|
119
|
+
className="inline-flex items-center shrink-0"
|
|
120
|
+
style={{ width: swatchSize, height: swatchSize }}
|
|
121
|
+
>
|
|
122
|
+
{swatch}
|
|
123
|
+
</button>
|
|
124
|
+
) : (
|
|
125
|
+
swatch
|
|
126
|
+
)}
|
|
127
|
+
<Input
|
|
128
|
+
variant={inputVariant}
|
|
129
|
+
size="sm"
|
|
130
|
+
prefix="#"
|
|
131
|
+
chars={6}
|
|
132
|
+
maxLength={6}
|
|
133
|
+
uppercase
|
|
134
|
+
value={draft}
|
|
135
|
+
onChange={(e) => setDraft(e.target.value.replace(/[^0-9a-fA-F]/g, '').toUpperCase().slice(0, 6))}
|
|
136
|
+
onBlur={commit}
|
|
137
|
+
onKeyDown={(e) => { if (e.key === 'Enter') commit() }}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
aria-label={label ? `${label} hex` : 'Hex color'}
|
|
140
|
+
/>
|
|
141
|
+
{trailing}
|
|
142
|
+
{onRemove && (
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
onClick={onRemove}
|
|
146
|
+
aria-label="Remove"
|
|
147
|
+
title="Remove"
|
|
148
|
+
className="inline-flex items-center justify-center w-6 h-6 shrink-0 rounded text-meta hover:text-emphasis hover:bg-fg-08 transition-colors"
|
|
149
|
+
>
|
|
150
|
+
<Icon name="x" size={12} />
|
|
151
|
+
</button>
|
|
152
|
+
)}
|
|
153
|
+
{hasRefs && (
|
|
154
|
+
<PopoverPanel
|
|
155
|
+
popover={popover}
|
|
156
|
+
panel={false}
|
|
157
|
+
focus={false}
|
|
158
|
+
className="bg-surface-secondary border border-fg-08 rounded p-2 shadow-lg"
|
|
159
|
+
style={{ minWidth: 200 }}
|
|
160
|
+
>
|
|
161
|
+
<div className="grid grid-cols-6 gap-1">
|
|
162
|
+
{paletteRefs.map((ref) => (
|
|
163
|
+
<ColorSwatch
|
|
164
|
+
key={ref.id}
|
|
165
|
+
hex={'#' + (normalizeDigits(ref.value) ?? '')}
|
|
166
|
+
size="fill"
|
|
167
|
+
selected={normalizeDigits(ref.value) === digits && digits != null}
|
|
168
|
+
title={ref.label}
|
|
169
|
+
onClick={() => pick(ref)}
|
|
170
|
+
/>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
</PopoverPanel>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return label ? <LabeledControl label={label}>{row}</LabeledControl> : row
|
|
179
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import ColorSwatch from './ColorSwatch.jsx'
|
|
3
|
+
import { resolveCssVar, isLight } from '../hooks/cssVar.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ColorRamp — one specimen row of color chips for a token-doc page. A label
|
|
7
|
+
* (+ optional note) above a run of ColorSwatch chips, each captioned with its
|
|
8
|
+
* name and resolved value. Merges the former Ramp (static hex) + ColorRamp
|
|
9
|
+
* (live CSS var) widgets into one component with two mutually-exclusive inputs:
|
|
10
|
+
*
|
|
11
|
+
* live — `vars` (custom-property names) or the `ramp` + `stops` sugar. Each
|
|
12
|
+
* chip resolves `--{token}` on mount via resolveCssVar and prints the
|
|
13
|
+
* declared value; the swatch fill reads the same var() live, so the
|
|
14
|
+
* theme file stays the single source of truth.
|
|
15
|
+
* static — `colors` (literal color strings). No resolution; the literal is
|
|
16
|
+
* both the fill and the printed value. This is the old Ramp mode.
|
|
17
|
+
*
|
|
18
|
+
* Precedence: colors > vars > ramp/stops. The local Chip/Swatch dups the two
|
|
19
|
+
* source files carried are gone — every chip is a DS ColorSwatch. The anchor
|
|
20
|
+
* marker is a dot whose fill flips black/white through isLight so it reads on
|
|
21
|
+
* any stop.
|
|
22
|
+
*
|
|
23
|
+
* Presentational — resolves once on mount; no theme subscription.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} label row heading (defaults to `ramp`); omit in vars/colors mode to hide
|
|
26
|
+
* @param {string} ramp ramp key sugar → each chip resolves `--{ramp}-{stop}`
|
|
27
|
+
* @param {number[]} stops stop list for the `ramp` sugar (default 100–500)
|
|
28
|
+
* @param {Array<string|[string,string]>} vars live custom-property names (bare, or [name, token])
|
|
29
|
+
* @param {Array<string|[string,string]>} colors static literal colors (bare, or [name, color])
|
|
30
|
+
* @param {string|number} anchor chip that gets the anchor dot + ★ — matches a stop, name, or index
|
|
31
|
+
* @param {string} note right-aligned italic caption
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Flatten any of the three inputs into a uniform chip list. */
|
|
35
|
+
function buildChips({ ramp, stops, vars, colors }) {
|
|
36
|
+
if (colors) {
|
|
37
|
+
return colors.map((c, i) => {
|
|
38
|
+
const [name, literal] = Array.isArray(c) ? c : [c, c]
|
|
39
|
+
return { key: `c-${i}-${literal}`, name, literal }
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
if (vars) {
|
|
43
|
+
return vars.map((v, i) => {
|
|
44
|
+
const [name, raw] = Array.isArray(v) ? v : [v.replace(/^--/, ''), v]
|
|
45
|
+
const token = raw.startsWith('--') ? raw : `--${raw}`
|
|
46
|
+
return { key: `v-${i}-${token}`, name, token }
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
return stops.map((s) => ({
|
|
50
|
+
key: `r-${ramp}-${s}`,
|
|
51
|
+
name: `${ramp}-${s}`,
|
|
52
|
+
token: `--${ramp}-${s}`,
|
|
53
|
+
stop: s,
|
|
54
|
+
}))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function ColorRamp({
|
|
58
|
+
label,
|
|
59
|
+
ramp,
|
|
60
|
+
stops = [100, 200, 300, 400, 500],
|
|
61
|
+
vars,
|
|
62
|
+
colors,
|
|
63
|
+
anchor,
|
|
64
|
+
note,
|
|
65
|
+
}) {
|
|
66
|
+
const chips = buildChips({ ramp, stops, vars, colors })
|
|
67
|
+
const [values, setValues] = useState({})
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const next = {}
|
|
71
|
+
for (const c of chips) {
|
|
72
|
+
if (c.token) next[c.key] = resolveCssVar(c.token)
|
|
73
|
+
}
|
|
74
|
+
setValues(next)
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
}, [ramp, JSON.stringify(stops), JSON.stringify(vars), JSON.stringify(colors)])
|
|
77
|
+
|
|
78
|
+
const rampLabel = label ?? ramp
|
|
79
|
+
const cols = Math.min(chips.length, 10)
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="flex flex-col gap-3 py-5 border-b border-fg-08 last:border-b-0">
|
|
83
|
+
{(rampLabel || note) && (
|
|
84
|
+
<div className="flex items-baseline justify-between gap-6 flex-wrap">
|
|
85
|
+
{rampLabel && <span className="kol-helper-12 tracking-widest text-emphasis">{rampLabel}</span>}
|
|
86
|
+
{note && <span className="kol-mono-12 text-meta italic max-w-[60ch] text-right">{note}</span>}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
|
|
90
|
+
{chips.map((c, i) => {
|
|
91
|
+
const value = c.literal ?? values[c.key] ?? ''
|
|
92
|
+
const fill = c.literal ?? `var(${c.token})`
|
|
93
|
+
const isAnchor = anchor != null && (anchor === c.stop || anchor === c.name || anchor === i)
|
|
94
|
+
return (
|
|
95
|
+
<div key={c.key} className="flex flex-col gap-1.5">
|
|
96
|
+
<div className="relative">
|
|
97
|
+
<ColorSwatch hex={fill} size="fill" radius="sm" title={`${c.name} ${value}`} />
|
|
98
|
+
{isAnchor && value && (
|
|
99
|
+
<span className="absolute inset-0 flex items-center justify-center" aria-hidden="true">
|
|
100
|
+
<span className="w-2.5 h-2.5 rounded-full" style={{ background: isLight(value) ? '#000' : '#fff' }} />
|
|
101
|
+
</span>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
<div className="flex flex-col items-start gap-0.5 leading-tight">
|
|
105
|
+
<span className="kol-helper-10 text-emphasis">{c.name}{isAnchor && ' ★'}</span>
|
|
106
|
+
<span className="kol-helper-10 text-meta">{value}</span>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
})}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-loader'
|
|
3
3
|
import { MenuDropdownItem } from './MenuItem.jsx'
|
|
4
|
-
import { PopoverPanel, usePopover } from '
|
|
4
|
+
import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
|
|
5
5
|
|
|
6
6
|
const SIZE_MAP = {
|
|
7
7
|
sm: { fontSize: 12, paddingY: 4, paddingX: 12, radius: 14, panelRadius: 14, icon: 10 },
|
|
@@ -17,9 +17,10 @@ const Dropdown = ({
|
|
|
17
17
|
onChange,
|
|
18
18
|
size,
|
|
19
19
|
variant = 'default',
|
|
20
|
+
defaultOpen = false,
|
|
20
21
|
className = ''
|
|
21
22
|
}) => {
|
|
22
|
-
const [isOpen, setIsOpen] = useState(
|
|
23
|
+
const [isOpen, setIsOpen] = useState(defaultOpen)
|
|
23
24
|
const [resolvedSize, setResolvedSize] = useState('md')
|
|
24
25
|
const [dropdownWidth, setDropdownWidth] = useState('100px')
|
|
25
26
|
|