@kolkrabbi/kol-component 0.317.6 → 0.318.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.317.6",
3
+ "version": "0.318.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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). `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` (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).
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
@@ -93,7 +93,7 @@ const Button = ({
93
93
  warned.add(variant)
94
94
  console.warn(`Button: variant="${variant}" is deprecated — use tone="${VARIANT_TONE[variant]}"`)
95
95
  }
96
- const KNOWN = ['primary', 'secondary', 'accent', 'outline', 'ghost', 'nav', 'tab', 'tab-filled', 'danger', 'grey']
96
+ const KNOWN = ['primary', 'secondary', 'accent', 'outline', 'ghost', 'nav', 'tab', 'tab-filled', 'danger', 'grey', 'float']
97
97
  const known = !resolvedVariant || KNOWN.includes(resolvedVariant)
98
98
  if (!known && import.meta.env.DEV) console.warn(`Button: unknown variant "${resolvedVariant}" — rendering the inherited tone (primary)`)
99
99
  const variantClass = resolvedVariant && known ? `kol-btn-${resolvedVariant}` : ''
@@ -11,6 +11,8 @@ 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
16
  * tone="inverse" — the dark chip (`fg-ab-24`) for a washed plane
15
17
  * (ControlToneInverse, 2026-08-27); same prop on ViewToggle · Dropdown
16
18
  * size="xs" — the panel rung (ControlsXsRung, 2026-09-01):
@@ -46,13 +46,24 @@ export default function useScrollSpy(ids, { rootMargin = '-30% 0px -60% 0px', ed
46
46
  }
47
47
  }
48
48
 
49
+ /* NESTED IDS (DocsToc `children`, 2026-10-30): a section that contains its
50
+ * entries intersects for as long as any of them does, so the innermost
51
+ * visible element wins, and when an entry leaves while its section is
52
+ * still in the band the section takes it back. Flat ids never contain one
53
+ * another, so both rules are inert for them. */
54
+ const live = new Set()
49
55
  const observer = new IntersectionObserver(
50
56
  (entries) => {
57
+ entries.forEach((e) => (e.isIntersecting ? live.add(e.target) : live.delete(e.target)))
51
58
  if (edgeLockRef.current) return
52
- const visible = entries
53
- .filter((e) => e.isIntersecting)
59
+ const hits = entries.filter((e) => e.isIntersecting)
60
+ const visible = hits
61
+ .filter((e) => !hits.some((o) => o !== e && e.target.contains(o.target)))
54
62
  .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
55
- if (visible[0]) setActiveId(visible[0].target.id)
63
+ if (visible[0]) { setActiveId(visible[0].target.id); return }
64
+ const left = entries.find((e) => !e.isIntersecting)
65
+ const owner = left && [...live].find((el) => el.contains(left.target))
66
+ if (owner) setActiveId(owner.id)
56
67
  },
57
68
  { root: scroller ?? null, rootMargin, threshold: 0 },
58
69
  )
@@ -58,11 +58,22 @@ import useScrollSpy from '../hooks/useScrollSpy.js'
58
58
  * move handler is what makes this pattern jank; so is re-rendering React seven
59
59
  * times a frame to animate seven numbers.
60
60
  *
61
+ * SECTIONED RAIL (`children`, DocsTocRailChildren, kol-atlas 2026-10-30 — the
62
+ * type-scale page wanted each section's ticks to BE its styles). An entry may
63
+ * carry `children: [{ id, label }]`; under it the gap is one tick per child
64
+ * instead of `minors` inert graduations, so the rail's height follows the
65
+ * content. A child tick is today's graduation at rest — 3px, no label — but it
66
+ * is a real link (tab stop, not aria-hidden), its label appears only near the
67
+ * lens's peak (a steeper curve than the section labels, because at a 3px pitch
68
+ * the section curve would print five labels on top of one another), and the
69
+ * spy observes its id: the active child takes the active ink and its section
70
+ * stays lit as the parent. No `children`, no change.
71
+ *
61
72
  * HEADING DISCOVERY STAYS THE CONSUMER'S. `toc` is the contract for both
62
73
  * variants — reading `<section id>` and its `<h2>` out of the DOM is app
63
74
  * knowledge and does not belong in the package.
64
75
  *
65
- * @param {Array<{id: string, label: string}>} toc headings to render + observe
76
+ * @param {Array<{id: string, label: string, children?: Array<{id: string, label: string}>}>} toc headings to render + observe; RAIL: `children` are the section's entries, one tick each in place of `minors`
66
77
  * @param {'list'|'rail'} variant render (default 'list' — the docs-sidebar column)
67
78
  * @param {Function} onNavigate (event) => void — optional click handler on every link
68
79
  * @param {string} rootMargin IntersectionObserver rootMargin passed to the
@@ -92,13 +103,20 @@ export default function DocsToc({
92
103
  ariaLabel = 'On this page',
93
104
  className = '',
94
105
  }) {
95
- const activeId = useScrollSpy(toc.map((item) => item.id), { rootMargin, root })
106
+ /* Children are observed too, in document order; the active SECTION is the
107
+ * active id itself or the entry that owns it. */
108
+ const activeId = useScrollSpy(
109
+ toc.flatMap((item) => [item.id, ...(item.children ?? []).map((c) => c.id)]),
110
+ { rootMargin, root },
111
+ )
112
+ const activeSection = toc.find((item) => item.id === activeId || item.children?.some((c) => c.id === activeId))?.id
96
113
 
97
114
  if (variant === 'rail') {
98
115
  return (
99
116
  <TocRail
100
117
  toc={toc}
101
118
  activeId={activeId}
119
+ activeSection={activeSection}
102
120
  onNavigate={onNavigate}
103
121
  minors={minors}
104
122
  falloff={falloff}
@@ -131,7 +149,7 @@ export default function DocsToc({
131
149
  * Layout, color, hover and focus live in `.shell-nav-item` now;
132
150
  * active is the shared `is-active` marker, not `text-emphasis`
133
151
  * typed at one call site. Matches RailRow exactly. */
134
- className={`shell-nav-item kol-mono-14${activeId === item.id ? ' is-active' : ''}`}
152
+ className={`shell-nav-item kol-mono-14${activeSection === item.id ? ' is-active' : ''}`}
135
153
  >
136
154
  {item.label}
137
155
  </a>
@@ -148,7 +166,7 @@ export default function DocsToc({
148
166
  * do not belong in the other's render path.
149
167
  *
150
168
  * The magnitudes go to the DOM as `--m`, never to state — see the header. */
151
- function TocRail({ toc, activeId, onNavigate, minors, falloff, snap, position, minSections, ariaLabel, className }) {
169
+ function TocRail({ toc, activeId, activeSection, onNavigate, minors, falloff, snap, position, minSections, ariaLabel, className }) {
152
170
  const navRef = useRef(null)
153
171
  const centresRef = useRef([])
154
172
  const frameRef = useRef(0)
@@ -182,13 +200,14 @@ function TocRail({ toc, activeId, onNavigate, minors, falloff, snap, position, m
182
200
  * off no snap at all — the honest fisheye, and better on a long rail
183
201
  * with many headings and no graduations
184
202
  *
185
- * Majors are every (minors + 1)th child, because the render interleaves
186
- * one heading with `minors` ticks and stops after the last heading. */
203
+ * The labelled marks are the rail's links — headings and, under a
204
+ * sectioned entry, its children — read off the DOM so the count no longer
205
+ * has to be every (minors + 1)th row. */
187
206
  const marks = centresRef.current
188
207
  const targets = snap === 'off'
189
208
  ? null
190
209
  : snap === 'label'
191
- ? marks.filter((_, i) => i % (minors + 1) === 0)
210
+ ? marks.filter((_, i) => nav.children[i]?.tagName === 'A')
192
211
  : marks
193
212
  const focus = y == null
194
213
  ? null
@@ -200,7 +219,7 @@ function TocRail({ toc, activeId, onNavigate, minors, falloff, snap, position, m
200
219
  const m = focus == null ? 0 : Math.exp(-(((focus - c) / falloff) ** 2))
201
220
  nav.children[i]?.style.setProperty('--m', m.toFixed(3))
202
221
  })
203
- }, [falloff, snap, minors])
222
+ }, [falloff, snap])
204
223
 
205
224
  const onMove = useCallback((e) => {
206
225
  /* Reduced motion: the curve is the animation, so there is nothing to damp —
@@ -238,8 +257,8 @@ function TocRail({ toc, activeId, onNavigate, minors, falloff, snap, position, m
238
257
  onPointerLeave={(e) => { delete e.currentTarget.dataset.live; onLeave() }}
239
258
  className={`kol-toc kol-toc--${position} ${className}`.trim()}
240
259
  >
241
- {toc.flatMap(({ id, label }, i) => {
242
- const active = id === activeId
260
+ {toc.flatMap(({ id, label, children }, i) => {
261
+ const active = id === activeSection
243
262
  const row = (
244
263
  <a
245
264
  key={id}
@@ -275,6 +294,26 @@ function TocRail({ toc, activeId, onNavigate, minors, falloff, snap, position, m
275
294
  <span aria-hidden="true" className="kol-toc-rule" />
276
295
  </a>
277
296
  )
297
+ /* A sectioned entry's gap is its children, one tick each — a link with
298
+ * its label, not an inert graduation. */
299
+ if (children?.length) {
300
+ return [row, ...children.map((c) => {
301
+ const on = c.id === activeId
302
+ return (
303
+ <a
304
+ key={c.id}
305
+ href={`#${c.id}`}
306
+ onClick={onNavigate}
307
+ aria-current={on ? 'true' : undefined}
308
+ data-active={on ? '' : undefined}
309
+ className="kol-toc-row kol-toc-tick kol-toc-child"
310
+ >
311
+ <span className="kol-toc-label kol-helper-10 text-meta">{c.label}</span>
312
+ <span aria-hidden="true" className="kol-toc-rule" />
313
+ </a>
314
+ )
315
+ })]
316
+ }
278
317
  /* No graduations after the last heading — a ruler ends on a mark. */
279
318
  if (i === toc.length - 1) return [row]
280
319
  return [row, ...Array.from({ length: minors }, (_, k) => (
@@ -4,6 +4,7 @@ import CloseButton from '../atoms/CloseButton.jsx'
4
4
  import Button from '../atoms/Button.jsx'
5
5
  import { Icon } from '@kolkrabbi/kol-icons'
6
6
  import { RAIL_ROW } from '../hooks/railRow.js'
7
+ import { Tooltip } from '../utilities/Popover.jsx'
7
8
 
8
9
  /* taxonomy-ok: nests kol-icons's Icon */
9
10
 
@@ -59,7 +60,8 @@ import { RAIL_ROW } from '../hooks/railRow.js'
59
60
  * that component shoudl probablyget its own variant"*): the pill on a DESK — shown from md up, hidden
60
61
  * below (the phone has its own nav), placed at the content's bottom-left, 64 in from the rail's edge
61
62
  * (`--kol-shell-rail-width`) and 64 up, not centred
62
- * @param {boolean} [shadow=false] a drop shadow — for a pill that floats over content (the transport), not the nav
63
+ * @param {boolean} [shadow=false] the KOL float shadow (`--kol-shadow-float`) — for a pill that floats over content (the transport,
64
+ * a nav over a clashing canvas). Every pill slot names itself on hover in the KOL `Tooltip`
63
65
  * @param {'fill'|'circle'} [active='fill'] the lit slot: `fill` its whole 56 slot (today), `circle` a 44×44 1:1 circle
64
66
  * @param {boolean} [foldable=false] a sideways swipe (> 40px) folds the pill into a 28×72 edge tab; a tap restores it
65
67
  * @param {boolean} [folded] · {Function} [onFoldChange] the folded state, controlled (else the pill holds it)
@@ -207,7 +209,8 @@ function StackedTabs({ tabs, value, onChange, placement, shape = 'bar', collapse
207
209
  background: 'var(--kol-surface-secondary)',
208
210
  /* without the hairline the 1px it took stays, transparent, so the slots never move */
209
211
  border: `1px solid ${border ? 'var(--kol-oq-08)' : 'transparent'}`,
210
- boxShadow: shadow ? '0 8px 40px rgba(0, 0, 0, 0.45)' : undefined,
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,
211
214
  transition: `width ${COLLAPSE_MS}ms ease`,
212
215
  ...swipeProps.style,
213
216
  } : phone ? {
@@ -224,7 +227,9 @@ function StackedTabs({ tabs, value, onChange, placement, shape = 'bar', collapse
224
227
  {tabs.map((t) => {
225
228
  const active = t.selected ?? t.id === value
226
229
  const circle = pill && activeShape === 'circle'
227
- return (
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 = (
228
233
  <button
229
234
  key={t.id}
230
235
  type="button"
@@ -246,6 +251,7 @@ function StackedTabs({ tabs, value, onChange, placement, shape = 'bar', collapse
246
251
  {!phone && <span className="kol-helper-10 truncate max-w-full px-1">{t.label}</span>}
247
252
  </button>
248
253
  )
254
+ return pill ? <Tooltip key={t.id} label={t.label} placement={top ? 'bottom' : 'top'} offset={10} asChild>{slot}</Tooltip> : slot
249
255
  })}
250
256
  </div>
251
257
  {phone && (