@kolkrabbi/kol-component 0.321.0 → 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.321.0",
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": {
@@ -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()