---
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, isWebGPUAvailable, Conv2d, Linear, init,
  conv2d, maxPool2d, relu, sin, greater, where, mul, add,
  narrow, concat, reshape, permute, randn, ones, zeros,
  type Tensor, type CompiledForward,
} from "tensorgrad"

// ============================================================================
// One organism from "From Cells to Pixels" (Pajouheshgar et al., SIGGRAPH
// 2026), running the authors' own trained weights — zero training, zero local
// files. A neural cellular automaton evolves a 96×96 cell grid, and a
// jointly-trained SIREN field decodes each cell's 32-dim state + sub-cell
// coordinates into 384×384 RGBA: the chameleon. Weights (~280KB, base64
// float32 in JSON) and the target image load from assets/ — a local file in
// chameleon/assets/ (e.g. your own trained weights) shadows the hosted copy.
// Scratch it and it regrows. The Menagerie bulb is the 24-organism version.
// ============================================================================

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) }

// Fetch once from the network, thereafter from CacheStorage (offline-friendly);
// falls back to a plain fetch if the Cache API is unavailable.
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()
  }
}

// ============================================================================
// The model — same fields (and so same tensorgrad param names) as the other
// Cells-to-Pixels bulbs. Init is irrelevant: the imported weights are
// uploaded before any run.
// ============================================================================
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() })
}

// ============================================================================
// Weight importer — the authors' JSON layout → tensorgrad's param record.
//   nca.w1.weight [FC, 4C]: input columns are FEATURE-major (k·C + c, k over
//     identity/sobelX/sobelY/laplacian — the layout their GLSL shader wants);
//     our grouped perception conv emits CHANNEL-major (c·4 + k, the torch
//     training layout) — permute the columns back.
//   nca.w2.weight.T [FC, C]: shipped pre-transposed for the shader; our
//     Conv2d weight is [C, FC] — transpose.
//   lppn.net.*: torch Linear [out, in]; tensorgrad Linear is [in, out] (x@W)
//     — transpose all four.
// ============================================================================
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 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": lin("lppn.net.0.linear.weight", SIN, H), "s1.b": dec("lppn.net.0.linear.bias", H),
    "s2.W": lin("lppn.net.1.linear.weight", H, H), "s2.b": dec("lppn.net.1.linear.bias", H),
    "s3.W": lin("lppn.net.2.linear.weight", H, H), "s3.b": dec("lppn.net.2.linear.bias", H),
    "sOut.W": lin("lppn.net.3.weight", H, 4), "sOut.b": dec("lppn.net.3.bias", 4),
  }
}

// ============================================================================
// Graphs — the CA step and SIREN decode, verbatim from the reference:
// circular perception padding, Bernoulli-0.5 update mask, pre×post alive
// gating (the torch training semantics).
// ============================================================================
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, filters: Tensor): Tensor {
  const pre = alive(s)
  const z = conv2d(circularPad1(s), 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 +
// depth-to-space (ch=1 reuses it for the alpha)
function bilinearUp(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, 1, 4, 2, 5, 3]), [1, ch, GH, GH])
}

// the Cells2Pixels decoder, alive-gated the AUTHORS' renderer's way: the alpha
// channel is bilinearly upsampled to render res and maxpooled over 3×3 RENDER
// pixels (a smooth, sub-cell-accurate silhouette).
function decode(m: NCAModel, s: Tensor, bilin: Tensor, bilin1: Tensor, coords: Tensor): Tensor {
  const up = bilinearUp(s, bilin, C)
  const feats = concat([coords, up], 1)                          // [1, 4+C, GH, GH]
  const rows = reshape(permute(feats, [0, 2, 3, 1]), [GGH, SIN])
  let h = sin(mul(m.s1.fwd(rows), OMEGA))
  h = sin(mul(m.s2.fwd(h), OMEGA))
  h = sin(mul(m.s3.fwd(h), OMEGA))
  const img = permute(reshape(m.sOut.fwd(h), [1, GH, GH, 4]), [0, 3, 1, 2])
  const aUp = bilinearUp(narrow(s, 1, 3, 1), bilin1, 1)          // [1,1,GH,GH]
  const mask = greater(maxPool2d(aUp, 3, { stride: 1, padding: 1 }), 0.1)
  return mul(img, boolToF32(mask, GH))
}

function demoFn(m: NCAModel, { state, filters }: { state: Tensor; filters: Tensor }) {
  return ncaStep(m, state, filters)
}

function renderFn(m: NCAModel, { state, bilin, bilin1, coords }: { state: Tensor; bilin: Tensor; bilin1: Tensor; coords: Tensor }) {
  return decode(m, state, bilin, bilin1, coords)
}

// ============================================================================
// Host-side constants: perception filters, bilinear tent tables, coord
// features, seed.
// ============================================================================
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
})()

const W1D = [
  [0.375, 0.625, 0],
  [0.125, 0.875, 0],
  [0, 0.875, 0.125],
  [0, 0.625, 0.375],
]
const makeBilin = (ch: number) => {
  const a = new Float32Array(ch * S * S * 9)            // [ch·S², 1, 3, 3]
  for (let c = 0; c < ch; 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
}
const BILIN = makeBilin(C)
const BILIN1 = makeBilin(1)

// sub-cell coord features [sin(πy), sin(πx), cos(πy), cos(πx)] per render
// pixel — the reference's num_frequencies=1 encoding, periodic per 4×4 tile
const COORDS1 = (() => {
  const a = new Float32Array(4 * GGH)
  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
      const cy = f[y % S]! * Math.PI, cx = f[x % S]! * Math.PI
      a[i] = Math.sin(cy)
      a[GGH + i] = Math.sin(cx)
      a[2 * GGH + i] = Math.cos(cy)
      a[3 * GGH + i] = Math.cos(cx)
    }
  return a
})()

// 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
}

// ============================================================================
// Root — the whole app in one component: the two compiled forwards, the
// imported weights, the frame loop, and the view. No training machinery
// anywhere.
// ============================================================================
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
  #step: CompiledForward | null = null
  #render: CompiledForward | null = null
  #state: Float32Array | null = null
  #token = 0
  #canvas: HTMLCanvasElement | null = null
  #ctx: CanvasRenderingContext2D | null = null
  #img: ImageData | null = null
  #cellsCtx: CanvasRenderingContext2D | null = null
  #cellsImg: ImageData | null = null
  #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")!
    this.#img = new ImageData(GH, GH)
    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() {
    if (!isWebGPUAvailable()) {
      this.#setStatus("This needs WebGPU (recent Chrome, Edge, or Safari).")
      return
    }
    this.#setStatus("compiling WGSL kernels…")
    this.#step = await compileForward({
      model: new NCAModel(), forward: demoFn,
      inputs: { state: [1, C, G, G], filters: [4 * C, 1, 3, 3] },
    })
    this.#render = await compileForward({
      model: new NCAModel(), forward: renderFn,
      inputs: { state: [1, C, G, G], bilin: [C * S * S, 1, 3, 3], bilin1: [S * S, 1, 3, 3], coords: [1, 4, GH, GH] },
    })
    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.#step.uploadParams(params)
      await this.#render.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.#step!.run({ state: this.#state, filters: FILTERS })
        if (token !== this.#token) return
        if (r.kind === "completed") {
          const next = r.output as Float32Array
          for (const [cx, cy] of this.#pendingCarves) carveDisc(next, cx, cy, DAMAGE_R)
          this.#pendingCarves.length = 0
          this.#state = this.#stabilize(next) ? SEED.slice() : next
        }
        const rr = await this.#render!.run({ state: this.#state!, bilin: BILIN, bilin1: BILIN1, coords: COORDS1 })
        if (token !== this.#token) return
        if (rr.kind === "completed") {
          this.#paintRender(rr.output as Float32Array)
          this.#paintCells(this.#state!)
        }
        frames++
        if (frames % 300 === 0) slog(`t=${frames} alive=${aliveCount(this.#state!)}`)
      }
      await nextFrame()
    }
  }

  // demo-time stabilizers: clamp the state into [-1,1] — the box the
  // training-time overflow penalty (weight 100) kept it in, so this is a
  // near-no-op while healthy — and reseed on death or runaway growth.
  #stabilize(s: Float32Array): boolean {
    for (let i = 0; i < STATE_LEN; i++) {
      const v = s[i]!
      if (v > 1) s[i] = 1
      else if (v < -1) s[i] = -1
    }
    const n = aliveCount(s)
    return n === 0 || n > GG * 0.6
  }

  #damageAt(e: PointerEvent) {
    if (!this.#state) return
    const rect = this.#canvas!.getBoundingClientRect()
    const x = Math.floor((e.clientX - rect.left) / rect.width * G)
    const y = Math.floor((e.clientY - rect.top) / rect.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()
  }

  // paused pokes still repaint: one render pass on demand
  #renderDamaged() {
    if (this.#renderBusy || !this.#render || !this.#state) return
    this.#renderBusy = true
    this.#render.run({ state: this.#state, bilin: BILIN, bilin1: BILIN1, coords: COORDS1 }).then(r => {
      this.#renderBusy = false
      if (r.kind === "completed") this.#paintRender(r.output as Float32Array)
    })
  }

  #paintRender(arr: Float32Array) { if (this.#ctx) this.#blit(arr, this.#img!, this.#ctx, GGH) }
  #paintCells(arr: Float32Array) { if (this.#cellsCtx) this.#blit(arr, this.#cellsImg!, this.#cellsCtx, GG) }

  // premultiplied CHW RGBA → ImageData: un-premultiply RGB with capped gain,
  // straight alpha, composites onto the themed page background
  #blit(arr: Float32Array, img: ImageData, ctx: CanvasRenderingContext2D, n: number) {
    const d = img.data
    for (let i = 0; i < n; i++) {
      const a = clamp01(arr[3 * n + 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[n + i]! * ia) * 255
      d[i * 4 + 2] = clamp01(arr[2 * n + i]! * ia) * 255
      d[i * 4 + 3] = a * 255
    }
    ctx.putImageData(img, 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", onMounted: (el: Element) => this.boot(el as HTMLCanvasElement) }),
        div({ class: "strip" },
          div({ class: "thumb-box" },
            canvas({ class: "thumb cells", key: "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
/* Theme tokens — same scheme as the other tensorgrad bulbs. The render canvas
   is transparent, so the organism composites straight 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 hosted 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 keeps 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.2",
    "domeleon": "^0.6.0"
  }
}
```