@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.
Files changed (110) hide show
  1. package/package.json +12 -4
  2. package/src/atoms/AnimatedTitle.jsx +108 -0
  3. package/src/atoms/AssetGrid.jsx +29 -0
  4. package/src/{primitives → atoms}/AssetPlaceholder.jsx +4 -2
  5. package/src/atoms/Avatar.jsx +4 -4
  6. package/src/atoms/Button.jsx +23 -9
  7. package/src/atoms/CopyButton.jsx +50 -0
  8. package/src/atoms/CurveOverlay.jsx +180 -0
  9. package/src/atoms/DocsToc.jsx +48 -0
  10. package/src/{molecules → atoms}/DropdownTagFilter.jsx +2 -1
  11. package/src/atoms/EmptyState.jsx +22 -0
  12. package/src/atoms/Figure.jsx +27 -0
  13. package/src/atoms/HlsVideo.jsx +56 -0
  14. package/src/atoms/Input.jsx +7 -2
  15. package/src/atoms/OverlayGlassPanel.jsx +40 -0
  16. package/src/atoms/PriceDisplay.jsx +34 -0
  17. package/src/atoms/ProsePreview.jsx +53 -0
  18. package/src/atoms/RotaryDial.jsx +150 -0
  19. package/src/atoms/SearchInput.jsx +108 -0
  20. package/src/{molecules → atoms}/SegmentedToggle.jsx +33 -14
  21. package/src/atoms/TextPressure.jsx +331 -0
  22. package/src/atoms/TiltCard.jsx +127 -0
  23. package/src/atoms/ToggleCheckbox.jsx +1 -1
  24. package/src/atoms/ToggleSwitch.jsx +1 -1
  25. package/src/atoms/TypeSample.jsx +50 -0
  26. package/src/atoms/TypeSpecCard.jsx +42 -0
  27. package/src/{molecules → atoms}/ViewToggle.jsx +6 -3
  28. package/src/graphics/Graphic.jsx +56 -18
  29. package/src/graphics/graphicData.js +19 -0
  30. package/src/hooks/cssVar.js +53 -0
  31. package/src/hooks/useAxisAnimation.js +91 -0
  32. package/src/hooks/usePrefersReducedMotion.js +21 -0
  33. package/src/hooks/useTilt.js +49 -0
  34. package/src/index.js +119 -39
  35. package/src/{primitives → molecules}/Accordion.jsx +4 -1
  36. package/src/molecules/AlignmentGrid.jsx +53 -0
  37. package/src/molecules/ArticleCard.jsx +178 -0
  38. package/src/molecules/CardFeatureItem.jsx +130 -0
  39. package/src/molecules/CodeBlock.jsx +11 -0
  40. package/src/molecules/ColorInputRow.jsx +179 -0
  41. package/src/molecules/ColorRamp.jsx +114 -0
  42. package/src/{atoms → molecules}/ColorSwatch.jsx +1 -1
  43. package/src/molecules/Dropdown.jsx +3 -2
  44. package/src/molecules/FramedMediaBand.jsx +56 -0
  45. package/src/{primitives → molecules}/Image.jsx +1 -1
  46. package/src/molecules/ImageBlock.jsx +34 -0
  47. package/src/molecules/MediaCard.jsx +97 -0
  48. package/src/molecules/MediaRow.jsx +49 -0
  49. package/src/molecules/MenuItem.jsx +3 -2
  50. package/src/molecules/MenuPopover.jsx +11 -96
  51. package/src/molecules/SelectionOverlay.jsx +108 -0
  52. package/src/molecules/ShapeDropdown.jsx +92 -0
  53. package/src/molecules/ShellDrawer.jsx +169 -0
  54. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  55. package/src/{atoms → molecules}/Slider.jsx +1 -1
  56. package/src/molecules/SpecList.jsx +30 -0
  57. package/src/molecules/SpectrumControls.jsx +504 -0
  58. package/src/molecules/SwatchControls.jsx +217 -0
  59. package/src/molecules/TabsRow.jsx +87 -0
  60. package/src/molecules/VideoBlock.jsx +86 -0
  61. package/src/molecules/WorkListItem.jsx +83 -0
  62. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  63. package/src/organisms/ArticleHeader.jsx +95 -0
  64. package/src/organisms/AsciiCursor.jsx +526 -0
  65. package/src/organisms/BentoCard.jsx +187 -0
  66. package/src/organisms/Canvas.jsx +299 -0
  67. package/src/organisms/ColorLoader.jsx +155 -0
  68. package/src/{molecules → organisms}/ContentFilters.jsx +2 -2
  69. package/src/organisms/CtaGlobal.jsx +67 -0
  70. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  71. package/src/organisms/EditorShell.jsx +111 -0
  72. package/src/organisms/ErrorBoundary.jsx +70 -0
  73. package/src/organisms/FeatureSplit.jsx +82 -0
  74. package/src/organisms/FeaturedCarousel.jsx +258 -0
  75. package/src/organisms/FeaturesCardSection.jsx +90 -0
  76. package/src/organisms/FullBleedHero.jsx +111 -0
  77. package/src/organisms/GalleryCarousel.jsx +83 -0
  78. package/src/organisms/LoaderOverlay.jsx +30 -0
  79. package/src/organisms/MediaViewer.jsx +95 -0
  80. package/src/organisms/NewsletterBand.jsx +122 -0
  81. package/src/organisms/ParallaxShelf.jsx +141 -0
  82. package/src/organisms/PortableTextRenderer.jsx +115 -0
  83. package/src/organisms/ProductDetailLayout.jsx +189 -0
  84. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  85. package/src/organisms/SpectrumGrid.jsx +90 -0
  86. package/src/organisms/StackHero.jsx +83 -0
  87. package/src/organisms/WorkCard.jsx +120 -0
  88. package/src/organisms/WorkViewToggle.jsx +170 -0
  89. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  90. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  91. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  92. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  93. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  94. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  95. package/src/organisms/foundry/glyphData.js +30 -0
  96. package/src/organisms/foundry/index.js +21 -0
  97. package/src/primitives/CodeBlock.jsx +0 -41
  98. /package/src/{molecules → atoms}/Badge.jsx +0 -0
  99. /package/src/{primitives → atoms}/ExitPreview.jsx +0 -0
  100. /package/src/{primitives → atoms}/FullscreenOverlay.jsx +0 -0
  101. /package/src/{molecules → atoms}/LabeledControl.jsx +0 -0
  102. /package/src/{molecules → atoms}/Pill.jsx +0 -0
  103. /package/src/{molecules → atoms}/Popover.jsx +0 -0
  104. /package/src/{molecules → atoms}/QuantityInput.jsx +0 -0
  105. /package/src/{molecules → atoms}/QuantityStepper.jsx +0 -0
  106. /package/src/{molecules → atoms}/Section.jsx +0 -0
  107. /package/src/{molecules → atoms}/SectionLabel.jsx +0 -0
  108. /package/src/{molecules → atoms}/Tag.jsx +0 -0
  109. /package/src/{molecules → atoms}/ToggleBracket.jsx +0 -0
  110. /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
+ }
@@ -34,7 +34,7 @@
34
34
  * onClick — if provided, renders as <button>; else <span>.
35
35
  * title — passes through.
36
36
  */
37
- import TransparentX from './TransparentX'
37
+ import TransparentX from '../atoms/TransparentX'
38
38
 
39
39
  const SIZE_CLASSES = {
40
40
  fill: 'w-full aspect-square',
@@ -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 './Popover.jsx'
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(false)
23
+ const [isOpen, setIsOpen] = useState(defaultOpen)
23
24
  const [resolvedSize, setResolvedSize] = useState('md')
24
25
  const [dropdownWidth, setDropdownWidth] = useState('100px')
25
26