diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..0a2388d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,22 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.12.0
+ - run: npm install --no-audit --no-fund
+ - run: npm test
+ - run: npm run build
diff --git a/README.md b/README.md
deleted file mode 100644
index 103372b..0000000
--- a/README.md
+++ /dev/null
@@ -1,2 +0,0 @@
-# webdev-agent-kit-frontend
-Landing for https://github.com/ytvee-dev/webdev-agent-kit
diff --git a/astro.config.mjs b/astro.config.mjs
new file mode 100644
index 0000000..01fd110
--- /dev/null
+++ b/astro.config.mjs
@@ -0,0 +1,14 @@
+import { defineConfig } from 'astro/config'
+
+export default defineConfig({
+ output: 'static',
+ compressHTML: true,
+ build: {
+ inlineStylesheets: 'auto',
+ },
+ vite: {
+ build: {
+ target: 'es2022',
+ },
+ },
+})
diff --git a/docs/animation-architecture.md b/docs/animation-architecture.md
new file mode 100644
index 0000000..5daa2c0
--- /dev/null
+++ b/docs/animation-architecture.md
@@ -0,0 +1,46 @@
+# Calcifer graph hero
+
+## Rendering
+
+The section is statically rendered by Astro. The heading, description, actions and technology labels are present in the first HTML response. The animated canvas is decorative and does not contain SEO-critical content.
+
+The graph uses two Three.js draw calls:
+
+- `THREE.Points` with a custom shader for every node;
+- `THREE.LineSegments` for every connection.
+
+No post-processing pass is used. Soft glow is calculated inside the point fragment shader, which avoids an additional framebuffer and reduces GPU cost on laptops and mobile devices.
+
+## Shape generation
+
+The flame body is generated deterministically from a seeded pseudo-random function. Points are accepted inside a union of a rounded body and five tapered flame tongues. Arms, eyes, pupils and the mouth are generated from explicit curves so the face remains readable at every quality level.
+
+The graph connects flame points to nearby neighbours. Face rings and mouth curves have stronger internal springs. Each facial group is also attached to nearby flame nodes.
+
+## Physics
+
+Every node stores its current position, velocity and immutable target position. Each frame applies:
+
+1. spring forces along graph edges;
+2. a target-anchor force that preserves the silhouette;
+3. velocity damping;
+4. a very small role-aware idle drift.
+
+While a node is dragged, its target anchor is weakened for connected nodes up to four graph steps away. Influence decays by graph distance. The selected node follows the pointer directly, while its neighbours follow through the spring network. After release, target anchors restore the original silhouette.
+
+## Torch reveal
+
+The code wall is a DOM layer behind the canvas. It remains transparent until a node is dragged. Pointer position updates CSS custom properties that control a radial mask. Pointer speed controls opacity, producing a torch-like reveal without rendering code into WebGL.
+
+## Loading and fallbacks
+
+The first render displays an inline SVG graph generated from the same deterministic model. Three.js is code-split and loaded after browser idle time or the first pointer interaction.
+
+The implementation also:
+
+- caps device pixel ratio;
+- reduces node count for coarse pointers and small viewports;
+- pauses animation when the document is hidden;
+- disposes GPU resources on disconnect;
+- preserves the SVG fallback after WebGL context failure;
+- keeps the static SVG when `prefers-reduced-motion: reduce` is enabled.
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..60620b8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "webdev-agent-kit-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "engines": {
+ "node": ">=22.12.0",
+ "npm": ">=9.6.5"
+ },
+ "scripts": {
+ "dev": "astro dev",
+ "build": "astro check && astro build",
+ "check": "astro check",
+ "preview": "astro preview",
+ "test": "node --import tsx --test tests/**/*.test.ts"
+ },
+ "dependencies": {
+ "astro": "7.0.7",
+ "three": "0.185.1"
+ },
+ "devDependencies": {
+ "@astrojs/check": "0.9.9",
+ "@types/node": "26.1.1",
+ "@types/three": "0.185.1",
+ "tsx": "4.23.0",
+ "typescript": "6.0.3"
+ }
+}
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..c2a49f4
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Allow: /
diff --git a/src/components/CalciferGraph.astro b/src/components/CalciferGraph.astro
new file mode 100644
index 0000000..87688df
--- /dev/null
+++ b/src/components/CalciferGraph.astro
@@ -0,0 +1,284 @@
+---
+import { createCalciferGraph } from '../scripts/graph/generateGraph'
+
+const fallbackGraph = createCalciferGraph({ nodeCount: 60, seed: 20260710 })
+const toColor = (color: readonly [number, number, number]): string =>
+ `rgb(${Math.round(color[0] * 255)} ${Math.round(color[1] * 255)} ${Math.round(color[2] * 255)})`
+
+const codeWall = `
+type AgentTask = {
+ scope: "frontend"
+ evidence: Verification[]
+ approvalRequired: boolean
+}
+
+const workflow = async (task: AgentTask) => {
+ const context = await inspectProject(task.scope)
+ const plan = createSmallestSafePlan(context)
+ const result = await executeApprovedScope(plan)
+
+ return verifyWithEvidence(result)
+}
+
+export const guardrails = {
+ broadRewrites: false,
+ unapprovedPackages: false,
+ fakeVerification: false,
+}
+`
+---
+
+
+ {codeWall}
+
+
+
+
+
+ The graph is decorative. Drag a visible node to pull its connected neighbours and reveal code in
+ the darkness behind it.
+
+
+
+
+
+
diff --git a/src/components/Hero.astro b/src/components/Hero.astro
new file mode 100644
index 0000000..0b9ac1b
--- /dev/null
+++ b/src/components/Hero.astro
@@ -0,0 +1,287 @@
+---
+import CalciferGraph from './CalciferGraph.astro'
+
+const stack = ['React', 'Next.js', 'TypeScript', 'CSS Modules', 'Redux', 'TanStack', 'Axios', 'Codex', 'Claude']
+---
+
+
+
+
+
+
+
Project-local operating kit
+
+
Make frontend coding agents safer to use.
+
+
+ WebDev Agent Kit helps AI coding agents plan, implement, debug, refactor, review and visually
+ verify frontend work without broad rewrites, unapproved packages, fake verification or generic
+ UI output.
+
+
+
+
+
+ {stack.map((item) => - {item}
)}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
new file mode 100644
index 0000000..35b6bfe
--- /dev/null
+++ b/src/layouts/BaseLayout.astro
@@ -0,0 +1,32 @@
+---
+import '../styles/global.css'
+
+interface Props {
+ title: string
+ description: string
+}
+
+const { title, description } = Astro.props
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
diff --git a/src/pages/index.astro b/src/pages/index.astro
new file mode 100644
index 0000000..8934a78
--- /dev/null
+++ b/src/pages/index.astro
@@ -0,0 +1,14 @@
+---
+import Hero from '../components/Hero.astro'
+import BaseLayout from '../layouts/BaseLayout.astro'
+
+const title = 'WebDev Agent Kit — safer frontend coding agents'
+const description =
+ 'A project-local operating kit that helps AI coding agents plan, implement, debug, review and verify frontend work with controlled scope.'
+---
+
+
+
+
+
+
diff --git a/src/scripts/graph/generateGraph.ts b/src/scripts/graph/generateGraph.ts
new file mode 100644
index 0000000..908b91c
--- /dev/null
+++ b/src/scripts/graph/generateGraph.ts
@@ -0,0 +1,404 @@
+import { createRandom } from './random'
+import type {
+ CalciferGraphData,
+ GraphEdgeSeed,
+ GraphNodeRole,
+ GraphNodeSeed,
+ Rgb,
+} from './types'
+
+interface CreateGraphOptions {
+ nodeCount: number
+ seed?: number
+}
+
+interface Point {
+ x: number
+ y: number
+}
+
+const clamp = (value: number, min: number, max: number): number =>
+ Math.min(max, Math.max(min, value))
+
+const mix = (from: number, to: number, amount: number): number =>
+ from + (to - from) * amount
+
+const mixColor = (from: Rgb, to: Rgb, amount: number): Rgb => [
+ mix(from[0], to[0], amount),
+ mix(from[1], to[1], amount),
+ mix(from[2], to[2], amount),
+]
+
+const pointInsideTongue = (
+ x: number,
+ y: number,
+ centerX: number,
+ baseY: number,
+ height: number,
+ width: number,
+ lean: number,
+): boolean => {
+ const progress = (y - baseY) / height
+
+ if (progress < 0 || progress > 1) {
+ return false
+ }
+
+ const center = centerX + lean * progress
+ const taper = Math.pow(1 - progress, 0.72)
+ const ripple = 0.86 + Math.sin(progress * Math.PI * 2.2 + centerX * 8) * 0.14
+
+ return Math.abs(x - center) <= width * taper * ripple
+}
+
+const pointInsideFlame = (x: number, y: number): boolean => {
+ const body = Math.pow(x / 0.79, 2) + Math.pow((y + 0.14) / 0.82, 2) <= 1
+ const lowerGlow = Math.pow(x / 0.65, 2) + Math.pow((y + 0.55) / 0.42, 2) <= 1
+ const center = pointInsideTongue(x, y, 0.02, 0.2, 0.93, 0.34, 0.08)
+ const leftInner = pointInsideTongue(x, y, -0.34, 0.19, 0.66, 0.25, -0.08)
+ const rightInner = pointInsideTongue(x, y, 0.36, 0.18, 0.69, 0.23, 0.1)
+ const leftOuter = pointInsideTongue(x, y, -0.62, 0.02, 0.48, 0.18, -0.08)
+ const rightOuter = pointInsideTongue(x, y, 0.63, 0.04, 0.46, 0.17, 0.09)
+
+ return body || lowerGlow || center || leftInner || rightInner || leftOuter || rightOuter
+}
+
+const getFlameColor = (x: number, y: number): Rgb => {
+ const centerDistance = clamp(
+ Math.sqrt(Math.pow(x / 0.86, 2) + Math.pow((y + 0.34) / 1.12, 2)),
+ 0,
+ 1,
+ )
+ const heightHeat = clamp((y + 0.12) / 1.05, 0, 1)
+ const edgeHeat = clamp(Math.abs(x) / 0.82, 0, 1)
+ const heat = clamp(centerDistance * 0.45 + heightHeat * 0.42 + edgeHeat * 0.23, 0, 1)
+ const yellow: Rgb = [1, 0.82, 0.22]
+ const orange: Rgb = [1, 0.29, 0.035]
+ const red: Rgb = [0.72, 0.045, 0.018]
+
+ if (heat < 0.58) {
+ return mixColor(yellow, orange, heat / 0.58)
+ }
+
+ return mixColor(orange, red, (heat - 0.58) / 0.42)
+}
+
+const quadraticPoint = (start: Point, control: Point, end: Point, progress: number): Point => {
+ const inverse = 1 - progress
+
+ return {
+ x:
+ inverse * inverse * start.x +
+ 2 * inverse * progress * control.x +
+ progress * progress * end.x,
+ y:
+ inverse * inverse * start.y +
+ 2 * inverse * progress * control.y +
+ progress * progress * end.y,
+ }
+}
+
+const addNode = (
+ nodes: GraphNodeSeed[],
+ random: () => number,
+ role: GraphNodeRole,
+ point: Point,
+ size: number,
+ color: Rgb,
+ anchorStrength: number,
+ z = 0,
+): number => {
+ const id = nodes.length
+
+ nodes.push({
+ id,
+ role,
+ x: point.x,
+ y: point.y,
+ z,
+ size,
+ color,
+ anchorStrength,
+ phase: random() * Math.PI * 2,
+ })
+
+ return id
+}
+
+const addEdge = (
+ edges: GraphEdgeSeed[],
+ keys: Set,
+ nodes: GraphNodeSeed[],
+ source: number,
+ target: number,
+ stiffness: number,
+): void => {
+ if (source === target) {
+ return
+ }
+
+ const first = Math.min(source, target)
+ const second = Math.max(source, target)
+ const key = `${first}:${second}`
+
+ if (keys.has(key)) {
+ return
+ }
+
+ const sourceNode = nodes[source]
+ const targetNode = nodes[target]
+
+ if (!sourceNode || !targetNode) {
+ return
+ }
+
+ keys.add(key)
+ edges.push({
+ source: first,
+ target: second,
+ restLength: Math.hypot(sourceNode.x - targetNode.x, sourceNode.y - targetNode.y),
+ stiffness,
+ })
+}
+
+const nearestNode = (
+ nodes: GraphNodeSeed[],
+ point: Point,
+ roles: ReadonlySet,
+ excluded = -1,
+): number => {
+ let nearest = -1
+ let nearestDistance = Number.POSITIVE_INFINITY
+
+ for (const node of nodes) {
+ if (node.id === excluded || !roles.has(node.role)) {
+ continue
+ }
+
+ const distance = Math.hypot(node.x - point.x, node.y - point.y)
+
+ if (distance < nearestDistance) {
+ nearest = node.id
+ nearestDistance = distance
+ }
+ }
+
+ return nearest
+}
+
+const connectNearestNeighbours = (
+ nodes: GraphNodeSeed[],
+ edges: GraphEdgeSeed[],
+ keys: Set,
+ nodeIds: number[],
+ neighbours: number,
+ maxDistance: number,
+ stiffness: number,
+): void => {
+ for (const source of nodeIds) {
+ const sourceNode = nodes[source]
+
+ if (!sourceNode) {
+ continue
+ }
+
+ const candidates = nodeIds
+ .filter((target) => target !== source)
+ .map((target) => {
+ const targetNode = nodes[target]
+ return {
+ target,
+ distance: targetNode
+ ? Math.hypot(sourceNode.x - targetNode.x, sourceNode.y - targetNode.y)
+ : Number.POSITIVE_INFINITY,
+ }
+ })
+ .filter(({ distance }) => distance <= maxDistance)
+ .sort((left, right) => left.distance - right.distance)
+ .slice(0, neighbours)
+
+ for (const candidate of candidates) {
+ addEdge(edges, keys, nodes, source, candidate.target, stiffness)
+ }
+ }
+}
+
+export const createCalciferGraph = ({
+ nodeCount,
+ seed = 20260710,
+}: CreateGraphOptions): CalciferGraphData => {
+ const random = createRandom(seed)
+ const nodes: GraphNodeSeed[] = []
+ const edges: GraphEdgeSeed[] = []
+ const edgeKeys = new Set()
+ const flameNodeIds: number[] = []
+ const desiredFlameCount = Math.max(54, Math.floor(nodeCount * 0.72))
+ let attempts = 0
+
+ while (flameNodeIds.length < desiredFlameCount && attempts < desiredFlameCount * 80) {
+ attempts += 1
+ const x = mix(-0.92, 0.92, random())
+ const y = mix(-0.96, 1.12, random())
+
+ if (!pointInsideFlame(x, y)) {
+ continue
+ }
+
+ const coreBias = 1 - clamp(Math.hypot(x / 0.9, (y + 0.15) / 1.16), 0, 1)
+ const size = mix(3.2, 7.4, random() * 0.64 + coreBias * 0.36)
+ const id = addNode(
+ nodes,
+ random,
+ 'flame',
+ { x, y },
+ size,
+ getFlameColor(x, y),
+ mix(4.6, 7.8, 1 - coreBias),
+ mix(-0.03, 0.03, random()),
+ )
+ flameNodeIds.push(id)
+ }
+
+ const armNodeIds: number[] = []
+ const armDefinitions = [
+ {
+ start: { x: -0.66, y: -0.15 },
+ control: { x: -0.98, y: -0.52 },
+ end: { x: -1.19, y: -0.23 },
+ },
+ {
+ start: { x: 0.66, y: -0.15 },
+ control: { x: 0.98, y: -0.52 },
+ end: { x: 1.19, y: -0.23 },
+ },
+ ]
+
+ for (const definition of armDefinitions) {
+ const arm: number[] = []
+
+ for (let index = 0; index < 11; index += 1) {
+ const progress = index / 10
+ const point = quadraticPoint(definition.start, definition.control, definition.end, progress)
+ const id = addNode(
+ nodes,
+ random,
+ 'arm',
+ point,
+ mix(4.4, 6.8, 1 - Math.abs(progress - 0.85)),
+ mixColor([1, 0.34, 0.045], [0.83, 0.07, 0.018], progress),
+ 6.8,
+ 0.015,
+ )
+ arm.push(id)
+ armNodeIds.push(id)
+
+ if (index > 0) {
+ addEdge(edges, edgeKeys, nodes, arm[index - 1] ?? id, id, 16)
+ }
+ }
+
+ const root = arm[0]
+ const rootNode = root === undefined ? undefined : nodes[root]
+
+ if (root !== undefined && rootNode) {
+ const nearest = nearestNode(nodes, rootNode, new Set(['flame']), root)
+ addEdge(edges, edgeKeys, nodes, root, nearest, 17)
+ }
+ }
+
+ const eyeNodeIds: number[] = []
+ const pupilNodeIds: number[] = []
+
+ for (const centerX of [-0.27, 0.27]) {
+ const ring: number[] = []
+
+ for (let index = 0; index < 14; index += 1) {
+ const angle = (index / 14) * Math.PI * 2
+ const point = {
+ x: centerX + Math.cos(angle) * 0.155,
+ y: -0.12 + Math.sin(angle) * 0.135,
+ }
+ const id = addNode(nodes, random, 'eye', point, 5.9, [1, 0.95, 0.79], 12, 0.09)
+ ring.push(id)
+ eyeNodeIds.push(id)
+ }
+
+ for (let index = 0; index < ring.length; index += 1) {
+ const current = ring[index]
+ const next = ring[(index + 1) % ring.length]
+
+ if (current !== undefined && next !== undefined) {
+ addEdge(edges, edgeKeys, nodes, current, next, 22)
+ }
+ }
+
+ for (let index = 0; index < 4; index += 1) {
+ const angle = (index / 4) * Math.PI * 2
+ const point = {
+ x: centerX + (centerX < 0 ? 0.025 : -0.025) + Math.cos(angle) * 0.035,
+ y: -0.135 + Math.sin(angle) * 0.035,
+ }
+ const id = addNode(nodes, random, 'pupil', point, 6.8, [0.06, 0.045, 0.035], 16, 0.13)
+ pupilNodeIds.push(id)
+
+ const ringTarget = ring[(index * 3 + 1) % ring.length]
+ if (ringTarget !== undefined) {
+ addEdge(edges, edgeKeys, nodes, id, ringTarget, 18)
+ }
+ }
+ }
+
+ const mouthNodeIds: number[] = []
+
+ for (let index = 0; index < 15; index += 1) {
+ const progress = index / 14
+ const point = quadraticPoint(
+ { x: -0.21, y: -0.39 },
+ { x: 0, y: -0.56 },
+ { x: 0.21, y: -0.39 },
+ progress,
+ )
+ const id = addNode(nodes, random, 'mouth', point, 5.2, [0.31, 0.025, 0.018], 15, 0.12)
+ mouthNodeIds.push(id)
+
+ if (index > 0) {
+ addEdge(edges, edgeKeys, nodes, mouthNodeIds[index - 1] ?? id, id, 22)
+ }
+ }
+
+ connectNearestNeighbours(nodes, edges, edgeKeys, flameNodeIds, 3, 0.31, 10.5)
+ connectNearestNeighbours(nodes, edges, edgeKeys, armNodeIds, 2, 0.3, 14)
+
+ const connectedNodeIds = new Set()
+ for (const edge of edges) {
+ connectedNodeIds.add(edge.source)
+ connectedNodeIds.add(edge.target)
+ }
+
+ for (const nodeId of flameNodeIds) {
+ if (connectedNodeIds.has(nodeId)) {
+ continue
+ }
+
+ const node = nodes[nodeId]
+ if (!node) {
+ continue
+ }
+
+ const nearest = nearestNode(nodes, node, new Set(['flame']), nodeId)
+ addEdge(edges, edgeKeys, nodes, nodeId, nearest, 10.5)
+ }
+
+ for (const nodeId of [...eyeNodeIds, ...pupilNodeIds, ...mouthNodeIds]) {
+ const node = nodes[nodeId]
+
+ if (!node) {
+ continue
+ }
+
+ const nearest = nearestNode(nodes, node, new Set(['flame']), nodeId)
+ addEdge(edges, edgeKeys, nodes, nodeId, nearest, 8)
+ }
+
+ return { nodes, edges }
+}
diff --git a/src/scripts/graph/mountCalciferGraph.ts b/src/scripts/graph/mountCalciferGraph.ts
new file mode 100644
index 0000000..1617a93
--- /dev/null
+++ b/src/scripts/graph/mountCalciferGraph.ts
@@ -0,0 +1,463 @@
+import * as THREE from 'three'
+import { createCalciferGraph } from './generateGraph'
+import { createDragInfluence, createPhysicsState, stepGraphPhysics } from './physics'
+import type { CalciferGraphData, Rgb } from './types'
+
+interface PointerSample {
+ x: number
+ y: number
+ time: number
+}
+
+const vertexShader = `
+ attribute float aSize;
+ attribute float aHeat;
+ varying vec3 vColor;
+ varying float vHeat;
+ uniform float uPixelRatio;
+ uniform float uViewportScale;
+
+ void main() {
+ vColor = color;
+ vHeat = aHeat;
+ vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
+ gl_Position = projectionMatrix * viewPosition;
+ gl_PointSize = aSize * uPixelRatio * uViewportScale;
+ }
+`
+
+const fragmentShader = `
+ varying vec3 vColor;
+ varying float vHeat;
+
+ void main() {
+ float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
+ if (distanceToCenter > 0.5) discard;
+
+ float core = 1.0 - smoothstep(0.08, 0.34, distanceToCenter);
+ float halo = 1.0 - smoothstep(0.18, 0.5, distanceToCenter);
+ float alpha = clamp(core + halo * (0.36 + vHeat * 0.18), 0.0, 1.0);
+ vec3 glow = mix(vColor, vec3(1.0, 0.86, 0.52), halo * 0.18 + vHeat * 0.12);
+ gl_FragColor = vec4(glow, alpha);
+ }
+`
+
+const clamp = (value: number, min: number, max: number): number =>
+ Math.min(max, Math.max(min, value))
+
+const getQualityNodeCount = (): number => {
+ const coarsePointer = window.matchMedia('(pointer: coarse)').matches
+ const width = window.innerWidth
+
+ if (coarsePointer || width < 640) {
+ return 160
+ }
+
+ if (width < 1100) {
+ return 220
+ }
+
+ return 310
+}
+
+const writeColor = (target: Float32Array, offset: number, color: Rgb, multiplier = 1): void => {
+ target[offset] = color[0] * multiplier
+ target[offset + 1] = color[1] * multiplier
+ target[offset + 2] = color[2] * multiplier
+}
+
+const createPointGeometry = (graph: CalciferGraphData): THREE.BufferGeometry => {
+ const positions = new Float32Array(graph.nodes.length * 3)
+ const colors = new Float32Array(graph.nodes.length * 3)
+ const sizes = new Float32Array(graph.nodes.length)
+ const heat = new Float32Array(graph.nodes.length)
+
+ for (const node of graph.nodes) {
+ const offset = node.id * 3
+ positions[offset] = node.x
+ positions[offset + 1] = node.y
+ positions[offset + 2] = node.z
+ writeColor(colors, offset, node.color)
+ sizes[node.id] = node.size
+ heat[node.id] = 0
+ }
+
+ const geometry = new THREE.BufferGeometry()
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
+ geometry.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1))
+ geometry.setAttribute('aHeat', new THREE.BufferAttribute(heat, 1))
+
+ return geometry
+}
+
+const createLineGeometry = (graph: CalciferGraphData): THREE.BufferGeometry => {
+ const positions = new Float32Array(graph.edges.length * 6)
+ const colors = new Float32Array(graph.edges.length * 6)
+
+ for (let index = 0; index < graph.edges.length; index += 1) {
+ const edge = graph.edges[index]
+
+ if (!edge) {
+ continue
+ }
+
+ const source = graph.nodes[edge.source]
+ const target = graph.nodes[edge.target]
+
+ if (!source || !target) {
+ continue
+ }
+
+ const offset = index * 6
+ positions[offset] = source.x
+ positions[offset + 1] = source.y
+ positions[offset + 2] = source.z
+ positions[offset + 3] = target.x
+ positions[offset + 4] = target.y
+ positions[offset + 5] = target.z
+ writeColor(colors, offset, source.color, 0.58)
+ writeColor(colors, offset + 3, target.color, 0.58)
+ }
+
+ const geometry = new THREE.BufferGeometry()
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
+
+ return geometry
+}
+
+const getPointerWorld = (
+ event: PointerEvent,
+ canvas: HTMLCanvasElement,
+ camera: THREE.OrthographicCamera,
+): THREE.Vector3 => {
+ const rect = canvas.getBoundingClientRect()
+ const pointer = new THREE.Vector3(
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
+ -((event.clientY - rect.top) / rect.height) * 2 + 1,
+ 0,
+ )
+
+ pointer.unproject(camera)
+ pointer.z = 0
+
+ return pointer
+}
+
+const findNearestNode = (
+ graph: CalciferGraphData,
+ state: ReturnType,
+ point: THREE.Vector3,
+ threshold: number,
+): number => {
+ let nearest = -1
+ let nearestDistance = threshold
+
+ for (let index = 0; index < graph.nodes.length; index += 1) {
+ const node = state.nodes[index]
+
+ if (!node) {
+ continue
+ }
+
+ const distance = Math.hypot(node.x - point.x, node.y - point.y)
+
+ if (distance < nearestDistance) {
+ nearest = index
+ nearestDistance = distance
+ }
+ }
+
+ return nearest
+}
+
+export const mountCalciferGraph = (
+ host: HTMLElement,
+ canvas: HTMLCanvasElement,
+): (() => void) => {
+ const graph = createCalciferGraph({ nodeCount: getQualityNodeCount() })
+ const state = createPhysicsState(graph)
+ const renderer = new THREE.WebGLRenderer({
+ canvas,
+ alpha: true,
+ antialias: !window.matchMedia('(pointer: coarse)').matches,
+ powerPreference: 'high-performance',
+ })
+ const scene = new THREE.Scene()
+ const camera = new THREE.OrthographicCamera(-1.45, 1.45, 1.35, -1.35, 0.1, 20)
+ camera.position.z = 5
+
+ const pointGeometry = createPointGeometry(graph)
+ const pointMaterial = new THREE.ShaderMaterial({
+ vertexShader,
+ fragmentShader,
+ transparent: true,
+ vertexColors: true,
+ depthWrite: false,
+ depthTest: false,
+ uniforms: {
+ uPixelRatio: { value: 1 },
+ uViewportScale: { value: 1 },
+ },
+ })
+ const points = new THREE.Points(pointGeometry, pointMaterial)
+ points.renderOrder = 2
+
+ const lineGeometry = createLineGeometry(graph)
+ const lineMaterial = new THREE.LineBasicMaterial({
+ vertexColors: true,
+ transparent: true,
+ opacity: 0.34,
+ depthWrite: false,
+ depthTest: false,
+ blending: THREE.AdditiveBlending,
+ })
+ const lines = new THREE.LineSegments(lineGeometry, lineMaterial)
+ lines.renderOrder = 1
+
+ scene.add(lines, points)
+
+ let previousFrameTime = performance.now()
+ let elapsedSeconds = 0
+ const resizeObserver = new ResizeObserver(() => resize())
+ const positionAttribute = pointGeometry.getAttribute('position') as THREE.BufferAttribute
+ const colorAttribute = pointGeometry.getAttribute('color') as THREE.BufferAttribute
+ const heatAttribute = pointGeometry.getAttribute('aHeat') as THREE.BufferAttribute
+ const sizeAttribute = pointGeometry.getAttribute('aSize') as THREE.BufferAttribute
+ const linePositionAttribute = lineGeometry.getAttribute('position') as THREE.BufferAttribute
+ const lineColorAttribute = lineGeometry.getAttribute('color') as THREE.BufferAttribute
+ let frameId = 0
+ let disposed = false
+ let paused = document.hidden
+ let draggedIndex = -1
+ let draggedPoint = new THREE.Vector3()
+ let dragInfluence: Float32Array | undefined
+ let pointerSample: PointerSample | undefined
+
+ const resize = (): void => {
+ const width = Math.max(1, host.clientWidth)
+ const height = Math.max(1, host.clientHeight)
+ const pixelRatio = Math.min(window.devicePixelRatio, width < 700 ? 1.25 : 1.6)
+ const aspect = width / height
+ const vertical = 1.28
+
+ camera.top = vertical
+ camera.bottom = -vertical
+ camera.left = -vertical * aspect
+ camera.right = vertical * aspect
+ camera.updateProjectionMatrix()
+
+ renderer.setPixelRatio(pixelRatio)
+ renderer.setSize(width, height, false)
+ pointMaterial.uniforms.uPixelRatio = { value: pixelRatio }
+ pointMaterial.uniforms.uViewportScale = {
+ value: clamp(Math.min(width, height) / 620, 0.78, 1.18),
+ }
+ }
+
+ const setTorch = (event: PointerEvent, intensity: number): void => {
+ const rect = host.getBoundingClientRect()
+ const x = clamp(event.clientX - rect.left, 0, rect.width)
+ const y = clamp(event.clientY - rect.top, 0, rect.height)
+
+ host.style.setProperty('--torch-x', `${x}px`)
+ host.style.setProperty('--torch-y', `${y}px`)
+ host.style.setProperty('--torch-intensity', intensity.toFixed(3))
+ }
+
+ const updateGeometry = (elapsed: number): void => {
+ const heatArray = heatAttribute.array as Float32Array
+ const sizeArray = sizeAttribute.array as Float32Array
+ const colorArray = colorAttribute.array as Float32Array
+
+ for (let index = 0; index < graph.nodes.length; index += 1) {
+ const seed = graph.nodes[index]
+ const node = state.nodes[index]
+
+ if (!seed || !node) {
+ continue
+ }
+
+ const offset = index * 3
+ const influence = dragInfluence?.[index] ?? 0
+ const flicker =
+ seed.role === 'flame' || seed.role === 'arm'
+ ? (Math.sin(elapsed * 4.3 + seed.phase) + Math.sin(elapsed * 7.1 + seed.phase * 1.7)) *
+ 0.055
+ : 0
+ const activeHeat = clamp(influence * 0.9 + Math.max(0, flicker), 0, 1)
+ const brightness = 1 + activeHeat * 0.2
+
+ positionAttribute.setXYZ(index, node.x, node.y, node.z)
+ writeColor(colorArray, offset, seed.color, brightness)
+ heatArray[index] = activeHeat
+ sizeArray[index] = seed.size * (1 + flicker * 0.6 + influence * 0.18)
+ }
+
+ for (let index = 0; index < graph.edges.length; index += 1) {
+ const edge = graph.edges[index]
+
+ if (!edge) {
+ continue
+ }
+
+ const source = state.nodes[edge.source]
+ const target = state.nodes[edge.target]
+ const sourceSeed = graph.nodes[edge.source]
+ const targetSeed = graph.nodes[edge.target]
+
+ if (!source || !target || !sourceSeed || !targetSeed) {
+ continue
+ }
+
+ const offset = index * 6
+ const edgeHeat = Math.max(dragInfluence?.[edge.source] ?? 0, dragInfluence?.[edge.target] ?? 0)
+ const multiplier = 0.56 + edgeHeat * 0.62
+
+ linePositionAttribute.setXYZ(index * 2, source.x, source.y, source.z)
+ linePositionAttribute.setXYZ(index * 2 + 1, target.x, target.y, target.z)
+ writeColor(lineColorAttribute.array as Float32Array, offset, sourceSeed.color, multiplier)
+ writeColor(lineColorAttribute.array as Float32Array, offset + 3, targetSeed.color, multiplier)
+ }
+
+ positionAttribute.needsUpdate = true
+ colorAttribute.needsUpdate = true
+ heatAttribute.needsUpdate = true
+ sizeAttribute.needsUpdate = true
+ linePositionAttribute.needsUpdate = true
+ lineColorAttribute.needsUpdate = true
+ }
+
+ const animate = (): void => {
+ if (disposed) {
+ return
+ }
+
+ const currentFrameTime = performance.now()
+ const delta = Math.min((currentFrameTime - previousFrameTime) / 1000, 1 / 30)
+ previousFrameTime = currentFrameTime
+
+ if (!paused) {
+ elapsedSeconds += delta
+ stepGraphPhysics(state, graph.edges, elapsedSeconds, delta, {
+ draggedIndex: draggedIndex >= 0 ? draggedIndex : undefined,
+ draggedX: draggedIndex >= 0 ? draggedPoint.x : undefined,
+ draggedY: draggedIndex >= 0 ? draggedPoint.y : undefined,
+ dragInfluence,
+ })
+ updateGeometry(elapsedSeconds)
+ renderer.render(scene, camera)
+ }
+
+ frameId = window.requestAnimationFrame(animate)
+ }
+
+ const onPointerDown = (event: PointerEvent): void => {
+ const point = getPointerWorld(event, canvas, camera)
+ const threshold = event.pointerType === 'touch' ? 0.24 : 0.16
+ const nearest = findNearestNode(graph, state, point, threshold)
+
+ if (nearest < 0) {
+ return
+ }
+
+ event.preventDefault()
+ draggedIndex = nearest
+ draggedPoint = point
+ dragInfluence = createDragInfluence(graph.nodes.length, graph.edges, nearest)
+ pointerSample = { x: event.clientX, y: event.clientY, time: performance.now() }
+ canvas.setPointerCapture(event.pointerId)
+ canvas.style.cursor = 'grabbing'
+ host.dataset.dragging = 'true'
+ setTorch(event, 0.72)
+ }
+
+ const onPointerMove = (event: PointerEvent): void => {
+ if (draggedIndex < 0) {
+ const point = getPointerWorld(event, canvas, camera)
+ canvas.style.cursor = findNearestNode(graph, state, point, 0.16) >= 0 ? 'grab' : 'default'
+ return
+ }
+
+ event.preventDefault()
+ draggedPoint = getPointerWorld(event, canvas, camera)
+ const now = performance.now()
+ const previous = pointerSample
+ const elapsed = Math.max(8, now - (previous?.time ?? now))
+ const distance = Math.hypot(
+ event.clientX - (previous?.x ?? event.clientX),
+ event.clientY - (previous?.y ?? event.clientY),
+ )
+ const speed = distance / elapsed
+ const intensity = clamp(0.58 + speed * 0.7, 0.58, 1)
+
+ pointerSample = { x: event.clientX, y: event.clientY, time: now }
+ setTorch(event, intensity)
+ }
+
+ const releasePointer = (event: PointerEvent): void => {
+ if (draggedIndex < 0) {
+ return
+ }
+
+ if (canvas.hasPointerCapture(event.pointerId)) {
+ canvas.releasePointerCapture(event.pointerId)
+ }
+
+ draggedIndex = -1
+ dragInfluence = undefined
+ pointerSample = undefined
+ canvas.style.cursor = 'default'
+ host.dataset.dragging = 'false'
+ host.style.setProperty('--torch-intensity', '0')
+ }
+
+ const onVisibilityChange = (): void => {
+ paused = document.hidden
+ }
+
+ const onContextLost = (event: Event): void => {
+ event.preventDefault()
+ host.dataset.failed = 'true'
+ host.dataset.ready = 'false'
+ }
+
+ const onContextRestored = (): void => {
+ host.dataset.failed = 'false'
+ resize()
+ }
+
+ canvas.addEventListener('pointerdown', onPointerDown)
+ canvas.addEventListener('pointermove', onPointerMove)
+ canvas.addEventListener('pointerup', releasePointer)
+ canvas.addEventListener('pointercancel', releasePointer)
+ canvas.addEventListener('webglcontextlost', onContextLost)
+ canvas.addEventListener('webglcontextrestored', onContextRestored)
+ document.addEventListener('visibilitychange', onVisibilityChange)
+ resizeObserver.observe(host)
+ resize()
+ updateGeometry(0)
+ renderer.render(scene, camera)
+ host.dataset.ready = 'true'
+ host.dataset.failed = 'false'
+ frameId = window.requestAnimationFrame(animate)
+
+ return () => {
+ disposed = true
+ window.cancelAnimationFrame(frameId)
+ resizeObserver.disconnect()
+ canvas.removeEventListener('pointerdown', onPointerDown)
+ canvas.removeEventListener('pointermove', onPointerMove)
+ canvas.removeEventListener('pointerup', releasePointer)
+ canvas.removeEventListener('pointercancel', releasePointer)
+ canvas.removeEventListener('webglcontextlost', onContextLost)
+ canvas.removeEventListener('webglcontextrestored', onContextRestored)
+ document.removeEventListener('visibilitychange', onVisibilityChange)
+ pointGeometry.dispose()
+ pointMaterial.dispose()
+ lineGeometry.dispose()
+ lineMaterial.dispose()
+ renderer.dispose()
+ }
+}
diff --git a/src/scripts/graph/physics.ts b/src/scripts/graph/physics.ts
new file mode 100644
index 0000000..16ff6ec
--- /dev/null
+++ b/src/scripts/graph/physics.ts
@@ -0,0 +1,163 @@
+import type {
+ CalciferGraphData,
+ GraphEdgeSeed,
+ PhysicsState,
+ PhysicsStepOptions,
+} from './types'
+
+const clamp = (value: number, min: number, max: number): number =>
+ Math.min(max, Math.max(min, value))
+
+export const createPhysicsState = (graph: CalciferGraphData): PhysicsState => ({
+ nodes: graph.nodes.map((node) => ({
+ x: node.x,
+ y: node.y,
+ z: node.z,
+ targetX: node.x,
+ targetY: node.y,
+ targetZ: node.z,
+ vx: 0,
+ vy: 0,
+ vz: 0,
+ anchorStrength: node.anchorStrength,
+ phase: node.phase,
+ role: node.role,
+ })),
+})
+
+const applyEdgeSpring = (
+ state: PhysicsState,
+ edge: GraphEdgeSeed,
+ accelerationX: Float32Array,
+ accelerationY: Float32Array,
+ accelerationZ: Float32Array,
+): void => {
+ const source = state.nodes[edge.source]
+ const target = state.nodes[edge.target]
+
+ if (!source || !target) {
+ return
+ }
+
+ const dx = target.x - source.x
+ const dy = target.y - source.y
+ const dz = target.z - source.z
+ const distance = Math.max(0.0001, Math.hypot(dx, dy, dz))
+ const displacement = distance - edge.restLength
+ const force = displacement * edge.stiffness
+ const inverseDistance = 1 / distance
+ const forceX = dx * inverseDistance * force
+ const forceY = dy * inverseDistance * force
+ const forceZ = dz * inverseDistance * force
+
+ accelerationX[edge.source] = (accelerationX[edge.source] ?? 0) + forceX
+ accelerationY[edge.source] = (accelerationY[edge.source] ?? 0) + forceY
+ accelerationZ[edge.source] = (accelerationZ[edge.source] ?? 0) + forceZ
+ accelerationX[edge.target] = (accelerationX[edge.target] ?? 0) - forceX
+ accelerationY[edge.target] = (accelerationY[edge.target] ?? 0) - forceY
+ accelerationZ[edge.target] = (accelerationZ[edge.target] ?? 0) - forceZ
+}
+
+export const stepGraphPhysics = (
+ state: PhysicsState,
+ edges: GraphEdgeSeed[],
+ elapsedSeconds: number,
+ deltaSeconds: number,
+ options: PhysicsStepOptions = {},
+): void => {
+ const delta = clamp(deltaSeconds, 1 / 240, 1 / 30)
+ const count = state.nodes.length
+ const accelerationX = new Float32Array(count)
+ const accelerationY = new Float32Array(count)
+ const accelerationZ = new Float32Array(count)
+ const motionScale = options.motionScale ?? 1
+
+ for (const edge of edges) {
+ applyEdgeSpring(state, edge, accelerationX, accelerationY, accelerationZ)
+ }
+
+ for (let index = 0; index < count; index += 1) {
+ const node = state.nodes[index]
+
+ if (!node) {
+ continue
+ }
+
+ if (index === options.draggedIndex) {
+ node.x = options.draggedX ?? node.x
+ node.y = options.draggedY ?? node.y
+ node.vx = 0
+ node.vy = 0
+ node.vz = 0
+ continue
+ }
+
+ const faceScale = node.role === 'flame' || node.role === 'arm' ? 1 : 0.2
+ const driftX = Math.sin(elapsedSeconds * 1.17 + node.phase) * 0.013 * faceScale * motionScale
+ const driftY = Math.cos(elapsedSeconds * 1.43 + node.phase * 1.31) * 0.015 * faceScale * motionScale
+ const influence = options.dragInfluence?.[index] ?? 0
+ const anchorScale = 1 - influence * 0.72
+ const anchor = node.anchorStrength * anchorScale
+ const targetX = node.targetX + driftX
+ const targetY = node.targetY + driftY
+ const targetZ = node.targetZ + Math.sin(elapsedSeconds * 0.88 + node.phase) * 0.008 * faceScale
+
+ accelerationX[index] =
+ (accelerationX[index] ?? 0) + (targetX - node.x) * anchor
+ accelerationY[index] =
+ (accelerationY[index] ?? 0) + (targetY - node.y) * anchor
+ accelerationZ[index] =
+ (accelerationZ[index] ?? 0) + (targetZ - node.z) * anchor
+
+ node.vx += (accelerationX[index] ?? 0) * delta
+ node.vy += (accelerationY[index] ?? 0) * delta
+ node.vz += (accelerationZ[index] ?? 0) * delta
+
+ const damping = Math.exp(-5.8 * delta)
+ node.vx *= damping
+ node.vy *= damping
+ node.vz *= damping
+
+ node.x += node.vx * delta
+ node.y += node.vy * delta
+ node.z += node.vz * delta
+ }
+}
+
+export const createDragInfluence = (
+ nodeCount: number,
+ edges: GraphEdgeSeed[],
+ draggedIndex: number,
+): Float32Array => {
+ const adjacency = Array.from({ length: nodeCount }, () => [] as number[])
+
+ for (const edge of edges) {
+ adjacency[edge.source]?.push(edge.target)
+ adjacency[edge.target]?.push(edge.source)
+ }
+
+ const influence = new Float32Array(nodeCount)
+ const queue: Array<{ index: number; depth: number }> = [{ index: draggedIndex, depth: 0 }]
+ const visited = new Set([draggedIndex])
+
+ while (queue.length > 0) {
+ const current = queue.shift()
+
+ if (!current || current.depth > 4) {
+ continue
+ }
+
+ influence[current.index] = Math.max(influence[current.index] ?? 0, Math.pow(0.58, current.depth))
+
+ for (const neighbour of adjacency[current.index] ?? []) {
+ if (visited.has(neighbour)) {
+ continue
+ }
+
+ visited.add(neighbour)
+ queue.push({ index: neighbour, depth: current.depth + 1 })
+ }
+ }
+
+ return influence
+}
diff --git a/src/scripts/graph/random.ts b/src/scripts/graph/random.ts
new file mode 100644
index 0000000..8a5022a
--- /dev/null
+++ b/src/scripts/graph/random.ts
@@ -0,0 +1,11 @@
+export const createRandom = (seed: number): (() => number) => {
+ let state = seed >>> 0
+
+ return () => {
+ state += 0x6d2b79f5
+ let value = state
+ value = Math.imul(value ^ (value >>> 15), value | 1)
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296
+ }
+}
diff --git a/src/scripts/graph/types.ts b/src/scripts/graph/types.ts
new file mode 100644
index 0000000..f4252d8
--- /dev/null
+++ b/src/scripts/graph/types.ts
@@ -0,0 +1,54 @@
+export type GraphNodeRole = 'flame' | 'arm' | 'eye' | 'pupil' | 'mouth'
+
+export type Rgb = readonly [number, number, number]
+
+export interface GraphNodeSeed {
+ id: number
+ role: GraphNodeRole
+ x: number
+ y: number
+ z: number
+ size: number
+ color: Rgb
+ anchorStrength: number
+ phase: number
+}
+
+export interface GraphEdgeSeed {
+ source: number
+ target: number
+ restLength: number
+ stiffness: number
+}
+
+export interface CalciferGraphData {
+ nodes: GraphNodeSeed[]
+ edges: GraphEdgeSeed[]
+}
+
+export interface PhysicsNodeState {
+ x: number
+ y: number
+ z: number
+ targetX: number
+ targetY: number
+ targetZ: number
+ vx: number
+ vy: number
+ vz: number
+ anchorStrength: number
+ phase: number
+ role: GraphNodeRole
+}
+
+export interface PhysicsState {
+ nodes: PhysicsNodeState[]
+}
+
+export interface PhysicsStepOptions {
+ draggedIndex?: number | undefined
+ draggedX?: number | undefined
+ draggedY?: number | undefined
+ dragInfluence?: Float32Array | undefined
+ motionScale?: number | undefined
+}
diff --git a/src/styles/global.css b/src/styles/global.css
new file mode 100644
index 0000000..17775ee
--- /dev/null
+++ b/src/styles/global.css
@@ -0,0 +1,62 @@
+:root {
+ color-scheme: dark;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ --page-background: #090807;
+ --surface: #11100e;
+ --surface-border: rgba(255, 224, 178, 0.12);
+ --text-primary: #fffaf0;
+ --text-secondary: #bbb5ab;
+ --text-muted: #827c73;
+ --accent: #ffb431;
+ --accent-hot: #ff5a1f;
+ --focus: #ffd36a;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ min-width: 320px;
+ background: var(--page-background);
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ overflow-x: hidden;
+ background:
+ radial-gradient(circle at 72% 46%, rgba(121, 34, 8, 0.12), transparent 32rem),
+ linear-gradient(180deg, #0a0908 0%, #070706 100%);
+ color: var(--text-primary);
+}
+
+button,
+a {
+ font: inherit;
+}
+
+a {
+ color: inherit;
+}
+
+::selection {
+ background: rgba(255, 164, 45, 0.28);
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/tests/generateGraph.test.ts b/tests/generateGraph.test.ts
new file mode 100644
index 0000000..ebb0455
--- /dev/null
+++ b/tests/generateGraph.test.ts
@@ -0,0 +1,40 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { createCalciferGraph } from '../src/scripts/graph/generateGraph'
+
+test('graph generation is deterministic for a fixed seed', () => {
+ const first = createCalciferGraph({ nodeCount: 180, seed: 42 })
+ const second = createCalciferGraph({ nodeCount: 180, seed: 42 })
+
+ assert.deepEqual(first.nodes, second.nodes)
+ assert.deepEqual(first.edges, second.edges)
+})
+
+test('graph contains a connected flame, face and arms', () => {
+ const graph = createCalciferGraph({ nodeCount: 180, seed: 7 })
+ const roles = new Set(graph.nodes.map((node) => node.role))
+ const degree = new Uint16Array(graph.nodes.length)
+
+ for (const edge of graph.edges) {
+ degree[edge.source] = (degree[edge.source] ?? 0) + 1
+ degree[edge.target] = (degree[edge.target] ?? 0) + 1
+ }
+
+ assert.ok(graph.nodes.length >= 170)
+ assert.deepEqual(roles, new Set(['flame', 'arm', 'eye', 'pupil', 'mouth']))
+ assert.ok(Array.from(degree).every((value) => value > 0))
+ assert.ok(graph.nodes.every((node) => node.x >= -1.3 && node.x <= 1.3))
+ assert.ok(graph.nodes.every((node) => node.y >= -1 && node.y <= 1.2))
+})
+
+test('all edges reference valid distinct nodes', () => {
+ const graph = createCalciferGraph({ nodeCount: 220, seed: 2026 })
+
+ for (const edge of graph.edges) {
+ assert.notEqual(edge.source, edge.target)
+ assert.ok(edge.source >= 0 && edge.source < graph.nodes.length)
+ assert.ok(edge.target >= 0 && edge.target < graph.nodes.length)
+ assert.ok(edge.restLength > 0)
+ assert.ok(edge.stiffness > 0)
+ }
+})
diff --git a/tests/physics.test.ts b/tests/physics.test.ts
new file mode 100644
index 0000000..8ade16c
--- /dev/null
+++ b/tests/physics.test.ts
@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { createCalciferGraph } from '../src/scripts/graph/generateGraph'
+import { createDragInfluence, createPhysicsState, stepGraphPhysics } from '../src/scripts/graph/physics'
+
+test('anchor force returns a displaced node toward its target', () => {
+ const graph = createCalciferGraph({ nodeCount: 80, seed: 3 })
+ const state = createPhysicsState(graph)
+ const node = state.nodes[0]
+
+ if (!node) {
+ throw new Error('Expected at least one physics node')
+ }
+
+ node.x += 0.8
+ const initialDistance = Math.abs(node.x - node.targetX)
+
+ for (let step = 0; step < 180; step += 1) {
+ stepGraphPhysics(state, graph.edges, step / 60, 1 / 60)
+ }
+
+ assert.ok(Math.abs(node.x - node.targetX) < initialDistance)
+})
+
+test('drag influence decays over graph distance', () => {
+ const graph = createCalciferGraph({ nodeCount: 140, seed: 9 })
+ const source = graph.edges[0]?.source
+ const target = graph.edges[0]?.target
+
+ assert.notEqual(source, undefined)
+ assert.notEqual(target, undefined)
+
+ const influence = createDragInfluence(graph.nodes.length, graph.edges, source ?? 0)
+
+ assert.equal(influence[source ?? 0], 1)
+ assert.ok((influence[target ?? 0] ?? 0) > 0)
+ assert.ok((influence[target ?? 0] ?? 0) < 1)
+})
+
+test('dragged node is pinned to the pointer target', () => {
+ const graph = createCalciferGraph({ nodeCount: 100, seed: 11 })
+ const state = createPhysicsState(graph)
+
+ stepGraphPhysics(state, graph.edges, 1, 1 / 60, {
+ draggedIndex: 0,
+ draggedX: 0.91,
+ draggedY: -0.73,
+ })
+
+ assert.equal(state.nodes[0]?.x, 0.91)
+ assert.equal(state.nodes[0]?.y, -0.73)
+})
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..7f677ba
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "compilerOptions": {
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "noFallthroughCasesInSwitch": true,
+ "verbatimModuleSyntax": true
+ }
+}