@kolkrabbi/kol-component 0.319.1 → 0.320.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.319.1",
3
+ "version": "0.320.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,7 +26,7 @@
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.2.0",
29
+ "@kolkrabbi/kol-growth": "^0.3.0",
30
30
  "@kolkrabbi/kol-search": "^0.3.0",
31
31
  "@kolkrabbi/kol-markdown": "^0.2.0"
32
32
  },
@@ -39,7 +39,7 @@
39
39
  "react-dom": "^18.3.0 || ^19.0.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@kolkrabbi/kol-icons": "^0.53.5"
42
+ "@kolkrabbi/kol-icons": "^0.54.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` (2026-10-30, FloatingStyleAndSignInDemos — the floating style B) is the shape only: a 44 control (44×44 icon-only), an 18 radius; the ground stays the `tone`'s. 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'} 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).
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
@@ -69,7 +69,8 @@ const Button = ({
69
69
  // button is a solo glyph in a pinned square, so it takes SOLO (16/20/24) —
70
70
  // it took the text-adjacent ladder until component 0.20.0, which put a 14px
71
71
  // glyph in a 28px square at `sm`. `iconSize` still overrides either.
72
- const resolvedIconSize = iconSize ?? glyphSize(size, Boolean(iconOnly))
72
+ /* a float glyph follows the pointer (18, 20 on touch — `--kol-float-ic`), so its size is the token, not a number */
73
+ const resolvedIconSize = iconSize ?? (variant === 'float' ? 'var(--kol-float-ic)' : glyphSize(size, Boolean(iconOnly)))
73
74
 
74
75
  // `control` is a legacy alias for `ghost` (kept so web call-sites passing
75
76
  // variant="control" keep working post-migration).
@@ -11,8 +11,9 @@ import { glyphSize } from '../hooks/glyphLadders.js'
11
11
  * variant="outline" — bordered, transparent bg — THE secondary
12
12
  * treatment (2026-07-08 chrome law: one
13
13
  * secondary, always subordinate to filled)
14
- * variant="float" — the floating style B (2026-10-30): 44 tall, an 18 radius, on the page's ground
15
- * (surface-primary) inside a float box; AccountForm's box passes it
14
+ * variant="float" — the floating style (round 3, W16 2026-10-31): a capsule on the float's well
15
+ * (`--kol-float-well`), 16 in, sm · md · lg 28 · 36 · 44 (touch 36 · 44 · 52) with
16
+ * 12 · 13 · 14 (13 · 15 · 16) type; AccountForm's box passes it
16
17
  * tone="inverse" — the dark chip (`fg-ab-24`) for a washed plane
17
18
  * (ControlToneInverse, 2026-08-27); same prop on ViewToggle · Dropdown
18
19
  * size="xs" — the panel rung (ControlsXsRung, 2026-09-01):
@@ -44,7 +44,9 @@
44
44
  * outline shell; the inset ring marks ONLY the selected cell) |
45
45
  * 'tonal' (filled tiles, but the clicked cell marks itself by
46
46
  * TONE — surface-tertiary fill, no ring; 2026-08-12).
47
- * Every variant renders at the SAME pinned button-ladder
47
+ * 'float' (W16, round 3 — 2026-10-31): a capsule track 6 in on its `surface`, pills 4 apart at the
48
+ * float heights (28/36/44, touch 36/44/52), the selected pill filled with the ink.
49
+ * Every other variant renders at the SAME pinned button-ladder
48
50
  * height (22/26/32/40) — icon or text, the box never moves.
49
51
  * size — mirrors Button exactly: 'sm' (26px, mono-12, 4/12 pad) |
50
52
  * 'md' (default, 32px, mono-14, 6/16 pad) | 'lg' (40px,
@@ -57,13 +59,16 @@
57
59
  * selected cell on `--kol-surface-sunken` / `fg-96`, rest cells as the default paints
58
60
  * them; `filled` and `tonal` keep their own selected law. Through `toneClass`, so a
59
61
  * `kol-tone-sunken` wrapper reaches it too. Unset = inherit.
62
+ * surface — `variant="float"` only: 'solid' (default) | 'glass-dark' | 'glass-light' — the track's material
63
+ * (`floatSurfaceClass`, kol-theme `.kol-float-*`)
60
64
  * ariaLabel — accessible name for the group
61
65
  * className — additional classes on the outer shell
62
66
  */
63
- import { toneClass } from '../utilities/tone.js'
67
+ import { toneClass, floatSurfaceClass } from '../utilities/tone.js'
64
68
  import { Tooltip } from '../utilities/Popover.jsx'
65
69
 
66
- export default function SegmentedToggle({ value, onChange, options = [], variant = 'default', size = 'md', tone = 'default', ariaLabel, className = '' }) {
70
+ export default function SegmentedToggle({ value, onChange, options = [], variant = 'default', size = 'md', tone = 'default', surface = 'solid', ariaLabel, className = '' }) {
71
+ const float = variant === 'float'
67
72
  const cellType = { xs: 'kol-mono-8', sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-16' }[size]
68
73
  const stateless = value == null
69
74
  const focusIdx = Math.max(0, options.findIndex((opt) => opt.value === value))
@@ -95,6 +100,7 @@ export default function SegmentedToggle({ value, onChange, options = [], variant
95
100
  (variant === 'filled' || variant === 'tonal') && 'kol-seg--filled',
96
101
  variant === 'tonal' && 'kol-seg--tonal',
97
102
  size !== 'md' && `kol-seg--${size}`,
103
+ float && `kol-seg--float ${floatSurfaceClass(surface)}`,
98
104
  toneClass(resolvedTone),
99
105
  className,
100
106
  ].filter(Boolean).join(' ')}
package/src/index.js CHANGED
@@ -54,6 +54,7 @@ export { default as LabeledControl } from './molecules/LabeledControl.jsx'
54
54
  export { default as OverlayGlassPanel } from './utilities/OverlayGlassPanel.jsx'
55
55
  export { default as Pill } from './atoms/Pill.jsx'
56
56
  export { usePopover, PopoverPanel, Tooltip } from './utilities/Popover.jsx'
57
+ export { floatSurfaceClass, FLOAT_SURFACES } from './utilities/tone.js'
57
58
  /* armLongPress — the touch hold the panel value controls report (moved from kol-hardware 2026-10-01) */
58
59
  export { armLongPress } from './utilities/armLongPress.js'
59
60
  export { default as ContextMenu, useContextMenu } from './molecules/ContextMenu.jsx'
@@ -149,7 +150,7 @@ export { default as SplitToolButton } from './molecules/SplitToolButton.jsx'
149
150
  export { default as ToolPalette } from './organisms/ToolPalette.jsx'
150
151
  export { default as DocumentEditor } from './organisms/DocumentEditor.jsx'
151
152
  export { default as SwatchControls, SwatchStack, EyedropPick } from './molecules/SwatchControls.jsx'
152
- export { default as TabsRow, TABBAR_H, TABBAR_PILL_INSET, TABBAR_PILL_TAB, tabbarPillWidth } from './molecules/TabsRow.jsx'
153
+ export { default as TabsRow, TABBAR_H, TABBAR_PILL_H, TABBAR_PILL_INSET, TABBAR_PILL_TAB, TABBAR_PILL_GAP, TABBAR_PILL_LABEL_TAB, TABBAR_PILL_SPLIT_GAP, tabbarPillWidth, tabbarPillSlot } from './molecules/TabsRow.jsx'
153
154
  /** @deprecated 2026-10-17 — alias of `TabsRow variant="chip"` */
154
155
  export { default as TabChips } from './molecules/TabChips.jsx'
155
156
 
@@ -5,6 +5,7 @@ import Button from '../atoms/Button.jsx'
5
5
  import { Icon } from '@kolkrabbi/kol-icons'
6
6
  import { RAIL_ROW } from '../hooks/railRow.js'
7
7
  import { Tooltip } from '../utilities/Popover.jsx'
8
+ import { floatSurfaceClass } from '../utilities/tone.js'
8
9
 
9
10
  /* taxonomy-ok: nests kol-icons's Icon */
10
11
 
@@ -33,7 +34,8 @@ import { Tooltip } from '../utilities/Popover.jsx'
33
34
  * keeps one line that scrolls sideways with no scrollbar, `.kol-scroll-fade--x` fading the clipped edge(s), and the
34
35
  * active tab scrolled into view when it changes — arrow keys included; `wrap` breaks the tabs onto a second line.
35
36
  * Every variant but `stacked`, which has its phone bar
36
- * @param {'underline'|'chip'|'text'|'stacked'} [variant='underline']
37
+ * @param {'underline'|'chip'|'text'|'stacked'|'float'} [variant='underline'] `float` (W16, round 3): `chip`'s row as the floating
38
+ * style's capsules — float heights (28/36/44, touch 36/44/52), 4 apart, the selected one oq-16
37
39
  * @param {string} [look] @deprecated 2026-10-30 — the old spelling of `variant`; `variant` wins when both are passed
38
40
  * @param {'mono'|'sans'|'mono-light'} [type='mono'] `underline` only: the tab's type — `mono-light` is `sans`'s row on JetBrains Mono at 300 (his word, 2026-10-30)
39
41
  * @param {boolean} [filled=false] - `chip` only: every chip shows at rest (Button `tab-filled`), so the row reads as choices
@@ -62,7 +64,15 @@ import { Tooltip } from '../utilities/Popover.jsx'
62
64
  * (`--kol-shell-rail-width`) and 64 up, not centred
63
65
  * @param {boolean} [shadow=false] the KOL float shadow (`--kol-shadow-float`) — for a pill that floats over content (the transport,
64
66
  * a nav over a clashing canvas). Every pill slot names itself on hover in the KOL `Tooltip`
65
- * @param {'fill'|'circle'} [active='fill'] the lit slot: `fill` its whole 56 slot (today), `circle` a 44×44 1:1 circle
67
+ * @param {'fill'|'circle'} [active='fill'] the lit slot — since W16 (round 3, 2026-10-31) both are the whole 56 slot as a circle,
68
+ * oq-16; the prop stays so call sites keep working
69
+ * THE FLOAT (W16, round 3 — 2026-10-31, accepted: *"accept the style, both glass styles"*). The pill is fxr's live nav: 66 tall, 56
70
+ * buttons 4 apart, 5 in, 28 glyphs — the same on mouse and touch. Its name on hover is the float Tooltip.
71
+ * @param {'solid'|'glass-dark'|'glass-light'} [surface='solid'] the pill's material (`floatSurfaceClass`); a glass has no
72
+ * hairline (its rim is the edge) and always its own shadow; `solid` keeps `border` and `shadow`
73
+ * @param {boolean} [split=false] the LAST tab (AppFloatNav's ···) stands apart as its own 66 circle, 8 off the bar (iOS 27's
74
+ * grouped toolbar); while `collapsed` the bar fades and the circle is the ×
75
+ * @param {boolean} [labels=false] the tab bar with labels: 66 tall, items 58 tall and 84 wide, 4 in, a 24 glyph over an 11 label
66
76
  * @param {boolean} [foldable=false] a sideways swipe (> 40px) folds the pill into a 28×72 edge tab; a tap restores it
67
77
  * @param {boolean} [folded] · {Function} [onFoldChange] the folded state, controlled (else the pill holds it)
68
78
  * @param {'left'|'right'} [foldSide='left'] the screen edge the tab sits on
@@ -70,21 +80,38 @@ import { Tooltip } from '../utilities/Popover.jsx'
70
80
  * lights it whatever `value` is — action slots (the transport) light more than one.
71
81
  */
72
82
  export const TABBAR_H = 56
73
- /* the pill's air off the bottom edge — a page under it owes TABBAR_H + this */
83
+ /* the pill's height (W16, round 3 — fxr's live nav: was TABBAR_H) — a page under it owes TABBAR_PILL_H + TABBAR_PILL_INSET */
84
+ export const TABBAR_PILL_H = 66
85
+ /* the pill's air off the bottom edge */
74
86
  export const TABBAR_PILL_INSET = 16
75
- /* the pill's geometry, shared with the sheet that aligns to it: 56 per tab, 6px pad and 1px hairline each side */
87
+ /* the pill's geometry, shared with the sheets that align to it: 56 per tab, 4 between, 5 in (4 + the 1px hairline) each side;
88
+ * with labels 84 per tab, none between, 4 in; a split last tab is a TABBAR_PILL_H circle 8 off the bar */
76
89
  export const TABBAR_PILL_TAB = 56
77
- export const tabbarPillWidth = (count) => count * TABBAR_PILL_TAB + 14
90
+ export const TABBAR_PILL_GAP = 4
91
+ export const TABBAR_PILL_LABEL_TAB = 84
92
+ export const TABBAR_PILL_SPLIT_GAP = 8
93
+ const pillBar = (n, labels) => (labels ? n * TABBAR_PILL_LABEL_TAB + 8 : n * TABBAR_PILL_TAB + Math.max(0, n - 1) * TABBAR_PILL_GAP + 10)
94
+ /* the whole pill's width — the bar, plus the split circle when `split` */
95
+ export const tabbarPillWidth = (count, { labels = false, split = false } = {}) => (split
96
+ ? pillBar(count - 1, labels) + TABBAR_PILL_SPLIT_GAP + TABBAR_PILL_H
97
+ : pillBar(count, labels))
98
+ /* a slot's centre, from the pill's left edge (a split last slot is the circle's) */
99
+ export const tabbarPillSlot = (at, { count, labels = false, split = false } = {}) => {
100
+ if (split && at === count - 1) return pillBar(count - 1, labels) + TABBAR_PILL_SPLIT_GAP + TABBAR_PILL_H / 2
101
+ return labels ? 4 + at * TABBAR_PILL_LABEL_TAB + TABBAR_PILL_LABEL_TAB / 2 : 5 + at * (TABBAR_PILL_TAB + TABBAR_PILL_GAP) + TABBAR_PILL_TAB / 2
102
+ }
78
103
  /* the collapse's motion — one duration for the width and the glyph swap (300, user 2026-10-23: 180 read as abrupt) */
79
104
  const COLLAPSE_MS = 300
80
105
 
81
- export default function TabsRow({ tabs: raw = [], value, onChange, onClose, onMinimise, variant, look: lookLegacy, type = 'mono', filled = false, placement = 'inline', shape = 'bar', collapsed = false, onExpand, edge, border, shadow, dock, active, foldable, folded, onFoldChange, foldSide, size, tracked = false, overflow = 'scroll', ariaLabel, className, style }) {
106
+ export default function TabsRow({ tabs: raw = [], value, onChange, onClose, onMinimise, variant, look: lookLegacy, type = 'mono', filled = false, placement = 'inline', shape = 'bar', collapsed = false, onExpand, edge, border, shadow, dock, active, foldable, folded, onFoldChange, foldSide, surface, split, labels, size, tracked = false, overflow = 'scroll', ariaLabel, className, style }) {
82
107
  const tabs = raw.map((t) => ({ ...t, id: t.id ?? t.value }))
83
108
  const look = variant ?? lookLegacy ?? 'underline'
84
109
  const wrap = overflow === 'wrap'
110
+ /* `float` (W16, round 3): the chip row as capsules — float heights, pills 4 apart, the selected one oq-16 */
111
+ if (look === 'float') return <Overflow mode={overflow} value={value}><ChipTabs tabs={tabs} value={value} onChange={onChange} size={size ?? 'md'} float ariaLabel={ariaLabel} className={`${wrap ? 'flex-wrap ' : ''}${className ?? ''}`} /></Overflow>
85
112
  if (look === 'chip') return <Overflow mode={overflow} value={value}><ChipTabs tabs={tabs} value={value} onChange={onChange} size={size ?? 'sm'} filled={filled} ariaLabel={ariaLabel} className={`${wrap ? 'flex-wrap ' : ''}${className ?? ''}`} /></Overflow>
86
113
  if (look === 'text') return <Overflow mode={overflow} value={value}><TextTabs tabs={tabs} value={value} onChange={onChange} size={size ?? 14} tracked={tracked} className={`${wrap ? 'flex-wrap gap-y-2 ' : ''}${className ?? 'gap-6'}`} style={style} /></Overflow>
87
- if (look === 'stacked') return <StackedTabs tabs={tabs} value={value} onChange={onChange} placement={placement} shape={shape} collapsed={collapsed} onExpand={onExpand} edge={edge} border={border} shadow={shadow} dock={dock} active={active} foldable={foldable} folded={folded} onFoldChange={onFoldChange} foldSide={foldSide} ariaLabel={ariaLabel} className={className ?? ''} />
114
+ if (look === 'stacked') return <StackedTabs tabs={tabs} value={value} onChange={onChange} placement={placement} shape={shape} collapsed={collapsed} onExpand={onExpand} edge={edge} border={border} shadow={shadow} dock={dock} active={active} foldable={foldable} folded={folded} onFoldChange={onFoldChange} foldSide={foldSide} surface={surface} split={split} labels={labels} ariaLabel={ariaLabel} className={className ?? ''} />
88
115
  return <Overflow mode={overflow} value={value}><UnderlineTabs tabs={tabs} value={value} onChange={onChange} onClose={onClose} onMinimise={onMinimise} wrap={wrap} type={type} /></Overflow>
89
116
  }
90
117
 
@@ -108,14 +135,14 @@ function Overflow({ mode, value, children }) {
108
135
  }
109
136
 
110
137
  /* chip — TabChips verbatim */
111
- function ChipTabs({ tabs, value, onChange, size, filled, ariaLabel, className }) {
138
+ function ChipTabs({ tabs, value, onChange, size, filled, float = false, ariaLabel, className }) {
112
139
  return (
113
- <div className={`flex items-center ${filled ? 'gap-2' : 'gap-1'} ${className}`.trim()} role="tablist" aria-label={ariaLabel}>
140
+ <div className={`flex items-center ${filled ? 'gap-2' : 'gap-1'}${float ? ' kol-tabs-float' : ''} ${className}`.trim()} role="tablist" aria-label={ariaLabel}>
114
141
  {tabs.map((t) => {
115
142
  const active = t.id === value
116
143
  return (
117
144
  /* a tab is selected, not pressed — the role's own attribute replaces Button's aria-pressed */
118
- <Button key={t.id} variant={filled ? 'tab-filled' : 'tab'} size={size} pressed={active} role="tab" aria-selected={active} aria-pressed={undefined} data-active={active || undefined} onClick={() => onChange?.(t.id)}>
145
+ <Button key={t.id} variant={float ? 'float' : filled ? 'tab-filled' : 'tab'} size={size} pressed={active} role="tab" aria-selected={active} aria-pressed={undefined} data-active={active || undefined} onClick={() => onChange?.(t.id)}>
119
146
  {t.label}
120
147
  </Button>
121
148
  )
@@ -147,17 +174,24 @@ function TextTabs({ tabs, value, onChange, size, tracked, className, style }) {
147
174
  /* the fold: a sideways swipe past this folds the pill, as frame E's prototype measures it */
148
175
  const FOLD_SWIPE = 40
149
176
 
150
- function StackedTabs({ tabs, value, onChange, placement, shape = 'bar', collapsed = false, onExpand, edge = 'bottom', border = true, shadow = false, dock = false, active: activeShape = 'fill', foldable = false, folded: foldedProp, onFoldChange, foldSide = 'left', ariaLabel = 'Sections', className }) {
177
+ function StackedTabs({ tabs, value, onChange, placement, shape = 'bar', collapsed = false, onExpand, edge = 'bottom', border = true, shadow = false, dock = false, active: activeShape = 'fill', foldable = false, folded: foldedProp, onFoldChange, foldSide = 'left', surface = 'solid', split: splitProp = false, labels = false, ariaLabel = 'Sections', className }) {
151
178
  const [foldedOwn, setFoldedOwn] = useState(false)
152
179
  const swipe = useRef({ x: null, swiped: false })
153
180
  if (tabs.length === 0) return null
154
181
  const phone = placement === 'phone'
155
182
  const pill = phone && shape === 'pill'
156
183
  const isCollapsed = phone && collapsed
157
- const W = tabbarPillWidth(tabs.length)
184
+ const split = pill && splitProp && tabs.length > 1
185
+ const W = tabbarPillWidth(tabs.length, { labels, split })
158
186
  const top = pill && edge === 'top'
159
187
  const isFolded = pill && foldable && (foldedProp ?? foldedOwn)
160
188
  const setFolded = (v) => { if (foldedProp === undefined) setFoldedOwn(v); onFoldChange?.(v) }
189
+ /* THE MATERIAL (W16): solid keeps the pill's ground, hairline and shadow props; a glass is the theme's class, no hairline */
190
+ const glass = surface === 'glass-dark' || surface === 'glass-light'
191
+ const material = glass ? floatSurfaceClass(surface) : ''
192
+ const solidLook = glass ? {} : { background: 'var(--kol-surface-secondary)', boxShadow: shadow ? 'var(--kol-shadow-float)' : undefined }
193
+ /* without the hairline the 1px it took stays, transparent, so the slots never move */
194
+ const edgeLine = `1px solid ${border && !glass ? 'var(--kol-oq-08)' : 'transparent'}`
161
195
  /* the edge offset: the pill's inset from the safe area on its edge */
162
196
  const inset = `calc(env(safe-area-inset-${top ? 'top' : 'bottom'}, 0px) + ${TABBAR_PILL_INSET}px)`
163
197
  const swipeProps = pill && foldable ? {
@@ -178,101 +212,155 @@ function StackedTabs({ tabs, value, onChange, placement, shape = 'bar', collapse
178
212
  type="button"
179
213
  aria-label={`Show ${ariaLabel.toLowerCase()}`}
180
214
  onClick={() => setFolded(false)}
181
- className="kol-mobile-tabbar-tab md:hidden fixed z-[calc(var(--kol-z-sticky)+1)] flex items-center justify-center cursor-pointer"
215
+ className={`kol-mobile-tabbar-tab md:hidden fixed z-[calc(var(--kol-z-sticky)+1)] flex items-center justify-center cursor-pointer ${material}`.trim()}
182
216
  style={{
183
217
  [foldSide]: 0,
184
218
  [top ? 'top' : 'bottom']: `calc(env(safe-area-inset-${top ? 'top' : 'bottom'}, 0px) + ${TABBAR_PILL_INSET + 8}px)`,
185
219
  width: 28, height: 72, padding: 0, border: 'none',
186
220
  borderRadius: foldSide === 'left' ? '0 16px 16px 0' : '16px 0 0 16px',
187
- background: 'var(--kol-surface-secondary)', color: RAIL_ROW.rest,
221
+ ...(glass ? {} : { background: 'var(--kol-surface-secondary)' }), color: RAIL_ROW.rest,
188
222
  }}
189
223
  >
190
224
  <Icon name={foldSide === 'left' ? 'chevron-right' : 'chevron-left'} size={18} />
191
225
  </button>
192
226
  )
193
227
  }
228
+ const inBar = split ? tabs.slice(0, -1) : tabs
229
+ const last = split ? tabs[tabs.length - 1] : null
230
+ const slotW = labels ? TABBAR_PILL_LABEL_TAB : TABBAR_PILL_TAB
231
+ const slotH = labels ? 58 : TABBAR_PILL_TAB
232
+ const ink = (on) => (phone ? (on ? RAIL_ROW.active : RAIL_ROW.rest) : (on ? 'var(--kol-fg-default)' : 'var(--kol-fg-48)'))
233
+ const tip = (t, el) => <Tooltip key={t.id} label={t.label} placement={top ? 'bottom' : 'top'} offset={10} variant="float" asChild>{el}</Tooltip>
234
+ const slot = (t) => {
235
+ const active = t.selected ?? t.id === value
236
+ if (pill) {
237
+ /* round 3: the lit slot is the whole slot as a circle (a capsule with labels), oq-16 */
238
+ return tip(t, (
239
+ <button
240
+ key={t.id}
241
+ type="button"
242
+ onClick={() => onChange?.(t.id)}
243
+ aria-current={active ? 'page' : undefined}
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) }}
247
+ >
248
+ {t.icon && <Icon name={t.icon} size={labels ? 24 : 28} />}
249
+ {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>}
250
+ </button>
251
+ ))
252
+ }
253
+ return (
254
+ <button
255
+ key={t.id}
256
+ type="button"
257
+ onClick={() => onChange?.(t.id)}
258
+ aria-current={active ? 'page' : undefined}
259
+ /* the PHONE bar is icons alone (user 2026-10-23: *"skip the title and show just the icon bigger"*) — the
260
+ * label is the button's accessible name */
261
+ aria-label={phone ? t.label : undefined}
262
+ className="flex-1 min-w-0 flex flex-col items-center justify-center gap-1 cursor-pointer"
263
+ /* the PHONE bar wears the rail's row type and ink (review 2, § 7.1 — RAIL_ROW, the one constant the rail reads);
264
+ * an inline stacked row keeps MobileTabBar's own */
265
+ style={{ background: 'transparent', border: 'none', color: ink(active) }}
266
+ >
267
+ {t.icon && <Icon name={t.icon} size={phone ? 24 : 18} />}
268
+ {/* helper is single-line chrome, which a tab label always is */}
269
+ {!phone && <span className="kol-helper-10 truncate max-w-full px-1">{t.label}</span>}
270
+ </button>
271
+ )
272
+ }
273
+ /* the isCollapsed × — the bar's More slot, or (a whole pill, no split) the pill shrunk to one circle; present always so it cross-fades */
274
+ const closeX = phone && !split && (
275
+ <button
276
+ type="button"
277
+ aria-label="Close"
278
+ aria-hidden={!isCollapsed}
279
+ tabIndex={isCollapsed ? 0 : -1}
280
+ onClick={onExpand}
281
+ className="absolute flex items-center justify-center rounded-full cursor-pointer"
282
+ style={{
283
+ /* no fill — the × is the only glyph, a filled circle inside the circle read as two rings (user 2026-10-23) */
284
+ ...(pill ? { right: 4, top: 4, width: TABBAR_PILL_TAB, height: TABBAR_PILL_TAB } : { right: 0, top: 0, bottom: 0, width: `${100 / tabs.length}%` }),
285
+ background: 'transparent', border: 'none', color: RAIL_ROW.active,
286
+ opacity: isCollapsed ? 1 : 0, pointerEvents: isCollapsed ? 'auto' : 'none', transition: `opacity ${COLLAPSE_MS}ms ease`,
287
+ }}
288
+ >
289
+ <Icon name="x" size={pill ? 28 : 24} />
290
+ </button>
291
+ )
292
+ const row = (
293
+ <div className={`flex items-stretch ${pill ? 'flex-none' : 'flex-1 min-w-0'}`} style={{ gap: pill && !labels ? TABBAR_PILL_GAP : undefined, opacity: isCollapsed ? 0 : 1, pointerEvents: isCollapsed ? 'none' : 'auto', transition: `opacity ${COLLAPSE_MS}ms ease` }}>
294
+ {inBar.map(slot)}
295
+ </div>
296
+ )
297
+ if (!pill) {
298
+ return (
299
+ <nav
300
+ className={`${phone ? 'kol-mobile-tabbar md:hidden fixed inset-x-0 bottom-0 z-[var(--kol-z-sticky)] ' : ''}flex items-stretch ${className}`.trim()}
301
+ style={phone ? {
302
+ height: TABBAR_H,
303
+ /* the pill's own ground, not the page's — it floats OVER content, so a
304
+ * transparent bar would show rows sliding through the labels */
305
+ background: 'var(--kol-surface-primary)',
306
+ borderTop: '1px solid var(--kol-oq-08)',
307
+ paddingBottom: 'env(safe-area-inset-bottom, 0px)',
308
+ } : { height: TABBAR_H }}
309
+ aria-label={ariaLabel}
310
+ >
311
+ {row}
312
+ {closeX}
313
+ </nav>
314
+ )
315
+ }
316
+ const barW = W - (split ? TABBAR_PILL_SPLIT_GAP + TABBAR_PILL_H : 0)
194
317
  return (
195
318
  <nav
196
319
  {...swipeProps}
197
- className={`${pill
198
- ? `kol-mobile-tabbar kol-mobile-tabbar-pill ${dock ? 'hidden md:flex' : 'md:hidden'} fixed z-[calc(var(--kol-z-sticky)+1)] rounded-full p-1.5 overflow-hidden `
199
- : phone ? 'kol-mobile-tabbar md:hidden fixed inset-x-0 bottom-0 z-[var(--kol-z-sticky)] ' : ''}flex items-stretch ${className}`.trim()}
200
- style={pill ? {
201
- /* THE PILL (user 2026-10-23, iOS Files beside the full-width bar): floats off the bottom edge, hugs its tabs,
202
- * the same ground and hairline as the bar, no shadow (user 2026-10-23). Anchored by its RIGHT edge at the
203
- * centre line, so the collapse shrinks it toward where More was and the × lands under the same thumb. */
204
- height: TABBAR_H,
205
- width: isCollapsed ? TABBAR_H : W,
320
+ className={`kol-mobile-tabbar kol-mobile-tabbar-pill ${dock ? 'hidden md:flex' : 'md:hidden'} fixed z-[calc(var(--kol-z-sticky)+1)] flex items-stretch ${className}`.trim()}
321
+ style={{
322
+ /* THE PILL (user 2026-10-23, iOS Files beside the full-width bar; round 3's size, W16): floats off the bottom edge,
323
+ * hugs its tabs. Anchored by its RIGHT edge at the centre line, so the collapse shrinks it toward where More was
324
+ * and the × lands under the same thumb. */
325
+ height: TABBAR_PILL_H,
326
+ gap: TABBAR_PILL_SPLIT_GAP,
206
327
  ...(dock
207
328
  ? { left: 'calc(var(--kol-shell-rail-width, 48px) + 64px)', bottom: 64 }
208
329
  : { right: `calc(50% - ${W / 2}px)`, [top ? 'top' : 'bottom']: inset }),
209
- background: 'var(--kol-surface-secondary)',
210
- /* without the hairline the 1px it took stays, transparent, so the slots never move */
211
- border: `1px solid ${border ? 'var(--kol-oq-08)' : 'transparent'}`,
212
- /* the KOL float shadow (kol-theme `--kol-shadow-float`, FloatingStyleAndSignInDemos 2026-10-30) — was a local 40px blur */
213
- boxShadow: shadow ? 'var(--kol-shadow-float)' : undefined,
214
- transition: `width ${COLLAPSE_MS}ms ease`,
215
330
  ...swipeProps.style,
216
- } : phone ? {
217
- height: TABBAR_H,
218
- /* the pill's own ground, not the page's — it floats OVER content, so a
219
- * transparent bar would show rows sliding through the labels */
220
- background: 'var(--kol-surface-primary)',
221
- borderTop: '1px solid var(--kol-oq-08)',
222
- paddingBottom: 'env(safe-area-inset-bottom, 0px)',
223
- } : { height: TABBAR_H }}
331
+ }}
224
332
  aria-label={ariaLabel}
225
333
  >
226
- <div className={`flex items-stretch ${pill ? 'flex-none' : 'flex-1 min-w-0'}`} style={{ width: pill ? W - 14 : undefined, opacity: isCollapsed ? 0 : 1, pointerEvents: isCollapsed ? 'none' : 'auto', transition: `opacity ${COLLAPSE_MS}ms ease` }}>
227
- {tabs.map((t) => {
228
- const active = t.selected ?? t.id === value
229
- const circle = pill && activeShape === 'circle'
230
- /* THE NAME ON HOVER IS THE KOL TOOLTIP (AppFloatNav, kol-fxr 2026-10-30 — the Round 3 board), on the pill's far side
231
- * from its edge; a thumb never hovers, so the phone is unchanged */
232
- const slot = (
233
- <button
234
- key={t.id}
235
- type="button"
236
- onClick={() => onChange?.(t.id)}
237
- aria-current={active ? 'page' : undefined}
238
- /* the PHONE bar is icons alone (user 2026-10-23: *"skip the title and show just the icon bigger"*) — the
239
- * label is the button's accessible name */
240
- aria-label={phone ? t.label : undefined}
241
- className={pill ? 'flex items-center justify-center rounded-full cursor-pointer' : 'flex-1 min-w-0 flex flex-col items-center justify-center gap-1 cursor-pointer'}
242
- /* the PHONE bar wears the rail's row type and ink (review 2, § 7.1 — RAIL_ROW, the one constant the rail reads);
243
- * an inline stacked row keeps MobileTabBar's own */
244
- style={{ background: pill && active && !circle ? 'var(--kol-oq-08)' : 'transparent', border: 'none', width: pill ? 56 : undefined, color: phone ? (active ? RAIL_ROW.active : RAIL_ROW.rest) : (active ? 'var(--kol-fg-default)' : 'var(--kol-fg-48)') }}
245
- >
246
- {/* the circle: 44×44, 1:1 inside the 56 slot (frame E — the fill stretched to the slot's width) */}
247
- {t.icon && (circle
248
- ? <span className="inline-flex items-center justify-center rounded-full" style={{ width: 44, height: 44, background: active ? 'var(--kol-oq-08)' : 'transparent' }}><Icon name={t.icon} size={28} /></span>
249
- : <Icon name={t.icon} size={pill ? 28 : phone ? 24 : 18} />)}
250
- {/* helper is single-line chrome, which a tab label always is */}
251
- {!phone && <span className="kol-helper-10 truncate max-w-full px-1">{t.label}</span>}
252
- </button>
253
- )
254
- return pill ? <Tooltip key={t.id} label={t.label} placement={top ? 'bottom' : 'top'} offset={10} asChild>{slot}</Tooltip> : slot
255
- })}
334
+ <div
335
+ className={`relative flex items-center rounded-full overflow-hidden ${material}`.trim()}
336
+ style={{
337
+ height: TABBAR_PILL_H,
338
+ width: isCollapsed && !split ? TABBAR_PILL_H : barW,
339
+ padding: labels ? 3 : 4,
340
+ border: edgeLine,
341
+ ...solidLook,
342
+ opacity: isCollapsed && split ? 0 : 1,
343
+ pointerEvents: isCollapsed && split ? 'none' : undefined,
344
+ transition: `width ${COLLAPSE_MS}ms ease, opacity ${COLLAPSE_MS}ms ease`,
345
+ }}
346
+ >
347
+ {row}
348
+ {closeX}
256
349
  </div>
257
- {phone && (
258
- /* the isCollapsed × — the pill's one tab-sized circle, the bar's More slot; present always so it cross-fades */
350
+ {split && tip(isCollapsed ? { id: last.id, label: 'Close' } : last, (
351
+ /* the split circle (round 3, iOS 27's grouped toolbar): ··· on its own, 8 off the bar; the × while collapsed */
259
352
  <button
353
+ key={last.id}
260
354
  type="button"
261
- aria-label="Close"
262
- aria-hidden={!isCollapsed}
263
- tabIndex={isCollapsed ? 0 : -1}
264
- onClick={onExpand}
265
- className="absolute flex items-center justify-center rounded-full cursor-pointer"
266
- style={{
267
- /* no fill — the × is the only glyph, a filled circle inside the circle read as two rings (user 2026-10-23) */
268
- ...(pill ? { right: 6, top: 6, width: TABBAR_H - 14, height: TABBAR_H - 14 } : { right: 0, top: 0, bottom: 0, width: `${100 / tabs.length}%` }),
269
- background: 'transparent', border: 'none', color: RAIL_ROW.active,
270
- opacity: isCollapsed ? 1 : 0, pointerEvents: isCollapsed ? 'auto' : 'none', transition: `opacity ${COLLAPSE_MS}ms ease`,
271
- }}
355
+ onClick={() => (isCollapsed ? onExpand?.() : onChange?.(last.id))}
356
+ aria-current={!isCollapsed && (last.selected ?? last.id === value) ? 'page' : undefined}
357
+ aria-label={isCollapsed ? 'Close' : last.label}
358
+ className={`flex items-center justify-center rounded-full cursor-pointer flex-none ${material}`.trim()}
359
+ style={{ width: TABBAR_PILL_H, height: TABBAR_PILL_H, padding: 0, border: edgeLine, ...solidLook, color: ink(isCollapsed || (last.selected ?? last.id === value)) }}
272
360
  >
273
- <Icon name="x" size={pill ? 28 : 24} />
361
+ <Icon name={isCollapsed ? 'x' : last.icon} size={labels ? 24 : 28} />
274
362
  </button>
275
- )}
363
+ ))}
276
364
  </nav>
277
365
  )
278
366
  }
@@ -10,8 +10,9 @@ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
10
10
  *
11
11
  * THE POINTER IS A FORCE — a move pushes the line aside, a press blasts a crater it regrows into (engine `poke`);
12
12
  * either wakes a finished line, which settles and sleeps again a second after the pointer stops. A resting pointer is
13
- * not a force; a finished line steps only within 0.36 · min(w, h) of the pointer, and runs 48px past the edges (engine
14
- * `bleed`), frozen there. Read on the window,
13
+ * not a force. THE LINE RESTS (engine `settle`, kol-fxr 2026-10-30): the packed interior cools and stops, so only the
14
+ * growing front and what the pointer touches move; it runs 48px past the edges (engine `bleed`), frozen there. Read on
15
+ * the window,
15
16
  * so the stage can sit behind a page; a press on a control is the control's.
16
17
  *
17
18
  * REDUCED MOTION — the finished line is grown off screen and drawn once: no animation, no pointer force.
@@ -37,7 +38,7 @@ export default function GrowthLineStage({ shape = 'orb', fit = 0.6, params = ORB
37
38
  const canvas = ref.current
38
39
  if (!canvas) return undefined
39
40
  let sim = null, raf = 0, w = 0, h = 0
40
- let ptr = null, sm = null, last = null, idle = 0, full = false
41
+ let ptr = null, sm = null, idle = 0, full = false
41
42
  const force = pointer && !reduced
42
43
  const start = () => {
43
44
  const r = canvas.getBoundingClientRect()
@@ -52,8 +53,9 @@ export default function GrowthLineStage({ shape = 'orb', fit = 0.6, params = ORB
52
53
  rand: seed == null ? Math.random : mulberry32(seed),
53
54
  cap: cap ?? Math.min(14000, Math.round(w * h / 112)),
54
55
  })
55
- /* the line runs past the edges and freezes there instead of piling into them */
56
- sim.setParams({ bleed: 48, ...params })
56
+ /* the line runs past the edges and freezes there instead of piling into them, and rests where it is done; one
57
+ * step a frame keeps 0.2.0's pace now that only the front grows */
58
+ sim.setParams({ bleed: 48, settle: 120, ...params })
57
59
  sim.seed()
58
60
  full = false
59
61
  return true
@@ -73,11 +75,11 @@ export default function GrowthLineStage({ shape = 'orb', fit = 0.6, params = ORB
73
75
  /* low-passed like the editor's stage pointer — the raw one twitches */
74
76
  const to = sm ? { x: sm.x + (ptr.x - sm.x) * 0.1, y: sm.y + (ptr.y - sm.y) * 0.1 } : { x: ptr.x, y: ptr.y }
75
77
  /* a RESTING pointer is not a force — poked every frame, the line under it churned forever */
76
- if (ptr.down || !sm || Math.hypot(to.x - sm.x, to.y - sm.y) > 0.3) { sim.poke(to.x, to.y, ptr.down); last = to; idle = 60 }
78
+ if (ptr.down || !sm || Math.hypot(to.x - sm.x, to.y - sm.y) > 0.3) { sim.poke(to.x, to.y, ptr.down); idle = 60 }
77
79
  sm = to
78
80
  } else sm = null
79
- /* a finished line moves only around the pointer — stepped whole it never settles and jitters */
80
- if (growing) { sim.step(); sim.step() } else if (idle > 0 && last) sim.step({ x: last.x, y: last.y, r: Math.min(w, h) * 0.36 })
81
+ /* settled stretches rest, so a step moves only the growing front and the pointer's wake */
82
+ if (growing || idle > 0) sim.step()
81
83
  if (idle > 0) idle--
82
84
  paint()
83
85
  raf = growing || idle > 0 ? requestAnimationFrame(frame) : 0
@@ -177,6 +177,8 @@ function TooltipOn({
177
177
  triggerClassName = 'inline-flex',
178
178
  asChild = false,
179
179
  disabled = false,
180
+ /* `float` (W16, round 3 — 2026-10-31): the floating style's tooltip, an inverted pill 24 (28 on touch) tall, 11 (12) type */
181
+ variant,
180
182
  }) {
181
183
  const [open, setOpen] = useState(false)
182
184
  const [covered, setCovered] = useState(false)
@@ -238,7 +240,7 @@ function TooltipOn({
238
240
  popover={popover}
239
241
  focus={false}
240
242
  panel={false}
241
- className="kol-tooltip"
243
+ className={variant === 'float' ? 'kol-tooltip kol-tooltip-float' : 'kol-tooltip'}
242
244
  style={ambient ?? undefined}
243
245
  >
244
246
  <span>{label}</span>
@@ -41,3 +41,16 @@ export const TONE_VARS = [
41
41
  'pressed-bg', 'pressed-image', 'pressed-fg',
42
42
  'panel-bg', 'panel-border-w', 'panel-border',
43
43
  ].map((k) => `--kol-tone-${k}`)
44
+
45
+ /**
46
+ * floatSurfaceClass — what a float is made of (W16, round 3 — 2026-10-31: *"accept the style, both glass styles"*).
47
+ * `surface="solid|glass-dark|glass-light"` on the float components resolves here to kol-theme's classes, never inline styles:
48
+ * `solid` (surface-secondary + the float shadow), `glass-dark` (Apple's dark glass, plus `.dark` — a dark material in either
49
+ * theme, so what sits on it takes dark-theme ink), `glass-light` (the light tint). `medium` marks a menu or a box (the
50
+ * deeper blur and shadow); a bar, a button or a track is small. Unknown → solid.
51
+ */
52
+ export const FLOAT_SURFACES = ['solid', 'glass-dark', 'glass-light']
53
+ export const floatSurfaceClass = (surface = 'solid', medium = false) => {
54
+ const s = FLOAT_SURFACES.includes(surface) ? surface : 'solid'
55
+ return `kol-float-${s}${medium ? ' kol-float-m' : ''}${s === 'glass-dark' ? ' dark' : ''}`
56
+ }