@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,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
+ }
@@ -6,7 +6,7 @@
6
6
  * visible at its intended size (aspect-ratio preserved).
7
7
  */
8
8
  import { useState } from 'react'
9
- import AssetPlaceholder from './AssetPlaceholder'
9
+ import AssetPlaceholder from '../atoms/AssetPlaceholder'
10
10
 
11
11
  export default function Image({
12
12
  src,
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ import { Icon } from '@kolkrabbi/kol-loader'
2
+
3
+ /* taxonomy-ok: nests kol-loader's Icon (a package import the relative-import
4
+ * check can't see) plus the same-file SelectIndicator. */
5
+
6
+ /**
7
+ * SelectIndicator — passive square check indicator for multi-select rows and
8
+ * cards. `on` = checked. Deliberately NOT ToggleCheckbox: that is a labeled
9
+ * form control with a real <input>, which double-fires inside a click-target
10
+ * card and misleads assistive tech — here the CARD is the toggle, this is
11
+ * only its visual state. Inline CSS vars because `bg-fg-default` isn't a
12
+ * generated Tailwind utility; the checked fill reads from
13
+ * `--kol-surface-on-primary` (solid fg, theme-correct).
14
+ *
15
+ * Shared by MediaCard and MediaRow; not exported from the package barrel.
16
+ */
17
+ export function SelectIndicator({ on = false }) {
18
+ return (
19
+ <span
20
+ className="w-4 h-4 shrink-0 rounded-sm border flex items-center justify-center transition-colors"
21
+ style={
22
+ on
23
+ ? { background: 'var(--kol-surface-on-primary)', borderColor: 'var(--kol-surface-on-primary)' }
24
+ : { background: 'var(--kol-fg-absolute-16, rgba(0,0,0,0.15))', borderColor: 'var(--kol-fg-absolute-48, rgba(0,0,0,0.4))' }
25
+ }
26
+ aria-hidden="true"
27
+ >
28
+ {on && <Icon name="check" size={11} style={{ color: 'var(--kol-surface-primary)' }} />}
29
+ </span>
30
+ )
31
+ }
32
+
33
+ /**
34
+ * MediaCard — grid tile for one media object: square thumbnail with a
35
+ * top-right download overlay (or a top-left select checkbox in select mode),
36
+ * then name / meta / actions stacked below. The grid-view counterpart to
37
+ * MediaRow (same slot contract).
38
+ *
39
+ * Presentational — the parent supplies rendered slots; no media loading,
40
+ * fetch, or rename logic lives here. Grid track sizing belongs to the parent
41
+ * list; the card just fills its cell.
42
+ *
43
+ * @param {ReactNode} thumb square thumbnail (image/video/placeholder)
44
+ * @param {ReactNode} name name cell (plain text or an inline editor)
45
+ * @param {string} meta one-line secondary text (e.g. "1.2 MB · 2026-06-19")
46
+ * @param {ReactNode} actions row of action buttons (hidden in select mode)
47
+ * @param {string} downloadHref href for the overlay download button (omit to hide)
48
+ * @param {boolean} selectMode selection mode on → checkbox replaces download, whole card toggles
49
+ * @param {boolean} selected this card is selected (stronger border + checked box)
50
+ * @param {Function} onSelect (event) => void — card click in select mode; gets shiftKey for range
51
+ */
52
+ export default function MediaCard({
53
+ thumb,
54
+ name,
55
+ meta,
56
+ actions,
57
+ downloadHref,
58
+ selectMode = false,
59
+ selected = false,
60
+ onSelect,
61
+ }) {
62
+ return (
63
+ <li
64
+ onClick={selectMode ? onSelect : undefined}
65
+ className={`flex flex-col rounded overflow-hidden border bg-fg-02 ${selectMode ? 'cursor-pointer select-none' : ''}`}
66
+ style={{ borderColor: selected ? 'var(--kol-fg-64)' : 'var(--kol-fg-12)' }}
67
+ >
68
+ <div className="aspect-square relative">
69
+ {thumb}
70
+ {selectMode ? (
71
+ <span
72
+ className="absolute top-3 left-3 rounded p-1"
73
+ style={{ background: 'var(--kol-fg-absolute-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
74
+ >
75
+ <SelectIndicator on={selected} />
76
+ </span>
77
+ ) : downloadHref ? (
78
+ <a
79
+ href={downloadHref}
80
+ aria-label="Download"
81
+ title="Download"
82
+ className="absolute top-3 right-3 inline-flex items-center justify-center w-8 h-8 rounded text-emphasis hover:bg-fg-absolute-24 transition-colors"
83
+ style={{ background: 'var(--kol-fg-absolute-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
84
+ onClick={(e) => e.stopPropagation()}
85
+ >
86
+ <Icon name="download" size={16} />
87
+ </a>
88
+ ) : null}
89
+ </div>
90
+ <div className="p-3 flex flex-col gap-2">
91
+ {name}
92
+ <p className="kol-mono-12 text-fg-48">{meta}</p>
93
+ {!selectMode && actions}
94
+ </div>
95
+ </li>
96
+ )
97
+ }
@@ -0,0 +1,49 @@
1
+ import { SelectIndicator } from './MediaCard.jsx'
2
+
3
+ /**
4
+ * MediaRow — list row for one media object: optional select checkbox, small
5
+ * thumbnail, name (flex), fixed-width date + size columns, then actions.
6
+ * The list-view counterpart to MediaCard (same slot contract).
7
+ *
8
+ * Presentational — the parent supplies rendered slots; interaction outside
9
+ * select mode lives in the thumb / name / actions slots. Column widths are
10
+ * consumer-tunable via `dateWidth` / `sizeWidth`.
11
+ *
12
+ * @param {ReactNode} thumb small thumbnail (48px square)
13
+ * @param {ReactNode} name name cell (plain text or an inline editor)
14
+ * @param {string} date right-aligned date column
15
+ * @param {string} size right-aligned size column
16
+ * @param {ReactNode} actions row of action buttons (hidden in select mode)
17
+ * @param {string} dateWidth Tailwind width class for the date column
18
+ * @param {string} sizeWidth Tailwind width class for the size column
19
+ * @param {boolean} selectMode selection mode on → checkbox shown, whole row toggles, actions hidden
20
+ * @param {boolean} selected this row is selected (highlight + checked box)
21
+ * @param {Function} onSelect (event) => void — row click in select mode; gets shiftKey for range
22
+ */
23
+ export default function MediaRow({
24
+ thumb,
25
+ name,
26
+ date,
27
+ size,
28
+ actions,
29
+ dateWidth = 'w-24',
30
+ sizeWidth = 'w-20',
31
+ selectMode = false,
32
+ selected = false,
33
+ onSelect,
34
+ }) {
35
+ return (
36
+ <li
37
+ onClick={selectMode ? onSelect : undefined}
38
+ className={`flex items-center gap-3 py-2 border-b ${selectMode ? 'cursor-pointer select-none' : ''} ${selected ? 'bg-fg-08' : ''}`}
39
+ style={{ borderColor: 'var(--kol-fg-08)' }}
40
+ >
41
+ {selectMode && <SelectIndicator on={selected} />}
42
+ <div className="w-12 h-12 shrink-0 rounded overflow-hidden">{thumb}</div>
43
+ <div className="flex-1 min-w-0">{name}</div>
44
+ <p className={`kol-mono-12 text-fg-32 shrink-0 text-right ${dateWidth}`}>{date}</p>
45
+ <p className={`kol-mono-12 text-fg-48 shrink-0 text-right ${sizeWidth}`}>{size}</p>
46
+ {!selectMode && <div className="shrink-0">{actions}</div>}
47
+ </li>
48
+ )
49
+ }
@@ -1,6 +1,6 @@
1
1
  import { useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-loader'
3
- import { PopoverPanel, usePopover } from './Popover.jsx'
3
+ import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
4
4
 
5
5
  /**
6
6
  * MenuItem — top-level menu entry. Trigger button + popover panel.
@@ -26,8 +26,9 @@ export function MenuItem({
26
26
  panelClassName = '',
27
27
  panelStyle,
28
28
  buttonClassName = '',
29
+ defaultOpen = false,
29
30
  }) {
30
- const [open, setOpen] = useState(false)
31
+ const [open, setOpen] = useState(defaultOpen)
31
32
  const popover = usePopover({
32
33
  open,
33
34
  onOpenChange: setOpen,
@@ -1,103 +1,18 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { MenuItem as MenuTrigger } from './MenuItem.jsx'
2
2
 
3
3
  /**
4
- * MenuPopover — generic action-menu / popover primitive.
4
+ * MenuPopover — DEPRECATED alias of MenuItem (2026-07-02 menu-family
5
+ * unification).
5
6
  *
6
- * <MenuPopover label="File">
7
- * <MenuItem onClick={…}>Save</MenuItem>
8
- * <MenuItem onClick={…}>Export…</MenuItem>
9
- * </MenuPopover>
10
- *
11
- * Opens on click, closes on outside-click + Escape, anchors to the trigger
12
- * via getBoundingClientRect + position:fixed so it escapes overflow:auto
13
- * clipping. Pass `panelClassName` to size the panel (e.g. wider for
14
- * Templates).
15
- *
16
- * For value-list selection (single value, active state) use `Dropdown`
17
- * instead — this primitive is for action menus / popover panels that
18
- * hold arbitrary children.
7
+ * The two triggers had an identical API (label, children incl. ({ close })
8
+ * render-prop, align, panelClassName, panelStyle, buttonClassName,
9
+ * defaultOpen) and did the same job — MenuPopover with hand-rolled
10
+ * fixed positioning, MenuItem on floating-ui (portal, auto-flip, focus
11
+ * management). One implementation now: MenuItem. This alias keeps existing
12
+ * call-sites working; migrate imports to MenuItem. Removal in the next major.
19
13
  */
20
- export function MenuPopover({
21
- label,
22
- children,
23
- align = 'start',
24
- panelClassName = '',
25
- panelStyle,
26
- buttonClassName = '',
27
- }) {
28
- const [open, setOpen] = useState(false)
29
- const wrapRef = useRef(null)
30
- const buttonRef = useRef(null)
31
- const [panelPos, setPanelPos] = useState(null)
32
-
33
- useEffect(() => {
34
- if (!open) return
35
- const onDown = (e) => {
36
- if (!wrapRef.current?.contains(e.target)) setOpen(false)
37
- }
38
- const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
39
- document.addEventListener('mousedown', onDown)
40
- document.addEventListener('keydown', onKey)
41
- return () => {
42
- document.removeEventListener('mousedown', onDown)
43
- document.removeEventListener('keydown', onKey)
44
- }
45
- }, [open])
46
-
47
- useEffect(() => {
48
- if (!open) { setPanelPos(null); return }
49
- const update = () => {
50
- const rect = buttonRef.current?.getBoundingClientRect()
51
- if (!rect) return
52
- setPanelPos({ top: rect.bottom + 4, left: rect.left, right: rect.right })
53
- }
54
- update()
55
- window.addEventListener('resize', update)
56
- window.addEventListener('scroll', update, true)
57
- return () => {
58
- window.removeEventListener('resize', update)
59
- window.removeEventListener('scroll', update, true)
60
- }
61
- }, [open])
62
-
63
- const close = () => setOpen(false)
64
-
65
- const positioned = panelPos && (
66
- align === 'end'
67
- ? { top: panelPos.top, right: window.innerWidth - panelPos.right }
68
- : { top: panelPos.top, left: panelPos.left }
69
- )
70
-
71
- return (
72
- <div ref={wrapRef} className="relative inline-block">
73
- <button
74
- ref={buttonRef}
75
- type="button"
76
- onClick={() => setOpen((v) => !v)}
77
- aria-haspopup="menu"
78
- aria-expanded={open}
79
- className={`kol-helper-12 px-3 h-8 inline-flex items-center gap-1 rounded text-meta hover:text-emphasis transition-colors ${buttonClassName}`}
80
- >
81
- <span>{label}</span>
82
- <svg width="10" height="10" viewBox="0 0 12 12" aria-hidden="true">
83
- <path d="m3 5 3 3 3-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" fill="none" />
84
- </svg>
85
- </button>
86
- {open && positioned && (
87
- <div
88
- role="menu"
89
- className={`fixed z-[1000] bg-surface-primary border border-fg-08 rounded shadow-lg ${panelClassName}`}
90
- style={{ ...positioned, ...panelStyle }}
91
- onClick={(e) => {
92
- /* close on item click — items inside fire their handler then bubble. */
93
- if (e.target.closest('[data-menu-item]')) close()
94
- }}
95
- >
96
- {typeof children === 'function' ? children({ close }) : children}
97
- </div>
98
- )}
99
- </div>
100
- )
14
+ export function MenuPopover(props) {
15
+ return <MenuTrigger {...props} />
101
16
  }
102
17
 
103
18
  /**
@@ -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