@kolkrabbi/kol-component 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/package.json +11 -3
  2. package/src/atoms/AnimatedTitle.jsx +108 -0
  3. package/src/atoms/AssetGrid.jsx +29 -0
  4. package/src/atoms/Button.jsx +17 -6
  5. package/src/atoms/CurveOverlay.jsx +180 -0
  6. package/src/atoms/DocsToc.jsx +48 -0
  7. package/src/atoms/EmptyState.jsx +22 -0
  8. package/src/atoms/Figure.jsx +27 -0
  9. package/src/atoms/HlsVideo.jsx +56 -0
  10. package/src/atoms/OverlayGlassPanel.jsx +40 -0
  11. package/src/atoms/PriceDisplay.jsx +34 -0
  12. package/src/atoms/ProsePreview.jsx +53 -0
  13. package/src/atoms/QuantityInput.jsx +76 -114
  14. package/src/atoms/RotaryDial.jsx +150 -0
  15. package/src/atoms/SearchInput.jsx +108 -0
  16. package/src/atoms/TextPressure.jsx +331 -0
  17. package/src/atoms/TiltCard.jsx +127 -0
  18. package/src/atoms/TypeSample.jsx +50 -0
  19. package/src/atoms/TypeSpecCard.jsx +42 -0
  20. package/src/hooks/cssVar.js +53 -0
  21. package/src/hooks/useAxisAnimation.js +91 -0
  22. package/src/hooks/usePrefersReducedMotion.js +21 -0
  23. package/src/hooks/useTilt.js +49 -0
  24. package/src/index.js +66 -2
  25. package/src/molecules/AlignmentGrid.jsx +53 -0
  26. package/src/molecules/ArticleCard.jsx +178 -0
  27. package/src/molecules/CardFeatureItem.jsx +130 -0
  28. package/src/molecules/ColorInputRow.jsx +179 -0
  29. package/src/molecules/ColorRamp.jsx +114 -0
  30. package/src/molecules/FramedMediaBand.jsx +56 -0
  31. package/src/molecules/ImageBlock.jsx +34 -0
  32. package/src/molecules/MenuPopover.jsx +4 -27
  33. package/src/molecules/SelectionOverlay.jsx +108 -0
  34. package/src/molecules/ShapeDropdown.jsx +92 -0
  35. package/src/molecules/ShellDrawer.jsx +169 -0
  36. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  37. package/src/molecules/SpecList.jsx +30 -0
  38. package/src/molecules/SpectrumControls.jsx +504 -0
  39. package/src/molecules/SwatchControls.jsx +217 -0
  40. package/src/molecules/TabsRow.jsx +87 -0
  41. package/src/molecules/VideoBlock.jsx +86 -0
  42. package/src/molecules/WorkListItem.jsx +83 -0
  43. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  44. package/src/organisms/ArticleHeader.jsx +95 -0
  45. package/src/organisms/AsciiCursor.jsx +526 -0
  46. package/src/organisms/BentoCard.jsx +187 -0
  47. package/src/organisms/Canvas.jsx +299 -0
  48. package/src/organisms/ColorLoader.jsx +155 -0
  49. package/src/organisms/CtaGlobal.jsx +67 -0
  50. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  51. package/src/organisms/EditorShell.jsx +111 -0
  52. package/src/organisms/ErrorBoundary.jsx +70 -0
  53. package/src/organisms/FeatureSplit.jsx +82 -0
  54. package/src/organisms/FeaturedCarousel.jsx +258 -0
  55. package/src/organisms/FeaturesCardSection.jsx +90 -0
  56. package/src/organisms/FullBleedHero.jsx +111 -0
  57. package/src/organisms/GalleryCarousel.jsx +83 -0
  58. package/src/organisms/LoaderOverlay.jsx +30 -0
  59. package/src/organisms/MediaViewer.jsx +95 -0
  60. package/src/organisms/NewsletterBand.jsx +122 -0
  61. package/src/organisms/ParallaxShelf.jsx +141 -0
  62. package/src/organisms/PortableTextRenderer.jsx +115 -0
  63. package/src/organisms/ProductDetailLayout.jsx +189 -0
  64. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  65. package/src/organisms/SpectrumGrid.jsx +90 -0
  66. package/src/organisms/StackHero.jsx +83 -0
  67. package/src/organisms/WorkCard.jsx +120 -0
  68. package/src/organisms/WorkViewToggle.jsx +170 -0
  69. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  70. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  71. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  72. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  73. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  74. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  75. package/src/organisms/foundry/glyphData.js +30 -0
  76. package/src/organisms/foundry/index.js +21 -0
  77. package/src/atoms/QuantityStepper.jsx +0 -149
@@ -0,0 +1,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
+ }
@@ -0,0 +1,56 @@
1
+ import Image from './Image'
2
+
3
+ /**
4
+ * FramedMediaBand — full-width media breather band: a centered, aspect-locked
5
+ * frame (bordered surface-secondary panel, rounded) holding one object-cover
6
+ * image. Extracted from the foundry TypefacePage, where it sat inlined 5x
7
+ * verbatim between content sections. The double radius (`rounded` panel,
8
+ * `rounded-[4px]` image) is intentional — the image nests inside the border.
9
+ *
10
+ * Presentational — the caller passes finished `src`/`srcSet` strings; any
11
+ * CDN size-ladder generation stays at the call site. `media` swaps an
12
+ * arbitrary node into the frame in place of the Image (video, canvas); it
13
+ * should self-size with `w-full h-full`.
14
+ *
15
+ * @param {string} src image src (a finished URL, no ladder logic here)
16
+ * @param {string} srcSet responsive srcSet, precomputed by the caller
17
+ * @param {string} sizes img sizes attr
18
+ * @param {string} alt alt text, authored at the call site
19
+ * @param {ReactNode} media replaces the Image inside the frame
20
+ * @param {ReactNode} caption optional caption line under the frame
21
+ * @param {string} aspectRatio frame aspect ratio, CSS value (e.g. '2/1')
22
+ * @param {string} maxWidth frame max-width class
23
+ * @param {string} className section-level extras (e.g. 'mt-12')
24
+ */
25
+ export default function FramedMediaBand({
26
+ src,
27
+ srcSet,
28
+ sizes = '(max-width: 1400px) 100vw, 1400px',
29
+ alt = '',
30
+ media,
31
+ caption,
32
+ aspectRatio = '2/1',
33
+ maxWidth = 'max-w-[1400px]',
34
+ className = '',
35
+ }) {
36
+ return (
37
+ <section className={`w-full overflow-hidden py-16 ${className}`.trim()}>
38
+ <div className={`${maxWidth} mx-auto`}>
39
+ <div style={{ aspectRatio }}>
40
+ <div className="w-full h-full bg-surface-secondary rounded border border-fg-08">
41
+ {media ?? (
42
+ <Image
43
+ src={src}
44
+ srcSet={srcSet}
45
+ sizes={sizes}
46
+ alt={alt}
47
+ className="w-full h-full object-cover rounded-[4px]"
48
+ />
49
+ )}
50
+ </div>
51
+ </div>
52
+ {caption && <p className="kol-mono-12 text-fg-48 mt-3">{caption}</p>}
53
+ </div>
54
+ </section>
55
+ )
56
+ }
@@ -0,0 +1,34 @@
1
+ import Figure from '../atoms/Figure.jsx'
2
+ import Image from './Image.jsx'
3
+
4
+ /**
5
+ * ImageBlock — a captioned prose image: the DS Figure shell (optional label,
6
+ * aspect-locked bordered frame, optional figcaption) wrapping a cover-fit DS
7
+ * Image. The long-form counterpart to VideoBlock — both compose the same
8
+ * Figure atom, so the frame chrome lives in one place.
9
+ *
10
+ * De-Sanitized: takes a resolved `src` string (no CMS `value` object, no
11
+ * SanityImage URL builder). Missing `src` degrades to Image's own
12
+ * AssetPlaceholder rather than rendering an empty frame.
13
+ *
14
+ * Label and caption render exactly as authored — no casing transforms.
15
+ *
16
+ * @param {string} src resolved image URL
17
+ * @param {string} alt alt text
18
+ * @param {string} label small mono label above the frame
19
+ * @param {string} caption figcaption below the frame
20
+ * @param {string} aspect CSS aspect-ratio for the frame (default '5/3')
21
+ * @param {string} className extra classes on the <figure>
22
+ */
23
+ export default function ImageBlock({ src, alt = '', label, caption, aspect = '5/3', className = '' }) {
24
+ return (
25
+ <Figure label={label} caption={caption} aspect={aspect} className={className}>
26
+ <Image
27
+ src={src}
28
+ alt={alt}
29
+ className="object-cover"
30
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
31
+ />
32
+ </Figure>
33
+ )
34
+ }
@@ -10,34 +10,11 @@ import { MenuItem as MenuTrigger } from './MenuItem.jsx'
10
10
  * fixed positioning, MenuItem on floating-ui (portal, auto-flip, focus
11
11
  * management). One implementation now: MenuItem. This alias keeps existing
12
12
  * call-sites working; migrate imports to MenuItem. Removal in the next major.
13
+ *
14
+ * Compose the rows with the exported `MenuDropdownItem` / `MenuDropdownDivider`
15
+ * / `MenuDropdownNest` from MenuItem.jsx — this file no longer ships its own
16
+ * duplicate row/divider components (they were dead: never barrel-exported).
13
17
  */
14
18
  export function MenuPopover(props) {
15
19
  return <MenuTrigger {...props} />
16
20
  }
17
-
18
- /**
19
- * MenuItem — action row inside a MenuPopover. Renders as a button so it
20
- * picks up disabled state, focus, and keyboard activation. The popover
21
- * closes automatically when an item is clicked (via the wrapper's
22
- * delegate click — the data-menu-item attr marks rows for that match).
23
- */
24
- export function MenuItem({ onClick, disabled, shortcut, iconLeft, children }) {
25
- return (
26
- <button
27
- type="button"
28
- data-menu-item
29
- onClick={onClick}
30
- disabled={disabled}
31
- role="menuitem"
32
- className="w-full kol-helper-12 px-3 h-8 inline-flex items-center gap-2 text-meta hover:text-emphasis hover:bg-fg-08 disabled:opacity-40 disabled:cursor-not-allowed text-left"
33
- >
34
- {iconLeft && <span className="shrink-0 w-4 inline-flex items-center justify-center text-meta">{iconLeft}</span>}
35
- <span className="flex-1">{children}</span>
36
- {shortcut && <span className="kol-helper-10 text-subtle shrink-0">{shortcut}</span>}
37
- </button>
38
- )
39
- }
40
-
41
- export function MenuDivider() {
42
- return <div className="border-t border-fg-08 my-1" />
43
- }
@@ -0,0 +1,108 @@
1
+ /* taxonomy-ok: presentational transform-chrome overlay. It nests no KOL
2
+ * component (pure inline-styled squares + label), so by the letter of the
3
+ * molecule test it reads as an atom — but the lobby spec places it as a
4
+ * molecule: a reusable compound bounding-box/handles primitive that pairs with
5
+ * the Canvas scale layer, not a base atom. Kept here per that spec. */
6
+
7
+ /**
8
+ * SelectionOverlay — pure transform chrome for a selected box.
9
+ *
10
+ * Renders a dashed outline, 8 named resize handles, and a `W × H` dimension
11
+ * label, all positioned in the **same 1080-virtual coordinate space** the
12
+ * target lives in (pairs with Canvas's scale layer — place it as a sibling of
13
+ * the box inside the same scale layer). Each handle carries a
14
+ * `data-handle="NW|N|NE|E|SE|S|SW|W"` attribute so a parent's pointer router
15
+ * can start the right resize mode. No interaction logic of its own — the drag
16
+ * math lives in the consumer, which reads `e.target.dataset.handle`.
17
+ *
18
+ * Ported from the brand editor with the `layer` model reduced to a flat `box`
19
+ * (per lobby spec): renders nothing when there's no positional box.
20
+ *
21
+ * @param {{x:number,y:number,w:number,h:number}} box virtual-coord position + size; null/x==null → renders nothing
22
+ * @param {boolean} showHandles render the 8 resize handles (default true)
23
+ * @param {boolean} showLabel render the `W × H` dimension label (default true)
24
+ * @param {number} handleSize handle square size in virtual px (default 10)
25
+ * @param {string} accentColor outline + handle + label color (default var(--kol-accent-primary))
26
+ * @param {Function} labelFormatter (box) => string — dimension readout (default `${round(w)} × ${round(h)}`)
27
+ */
28
+ const HANDLE_DIRS = [
29
+ { dir: 'NW', cursor: 'nwse-resize', x: 0, y: 0 },
30
+ { dir: 'N', cursor: 'ns-resize', x: 0.5, y: 0 },
31
+ { dir: 'NE', cursor: 'nesw-resize', x: 1, y: 0 },
32
+ { dir: 'E', cursor: 'ew-resize', x: 1, y: 0.5 },
33
+ { dir: 'SE', cursor: 'nwse-resize', x: 1, y: 1 },
34
+ { dir: 'S', cursor: 'ns-resize', x: 0.5, y: 1 },
35
+ { dir: 'SW', cursor: 'nesw-resize', x: 0, y: 1 },
36
+ { dir: 'W', cursor: 'ew-resize', x: 0, y: 0.5 },
37
+ ]
38
+
39
+ export default function SelectionOverlay({
40
+ box,
41
+ showHandles = true,
42
+ showLabel = true,
43
+ handleSize = 10,
44
+ accentColor = 'var(--kol-accent-primary)',
45
+ labelFormatter = (b) => `${Math.round(b.w)} × ${Math.round(b.h)}`,
46
+ }) {
47
+ if (!box || box.x == null) return null /* no positional box → no chrome */
48
+
49
+ const { x, y, w, h } = box
50
+
51
+ return (
52
+ <div
53
+ style={{
54
+ position: 'absolute',
55
+ left: x, top: y,
56
+ width: w, height: h,
57
+ pointerEvents: 'none',
58
+ zIndex: 100,
59
+ }}
60
+ >
61
+ <div
62
+ style={{
63
+ position: 'absolute', inset: 0,
64
+ outline: `1px dashed ${accentColor}`,
65
+ outlineOffset: 0,
66
+ }}
67
+ />
68
+ {showHandles && HANDLE_DIRS.map(({ dir, cursor, x: hx, y: hy }) => (
69
+ <div
70
+ key={dir}
71
+ data-handle={dir}
72
+ style={{
73
+ position: 'absolute',
74
+ left: `calc(${hx * 100}% - ${handleSize / 2}px)`,
75
+ top: `calc(${hy * 100}% - ${handleSize / 2}px)`,
76
+ width: handleSize,
77
+ height: handleSize,
78
+ background: 'white',
79
+ border: `1px solid ${accentColor}`,
80
+ cursor,
81
+ pointerEvents: 'auto',
82
+ }}
83
+ />
84
+ ))}
85
+ {showLabel && (
86
+ <span
87
+ style={{
88
+ position: 'absolute',
89
+ left: 0,
90
+ top: '100%',
91
+ marginTop: 6,
92
+ fontFamily: 'var(--kol-font-family-mono)',
93
+ fontSize: 10,
94
+ letterSpacing: '0.04em',
95
+ color: accentColor,
96
+ background: 'rgba(0,0,0,0.6)',
97
+ padding: '2px 6px',
98
+ borderRadius: 2,
99
+ whiteSpace: 'nowrap',
100
+ pointerEvents: 'none',
101
+ }}
102
+ >
103
+ {labelFormatter(box)}
104
+ </span>
105
+ )}
106
+ </div>
107
+ )
108
+ }
@@ -0,0 +1,92 @@
1
+ import { useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-loader'
3
+ import Button from '../atoms/Button.jsx'
4
+ import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
5
+ import { MenuDropdownItem } from './MenuItem.jsx'
6
+
7
+ /**
8
+ * ShapeDropdown — split icon-button + variant-menu molecule (the tool-palette
9
+ * idiom: Select · Text · [Shape ▾] · Pattern). The main button reflects the
10
+ * current variant and fires `onAction` with its id; the chevron half opens a
11
+ * menu of all variants — picking one fires `onChange` and closes.
12
+ *
13
+ * Composed from Button (both trigger halves), usePopover/PopoverPanel (menu
14
+ * positioning, dismiss, portal) and MenuDropdownItem (rows — same rows as the
15
+ * Dropdown molecule, ✓ marks the active variant).
16
+ *
17
+ * For single-value list selection with a text trigger use `Dropdown`; this is
18
+ * for tool bars where the trigger is itself an action.
19
+ *
20
+ * @param {Object} props
21
+ * @param {{id: string, label: string, icon?: string}[]} props.options - Variants: menu rows + trigger glyph. `icon` optional — the trigger falls back to the label.
22
+ * @param {string} props.value - Active variant id (controlled)
23
+ * @param {Function} props.onChange - Fires with the picked variant id (menu selection)
24
+ * @param {Function} props.onAction - Fires with the current variant id (main-button click)
25
+ * @param {string} props.className - Additional classes on the wrapper
26
+ */
27
+ const ShapeDropdown = ({ options = [], value, onChange, onAction, className = '' }) => {
28
+ const [open, setOpen] = useState(false)
29
+ const popover = usePopover({
30
+ open,
31
+ onOpenChange: setOpen,
32
+ placement: 'bottom-start',
33
+ offset: 4,
34
+ role: 'menu',
35
+ })
36
+
37
+ const current = options.find((option) => option.id === value) || options[0]
38
+
39
+ const handleSelect = (option) => {
40
+ onChange?.(option.id)
41
+ setOpen(false)
42
+ }
43
+
44
+ return (
45
+ <div className={`inline-flex items-center ${className}`.trim()}>
46
+ {current?.icon ? (
47
+ <Button
48
+ variant="ghost"
49
+ size="sm"
50
+ quiet
51
+ iconOnly={current.icon}
52
+ aria-label={current.label}
53
+ title={current.label}
54
+ onClick={() => onAction?.(current.id)}
55
+ />
56
+ ) : (
57
+ <Button variant="ghost" size="sm" quiet onClick={() => onAction?.(current?.id)}>
58
+ {current?.label}
59
+ </Button>
60
+ )}
61
+ {/* Button doesn't forward refs — anchor the popover on a span wrapper,
62
+ * same pattern as Tooltip in atoms/Popover.jsx. Clicks on the inner
63
+ * button bubble to the span, where useClick toggles the menu. */}
64
+ <span
65
+ ref={popover.refs.setReference}
66
+ {...popover.getReferenceProps()}
67
+ className="inline-flex"
68
+ >
69
+ <Button variant="ghost" size="sm" quiet iconOnly="chevron-down" iconSize={10} aria-label="Variants" />
70
+ </span>
71
+ <PopoverPanel
72
+ popover={popover}
73
+ panel={false}
74
+ focus={false}
75
+ className="bg-surface-secondary border border-fg-08 rounded shadow-lg"
76
+ >
77
+ {options.map((option) => (
78
+ <MenuDropdownItem
79
+ key={option.id}
80
+ onClick={() => handleSelect(option)}
81
+ iconLeft={option.icon ? <Icon name={option.icon} size={14} /> : undefined}
82
+ shortcut={option.id === current?.id ? <Icon name="check" size={11} /> : undefined}
83
+ >
84
+ {option.label}
85
+ </MenuDropdownItem>
86
+ ))}
87
+ </PopoverPanel>
88
+ </div>
89
+ )
90
+ }
91
+
92
+ export default ShapeDropdown