@kolkrabbi/kol-component 0.320.1 → 0.322.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.320.1",
3
+ "version": "0.322.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",
29
30
  "@kolkrabbi/kol-search": "^0.3.0",
30
- "@kolkrabbi/kol-growth": "^0.3.0",
31
31
  "@kolkrabbi/kol-markdown": "^0.2.0"
32
32
  },
33
33
  "peerDependencies": {
package/src/index.js CHANGED
@@ -129,7 +129,8 @@ export { default as ContentItem } from './molecules/ContentItem.jsx'
129
129
  export { default as ContentCollection } from './organisms/ContentCollection.jsx'
130
130
  export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
131
131
  export { MenuPopover } from './molecules/MenuPopover.jsx'
132
- export { ModalProvider, useModal } from './molecules/Modal.jsx'
132
+ export { ModalProvider, useModal, Dialog } from './molecules/Modal.jsx'
133
+ export { default as Combobox } from './molecules/Combobox.jsx'
133
134
  export { default as PaletteHarmonyWheel } from './molecules/PaletteHarmonyWheel.jsx'
134
135
  export { default as PropertyInput } from './molecules/PropertyInput.jsx'
135
136
  export { default as ShapeDropdown } from './molecules/ShapeDropdown.jsx'
@@ -225,7 +226,7 @@ export { RailHeader, RailTabs, RailHint, RailActions, RailFoot, RailNav } from '
225
226
  export { LabeledControlSection as RailSection, SettingsRow as RailRow } from './organisms/SettingsPanel.jsx'
226
227
  export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
227
228
  export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
228
- export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
229
+ export { default as ColumnBrowser, ResizeHandle } from './organisms/ColumnBrowser.jsx'
229
230
  export { default as KindPreview } from './molecules/KindPreview.jsx'
230
231
  /* MediaPlayer — the one player (2026-10-02): `variant` video · audio, `frame` for the Quick Look
231
232
  * window. VideoSheet · AudioSheet · AudioPreview · AudioPlayer are its aliases, on the ledger. */
@@ -0,0 +1,60 @@
1
+ import { useState } from 'react'
2
+ import SearchInput from './SearchInput.jsx'
3
+ import { MenuDropdownItem } from './MenuItem.jsx'
4
+ import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
5
+
6
+ /**
7
+ * Combobox — A searchable value field: type to filter, pick a row. (MetricDashboardUpstream, kol-website 2026-10-31 —
8
+ * metric's filter-value picker, plausible.io's.) Built from the parts, never a fork of them: the field is `SearchInput`
9
+ * (outline, sm) and the list is `Dropdown`'s own panel (`kol-dd-panel` · `kol-dd-div` · `kol-dd-list`, rows
10
+ * `MenuDropdownItem` at Dropdown's sm row), matched to the field's width and hung 1px under it.
11
+ *
12
+ * <Combobox value={q} onChange={setQ} options={[{ value: '/blog', label: '/blog' }]} placeholder="Select a value" />
13
+ *
14
+ * `value` is the field's text; picking a row hands its `value` to `onChange`. Rows match on label or value, capped at 50.
15
+ */
16
+
17
+ /* the row Dropdown sets at sm (Dropdown.jsx ROW.sm) */
18
+ const ROW_SM = 'kol-mono-12 px-2 h-[var(--kol-ctl-sm)]'
19
+
20
+ export default function Combobox({ value, onChange, options = [], placeholder, ariaLabel, className = '' }) {
21
+ const [open, setOpen] = useState(false)
22
+ const popover = usePopover({
23
+ open,
24
+ onOpenChange: setOpen,
25
+ placement: 'bottom-start',
26
+ offset: -1,
27
+ flip: false,
28
+ matchReferenceWidth: true,
29
+ click: false,
30
+ role: 'listbox',
31
+ })
32
+ const q = value.toLowerCase()
33
+ const shown = options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)).slice(0, 50)
34
+ return (
35
+ <div ref={popover.refs.setReference} className={`relative ${className}`}>
36
+ <SearchInput
37
+ className="w-full"
38
+ variant="outline"
39
+ size="sm"
40
+ value={value}
41
+ placeholder={placeholder}
42
+ aria-label={ariaLabel}
43
+ onFocus={() => setOpen(true)}
44
+ onChange={(e) => { onChange(e.target?.value ?? e); setOpen(true) }}
45
+ onClear={() => onChange('')}
46
+ />
47
+ <PopoverPanel popover={popover} panel={false} focus={false} className="kol-dd-panel" style={{ '--kol-dd-max-rows': 8, '--kol-dd-row-h': 'var(--kol-ctl-sm)' }}>
48
+ <div className="kol-dd-div" />
49
+ <div className="kol-dd-list" role="listbox">
50
+ {shown.map((o) => (
51
+ <MenuDropdownItem key={o.value} hover={false} size="sm" rowClass={ROW_SM} onClick={() => { onChange(o.value); setOpen(false) }}>
52
+ {o.label}
53
+ </MenuDropdownItem>
54
+ ))}
55
+ {!shown.length && <div className={`${ROW_SM} flex items-center text-meta`}>No matches</div>}
56
+ </div>
57
+ </PopoverPanel>
58
+ </div>
59
+ )
60
+ }
@@ -159,8 +159,10 @@ export function MenuDropdownItem({ onClick, onPointerEnter, onPointerLeave, disa
159
159
  )
160
160
  }
161
161
 
162
- export function MenuDropdownDivider() {
163
- return <div className="border-t border-oq-08 my-1" />
162
+ /* `className` — the rule's colour and spacing, whole (MetricDashboardUpstream, kol-website 2026-10-31): on a filled
163
+ * panel the default `border-oq-08` vanishes. */
164
+ export function MenuDropdownDivider({ className = 'border-oq-08 my-1' }) {
165
+ return <div className={`border-t ${className}`} />
164
166
  }
165
167
 
166
168
  /**
@@ -184,11 +186,15 @@ export function MenuDropdownDivider() {
184
186
  * lists. A scrolling panel clips its own flyouts, so give it to a panel with no
185
187
  * flyouts inside.
186
188
  *
189
+ * `rowClass` (flyout) — the nest row's type, padding, height and fill, whole, as `MenuDropdownItem rowClass`.
190
+ * `panelClassName` / `panelStyle` (flyout) — the panel's surface and its place beside the row; a `marginLeft` gap
191
+ * mirrors to `marginRight` when the panel flips (MetricDashboardUpstream, kol-website 2026-10-31).
192
+ *
187
193
  * Either way, a leaf MenuDropdownItem inside still closes the whole menu via the
188
194
  * standard data-menu-item bubble; the nest row itself only toggles.
189
195
  */
190
- export function MenuDropdownNest({ prefix, iconLeft, label, disabled, flyout = false, scroll = false, children }) {
191
- if (flyout) return <MenuFlyoutNest prefix={prefix} iconLeft={iconLeft} label={label} disabled={disabled} scroll={scroll}>{children}</MenuFlyoutNest>
196
+ export function MenuDropdownNest({ prefix, iconLeft, label, disabled, flyout = false, scroll = false, rowClass, panelClassName, panelStyle, children }) {
197
+ if (flyout) return <MenuFlyoutNest prefix={prefix} iconLeft={iconLeft} label={label} disabled={disabled} scroll={scroll} rowClass={rowClass} panelClassName={panelClassName} panelStyle={panelStyle}>{children}</MenuFlyoutNest>
192
198
  return <MenuAccordionNest prefix={prefix} iconLeft={iconLeft} label={label} disabled={disabled}>{children}</MenuAccordionNest>
193
199
  }
194
200
 
@@ -233,7 +239,7 @@ function MenuAccordionNest({ prefix, iconLeft, label, disabled, children }) {
233
239
  )
234
240
  }
235
241
 
236
- function MenuFlyoutNest({ prefix, iconLeft, label, disabled, scroll, children }) {
242
+ function MenuFlyoutNest({ prefix, iconLeft, label, disabled, scroll, rowClass, panelClassName, panelStyle, children }) {
237
243
  const [open, setOpen] = useState(false)
238
244
  const [fit, setFit] = useState({ dy: 0, flip: false })
239
245
  const row = useRef(null)
@@ -271,7 +277,7 @@ function MenuFlyoutNest({ prefix, iconLeft, label, disabled, scroll, children })
271
277
  requestAnimationFrame(() => panel.current?.querySelector('button:not(:disabled)')?.focus())
272
278
  }
273
279
  }}
274
- className={`${NEST_ROW} ${open ? 'text-emphasis' : 'text-body hover:text-emphasis'}`}
280
+ className={rowClass ? `kol-menu-btn w-full shrink-0 flex items-center gap-2 text-left disabled:opacity-40 disabled:cursor-not-allowed ${rowClass}` : `${NEST_ROW} ${open ? 'text-emphasis' : 'text-body hover:text-emphasis'}`}
275
281
  >
276
282
  <NestRowBody prefix={prefix} iconLeft={iconLeft} label={label} />
277
283
  <Icon name="chevron-right" size={10} className="text-emphasis shrink-0" />
@@ -281,8 +287,8 @@ function MenuFlyoutNest({ prefix, iconLeft, label, disabled, scroll, children })
281
287
  <div
282
288
  ref={panel}
283
289
  role="menu"
284
- className={`absolute ${fit.flip ? 'right-full' : 'left-full'} flex flex-col w-max min-w-[180px] max-w-[320px] py-1 bg-surface-secondary rounded border border-oq-08 shadow-lg z-10 ${scroll ? 'max-h-[60vh] overflow-y-auto' : ''}`}
285
- style={{ top: -5 + fit.dy }}
290
+ className={`absolute ${fit.flip ? 'right-full' : 'left-full'} flex flex-col w-max min-w-[180px] max-w-[320px] z-10 ${panelClassName ?? 'py-1 bg-surface-secondary rounded border border-oq-08 shadow-lg'} ${scroll ? 'max-h-[60vh] overflow-y-auto' : ''}`}
291
+ style={{ top: -5 + fit.dy, ...(fit.flip && panelStyle?.marginLeft != null ? { ...panelStyle, marginLeft: undefined, marginRight: panelStyle.marginLeft } : panelStyle) }}
286
292
  >
287
293
  {children}
288
294
  </div>
@@ -278,3 +278,42 @@ export function useModal() {
278
278
  },
279
279
  }
280
280
  }
281
+
282
+ /**
283
+ * Dialog — Modal's shell holding any content (MetricDashboardUpstream, kol-website 2026-10-31 — metric's filter and
284
+ * segment dialogs). The same `.kol-overlay-scrim` + `.kol-modal` surface as the prompts above, with `children` in place
285
+ * of a prompt. **z 150, not Modal's 1000**, so KOL's portalled floats (z 210+) open ABOVE it — a Dropdown or Combobox
286
+ * inside a dialog has to show its list (a native `<dialog>` cannot: `showModal()` puts it in the top layer, over every
287
+ * portal). Esc and a press on the scrim close it; Esc is left to an open float inside first.
288
+ *
289
+ * <Dialog open={open} onClose={() => setOpen(false)} title="Filter by page" width={560}>…</Dialog>
290
+ */
291
+ export function Dialog({ open, onClose, title, width = 560, children }) {
292
+ useEffect(() => {
293
+ if (!open) return undefined
294
+ const onKey = (e) => { if (e.key === 'Escape' && !document.querySelector('.kol-popover-float')) onClose() }
295
+ window.addEventListener('keydown', onKey)
296
+ return () => window.removeEventListener('keydown', onKey)
297
+ }, [open, onClose])
298
+ if (!open || typeof document === 'undefined') return null
299
+ return createPortal(
300
+ <div
301
+ onMouseDown={onClose}
302
+ className="kol-overlay-scrim"
303
+ style={{ position: 'fixed', inset: 0, zIndex: 150, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
304
+ >
305
+ <div
306
+ role="dialog"
307
+ aria-modal="true"
308
+ aria-label={title}
309
+ data-dialog
310
+ 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))` }}
313
+ >
314
+ {children}
315
+ </div>
316
+ </div>,
317
+ document.body,
318
+ )
319
+ }
@@ -477,7 +477,7 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPr
477
477
  * sidenav. the handles are different from the sidenav"*) — a 2.8s chase and a
478
478
  * 30px retarget, so the pill tracks the pointer instead of landing and holding
479
479
  * on the rail's 90px dwell. */
480
- function ResizeHandle({ axis, onDrag, onEnd }) {
480
+ export function ResizeHandle({ axis, onDrag, onEnd }) {
481
481
  const [dragging, setDragging] = useState(false)
482
482
  const start = useRef(null)
483
483
  const ref = useRef(null)
@@ -4,104 +4,184 @@ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
4
4
 
5
5
  /* THE GROWING LINE AS A STAGE (GrowthLineStage, kol-atlas 2026-10-30) — kol-fxr's HomeStage sim loop on
6
6
  * @kolkrabbi/kol-growth: one closed line grows into `shape`, packing tighter where it is dark. One <canvas> filling its
7
- * box at dpr ≤ 2; the sim runs in CSS px, so the spacing reads the same on a phone as on a desk. It grows to its cap and
8
- * then stops drawing (no rAF); a resize starts it again. Ink is the canvas's computed `color` (a `text-*` class), a
9
- * `data-theme` flip repaints a finished line; the stage clears to transparent, no paper.
7
+ * box at dpr ≤ 2; the sim runs in CSS px, so the spacing reads the same on a phone as on a desk. Ink is the canvas's
8
+ * computed `color` (a `text-*` class), a `data-theme` flip repaints a sleeping line; the stage clears to transparent.
10
9
  *
11
- * THE POINTER IS A FORCE — a move pushes the line aside, a press blasts a crater it regrows into (engine `poke`);
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. 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,
16
- * so the stage can sit behind a page; a press on a control is the control's.
10
+ * THE LINE SETTLES (engine `settle`, GrowthLineIntro 2026-10-31, kol-fxr plan 45): every node carries a heat its motion,
11
+ * a split, the pointer or a pressing neighbour raise and that fades by itself; the loop steps while the line grows or
12
+ * anything is warm — two steps a frame while it grows, one while it settles — and goes still without a hard stop. It runs
13
+ * 48px past the edges (engine `bleed`), frozen there. THE POINTER IS A FORCE, soft: a move pushes the line aside as hard
14
+ * as it moves (a resting pointer is no force), a press blasts a crater it regrows into. Read on the window, so the stage
15
+ * can sit behind a page; a press on a control is the control's.
17
16
  *
18
- * REDUCED MOTION — the finished line is grown off screen and drawn once: no animation, no pointer force.
17
+ * A RESIZE KEEPS THE LINE — the nodes re-centre by half the size change, the shape is re-sampled at the new aspect, and
18
+ * a bigger box wakes the line to grow on into it; it never reseeds. A SLOW WAVE (`wave`, default 24 s · 9 px · 640 px
19
+ * crests) drifts the drawn line every frame, the sim untouched; `wave={false}` lets a settled line sleep (no rAF).
20
+ *
21
+ * THE INTRO (`intro`): one line from load to page. It grows on the shape's density (at `fit` 0.5) for 3.8 s, drawn only
22
+ * through the shape in the emphasis ink; rests 4 s breathing (ink 0.7–1.0 on a 2 s period, growth off after the first
23
+ * breath); then lets go over 4 s — the density blends shape → orb, `params` take over, every node warms, the stage's ink
24
+ * fades up as the shape's fades out, and the pointer waits until it ends. `onIntroEnd` fires then.
25
+ *
26
+ * REDUCED MOTION — no intro (`onIntroEnd` fires at once), no wave, no pointer force: the finished line is grown off
27
+ * screen and drawn once.
19
28
  *
20
29
  * `shape` — 'orb' · an SVG path `d` (centred at `fit` of the box) · `(w, h) => canvas` (dark on light). */
21
- const ORB_PARAMS = { minGap: 6, maxGap: 24, contrast: 1.4, growth: 8 }
30
+ const ORB_PARAMS = { minGap: 6, maxGap: 24, contrast: 1.4, growth: 2.5 }
31
+ const WAVE = { ms: 24000, a: 9, l: 640 }
32
+ /* the intro, ms from its first frame: the line fills the shape · it breathes · the shape lets go */
33
+ const INTRO_PARAMS = { minGap: 5, maxGap: 18, contrast: 1.2, growth: 5 }
34
+ const MARK_MS = 3800, REST_MS = 4000, BREATH_MS = 2000, RELEASE_MS = 4000
22
35
 
23
- const shapeCanvas = (shape, w, h, fit) => {
36
+ const shapeCanvas = (shape, w, h, fit, W) => {
24
37
  if (typeof shape === 'function') return shape(w, h)
25
- if (shape === 'orb' || !shape) return orbCanvas(w / h)
26
- return pathCanvas(shape, w / h, fit)
38
+ if (shape === 'orb' || !shape) return orbCanvas(w / h, W)
39
+ return pathCanvas(shape, w / h, fit, W)
27
40
  }
28
41
 
29
- export default function GrowthLineStage({ shape = 'orb', fit = 0.6, params = ORB_PARAMS, cap, pointer = true, seed, className = '' }) {
42
+ export default function GrowthLineStage({ shape = 'orb', fit, params = ORB_PARAMS, cap, pointer = true, seed, wave = WAVE, intro = false, onIntroEnd, className = '' }) {
30
43
  const ref = useRef(null)
31
44
  const reduced = usePrefersReducedMotion()
32
45
  /* the props are read at (re)start; the effect re-runs when one changes by value */
33
- const key = JSON.stringify([typeof shape === 'function' ? null : shape, fit, params, cap, pointer, seed, reduced])
46
+ const key = JSON.stringify([typeof shape === 'function' ? null : shape, fit, params, cap, pointer, seed, wave, intro, reduced])
34
47
  const shapeRef = useRef(shape)
35
48
  shapeRef.current = shape
49
+ const endRef = useRef(onIntroEnd)
50
+ endRef.current = onIntroEnd
36
51
 
37
52
  useEffect(() => {
38
53
  const canvas = ref.current
39
54
  if (!canvas) return undefined
40
- let sim = null, raf = 0, w = 0, h = 0
41
- let ptr = null, sm = null, idle = 0, full = false
55
+ const k = fit ?? (intro ? 0.5 : 0.6)
56
+ const drift = wave && !reduced ? wave : null
57
+ const capOf = () => cap ?? Math.round(w * h / 90)
58
+ let sim = null, raf = 0, w = 0, h = 0, dpr = 1, it = null, orbed = false
59
+ let ptr = null, sm = null
42
60
  const force = pointer && !reduced
43
- const start = () => {
61
+ const fitCanvas = () => {
44
62
  const r = canvas.getBoundingClientRect()
45
- w = Math.max(1, r.width); h = Math.max(1, r.height)
46
- const src = shapeCanvas(shapeRef.current, w, h, fit)
47
- if (!src) return false
48
- const dpr = Math.min(2, window.devicePixelRatio || 1)
63
+ w = Math.max(1, r.width); h = Math.max(1, r.height); dpr = Math.min(2, window.devicePixelRatio || 1)
49
64
  canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr)
50
65
  canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0)
51
- sim = new Growth({
52
- w, h, density: densityFrom(src),
53
- rand: seed == null ? Math.random : mulberry32(seed),
54
- cap: cap ?? Math.min(14000, Math.round(w * h / 112)),
55
- })
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 })
59
- sim.seed()
60
- full = false
66
+ }
67
+ /* the shape as an alpha mask at the canvas's own resolution (dark → opaque), and the layer the line is drawn on
68
+ * before the mask cuts it */
69
+ const masks = () => {
70
+ const src = shapeCanvas(shapeRef.current, w, h, k, canvas.width)
71
+ const m = document.createElement('canvas'); m.width = canvas.width; m.height = canvas.height
72
+ const g = m.getContext('2d', { willReadFrequently: true })
73
+ if (src) g.drawImage(src, 0, 0, m.width, m.height)
74
+ const img = g.getImageData(0, 0, m.width, m.height), d = img.data
75
+ for (let i = 0; i < d.length; i += 4) { d[i + 3] = 255 - (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]); d[i] = d[i + 1] = d[i + 2] = 0 }
76
+ g.putImageData(img, 0, 0)
77
+ it.mask = m
78
+ it.layer = document.createElement('canvas'); it.layer.width = canvas.width; it.layer.height = canvas.height
79
+ }
80
+ const end = () => { it = null; endRef.current?.() }
81
+ const start = () => {
82
+ fitCanvas()
83
+ const src = shapeCanvas(shapeRef.current, w, h, k)
84
+ if (!src) return false
85
+ sim = new Growth({ w, h, density: densityFrom(src), rand: seed == null ? Math.random : mulberry32(seed), cap: capOf() })
86
+ sim.setParams({ bleed: 48, settle: 60, ...(intro && !reduced ? INTRO_PARAMS : params) })
87
+ if (intro && !reduced) {
88
+ sim.seed(0.5, 0.5)
89
+ const probe = document.createElement('span'); probe.className = 'text-emphasis'; canvas.parentElement.append(probe)
90
+ it = { t0: 0, ink: 1, out: 0, bright: getComputedStyle(probe).color, mark: sim.density, mix: null }
91
+ probe.remove(); masks()
92
+ } else {
93
+ sim.seed()
94
+ if (intro) endRef.current?.()
95
+ }
61
96
  return true
62
97
  }
63
- const paint = () => {
64
- const g = canvas.getContext('2d')
98
+ /* the shape lets go: the page's spacing, every node warm, the density blending from the shape to the orb */
99
+ const release = () => {
100
+ orbed = true
101
+ const orb = densityFrom(orbCanvas(w / h))
102
+ it.orb = orb; it.mix = { cols: orb.cols, rows: orb.rows, data: new Float32Array(orb.data.length) }
103
+ sim.density = it.mix
104
+ sim.setParams(params)
105
+ sim.full = false; sim.heat.fill(1); sim.hot = sim.x.length
106
+ }
107
+ const blend = (u) => {
108
+ const a = it.mark.data, b = it.orb.data, m = it.mix.data
109
+ /* the mark was sampled at its own grid; read it by position if the orb's differs */
110
+ if (a.length === m.length) for (let i = 0; i < m.length; i++) m[i] = a[i] + (b[i] - a[i]) * u
111
+ else for (let i = 0; i < m.length; i++) m[i] = b[i] * u
112
+ }
113
+ const paint = (now) => {
114
+ const g = canvas.getContext('2d'), ink = getComputedStyle(canvas).color
115
+ const wv = drift && { a: drift.a, k: (2 * Math.PI) / drift.l, p: (2 * Math.PI * now) / drift.ms }
65
116
  g.clearRect(0, 0, w, h)
66
- sim.draw(g, w, h, { ink: getComputedStyle(canvas).color, width: 1 })
117
+ if (!it) { sim.draw(g, w, h, { ink, width: 1, wave: wv }); return }
118
+ const L = it.layer.getContext('2d')
119
+ L.globalCompositeOperation = 'source-over'; L.setTransform(1, 0, 0, 1, 0, 0); L.clearRect(0, 0, it.layer.width, it.layer.height)
120
+ L.setTransform(dpr, 0, 0, dpr, 0, 0); sim.draw(L, w, h, { ink: it.bright, width: 1, wave: wv })
121
+ /* the whole line in the stage's ink once the shape lets go, fading up; the shape's in the emphasis ink, at the breath */
122
+ if (it.out > 0) { g.globalAlpha = it.out; sim.draw(g, w, h, { ink, width: 1, wave: wv }); g.globalAlpha = 1 }
123
+ L.setTransform(1, 0, 0, 1, 0, 0); L.globalCompositeOperation = 'destination-in'; L.drawImage(it.mask, 0, 0)
124
+ g.save(); g.setTransform(1, 0, 0, 1, 0, 0)
125
+ g.globalAlpha = it.ink * (1 - it.out); g.drawImage(it.layer, 0, 0)
126
+ g.restore()
67
127
  }
68
- /* grows until the cap, then the loop stops — a finished line costs nothing until the pointer touches it */
69
- const frame = () => {
70
- /* FINISHED ONCE: at the cap, merges drop the count back under it — read live, the whole line kept stepping
71
- * forever, which was the jitter */
72
- if (sim.x.length >= sim.cap) full = true
73
- const growing = !full
74
- if (ptr) {
75
- /* low-passed like the editor's stage pointer — the raw one twitches */
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 }
77
- /* a RESTING pointer is not a force — poked every frame, the line under it churned forever */
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 }
128
+ /* the line steps while it grows or anything is warm; with a wave it is drawn every frame, without one it sleeps */
129
+ const frame = (now) => {
130
+ let steps = !sim.full ? 2 : sim.hot > 0 ? 1 : 0
131
+ if (it) {
132
+ if (!it.t0) it.t0 = now
133
+ const t = now - it.t0
134
+ if (t < MARK_MS) steps = 4
135
+ else if (t < MARK_MS + REST_MS) {
136
+ /* it keeps filling the shape's far corners through the first breath, then holds */
137
+ if (!it.rest && t > MARK_MS + BREATH_MS) { it.rest = true; sim.setParams({ growth: 0 }) }
138
+ it.ink = 0.7 + 0.3 * Math.cos((2 * Math.PI * (t - MARK_MS)) / BREATH_MS)
139
+ } else {
140
+ if (!it.free) { it.free = true; it.ink = 1; release() }
141
+ const u = Math.min(1, (t - MARK_MS - REST_MS) / RELEASE_MS)
142
+ blend(u); it.out = u
143
+ if (u >= 1) { sim.density = it.orb; end() }
144
+ }
145
+ }
146
+ if (ptr && !it) {
147
+ /* low-passed — the raw one twitches; 0.35 follows without lag */
148
+ const to = sm ? { x: sm.x + (ptr.x - sm.x) * 0.35, y: sm.y + (ptr.y - sm.y) * 0.35 } : { x: ptr.x, y: ptr.y }
149
+ /* the push is the pointer's own speed — a resting pointer is no force, a slow drift a light brush, a sweep a shove */
150
+ const speed = sm ? Math.hypot(to.x - sm.x, to.y - sm.y) : 0
151
+ if (ptr.down || speed > 0.3) { sim.poke(to.x, to.y, ptr.down, ptr.down ? 1 : Math.min(1, speed / 6)); steps = Math.max(steps, 1) }
79
152
  sm = to
80
153
  } else sm = null
81
- /* settled stretches rest, so a step moves only the growing front and the pointer's wake */
82
- if (growing || idle > 0) sim.step()
83
- if (idle > 0) idle--
84
- paint()
85
- raf = growing || idle > 0 ? requestAnimationFrame(frame) : 0
86
- }
87
- const wake = () => { if (!raf && sim) raf = requestAnimationFrame(frame) }
88
- const restart = () => {
89
- cancelAnimationFrame(raf); raf = 0
90
- if (!start()) return
91
- /* reduced motion grows 4× the splits a step: nothing is watched growing, so it reaches the cap sooner */
92
- if (reduced) sim.setParams({ growth: sim.growth * 4 })
93
- raf = requestAnimationFrame(reduced ? settle : frame)
154
+ for (let i = 0; i < steps; i++) sim.step()
155
+ paint(now)
156
+ raf = drift || it || ptr || !sim.full || sim.hot > 0 ? requestAnimationFrame(frame) : 0
94
157
  }
158
+ const wake = () => { if (!raf && sim && !reduced) raf = requestAnimationFrame(frame) }
95
159
  /* reduced motion: grown to the end off screen, ~12ms of steps a frame so the page never blocks, then drawn once */
96
- const settle = () => {
160
+ const grow = () => {
97
161
  const t = performance.now()
98
- while (sim.x.length < sim.cap && performance.now() - t < 12) sim.step()
99
- if (sim.x.length < sim.cap) { raf = requestAnimationFrame(settle); return }
162
+ while ((!sim.full || sim.hot > 0) && performance.now() - t < 12) sim.step()
163
+ if (!sim.full || sim.hot > 0) { raf = requestAnimationFrame(grow); return }
100
164
  raf = 0
101
- paint()
165
+ paint(0)
166
+ }
167
+ /* A RESIZE KEEPS THE LINE: re-centred by half the size change, the frame and the shape re-sampled, never reseeded */
168
+ const resize = () => {
169
+ const r = canvas.getBoundingClientRect()
170
+ if (!sim || (Math.round(r.width) === Math.round(w) && Math.round(r.height) === Math.round(h))) return
171
+ const ow = w, oh = h
172
+ fitCanvas()
173
+ const dx = (w - ow) / 2, dy = (h - oh) / 2
174
+ for (let i = 0; i < sim.x.length; i++) { sim.x[i] += dx; sim.y[i] += dy }
175
+ sim.w = w; sim.h = h
176
+ sim.setParams({ cap: capOf() })
177
+ if (it) masks()
178
+ /* released from the intro, the line's shape is the orb */
179
+ else { const src = orbed ? orbCanvas(w / h) : shapeCanvas(shapeRef.current, w, h, k); if (src) sim.density = densityFrom(src) }
180
+ if (w > ow || h > oh) { sim.full = false; sim.heat.fill(0.5); sim.hot = sim.x.length }
181
+ if (reduced) { cancelAnimationFrame(raf); raf = requestAnimationFrame(grow) } else wake()
102
182
  }
103
- restart()
104
- const ro = new ResizeObserver(restart)
183
+ if (start()) raf = requestAnimationFrame(reduced ? grow : frame)
184
+ const ro = new ResizeObserver(resize)
105
185
  ro.observe(canvas)
106
186
  const at = (e) => {
107
187
  const r = canvas.getBoundingClientRect()
@@ -118,8 +198,8 @@ export default function GrowthLineStage({ shape = 'orb', fit = 0.6, params = ORB
118
198
  window.addEventListener('pointerup', onUp)
119
199
  document.addEventListener('pointerleave', onLeave)
120
200
  }
121
- /* the ink follows a theme flip on a finished line too */
122
- const mo = new MutationObserver(() => { if (!raf && sim) paint() })
201
+ /* the ink follows a theme flip on a sleeping line too */
202
+ const mo = new MutationObserver(() => { if (!raf && sim) paint(performance.now()) })
123
203
  mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
124
204
  return () => {
125
205
  cancelAnimationFrame(raf); ro.disconnect(); mo.disconnect()