@kolkrabbi/kol-component 0.322.0 → 0.324.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.322.0",
3
+ "version": "0.324.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,8 +26,8 @@
26
26
  "embla-carousel-react": "^8.6.0",
27
27
  "pdfjs-dist": "^6.3.289",
28
28
  "react-syntax-highlighter": "^16.1.1",
29
- "@kolkrabbi/kol-growth": "^0.4.0",
30
29
  "@kolkrabbi/kol-search": "^0.3.0",
30
+ "@kolkrabbi/kol-growth": "^0.4.0",
31
31
  "@kolkrabbi/kol-markdown": "^0.2.0"
32
32
  },
33
33
  "peerDependencies": {
@@ -39,7 +39,7 @@
39
39
  "react-dom": "^18.3.0 || ^19.0.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@kolkrabbi/kol-icons": "^0.54.0"
42
+ "@kolkrabbi/kol-icons": "^0.56.0"
43
43
  },
44
44
  "files": [
45
45
  "src",
@@ -12,7 +12,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
12
12
  *
13
13
  * @param {Object} props
14
14
  * @param {ReactNode} props.children - Button content
15
- * @param {'accent'|'danger'|'nav'|'tab'|'tab-filled'} props.variant - The intent; the GROUND is `tone`. `tab` is the tab chip (2026-10-02): quiet text at rest, a soft filled chip while `pressed`. `tab-filled` (2026-10-29) shows its chip at rest (oq-04), brighter on hover (oq-08) and brightest selected (oq-16). `float` (FloatingStyleAndSignInDemos; round 3's values, W16 2026-10-31) is the shape only: a capsule whose height and type step together — sm · md · lg 28 · 36 · 44 with 12 · 13 · 14 type and an 18 glyph, on `pointer: coarse` 36 · 44 · 52, 15 · 16 type and a 20 glyph; icon-only is the square; the ground stays the `tone`'s, the surface is the float it sits on. A composed float component (AppFloatNav, AccountForm's box) passes it, a consumer never sets it by hand. `primary · secondary · outline · ghost · grey · control` are deprecated aliases of tones (2026-10-01, on the retirement ledger): pass `tone` — `secondary` is `tone="inverted"`, `control` is `tone="ghost"`, the rest keep their name. Visual variant. UNSET (the default) inherits the tone of the nearest `kol-tone-*` wrapper, else renders primary; the five ground variants are aliases of `tone`. `danger` is the destructive treatment (--ui-error fill); `nav` is the chrome rung — transparent, oq-64 ink, one step brighter than `ghost`; `control` is an alias for `ghost` (legacy call-sites).
15
+ * @param {'accent'|'danger'|'nav'|'tab'|'tab-filled'|'bare'} props.variant - `bare` is a pressable surface with no button chrome (a tile, a bar row — the caller draws it). The intent; the GROUND is `tone`. `tab` is the tab chip (2026-10-02): quiet text at rest, a soft filled chip while `pressed`. `tab-filled` (2026-10-29) shows its chip at rest (oq-04), brighter on hover (oq-08) and brightest selected (oq-16). `float` (FloatingStyleAndSignInDemos; round 3's values, W16 2026-10-31) is the shape only: a capsule whose height and type step together — sm · md · lg 28 · 36 · 44 with 12 · 13 · 14 type and an 18 glyph, on `pointer: coarse` 36 · 44 · 52, 15 · 16 type and a 20 glyph; icon-only is the square; the ground stays the `tone`'s, the surface is the float it sits on. A composed float component (AppFloatNav, AccountForm's box) passes it, a consumer never sets it by hand. `primary · secondary · outline · ghost · grey · control` are deprecated aliases of tones (2026-10-01, on the retirement ledger): pass `tone` — `secondary` is `tone="inverted"`, `control` is `tone="ghost"`, the rest keep their name. Visual variant. UNSET (the default) inherits the tone of the nearest `kol-tone-*` wrapper, else renders primary; the five ground variants are aliases of `tone`. `danger` is the destructive treatment (--ui-error fill); `nav` is the chrome rung — transparent, oq-64 ink, one step brighter than `ghost`; `control` is an alias for `ghost` (legacy call-sites).
16
16
  * @param {'xs'|'sm'|'md'|'lg'} props.size - Button size (default: 'md'); xs is the panel rung (ControlsXsRung, 2026-09-01) — kol-mono-8 in a 22px shell (20 icon-only), opt-in by prop
17
17
  * @param {string} props.iconLeft - Icon name to display on the left
18
18
  * @param {string} props.iconRight - Icon name to display on the right
@@ -65,6 +65,12 @@ const Button = ({
65
65
  tone = 'default',
66
66
  ...props
67
67
  }) => {
68
+ /* `variant="bare"` (MetricRebuild, 2026-10-31): a pressable SURFACE — a stat tile, a bar row, a column head — that
69
+ * draws itself. No kol-btn chrome (ground, size, type, radius); the button keeps its type, the KOL focus ring
70
+ * (`kol-menu-btn`, as MenuItem's rows) and `disabled`. For a control that looks like a button, use any other variant. */
71
+ if (variant === 'bare') {
72
+ return <button type={type} onClick={onClick} disabled={disabled} className={`kol-menu-btn ${className}`.trim()} style={style} {...props}>{children}</button>
73
+ }
68
74
  // Two ladders, split on whether a label sits beside the glyph. An icon-only
69
75
  // button is a solo glyph in a pinned square, so it takes SOLO (16/20/24) —
70
76
  // it took the text-adjacent ladder until component 0.20.0, which put a 14px
package/src/index.js CHANGED
@@ -127,7 +127,8 @@ export { default as ContentCard } from './molecules/ContentCard.jsx'
127
127
  export { default as ContentRow } from './molecules/ContentRow.jsx'
128
128
  export { default as ContentItem } from './molecules/ContentItem.jsx'
129
129
  export { default as ContentCollection } from './organisms/ContentCollection.jsx'
130
- export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
130
+ export { MenuItem, MenuRow, MenuDivider, MenuFlyout, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
131
+ export { Menu, MenuTrigger, MenuPanel, MenuLabel, MenuFilter, MenuDatePicker, MenuMore } from './molecules/Menu.jsx'
131
132
  export { MenuPopover } from './molecules/MenuPopover.jsx'
132
133
  export { ModalProvider, useModal, Dialog } from './molecules/Modal.jsx'
133
134
  export { default as Combobox } from './molecules/Combobox.jsx'
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2
2
  import { toneClass, TONE_VARS } from '../utilities/tone.js'
3
3
  import { Icon } from '@kolkrabbi/kol-icons'
4
- import { MenuDropdownItem, MenuDropdownDivider } from './MenuItem.jsx'
4
+ import { MenuRow, MenuDivider } from './MenuItem.jsx'
5
5
  import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
6
6
  import { glyphSize, indicatorSize } from '../hooks/glyphLadders.js'
7
7
 
@@ -32,6 +32,8 @@ import { glyphSize, indicatorSize } from '../hooks/glyphLadders.js'
32
32
  * explicit `size` prop (user law 2026-07-28 — desktop is sm, always, and
33
33
  * smaller viewports never resolve larger than desktop). The old auto-ramp
34
34
  * (lg ≥1024 / md ≥768 / sm below) is gone.
35
+ *
36
+ * An action menu (rows as data under a trigger) is `Menu` (the Menu* family, 2026-10-31), not this.
35
37
  */
36
38
 
37
39
  /* xs (ControlsXsRung, 2026-09-01): the panel rung, opt-in by prop — the default stays sm (2026-07-28 law) */
@@ -297,11 +299,11 @@ const Dropdown = ({
297
299
 
298
300
  <div ref={listRef} className="kol-dd-list" role="listbox" style={scale !== 1 ? { zoom: scale } : undefined}>
299
301
  {options.map((option, i) => {
300
- if (option?.divider) return <MenuDropdownDivider key={`divider-${i}`} />
302
+ if (option?.divider) return <MenuDivider key={`divider-${i}`} />
301
303
  if (option?.heading) return <div key={`heading-${i}`} className={`${resolvedSize === 'xs' ? 'kol-mono-8' : 'kol-helper-12'} ${ROW_H[resolvedSize] ?? ROW_H.sm} ${ROW_PAD[resolvedSize] ?? ROW_PAD.sm} flex shrink-0 items-center text-meta`}>{option.heading}</div>
302
304
  const isActive = option.value === currentOption?.value
303
305
  return (
304
- <MenuDropdownItem
306
+ <MenuRow
305
307
  key={option.value}
306
308
  /* NO HOVER STATE ANYWHERE ON A DROPDOWN (user 2026-08-28,
307
309
  * "delete any hover state on dropdowns"): the trigger was
@@ -336,7 +338,7 @@ const Dropdown = ({
336
338
  </span>
337
339
  )}
338
340
  {option.label}
339
- </MenuDropdownItem>
341
+ </MenuRow>
340
342
  )
341
343
  })}
342
344
  </div>
@@ -0,0 +1,198 @@
1
+ import { useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-icons'
3
+ import Button from '../atoms/Button.jsx'
4
+ import Kbd from '../atoms/Kbd.jsx'
5
+ import SearchInput from './SearchInput.jsx'
6
+ import { MenuRow, MenuDivider, MenuFlyout } from './MenuItem.jsx'
7
+ import { PopoverPanel, usePopover, Tooltip } from '../utilities/Popover.jsx'
8
+
9
+ /**
10
+ * The Menu* family (metric Round 4, 2026-10-31) — one family, one prefix.
11
+ *
12
+ * MenuTrigger the chip: `Button variant="tab" size="sm"`, `pressed` while open; `iconOnly` is the ⋮
13
+ * MenuPanel the faint raised panel (kol-theme `.kol-menu-panel`)
14
+ * MenuRow · MenuDivider · MenuLabel · MenuFlyout — the parts (MenuRow / MenuFlyout / MenuDivider live in MenuItem.jsx)
15
+ * Menu the base: `trigger` (render prop `({ open, props })`) + `items` (rows as data)
16
+ * MenuFilter · MenuDatePicker · MenuMore — ready-made menus, data and callbacks only
17
+ *
18
+ * <Menu align="end" minWidth={224} trigger={({ open, props }) => <MenuTrigger open={open} icon="filter-lines" {...props}>Filter</MenuTrigger>} items={[
19
+ * { heading: 'Graph interval' }, // a MenuLabel row
20
+ * { label: 'Days', current: true, checked: true, onClick },
21
+ * { divider: true },
22
+ * { label: 'Segment', icon: 'star', disabled: true, tooltip: 'No segments saved' },
23
+ * { label: 'Browser', icon: 'layout', items: [{ label: 'Browser', onClick }] }, // a flyout
24
+ * { label: 'Last 7 days', kbd: 'W', onClick },
25
+ * { label: 'Export stats', trailing: 'download', onClick },
26
+ * ]} />
27
+ *
28
+ * row keys: `icon` (kol-icons name or node) · `trailing` (+ `trailingSize`) · `current` fills the row · `checked` ✓
29
+ * (before the `kbd` cap) · `kbd` · `disabled` + `tooltip` (why) · `items` (a flyout of the same rows).
30
+ * `search` ({ placeholder, empty }) puts a search field above the rows and filters them by label.
31
+ * `children` sit above the rows.
32
+ */
33
+
34
+ /* the ⋮ — kol-icons `more-vertical` (user 2026-10-31: *"just make a vertical version"* — was `more` turned 90°) */
35
+ const MORE = <Icon name="more-vertical" size={16} />
36
+
37
+ export function MenuTrigger({ open = false, iconOnly, icon, iconComponent, children, ...rest }) {
38
+ if (iconOnly) {
39
+ return <Button variant="tab" size="sm" pressed={open} iconOnly={iconOnly === true ? 'more-vertical' : iconOnly} iconComponent={iconOnly === true ? MORE : iconComponent} {...rest} className={`kol-menu-trigger ${rest.className ?? ''}`.trim()} />
40
+ }
41
+ /* a labelled trigger has NO hover wash — its ground shows only while its menu is open (user 2026-10-31, on Plausible:
42
+ * *"its not supposed to be solid, only bg on hover … no hover just click … only [...] has hover"*); the ⋮ keeps it */
43
+ return <Button variant="tab" size="sm" pressed={open} iconLeft={icon} iconComponent={iconComponent} {...rest} className={`kol-menu-trigger kol-menu-trigger--quiet ${rest.className ?? ''}`.trim()}>{children}</Button>
44
+ }
45
+
46
+ export const MenuPanel = ({ className = '', children, ...rest }) => (
47
+ <div className={`kol-menu-panel ${className}`.trim()} {...rest}>{children}</div>
48
+ )
49
+
50
+ export const MenuLabel = ({ children }) => <div className="kol-menu-label kol-helper-12">{children}</div>
51
+
52
+ const glyph = (icon, size) => (typeof icon === 'string' ? <Icon name={icon} size={size} /> : icon)
53
+ const Check = () => <Icon name="check" size={14} className="text-oq-88" />
54
+
55
+ function trailingOf(it) {
56
+ if (it.kbd != null) {
57
+ return (
58
+ <span className="inline-flex items-center gap-2">
59
+ {it.checked && <Check />}
60
+ <Kbd size="md" className="kol-menu-kbd">{it.kbd}</Kbd>
61
+ </span>
62
+ )
63
+ }
64
+ if (it.checked) return <Check />
65
+ return it.trailing ? <span className="text-oq-88 inline-flex">{glyph(it.trailing, it.trailingSize ?? 14)}</span> : undefined
66
+ }
67
+
68
+ function Rows({ items }) {
69
+ return items.map((it, i) => {
70
+ if (it.divider) return <MenuDivider key={`d${i}`} className="kol-menu-rule" />
71
+ if (it.heading) return <MenuLabel key={`h${i}`}>{it.heading}</MenuLabel>
72
+ const icon = it.icon && <span className="text-oq-88 inline-flex">{glyph(it.icon, 16)}</span>
73
+ const cls = `kol-menu-row kol-helper-12${it.current ? ' kol-menu-row--current' : ''}`
74
+ const row = it.items?.length && !it.disabled ? (
75
+ <MenuFlyout flyout iconLeft={icon} label={it.label} rowClass={cls} panelClassName="kol-menu-panel min-w-[200px]" panelStyle={{ marginLeft: 4 }}>
76
+ <Rows items={it.items} />
77
+ </MenuFlyout>
78
+ ) : (
79
+ <MenuRow disabled={it.disabled} rowClass={cls} onClick={it.onClick} iconLeft={icon} shortcut={trailingOf(it)}>
80
+ {it.label}
81
+ </MenuRow>
82
+ )
83
+ const key = it.key ?? `r${i}`
84
+ return it.tooltip
85
+ ? <Tooltip key={key} label={it.tooltip} placement="left" triggerClassName="block w-full">{row}</Tooltip>
86
+ : <div key={key}>{row}</div>
87
+ })
88
+ }
89
+
90
+ export function Menu({ items = [], trigger, align = 'start', minWidth, offset = 4, defaultOpen = false, stayOpen = false, search, className = '', children }) {
91
+ const [open, setOpen] = useState(defaultOpen)
92
+ const [q, setQ] = useState('')
93
+ const popover = usePopover({
94
+ open,
95
+ onOpenChange: setOpen,
96
+ placement: align === 'end' ? 'bottom-end' : 'bottom-start',
97
+ offset,
98
+ role: 'menu',
99
+ dismiss: stayOpen ? { outsidePress: false } : true,
100
+ })
101
+ let rows = items
102
+ if (search) {
103
+ const s = q.toLowerCase()
104
+ rows = items.filter((it) => it.label == null || String(it.label).toLowerCase().includes(s))
105
+ if (!rows.some((it) => it.label != null)) rows = [...rows, { key: 'empty', label: search.empty || 'Nothing matches', disabled: true }]
106
+ }
107
+ /* the wrapper takes the ref (a Button need not forward one); the trigger takes the handlers and the aria —
108
+ * `aria-pressed` cleared, an open menu is `aria-expanded`, its fill is the trigger's `pressed` */
109
+ return (
110
+ <>
111
+ <span ref={popover.refs.setReference} className={`inline-flex align-middle ${className}`.trim()}>
112
+ {trigger({ open, props: { ...popover.getReferenceProps(), 'aria-pressed': undefined } })}
113
+ </span>
114
+ <PopoverPanel popover={popover} panel={false} focus={false} className="kol-menu-panel w-max" style={minWidth ? { minWidth } : undefined}>
115
+ {/* a row's click closes the menu (it bubbles from `[data-menu-item]`); a flyout row only toggles */}
116
+ <div onClick={(e) => { if (e.target.closest('[data-menu-item]')) setOpen(false) }}>
117
+ {search && <div className="pb-1"><SearchInput variant="outline" size="sm" value={q} onChange={(e) => setQ(e.target?.value ?? e)} onClear={() => setQ('')} placeholder={search.placeholder || 'Search'} /></div>}
118
+ {children}
119
+ <Rows items={rows} />
120
+ </div>
121
+ </PopoverPanel>
122
+ </>
123
+ )
124
+ }
125
+
126
+ /* ── the ready-made menus ── */
127
+
128
+ /**
129
+ * MenuFilter — category rows (`groups`: `{ key, label, icon, fields: [{ key, label }], more, disabled: 'why' }` or
130
+ * `{ divider: true }`). A group with one field is a plain row that opens it; a `more` group with 2+ fields opens a flyout
131
+ * of them; a `more` group with none is disabled with ›; a disabled group shows its reason. `onPick({ group, field? })`.
132
+ * `active` (`[{ key, label, group, field? }]`) — metric Round 6 (user: *"dont put this tag there, it breaks the layout"*):
133
+ * the active filters live INSIDE the menu, an "Active" section on top (a row's click is `onPick` to edit it, its × is
134
+ * `onRemove(f)`, "Clear all" at 2+ is `onClear`), and the trigger holds its pressed ground while any is set. No count.
135
+ */
136
+ export function MenuFilter({ groups = [], onPick, active = [], onRemove, onClear, label = 'Filter', icon = 'filter-lines', align = 'end', minWidth = 224 }) {
137
+ const items = groups.map((g) => {
138
+ if (g.divider) return g
139
+ const one = g.fields?.length === 1 && !g.disabled
140
+ return {
141
+ key: g.key, label: g.label, icon: g.icon, disabled: !!g.disabled, tooltip: g.disabled || undefined,
142
+ trailing: g.more && g.disabled ? 'chevron-right' : undefined, trailingSize: 12,
143
+ items: g.more && !g.disabled && !one ? g.fields.map((f) => ({ key: f.key, label: f.label, onClick: () => onPick?.({ group: g.key, field: f.key }) })) : undefined,
144
+ onClick: () => onPick?.({ group: g.key }),
145
+ }
146
+ })
147
+ const rowCls = 'kol-menu-row kol-helper-12'
148
+ return (
149
+ <Menu align={align} minWidth={minWidth} offset={8} items={items}
150
+ trigger={({ open, props }) => <MenuTrigger open={open || active.length > 0} icon={icon} {...props}>{label}</MenuTrigger>}>
151
+ {active.length > 0 && (
152
+ <>
153
+ <MenuLabel>Active</MenuLabel>
154
+ {active.map((f) => (
155
+ <div key={f.key} className="flex items-center">
156
+ <div className="flex-1 min-w-0">
157
+ <MenuRow rowClass={rowCls} onClick={() => onPick?.({ group: f.group, field: f.field })}>{f.label}</MenuRow>
158
+ </div>
159
+ <Button tone="ghost" size="xs" iconOnly="x" aria-label={`Remove filter: ${f.label}`} onClick={() => onRemove?.(f)} />
160
+ </div>
161
+ ))}
162
+ {active.length > 1 && onClear && <MenuRow rowClass={rowCls} onClick={onClear}>Clear all</MenuRow>}
163
+ <MenuDivider className="kol-menu-rule" />
164
+ </>
165
+ )}
166
+ </Menu>
167
+ )
168
+ }
169
+
170
+ /**
171
+ * MenuDatePicker — range `groups` (`[[{ key, label, kbd }]]`, a rule between groups), `value` the current key (filled,
172
+ * ✓), `onChange(key)` (a custom-range key is the caller's to open its dialog). `compare` (`{ label, kbd, onClick }`)
173
+ * adds the compare row under a rule. The trigger shows `label` beside the calendar glyph (`icon` / `iconComponent`).
174
+ * metric Round 6 (user: *"relation ship between tab and dropdown is much better at reference"* — plausible.io): the
175
+ * trigger holds a value, so it wears its chip at rest — the tab's pressed oq-08, the panel's own ground, one family —
176
+ * and the panel opens right-aligned 8px under it (plausible.io measured: 8px, right edges flush).
177
+ */
178
+ export function MenuDatePicker({ groups = [], value, onChange, label, icon = 'calendar', iconComponent, compare, align = 'end', minWidth = 224 }) {
179
+ const items = [
180
+ ...groups.flatMap((g, i) => [
181
+ ...(i > 0 ? [{ divider: true }] : []),
182
+ ...g.map((r) => ({ key: r.key, label: r.label, current: r.key === value, checked: r.key === value, kbd: r.kbd, onClick: () => onChange?.(r.key) })),
183
+ ]),
184
+ ...(compare ? [{ divider: true }, { key: 'compare', label: compare.label, kbd: compare.kbd, onClick: compare.onClick }] : []),
185
+ ]
186
+ return (
187
+ <Menu align={align} minWidth={minWidth} offset={8} items={items}
188
+ trigger={({ open, props }) => <MenuTrigger open={open} icon={icon} iconComponent={iconComponent} {...props}>{label}</MenuTrigger>} />
189
+ )
190
+ }
191
+
192
+ /** MenuMore — the ⋮ chip and its rows (`items`, as `Menu`'s); `label` names it for a screen reader. */
193
+ export function MenuMore({ label, items = [], align = 'end', minWidth = 224, defaultOpen, stayOpen }) {
194
+ return (
195
+ <Menu align={align} minWidth={minWidth} offset={8} items={items} defaultOpen={defaultOpen} stayOpen={stayOpen}
196
+ trigger={({ open, props }) => <MenuTrigger open={open} iconOnly aria-label={label} {...props} />} />
197
+ )
198
+ }
@@ -99,7 +99,7 @@ export function MenuItem({
99
99
  }
100
100
 
101
101
  /**
102
- * MenuDropdownItem — action row inside a MenuItem's dropdown panel.
102
+ * MenuRow — action row inside a menu panel (`Menu`, `MenuItem`). Was `MenuDropdownItem` (alias kept, 2026-10-31).
103
103
  *
104
104
  * `height` (default none — the `h-8` rung) — an inline row height, for a panel
105
105
  * in a chrome whose own rows are shorter (DropdownGhostWidthAndListHeight,
@@ -134,7 +134,7 @@ const ROW_BY_SIZE = { xs: 'kol-mono-8 px-2 h-5', sm: 'kol-helper-12 px-2 h-8', m
134
134
  /* `flex`, not `inline-flex` (MenuDropdownItemBlockRow, kol-fxr 2026-10-13): as bare children of a
135
135
  * content-sized (`w-max`) panel, inline rows measured as ONE line — five starters ≈ 795px. A block
136
136
  * row still fills a fixed panel through `w-full` and lets a content-sized one hug its longest label. */
137
- export function MenuDropdownItem({ onClick, onPointerEnter, onPointerLeave, disabled, prefix, iconLeft, shortcut, hover = true, height, size = 'sm', rowClass, children }) {
137
+ export function MenuRow({ onClick, onPointerEnter, onPointerLeave, disabled, prefix, iconLeft, shortcut, hover = true, height, size = 'sm', rowClass, children }) {
138
138
  return (
139
139
  <button
140
140
  type="button"
@@ -161,12 +161,13 @@ export function MenuDropdownItem({ onClick, onPointerEnter, onPointerLeave, disa
161
161
 
162
162
  /* `className` — the rule's colour and spacing, whole (MetricDashboardUpstream, kol-website 2026-10-31): on a filled
163
163
  * panel the default `border-oq-08` vanishes. */
164
- export function MenuDropdownDivider({ className = 'border-oq-08 my-1' }) {
164
+ /* MenuDivider — a rule between a menu's groups. Was `MenuDropdownDivider` (alias kept, 2026-10-31). */
165
+ export function MenuDivider({ className = 'border-oq-08 my-1' }) {
165
166
  return <div className={`border-t ${className}`} />
166
167
  }
167
168
 
168
169
  /**
169
- * MenuDropdownNest — A dropdown row that opens a submenu. a row inside a dropdown panel that holds a submenu.
170
+ * MenuFlyout — a menu row that holds a submenu. Was `MenuDropdownNest` (alias kept, 2026-10-31).
170
171
  *
171
172
  * Default — ACCORDION: click the row to expand/collapse its children inline
172
173
  * (below the row, in the same panel); the trailing chevron rotates 90°.
@@ -193,7 +194,7 @@ export function MenuDropdownDivider({ className = 'border-oq-08 my-1' }) {
193
194
  * Either way, a leaf MenuDropdownItem inside still closes the whole menu via the
194
195
  * standard data-menu-item bubble; the nest row itself only toggles.
195
196
  */
196
- export function MenuDropdownNest({ prefix, iconLeft, label, disabled, flyout = false, scroll = false, rowClass, panelClassName, panelStyle, children }) {
197
+ export function MenuFlyout({ prefix, iconLeft, label, disabled, flyout = false, scroll = false, rowClass, panelClassName, panelStyle, children }) {
197
198
  if (flyout) return <MenuFlyoutNest prefix={prefix} iconLeft={iconLeft} label={label} disabled={disabled} scroll={scroll} rowClass={rowClass} panelClassName={panelClassName} panelStyle={panelStyle}>{children}</MenuFlyoutNest>
198
199
  return <MenuAccordionNest prefix={prefix} iconLeft={iconLeft} label={label} disabled={disabled}>{children}</MenuAccordionNest>
199
200
  }
@@ -296,3 +297,10 @@ function MenuFlyoutNest({ prefix, iconLeft, label, disabled, scroll, rowClass, p
296
297
  </div>
297
298
  )
298
299
  }
300
+
301
+ /** @deprecated 2026-10-31 → MenuRow (the Menu* family, metric Round 4) */
302
+ export const MenuDropdownItem = MenuRow
303
+ /** @deprecated 2026-10-31 → MenuDivider */
304
+ export const MenuDropdownDivider = MenuDivider
305
+ /** @deprecated 2026-10-31 → MenuFlyout */
306
+ export const MenuDropdownNest = MenuFlyout
@@ -64,7 +64,16 @@ import ToggleCheckbox from '../atoms/ToggleCheckbox.jsx'
64
64
 
65
65
  const ModalCtx = (globalThis[Symbol.for('@kolkrabbi/kol-component/ModalCtx')] ??= createContext(null))
66
66
 
67
- export function ModalProvider({ children }) {
67
+ /* THE MODAL VARIANTS (user 2026-10-31: *"do we not use variants for different styles?"*). `lift` — the overlay panel
68
+ * every KOL overlay wears (oq-04, no border, the scrim is the edge); `bordered` — the page surface behind an fg-08
69
+ * hairline. Each modal keeps its own default (prompts bordered, Dialog lift) until he picks one. */
70
+ const MODAL_VARIANTS = {
71
+ lift: { className: 'kol-overlay-panel', style: {} },
72
+ bordered: { className: 'border border-fg-08', style: { background: 'var(--kol-surface-primary)', color: 'var(--kol-surface-on-primary)' } },
73
+ }
74
+ const modalVariant = (v, fallback) => MODAL_VARIANTS[v] ?? MODAL_VARIANTS[fallback]
75
+
76
+ export function ModalProvider({ children, variant = 'bordered' }) {
68
77
  const [state, setState] = useState(null)
69
78
 
70
79
  const closeWith = useCallback((value) => {
@@ -74,34 +83,35 @@ export function ModalProvider({ children }) {
74
83
  })
75
84
  }, [])
76
85
 
77
- const prompt = useCallback((title, defaultValue = '', { okLabel, cancelLabel, type = 'text', placeholder } = {}) =>
78
- new Promise((resolve) => setState({ kind: 'prompt', title, defaultValue, okLabel, cancelLabel, type, placeholder, resolve })),
86
+ const prompt = useCallback((title, defaultValue = '', { okLabel, cancelLabel, type = 'text', placeholder, variant } = {}) =>
87
+ new Promise((resolve) => setState({ kind: 'prompt', title, defaultValue, okLabel, cancelLabel, type, placeholder, variant, resolve })),
79
88
  [])
80
89
 
81
- const confirm = useCallback((title, { okLabel, cancelLabel, options } = {}) =>
82
- new Promise((resolve) => setState({ kind: 'confirm', title, okLabel, cancelLabel, options: options?.length ? options : null, resolve })),
90
+ const confirm = useCallback((title, { okLabel, cancelLabel, options, variant } = {}) =>
91
+ new Promise((resolve) => setState({ kind: 'confirm', title, okLabel, cancelLabel, options: options?.length ? options : null, variant, resolve })),
83
92
  [])
84
93
 
85
- const alert = useCallback((title, { okLabel } = {}) =>
86
- new Promise((resolve) => setState({ kind: 'alert', title, okLabel, resolve })),
94
+ const alert = useCallback((title, { okLabel, variant } = {}) =>
95
+ new Promise((resolve) => setState({ kind: 'alert', title, okLabel, variant, resolve })),
87
96
  [])
88
97
 
89
- const form = useCallback((title, fields = [], { okLabel, cancelLabel } = {}) =>
90
- new Promise((resolve) => setState({ kind: 'form', title, fields, okLabel, cancelLabel, resolve })),
98
+ const form = useCallback((title, fields = [], { okLabel, cancelLabel, variant } = {}) =>
99
+ new Promise((resolve) => setState({ kind: 'form', title, fields, okLabel, cancelLabel, variant, resolve })),
91
100
  [])
92
101
 
93
102
  return (
94
103
  <ModalCtx.Provider value={{ prompt, confirm, alert, form }}>
95
104
  {children}
96
105
  {state && typeof document !== 'undefined' && createPortal(
97
- <ModalView state={state} closeWith={closeWith} />,
106
+ <ModalView state={state} closeWith={closeWith} variant={state.variant ?? variant} />,
98
107
  document.body,
99
108
  )}
100
109
  </ModalCtx.Provider>
101
110
  )
102
111
  }
103
112
 
104
- function ModalView({ state, closeWith }) {
113
+ function ModalView({ state, closeWith, variant }) {
114
+ const look = modalVariant(variant, 'bordered')
105
115
  const [val, setVal] = useState(state.defaultValue ?? '')
106
116
  const [values, setValues] = useState(() => Object.fromEntries((state.options ?? []).map((o) => [o.id, !!o.defaultValue])))
107
117
  const [fieldVals, setFieldVals] = useState(() => Object.fromEntries((state.fields ?? []).map((f) => [f.id, f.defaultValue ?? ''])))
@@ -184,10 +194,9 @@ function ModalView({ state, closeWith }) {
184
194
  aria-modal="true"
185
195
  aria-labelledby="kol-modal-title"
186
196
  onMouseDown={(e) => e.stopPropagation()}
187
- className="kol-modal flex flex-col gap-4 p-5 rounded border border-fg-08"
197
+ className={`kol-modal flex flex-col gap-4 p-5 rounded ${look.className}`}
188
198
  style={{
189
- background: 'var(--kol-surface-primary)',
190
- color: 'var(--kol-surface-on-primary)',
199
+ ...look.style,
191
200
  minWidth: 320,
192
201
  maxWidth: '90vw',
193
202
  }}
@@ -288,7 +297,8 @@ export function useModal() {
288
297
  *
289
298
  * <Dialog open={open} onClose={() => setOpen(false)} title="Filter by page" width={560}>…</Dialog>
290
299
  */
291
- export function Dialog({ open, onClose, title, width = 560, children }) {
300
+ export function Dialog({ open, onClose, title, width = 560, variant = 'lift', children }) {
301
+ const look = modalVariant(variant, 'lift')
292
302
  useEffect(() => {
293
303
  if (!open) return undefined
294
304
  const onKey = (e) => { if (e.key === 'Escape' && !document.querySelector('.kol-popover-float')) onClose() }
@@ -308,8 +318,11 @@ export function Dialog({ open, onClose, title, width = 560, children }) {
308
318
  aria-label={title}
309
319
  data-dialog
310
320
  onMouseDown={(e) => e.stopPropagation()}
311
- className="kol-modal flex flex-col rounded border border-fg-08 max-h-[85vh]"
312
- style={{ background: 'var(--kol-surface-primary)', color: 'var(--kol-surface-on-primary)', width: `min(${width}px, calc(100vw - 32px))` }}
321
+ /* THE OVERLAY PANEL (MetricRebuild, 2026-10-31 — user: the filter dialog read as "a borderless box floating
322
+ * mid-page"): it painted the page's own surface behind an fg-08 hairline, so on a dark page it was the page.
323
+ * `.kol-overlay-panel` is the lift every KOL overlay wears (oq-04, no border — the scrim is the edge). */
324
+ className={`kol-modal flex flex-col rounded max-h-[85vh] ${look.className}`}
325
+ style={{ ...look.style, width: `min(${width}px, calc(100vw - 32px))` }}
313
326
  >
314
327
  {children}
315
328
  </div>
@@ -37,13 +37,15 @@
37
37
  * stamps `reveal` on each part; nothing animates in the DS)
38
38
  * @param {object} slotStyle inline style per slot — `{ headline: { '--reveal-delay': '0.1s' } }`
39
39
  * @param {string} className extra classes on the block
40
- * @param {'page'|'app'|'section'|'content'} [level] THE HEADER LEVELS (2026-10-22, user: *"rename PageHeader to
40
+ * @param {'page'|'app'|'section'|'content'|'document'} [variant] THE HEADER VARIANTS (was `level` until 2026-10-31 — user: `variant`
41
+ * is the only word for a variant; `level` stays a deprecated alias on the retirement ledger) (2026-10-22, user: *"rename PageHeader to
41
42
  * something that describes section text elements"* — PageHeader is now an alias of this). A
42
43
  * level fills the role defaults; any explicit prop still wins. page = h1 display-01 over
43
44
  * `.kol-header-lede` — the hero; app = h1 display-01 at 46/60 over `.kol-header-lede-app`;
44
45
  * section = h2 display-03 over `.kol-header-lede-section`; content = h3 heading-04 under a
45
46
  * `kol-eyebrow`, over body-01 fg-64 at 64ch — a section of a page's CONTENT (a dev tab's
46
- * sections, DevSectionHeader, kol-fxr). A level sets actions inline, and the bottom rhythm
47
+ * sections, DevSectionHeader, kol-fxr); document = h1 36/1.2 bold display face over a 15/1.6 body-face lede
48
+ * to 724px — a document's own page (DocumentIndex). A level sets actions inline, and the bottom rhythm
47
49
  * to `--kol-page-header-mb` (40px; content 16px). Unset = the section family as before.
48
50
  * @param {ReactNode} media a mark above the eyebrow (a logo, an SVG) — with a `level` only
49
51
  */
@@ -60,7 +62,7 @@ export const HEADLINE_ROLE = {
60
62
  'heading-05': 'kol-sans-heading-05',
61
63
  }
62
64
 
63
- /* ONE HEADER, FOUR LEVELS (Round 11 + PageHeaderAppLevel, carried from PageHeader whole). `title` is the
65
+ /* ONE HEADER, FIVE LEVELS (Round 11 + PageHeaderAppLevel, carried from PageHeader whole). `title` is the
64
66
  * headline's type, `lede` the body's; the rest default the block for that level. */
65
67
  export const SECTION_TEXT_LEVELS = {
66
68
  page: { title: 'kol-sans-display-01', lede: 'kol-header-lede' },
@@ -69,6 +71,9 @@ export const SECTION_TEXT_LEVELS = {
69
71
  /* content (DevSectionHeader, kol-fxr 2026-10-22 — the user's approved look: *"maybe title and description is
70
72
  * one step bigger?"*; never `kol-helper-*` for a paragraph) */
71
73
  content: { title: 'kol-sans-heading-04', lede: 'kol-sans-body-01 text-fg-64', eyebrow: 'kol-eyebrow text-fg-48', as: 'h3', gap: 'gap-2', mb: '16px', measure: '64ch' },
74
+ /* document (DocumentIndexFigma, kol-atlas 2026-10-31) — the Research page header, the workspace sheet's: 36/1.2 bold
75
+ * display face at −0.005em, 12 under it; the lede 15/1.6 in the body face to a 724 edge */
76
+ document: { title: 'font-[family-name:var(--kol-font-family-sans)] text-[36px] font-bold leading-[1.2] tracking-[-0.005em]', lede: 'm-0 font-[family-name:var(--kol-font-family-sans-neutral)] text-[15px] leading-[1.6] text-fg-96', measure: '724px' },
72
77
  }
73
78
 
74
79
  export default function SectionText({
@@ -115,11 +120,14 @@ export default function SectionText({
115
120
  (PageHeader's `--kol-page-header-mb`). `slotStyle` reaches the slots; this
116
121
  was the one box it could not reach. */
117
122
  style,
123
+ variant: variantProp,
124
+ /* deprecated alias of `variant` (2026-10-31) */
118
125
  level,
119
126
  media,
120
127
  }) {
121
128
  /* a LEVEL fills the header defaults; an explicit prop always wins */
122
- const L = level ? SECTION_TEXT_LEVELS[level] ?? SECTION_TEXT_LEVELS.page : null
129
+ const variant = variantProp ?? level
130
+ const L = variant ? SECTION_TEXT_LEVELS[variant] ?? SECTION_TEXT_LEVELS.page : null
123
131
  const Headline = headlineAs ?? L?.as ?? (L ? 'h1' : 'h2')
124
132
  if (L) {
125
133
  headlineClass = `text-fg-96 ${headlineClass ?? L.title}`
@@ -242,8 +242,11 @@ function StackedTabs({ tabs, value, onChange, placement, shape = 'bar', collapse
242
242
  onClick={() => onChange?.(t.id)}
243
243
  aria-current={active ? 'page' : undefined}
244
244
  aria-label={t.label}
245
- className={`flex flex-col items-center justify-center rounded-full cursor-pointer flex-none${labels ? ' gap-0.5' : ''}`}
246
- style={{ width: slotW, height: slotH, padding: 0, border: 'none', background: active ? 'var(--kol-oq-16)' : 'transparent', color: ink(active) }}
245
+ /* a DOCK slot lights on hover like a menu row — the nav row's oq-04 ground and on-primary ink (HomeDockRound4,
246
+ * kol-fxr 2026-10-31; was forced from AppFloatNav with `!` over the inline ground). The rest ground and ink ride
247
+ * variables so the hover class can win without `!`. */
248
+ className={`flex flex-col items-center justify-center rounded-full cursor-pointer flex-none bg-[var(--slot-bg)] text-[var(--slot-ink)]${dock && !active ? ' hover:bg-[var(--kol-oq-04)] hover:text-[var(--kol-surface-on-primary)]' : ''}${labels ? ' gap-0.5' : ''}`}
249
+ style={{ width: slotW, height: slotH, padding: 0, border: 'none', '--slot-bg': active ? 'var(--kol-oq-16)' : 'transparent', '--slot-ink': ink(active) }}
247
250
  >
248
251
  {t.icon && <Icon name={t.icon} size={labels ? 24 : 28} />}
249
252
  {labels && <span className="truncate max-w-full px-1.5" style={{ fontFamily: 'var(--kol-font-family-mono)', fontSize: 11, lineHeight: 1.2, fontWeight: 600 }}>{t.label}</span>}
@@ -143,10 +143,13 @@ export default function SettingsPanel({
143
143
  * row living in a column narrower than the 160px label default
144
144
  * (SettingsShortcutsComboOverflow, kol-monitor 2026-09-01). `text` is a row whose value is text — the same label,
145
145
  * on the baseline, as tall as its text (LabeledControl's `text`; SettingsRowMonoBaseline, kol-monitor 2026-10-18).
146
+ * `stack` puts the label ABOVE the value, both at the row's start — a dated entry read as a bulletin (metric's
147
+ * Deploys tab, Round 3 2026-10-31: *"date above the text"*); `labelWidth` and `align` do nothing there.
146
148
  */
147
- export function SettingsRow({ label, hint, align = 'end', labelWidth = 160, text = false, children }) {
149
+ export function SettingsRow({ label, hint, align = 'end', labelWidth = 160, text = false, stack = false, children }) {
150
+ if (stack) align = 'fill'
148
151
  return (
149
- <LabeledControl inline text={text} label={typeof label === 'string' ? label.toUpperCase() : label} labelWidth={labelWidth}>
152
+ <LabeledControl inline={!stack} text={text} label={typeof label === 'string' ? label.toUpperCase() : label} labelWidth={labelWidth}>
150
153
  {hint
151
154
  ? <Tooltip label={hint} triggerClassName={`flex min-h-6 items-center w-full ${align === 'fill' ? '' : 'justify-end'}`.trim()}>{children}</Tooltip>
152
155
  /* `flex`, not `inline-flex`: an inline box rode the label's text baseline and sat 5px low (2026-10-31) */
@@ -67,7 +67,7 @@ const rowAlign = (row, fallback) => row.align ?? (row.type === 'choice' || row.t
67
67
  * first included: the rule is `.kol-section--divided + .kol-section--divided`,
68
68
  * so the first draws nothing and skipping it just costs the next its line.
69
69
  *
70
- * @param {Array<{label?: string, note?: string|string[], rows?: Array<{label: ReactNode, type?: 'choice'|'switch'|'slider', options?: Array, value?: ReactNode, on?: boolean, onChange?: Function, min?: number, max?: number, step?: number, formatValue?: Function, defaultValue?: number, displayWidth?: number, render?: Function, hint?: string, align?: string, labelWidth?: number|'auto', text?: boolean, id?: string}>, rowGap?: number}>} sections - The settings body, in order (`hint` → SettingsRow's hint; `note` a sentence or two under the eyebrow; a string `value` is drawn in the DS's mono-12 and sits on the baseline; `type: 'choice'` / `'switch'` / `'slider'` rows are drawn here — a slider is the DS Slider across the value column, fluid track + the Input readout at `displayWidth` chars (3, `preset="rail"`), its label column 80 unless set; `min` · `max` · `step` · `value` · `onChange`)
70
+ * @param {Array<{label?: string, note?: string|string[], rows?: Array<{label: ReactNode, type?: 'choice'|'switch'|'slider', options?: Array, value?: ReactNode, on?: boolean, onChange?: Function, min?: number, max?: number, step?: number, formatValue?: Function, defaultValue?: number, displayWidth?: number, render?: Function, hint?: string, align?: string, labelWidth?: number|'auto', text?: boolean, stack?: boolean, id?: string}>, rowGap?: number}>} sections - The settings body, in order (`hint` → SettingsRow's hint; `stack` → SettingsRow's stack, the label over the value; `note` a sentence or two under the eyebrow; a string `value` is drawn in the DS's mono-12 and sits on the baseline; `type: 'choice'` / `'switch'` / `'slider'` rows are drawn here — a slider is the DS Slider across the value column, fluid track + the Input readout at `displayWidth` chars (3, `preset="rail"`), its label column 80 unless set; `min` · `max` · `step` · `value` · `onChange`)
71
71
  * @param {boolean} [divided=false] - Hairline above each section
72
72
  * @param {number|'auto'} [labelWidth] - Default label column for every row — `'auto'` makes the label yield and the control hug, which is what a narrow column or a nowrap value needs
73
73
  * @param {string} [align] - Default row alignment passed to `SettingsRow`
@@ -87,6 +87,7 @@ export default function SettingsSections({ sections = [], divided = false, label
87
87
  align={rowAlign(row, align)}
88
88
  labelWidth={row.labelWidth ?? labelWidth ?? (row.type === 'slider' ? 80 : undefined)}
89
89
  text={row.text ?? (!row.render && !row.type && isText(row.value))}
90
+ stack={row.stack}
90
91
  >
91
92
  {control(row)}
92
93
  </SettingsRow>