Chameleon

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"
  }
}
```

Markdown source · More bulbs by samples · Typebulb home