@kolkrabbi/kol-component 0.318.0 → 0.319.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.318.0",
3
+ "version": "0.319.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,6 +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.1.0",
29
30
  "@kolkrabbi/kol-search": "^0.3.0",
30
31
  "@kolkrabbi/kol-markdown": "^0.2.0"
31
32
  },
package/src/index.js CHANGED
@@ -195,6 +195,8 @@ export { default as ErrorBoundary } from './utilities/ErrorBoundary.jsx'
195
195
  export { default as FeatureSplit } from './organisms/FeatureSplit.jsx'
196
196
  export { default as FeaturedCarousel } from './organisms/FeaturedCarousel.jsx'
197
197
  export { default as FeaturesCardSection } from './organisms/FeaturesCardSection.jsx'
198
+ /* the growing line on @kolkrabbi/kol-growth (GrowthLineStage, kol-atlas 2026-10-30) */
199
+ export { default as GrowthLineStage } from './organisms/GrowthLineStage.jsx'
198
200
  export { default as SectionHero, default as FullBleedHero } from './organisms/SectionHero.jsx'
199
201
  export { default as SectionSplit } from './organisms/SectionSplit.jsx'
200
202
  export { default as SectionCards } from './organisms/SectionCards.jsx'
@@ -0,0 +1,126 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { Growth, densityFrom, mulberry32, orbCanvas, pathCanvas } from '@kolkrabbi/kol-growth'
3
+ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
4
+
5
+ /* THE GROWING LINE AS A STAGE (GrowthLineStage, kol-atlas 2026-10-30) — kol-fxr's HomeStage sim loop on
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.
10
+ *
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 leaves. Read on the window,
13
+ * so the stage can sit behind a page; a press on a control is the control's.
14
+ *
15
+ * REDUCED MOTION — the finished line is grown off screen and drawn once: no animation, no pointer force.
16
+ *
17
+ * `shape` — 'orb' · an SVG path `d` (centred at `fit` of the box) · `(w, h) => canvas` (dark on light). */
18
+ const ORB_PARAMS = { minGap: 6, maxGap: 24, contrast: 1.4, growth: 8 }
19
+
20
+ const shapeCanvas = (shape, w, h, fit) => {
21
+ if (typeof shape === 'function') return shape(w, h)
22
+ if (shape === 'orb' || !shape) return orbCanvas(w / h)
23
+ return pathCanvas(shape, w / h, fit)
24
+ }
25
+
26
+ export default function GrowthLineStage({ shape = 'orb', fit = 0.6, params = ORB_PARAMS, cap, pointer = true, seed, className = '' }) {
27
+ const ref = useRef(null)
28
+ const reduced = usePrefersReducedMotion()
29
+ /* the props are read at (re)start; the effect re-runs when one changes by value */
30
+ const key = JSON.stringify([typeof shape === 'function' ? null : shape, fit, params, cap, pointer, seed, reduced])
31
+ const shapeRef = useRef(shape)
32
+ shapeRef.current = shape
33
+
34
+ useEffect(() => {
35
+ const canvas = ref.current
36
+ if (!canvas) return undefined
37
+ let sim = null, raf = 0, w = 0, h = 0
38
+ let ptr = null, sm = null, idle = 0
39
+ const force = pointer && !reduced
40
+ const start = () => {
41
+ const r = canvas.getBoundingClientRect()
42
+ w = Math.max(1, r.width); h = Math.max(1, r.height)
43
+ const src = shapeCanvas(shapeRef.current, w, h, fit)
44
+ if (!src) return false
45
+ const dpr = Math.min(2, window.devicePixelRatio || 1)
46
+ canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr)
47
+ canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0)
48
+ sim = new Growth({
49
+ w, h, density: densityFrom(src),
50
+ rand: seed == null ? Math.random : mulberry32(seed),
51
+ cap: cap ?? Math.min(14000, Math.round(w * h / 112)),
52
+ })
53
+ sim.setParams(params)
54
+ sim.seed()
55
+ return true
56
+ }
57
+ const paint = () => {
58
+ const g = canvas.getContext('2d')
59
+ g.clearRect(0, 0, w, h)
60
+ sim.draw(g, w, h, { ink: getComputedStyle(canvas).color, width: 1 })
61
+ }
62
+ /* grows until the cap, then the loop stops — a finished line costs nothing until the pointer touches it */
63
+ const frame = () => {
64
+ const growing = sim.x.length < sim.cap
65
+ if (ptr) {
66
+ /* low-passed like the editor's stage pointer — the raw one twitches */
67
+ sm = 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 }
68
+ sim.poke(sm.x, sm.y, ptr.down)
69
+ idle = 60
70
+ } else sm = null
71
+ if (growing || idle > 0) { sim.step(); if (growing) sim.step() }
72
+ if (idle > 0) idle--
73
+ paint()
74
+ raf = growing || idle > 0 ? requestAnimationFrame(frame) : 0
75
+ }
76
+ const wake = () => { if (!raf && sim) raf = requestAnimationFrame(frame) }
77
+ const restart = () => {
78
+ cancelAnimationFrame(raf); raf = 0
79
+ if (!start()) return
80
+ /* reduced motion grows 4× the splits a step: nothing is watched growing, so it reaches the cap sooner */
81
+ if (reduced) sim.setParams({ growth: sim.growth * 4 })
82
+ raf = requestAnimationFrame(reduced ? settle : frame)
83
+ }
84
+ /* reduced motion: grown to the end off screen, ~12ms of steps a frame so the page never blocks, then drawn once */
85
+ const settle = () => {
86
+ const t = performance.now()
87
+ while (sim.x.length < sim.cap && performance.now() - t < 12) sim.step()
88
+ if (sim.x.length < sim.cap) { raf = requestAnimationFrame(settle); return }
89
+ raf = 0
90
+ paint()
91
+ }
92
+ restart()
93
+ const ro = new ResizeObserver(restart)
94
+ ro.observe(canvas)
95
+ const at = (e) => {
96
+ const r = canvas.getBoundingClientRect()
97
+ const x = e.clientX - r.left, y = e.clientY - r.top
98
+ return x >= 0 && y >= 0 && x <= r.width && y <= r.height ? { x, y } : null
99
+ }
100
+ const onMove = (e) => { const p = at(e); ptr = p && { ...p, down: ptr?.down ?? false }; if (ptr) wake() }
101
+ const onDown = (e) => { if (e.target.closest?.('button, a, input, textarea, select, [role="menu"]')) return; const p = at(e); if (p) { ptr = { ...p, down: true }; wake() } }
102
+ const onUp = () => { if (ptr) ptr = { ...ptr, down: false } }
103
+ const onLeave = () => { ptr = null }
104
+ if (force) {
105
+ window.addEventListener('pointermove', onMove)
106
+ window.addEventListener('pointerdown', onDown)
107
+ window.addEventListener('pointerup', onUp)
108
+ document.addEventListener('pointerleave', onLeave)
109
+ }
110
+ /* the ink follows a theme flip on a finished line too */
111
+ const mo = new MutationObserver(() => { if (!raf && sim) paint() })
112
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
113
+ return () => {
114
+ cancelAnimationFrame(raf); ro.disconnect(); mo.disconnect()
115
+ if (force) {
116
+ window.removeEventListener('pointermove', onMove)
117
+ window.removeEventListener('pointerdown', onDown)
118
+ window.removeEventListener('pointerup', onUp)
119
+ document.removeEventListener('pointerleave', onLeave)
120
+ }
121
+ }
122
+ // eslint-disable-next-line react-hooks/exhaustive-deps
123
+ }, [key])
124
+
125
+ return <canvas ref={ref} aria-hidden="true" className={`block size-full ${className}`.trim()} />
126
+ }