@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.
- package/package.json +11 -3
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/atoms/Button.jsx +17 -6
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/OverlayGlassPanel.jsx +40 -0
- package/src/atoms/PriceDisplay.jsx +34 -0
- package/src/atoms/ProsePreview.jsx +53 -0
- package/src/atoms/QuantityInput.jsx +76 -114
- package/src/atoms/RotaryDial.jsx +150 -0
- package/src/atoms/SearchInput.jsx +108 -0
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -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 +66 -2
- package/src/molecules/AlignmentGrid.jsx +53 -0
- package/src/molecules/ArticleCard.jsx +178 -0
- package/src/molecules/CardFeatureItem.jsx +130 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/MenuPopover.jsx +4 -27
- 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/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/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/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
|