A neural cellular automaton grows a chameleon from one cell on your GPU, using weights from the paper “From Cells to Pixels”. Scratch it and it heals.
---
format: typebulb/v1
name: Chameleon
---
**code.tsx**
```tsx
import { App, Component, a, button, canvas, div, h1, img, p, path, polygon, rect, span, svg } from "domeleon"
import {
Module, compileForward, checkWebGPU, Conv2d, Linear, init, capture,
conv2d, maxPool2d, relu, sin, greater, where, mul, add, div as tdiv, max, clamp, packRGBA8,
narrow, concat, reshape, permute, randn, ones, zeros,
type Tensor, type CompiledForward,
} from "tensorgrad"
// "From Cells to Pixels" (Pajouheshgar et al., SIGGRAPH 2026) with the authors' trained
// weights: a neural CA evolves a 96×96 cell grid, and a jointly-trained SIREN decodes
// each cell's 32-dim state + sub-cell coords into 384×384 RGBA. Weights and target come
// from assets/, where a local chameleon/assets/ file shadows the hosted copy.
const C = 32, FC = 256, H = 64, OMEGA = 10 // channels, update-MLP width, SIREN width/freq (paper config)
const G = 96 // cell grid — the paper's training lattice
const S = 4, GH = G * S // render scale, render res (384)
const GG = G * G, STATE_LEN = C * GG, GGH = GH * GH
const SIN = 4 + C // SIREN input: 4 trig coord features + C state channels
const DAMAGE_R = 7 // scratch radius in cells
const MODEL_URL = "assets/chameleon.json"
const TARGET_URL = "assets/chameleon.png"
const nextFrame = () => new Promise(res => requestAnimationFrame(res))
const clamp01 = (v: number) => v < 0 ? 0 : v > 1 ? 1 : v
const slog = (s: string) => { if (tb.mode === "local") tb.log(s) }
async function cachedFetch(url: string): Promise<ArrayBuffer> {
const download = async (): Promise<ArrayBuffer> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`)
return res.arrayBuffer()
}
try {
const cache = await caches.open("tensorgrad-assets")
const hit = await cache.match(url)
if (hit) return hit.arrayBuffer()
const buf = await download()
try { await cache.put(url, new Response(buf)) } catch { /* caching is an optimization */ }
return buf
} catch {
return download()
}
}
// Init is irrelevant — every param, the three frame constants included, is uploaded
// before any run. Those constants are params rather than inputs because inputs are
// re-sent and structure-cloned to the worker every run, and `coords` alone is 2.4MB.
class NCAModel extends Module {
w1 = new Conv2d(4 * C, FC, 1, { init: init.zeros() })
w2 = new Conv2d(FC, C, 1, { bias: false, init: init.zeros() })
s1 = new Linear(SIN, H, { init: init.zeros() })
s2 = new Linear(H, H, { init: init.zeros() })
s3 = new Linear(H, H, { init: init.zeros() })
sOut = new Linear(H, 4, { init: init.zeros() })
filters: Tensor // perception kernels [4C, 1, 3, 3]
bilin: Tensor // bilinear tent bank [C·S², 1, 3, 3]
coords: Tensor // sub-cell coord features [GGH, 4]
constructor() {
super()
this.filters = this.param([4 * C, 1, 3, 3], { init: init.zeros() })
this.bilin = this.param([C * S * S, 1, 3, 3], { init: init.zeros() })
this.coords = this.param([GGH, 4], { init: init.zeros() })
}
}
// The authors' JSON layout → tensorgrad's param record.
// nca.w1.weight [FC, 4C]: columns are feature-major (k·C + c, k over identity/
// sobelX/sobelY/laplacian) for their GLSL shader; our grouped perception conv
// emits channel-major (c·4 + k, the torch training layout) — permute back.
// nca.w2.weight.T [FC, C]: pre-transposed for the shader; Conv2d wants [C, FC].
// lppn.net.*: torch Linear is [out, in]; tensorgrad's is [in, out] (x@W).
// ω is folded into the hidden weights: sin(ω·(xW + b)) = sin(x·(ωW) + ωb), so the
// graph skips a scalar multiply over [147456, 64].
type TheirTensor = { shape: number[]; dtype: string; data64: string }
function importModel(src: Record<string, TheirTensor>): Record<string, Float32Array> {
const dec = (key: string, ...shape: number[]) => {
const e = src[key]
if (!e) throw new Error(`checkpoint is missing ${key}`)
if (e.shape.length !== shape.length || e.shape.some((v, i) => v !== shape[i]))
throw new Error(`${key}: shape [${e.shape}] != expected [${shape}]`)
const bin = atob(e.data64)
const u8 = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i)
return new Float32Array(u8.buffer)
}
const scale = (a: Float32Array, k: number) => { for (let i = 0; i < a.length; i++) a[i]! *= k; return a }
const w1t = dec("nca.w1.weight", FC, 4 * C)
const w1 = new Float32Array(FC * 4 * C)
for (let o = 0; o < FC; o++)
for (let k = 0; k < 4; k++)
for (let c = 0; c < C; c++)
w1[o * 4 * C + c * 4 + k] = w1t[o * 4 * C + k * C + c]!
const w2t = dec("nca.w2.weight.T", FC, C)
const w2 = new Float32Array(C * FC)
for (let h = 0; h < FC; h++)
for (let c = 0; c < C; c++)
w2[c * FC + h] = w2t[h * C + c]!
const lin = (key: string, nin: number, nout: number) => {
const t = dec(key, nout, nin)
const w = new Float32Array(nin * nout)
for (let o = 0; o < nout; o++)
for (let i = 0; i < nin; i++)
w[i * nout + o] = t[o * nin + i]!
return w
}
return {
"w1.W": w1, "w1.b": dec("nca.w1.bias", FC), "w2.W": w2,
"s1.W": scale(lin("lppn.net.0.linear.weight", SIN, H), OMEGA), "s1.b": scale(dec("lppn.net.0.linear.bias", H), OMEGA),
"s2.W": scale(lin("lppn.net.1.linear.weight", H, H), OMEGA), "s2.b": scale(dec("lppn.net.1.linear.bias", H), OMEGA),
"s3.W": scale(lin("lppn.net.2.linear.weight", H, H), OMEGA), "s3.b": scale(dec("lppn.net.2.linear.bias", H), OMEGA),
"sOut.W": lin("lppn.net.3.weight", H, 4), "sOut.b": dec("lppn.net.3.bias", 4),
"filters": FILTERS, "bilin": BILIN, "coords": COORDS,
}
}
// CA step and SIREN decode, verbatim from the reference: circular perception padding,
// Bernoulli-0.5 update mask, pre×post alive gating. Both must stay in ONE compiled
// forward — split, each gets its own worker and GPU device, so the state would
// round-trip through the CPU mid-frame just to cross between them.
const alive = (s: Tensor) =>
greater(maxPool2d(narrow(s, 1, 3, 1), 3, { stride: 1, padding: 1 }), 0.1)
const boolToF32 = (m: Tensor, g: number) =>
where(m, ones([1, 1, g, g]), zeros([1, 1, g, g]))
function circularPad1(s: Tensor): Tensor {
const px = concat([narrow(s, 3, G - 1, 1), s, narrow(s, 3, 0, 1)], 3)
return concat([narrow(px, 2, G - 1, 1), px, narrow(px, 2, 0, 1)], 2)
}
function ncaStep(m: NCAModel, s: Tensor): Tensor {
const pre = alive(s)
const z = conv2d(circularPad1(s), m.filters, { padding: 0, groups: C }) // [1, 4C, G, G]
const ds = m.w2.fwd(relu(m.w1.fwd(z)))
const mask = greater(randn([1, 1, G, G]), 0) // Bernoulli(0.5)
const s2 = where(mask, add(s, ds), s)
const post = alive(s2)
return mul(s2, mul(boolToF32(pre, G), boolToF32(post, G)))
}
// exact bilinear ×S upsample of `ch` channels via a depthwise tent conv; the
// depth-to-space shuffle lands in the SIREN's [pixel, channel] row order, so nothing
// downstream has to transpose
function bilinearRows(x: Tensor, w: Tensor, ch: number): Tensor {
const shifted = conv2d(x, w, { padding: 1, groups: ch }) // [1, ch·S², G, G]
const t = reshape(shifted, [1, ch, S, S, G, G])
return reshape(permute(t, [0, 4, 2, 5, 3, 1]), [GGH, ch]) // [1, G, S, G, S, ch]
}
// alive gating follows the authors' renderer, not the CA: alpha is maxpooled over 3×3
// RENDER pixels, giving a sub-cell-accurate silhouette. Column 3 of `up` is that
// upsampled alpha, already computed by the C-channel upsample.
function decode(m: NCAModel, s: Tensor): Tensor {
const up = bilinearRows(s, m.bilin, C) // [GGH, C]
let h = sin(m.s1.fwd(concat([m.coords, up], 1))) // ω is in the weights
h = sin(m.s2.fwd(h))
h = sin(m.s3.fwd(h))
const img = m.sOut.fwd(h) // [GGH, 4], interleaved RGBA
const aUp = reshape(narrow(up, 1, 3, 1), [1, 1, GH, GH])
const mask = greater(maxPool2d(aUp, 3, { stride: 1, padding: 1 }), 0.1)
return mul(img, reshape(boolToF32(mask, GH), [GGH, 1]))
}
// un-premultiply with capped gain and pack on the GPU: the readback is ImageData's
// byte layout, so the image comes back at 4 bytes per pixel instead of 16
function toPixels(rgba: Tensor): Tensor {
const a = clamp(narrow(rgba, 1, 3, 1), 0, 1) // [GGH, 1]
const gain = where(greater(a, 0.01), tdiv(1, max(a, 0.2)), zeros([GGH, 1]))
return packRGBA8(concat([mul(narrow(rgba, 1, 0, 3), gain), a], 1)) // [GGH] i32
}
// advance=0 decodes the state as-is, for repainting a scratch without stepping the CA.
// The clamp is the box the training-time overflow penalty kept the state in.
function frameFn(m: NCAModel, { state, advance }: { state: Tensor; advance: Tensor }) {
const stepped = clamp(ncaStep(m, state), -1, 1)
return toPixels(decode(m, capture("state", where(greater(advance, 0), stepped, state))))
}
const F3 = [
[0, 0, 0, 0, 1, 0, 0, 0, 0], // identity
[-1, 0, 1, -2, 0, 2, -1, 0, 1], // sobel_x
[-1, -2, -1, 0, 0, 0, 1, 2, 1], // sobel_y
[1, 2, 1, 2, -12, 2, 1, 2, 1], // laplacian
]
const FILTERS = (() => {
const a = new Float32Array(4 * C * 9) // [4C, 1, 3, 3], channel-major (c·4+k)
for (let k = 0; k < 4; k++)
for (let c = 0; c < C; c++)
a.set(F3[k]!, (c * 4 + k) * 9)
return a
})()
// 1D tent weights for the four sub-cell offsets, ×S upsample
const W1D = [
[0.375, 0.625, 0],
[0.125, 0.875, 0],
[0, 0.875, 0.125],
[0, 0.625, 0.375],
]
const BILIN = (() => {
const a = new Float32Array(C * S * S * 9) // [C·S², 1, 3, 3]
for (let c = 0; c < C; c++)
for (let dy = 0; dy < S; dy++)
for (let dx = 0; dx < S; dx++) {
const off = (c * S * S + dy * S + dx) * 9
for (let ky = 0; ky < 3; ky++)
for (let kx = 0; kx < 3; kx++)
a[off + ky * 3 + kx] = W1D[dy]![ky]! * W1D[dx]![kx]!
}
return a
})()
// [sin(πy), sin(πx), cos(πy), cos(πx)] per render pixel — the reference's
// num_frequencies=1 encoding, periodic per 4×4 tile
const COORDS = (() => {
const a = new Float32Array(GGH * 4)
const f = [-0.75, -0.25, 0.25, 0.75]
for (let y = 0; y < GH; y++)
for (let x = 0; x < GH; x++) {
const i = (y * GH + x) * 4
const cy = f[y % S]! * Math.PI, cx = f[x % S]! * Math.PI
a[i] = Math.sin(cy)
a[i + 1] = Math.sin(cx)
a[i + 2] = Math.cos(cy)
a[i + 3] = Math.cos(cx)
}
return a
})()
const ADVANCE = new Float32Array([1]), HOLD = new Float32Array([0])
// the paper's seed: all zeros except channels 3..C = 1 at the grid center
const SEED = (() => {
const a = new Float32Array(STATE_LEN)
const x = G >> 1, y = G >> 1
for (let c = 3; c < C; c++) a[c * GG + y * G + x] = 1
return a
})()
function carveDisc(s: Float32Array, cx: number, cy: number, r: number) {
for (let y = Math.max(0, Math.floor(cy - r)); y <= Math.min(G - 1, Math.ceil(cy + r)); y++)
for (let x = Math.max(0, Math.floor(cx - r)); x <= Math.min(G - 1, Math.ceil(cx + r)); x++) {
if ((x - cx) ** 2 + (y - cy) ** 2 > r * r) continue
for (let c = 0; c < C; c++) s[c * GG + y * G + x] = 0
}
}
const aliveCount = (s: Float32Array) => {
let n = 0
for (let i = 0; i < GG; i++) if (s[3 * GG + i]! > 0.1) n++
return n
}
const icon = (...kids: any[]) => svg({ class: "ico", viewBox: "0 0 24 24", fill: "currentColor" }, ...kids)
const ICON = {
play: () => icon(polygon({ points: "8,5 8,19 19,12" })),
pause: () => icon(rect({ x: 7, y: 5, width: 3.5, height: 14, rx: 1 }), rect({ x: 13.5, y: 5, width: 3.5, height: 14, rx: 1 })),
refresh: () => icon(path({ d: "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" })),
}
class Root extends Component {
status = "starting…"
paused = false
#frame?: CompiledForward<NCAModel, { state: number[]; advance: number[] }, "rgba8">
#state?: Float32Array
#token = 0
#canvas?: HTMLCanvasElement
#ctx?: CanvasRenderingContext2D
#cellsCtx?: CanvasRenderingContext2D
#cellsImg?: ImageData
#painting = false
#pendingCarves: [number, number][] = [] // carves racing an in-flight step
#renderBusy = false
boot(cv: HTMLCanvasElement) {
if (this.#canvas) return
cv.width = cv.height = GH
this.#canvas = cv
this.#ctx = cv.getContext("2d")!
cv.addEventListener("pointerdown", e => {
this.#painting = true
cv.setPointerCapture(e.pointerId)
this.#damageAt(e)
})
cv.addEventListener("pointermove", e => { if (this.#painting) this.#damageAt(e) })
cv.addEventListener("pointerup", () => { this.#painting = false })
this.run()
}
bootCells(cv: HTMLCanvasElement) {
if (this.#cellsCtx) return
cv.width = cv.height = G
this.#cellsCtx = cv.getContext("2d")!
this.#cellsImg = new ImageData(G, G)
}
async run() {
const gpu = await checkWebGPU()
if (!gpu.ok) {
this.#setStatus(gpu.message)
return
}
this.#setStatus("compiling WGSL kernels…")
this.#frame = await compileForward({
model: new NCAModel(), forward: frameFn,
inputs: { state: [1, C, G, G], advance: [1] },
output: "rgba8",
})
this.#setStatus("fetching the authors' trained chameleon…")
try {
const buf = await cachedFetch(MODEL_URL)
const src = JSON.parse(new TextDecoder().decode(buf)) as Record<string, TheirTensor>
const params = importModel(src)
await this.#frame.uploadParams(params)
this.reseed()
slog(`loaded chameleon (${Object.keys(params).length} tensors)`)
this.#setStatus("growing the chameleon — scratch it!")
} catch (e) {
slog(`load failed: ${(e as Error).message}`)
this.#setStatus(`couldn't load the chameleon (${(e as Error).message})`)
return
}
this.#loop()
}
reseed() {
this.#state = SEED.slice()
this.#pendingCarves.length = 0
if (this.paused) this.togglePause()
}
togglePause() {
this.paused = !this.paused
this.update()
}
async #loop() {
const token = ++this.#token
let frames = 0
while (token === this.#token) {
if (!this.paused && this.#state) {
this.#pendingCarves.length = 0 // from here, carves race the step
const r = await this.#frame!.run({ state: this.#state, advance: ADVANCE })
if (token !== this.#token) return
if (r.kind === "completed") {
const next = r.captures.get("state") as Float32Array
for (const [cx, cy] of this.#pendingCarves) carveDisc(next, cx, cy, DAMAGE_R)
this.#pendingCarves.length = 0
const n = aliveCount(next)
this.#state = (n === 0 || n > GG * 0.6) ? SEED.slice() : next
this.#paintRender(r.output)
this.#paintCells(next) // not #state: on a reseed frame both canvases show the decoded frame
}
frames++
if (frames % 300 === 0) slog(`t=${frames} alive=${aliveCount(this.#state!)}`)
}
await nextFrame()
}
}
#damageAt(e: PointerEvent) {
if (!this.#state) return
const box = this.#canvas!.getBoundingClientRect()
const x = Math.floor((e.clientX - box.left) / box.width * G)
const y = Math.floor((e.clientY - box.top) / box.height * G)
carveDisc(this.#state, x, y, DAMAGE_R)
this.#pendingCarves.push([x, y]) // re-applied if a step was in flight
this.#paintCells(this.#state)
this.#renderDamaged()
}
// only while paused: running, the loop is already about to repaint
#renderDamaged() {
if (!this.paused || this.#renderBusy || !this.#frame || !this.#state) return
this.#renderBusy = true
this.#frame.run({ state: this.#state, advance: HOLD }).then(r => {
this.#renderBusy = false
if (r.kind === "completed") this.#paintRender(r.output)
})
}
#paintRender(bytes: ImageData['data']) {
if (!this.#ctx) return
this.#ctx.putImageData(new ImageData(bytes, GH, GH), 0, 0)
}
// the cell grid stays planar CHW at 96², so un-premultiply on the host with strided reads
#paintCells(arr: Float32Array) {
if (!this.#cellsCtx) return
const d = this.#cellsImg!.data
for (let i = 0; i < GG; i++) {
const a = clamp01(arr[3 * GG + i]!)
const ia = a > 0.01 ? 1 / Math.max(a, 0.2) : 0
d[i * 4] = clamp01(arr[i]! * ia) * 255
d[i * 4 + 1] = clamp01(arr[GG + i]! * ia) * 255
d[i * 4 + 2] = clamp01(arr[2 * GG + i]! * ia) * 255
d[i * 4 + 3] = a * 255
}
this.#cellsCtx.putImageData(this.#cellsImg!, 0, 0)
}
#setStatus(s: string) { this.status = s; this.update() }
// terminal test hooks (`typebulb send`)
testPoke() {
if (!this.#state) { slog("poke ignored: no state"); return }
const before = aliveCount(this.#state)
carveDisc(this.#state, G >> 1, G >> 1, DAMAGE_R)
this.#pendingCarves.push([G >> 1, G >> 1])
slog(`poked center: alive ${before} -> ${aliveCount(this.#state)}`)
}
testStats() {
slog(`alive=${this.#state ? aliveCount(this.#state) : -1} paused=${this.paused}`)
}
view() {
return div({ class: "wrap" },
div({ class: "header" },
h1({ class: "title" }, "Regrow the Chameleon"),
p({ class: "cap" },
"Every cell runs the same tiny neural network, and from a single seed they cooperate to grow the ",
"chameleon, then regrow it when you scratch it. The twist: the cells never hold color, they hold a ",
"latent code that a shared decoder paints into a far higher-resolution image. It runs live on your ",
"GPU with the weights and methods ",
a({ class: "link", href: "https://cells2pixels.github.io/", target: "_blank", rel: "noopener" }, "“From Cells to Pixels”"),
".",
),
),
div({ class: "stage-box" },
canvas({ class: "grid", key: "grid", ariaLabel: "chameleon", onMounted: (el: Element) => this.boot(el as HTMLCanvasElement) }),
div({ class: "strip" },
div({ class: "thumb-box" },
canvas({ class: "thumb cells", key: "cells", ariaLabel: "cells", onMounted: (el: Element) => this.bootCells(el as HTMLCanvasElement) }),
span({ class: "thumb-label" }, `cells ${G}²`),
),
span({ class: "strip-arrow" }, "→"),
div({ class: "thumb-box" },
img({ class: "thumb", key: "target", src: TARGET_URL, alt: "chameleon target" }),
span({ class: "thumb-label" }, "target"),
),
),
),
div({ class: "dock" },
div({ class: "bar" },
button(
{ class: "icon-btn", title: this.paused ? "play" : "pause", onClick: () => this.togglePause() },
this.paused ? ICON.play() : ICON.pause(),
),
button({ class: "icon-btn", title: "regrow from a single seed cell", onClick: () => this.reseed() }, ICON.refresh()),
),
div({ class: "readout" }, this.status),
),
)
}
}
const root = new Root()
new App({ root, id: "app" })
tb.onMessage((m: unknown) => {
if (m === "poke") root.testPoke()
if (m === "stats") root.testStats()
})
```
**styles.css**
```css
/* The render canvas is transparent, so the organism composites onto --bg. */
:root {
color-scheme: light;
--font: ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, Menlo, Consolas, monospace;
--text: 0.9rem;
--accent: #ff7a18;
--bg: #eef1f6;
--fg: #1a1d24;
--muted: #5a6273;
--readout: #3a4250;
--panel: #ffffffcc;
--panel-border: #cdd3de;
--btn-bg: #ffffff;
--btn-fg-hover: #11141c;
--btn-border-hover: #aab2c0;
}
html[data-theme="dark"] {
color-scheme: dark;
--bg: #0b0d12;
--fg: #e7e9ee;
--muted: #aeb6c4;
--readout: #cdd3dd;
--panel: #11141ccc;
--panel-border: #2a2f3a;
--btn-bg: #1b1f2a;
--btn-fg-hover: #ffffff;
--btn-border-hover: #3a4250;
}
html { font-size: 106.25%; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font-family: var(--font); }
body { font-size: var(--text); }
#app { height: 100dvh; min-height: 560px; }
.wrap { height: 100%; display: flex; flex-direction: column; --gap: 12px; }
.header, .dock { display: flex; flex-direction: column; align-items: center; gap: var(--gap); padding: var(--gap) 18px; text-align: center; }
.header > *, .dock > * { margin: 0; }
.title { font-size: 1.5rem; font-weight: 680; letter-spacing: .04em; text-transform: uppercase; color: var(--fg); }
.cap { max-width: 720px; line-height: 1.5; color: var(--muted); }
.cap .link { color: var(--accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); }
.cap .link:hover { border-bottom-color: var(--accent); }
.stage-box { flex: 1 1 auto; min-height: 220px; display: flex; align-items: center; justify-content: center; gap: 18px; padding: 4px 18px; }
.grid {
flex: 0 1 auto;
min-height: 120px;
height: min(64vmin, 560px);
max-height: 100%;
aspect-ratio: 1;
touch-action: none;
cursor: crosshair;
}
.strip { flex: none; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; }
.strip-arrow { color: var(--muted); font-size: 1.05rem; transform: rotate(90deg); }
.thumb-box { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.thumb { width: 84px; aspect-ratio: 1; border: 1px solid var(--panel-border); border-radius: 6px; object-fit: contain; }
.thumb.cells { image-rendering: pixelated; }
/* the png is the tight 512² target; the paper centers it in a 768² frame (the 96²
lattice), so pad 1/6 per side to match the cells' framing */
img.thumb { box-sizing: border-box; padding: 16.667%; }
.thumb-label { font-family: var(--font-mono); font-size: 0.72rem; color: var(--muted); }
.dock { padding-bottom: calc(var(--gap) + 4px); }
.bar { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 8px; }
.icon-btn { cursor: pointer; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; color: var(--readout); background: var(--btn-bg); border: 1px solid var(--panel-border); border-radius: 50%; }
.icon-btn:hover { color: var(--btn-fg-hover); border-color: var(--btn-border-hover); }
.icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ico { width: 1.45em; height: 1.45em; flex: none; }
.readout { font-family: var(--font-mono); color: var(--readout); user-select: text; cursor: text; }
@media (max-width: 620px) {
/* min-height: auto restores the flex min-content floor, so a squeezed stage-box
scrolls instead of letting the strip overflow into the dock */
.stage-box { flex-direction: column; gap: 10px; min-height: auto; }
.strip { flex-direction: row; gap: 8px; }
.strip-arrow { transform: none; margin-bottom: 14px; }
.thumb { width: min(84px, 22vw); }
}
@media (max-height: 640px) {
.wrap { --gap: 8px; }
.cap { display: none; }
.title { font-size: 1.3rem; }
}
```
**index.html**
```html
<div id="app"></div>
```
**config.json**
```json
{
"description": "A neural cellular automaton grows a chameleon from one cell on your GPU, using weights from the paper “From Cells to Pixels”. Scratch it and it heals.",
"dependencies": {
"tensorgrad": "^0.4.9",
"domeleon": "^0.6.6"
}
}
```