{
  "id": "mg_portfolio_illustrator",
  "name": "Illustrator",
  "width": 1440,
  "height": 900,
  "cornerRadius": 0,
  "fit": "Layout",
  "backgroundColor": "#f4ecdf",
  "opacity": 1,
  "x": -720,
  "y": -450,
  "responsive": {
    "enabled": true,
    "mode": "constraint",
    "width": {
      "type": "fixed"
    },
    "height": {
      "type": "fixed"
    },
    "maintainAspectRatio": false,
    "scaleObjects": false,
    "breakpoints": [
      {
        "id": "bp_mobile",
        "name": "Mobile",
        "minWidth": 0,
        "maxWidth": 599
      },
      {
        "id": "bp_tablet",
        "name": "Tablet",
        "minWidth": 600,
        "maxWidth": 1023
      },
      {
        "id": "bp_desktop",
        "name": "Desktop",
        "minWidth": 1024
      }
    ]
  },
  "layoutProps": {
    "mode": "linear",
    "direction": "vertical",
    "gap": 0,
    "padding": {
      "top": 0,
      "right": 0,
      "bottom": 0,
      "left": 0
    },
    "alignItems": "stretch",
    "layoutSizingHorizontal": "fixed",
    "layoutSizingVertical": "fixed"
  },
  "seo": {
    "title": "Illustrator — Exode Motion Graphics",
    "description": "A botanical ink drawing that grows stroke by stroke over watercolour washes on warm paper. Click to grow a new plate."
  },
  "stateMachine": {
    "inputs": [
      {
        "id": "in_name",
        "name": "name",
        "value": {
          "type": "Text",
          "value": "Iris Moreau",
          "defaultValue": "Iris Moreau"
        }
      },
      {
        "id": "in_links",
        "name": "links",
        "value": {
          "type": "Text",
          "value": "Portfolio,Prints,Commissions,Contact",
          "defaultValue": "Portfolio,Prints,Commissions,Contact"
        }
      },
      {
        "id": "in_role",
        "name": "role",
        "value": {
          "type": "Text",
          "value": "Illustrator & printmaker. Botanical drawings for books, packaging and walls.",
          "defaultValue": "Illustrator & printmaker. Botanical drawings for books, packaging and walls."
        }
      },
      {
        "id": "in_speed",
        "name": "speed",
        "value": {
          "type": "Number",
          "value": 1,
          "defaultValue": 1
        }
      },
      {
        "id": "in_accent",
        "name": "accent",
        "value": {
          "type": "Text",
          "value": "#c8553d",
          "defaultValue": "#c8553d"
        }
      },
      {
        "id": "in_accent2",
        "name": "accent2",
        "value": {
          "type": "Text",
          "value": "#6b8f71",
          "defaultValue": "#6b8f71"
        }
      },
      {
        "id": "in_bg",
        "name": "bg",
        "value": {
          "type": "Text",
          "value": "#f4ecdf",
          "defaultValue": "#f4ecdf"
        }
      },
      {
        "id": "in_text",
        "name": "text",
        "value": {
          "type": "Text",
          "value": "#1d1a16",
          "defaultValue": "#1d1a16"
        }
      }
    ],
    "layers": [
      {
        "name": "Base",
        "entryStateId": "s_idle",
        "states": [
          {
            "id": "s_idle",
            "name": "Idle",
            "loop": true,
            "transitions": []
          }
        ]
      }
    ],
    "logicNodes": []
  },
  "animations": [],
  "physics": {
    "enabled": false,
    "gravity": {
      "x": 0,
      "y": 9.8
    }
  },
  "animationFlow": [],
  "navigation": [],
  "stateVariables": [],
  "objects": [
    {
      "id": "stage",
      "name": "Stage",
      "type": "Group",
      "transform": {
        "x": 0,
        "y": 0,
        "rotation": 0,
        "scale_x": 1,
        "scale_y": 1,
        "opacity": 1,
        "skew_x": 0,
        "skew_y": 0
      },
      "width": 1440,
      "height": 900,
      "geometry": {
        "type": "Rectangle",
        "width": 1440,
        "height": 900,
        "cornerRadius": 0
      },
      "cornerRadius": 0,
      "children": [],
      "clipContent": true,
      "style": {
        "fill": {
          "type": "Solid",
          "color": "#f4ecdf",
          "opacity": 1
        },
        "effects": [
          {
            "id": "fx_portfolio-illustrator",
            "type": "CustomEffect",
            "name": "Illustrator",
            "isEnabled": true,
            "params": {},
            "code": "/* Shared by every motion-ui effect (prepended to each one).\n   Rules for this file and the effects: the .bexode compiler minifies effect\n   code, so use explicit semicolons, block comments only, and never write a\n   minus followed by a negative value (it collapses to a decrement). */\n\n/* Read a live state-machine input, falling back to a default. */\nfunction inp(env, name, fallback) {\n  const v = env.inputs ? env.inputs[name] : undefined;\n  if (v === undefined || v === null || v === '') return fallback;\n  if (typeof fallback === 'number') {\n    const n = Number(v);\n    return isFinite(n) ? n : fallback;\n  }\n  if (typeof fallback === 'boolean') return v === true || v === 1 || v === 'true';\n  return v;\n}\n\n/* '#rgb' / '#rrggbb' to [r, g, b]; anything else falls back to white. */\nfunction rgbOf(hex) {\n  let h = String(hex || '').trim().replace('#', '');\n  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];\n  if (!/^[0-9a-fA-F]{6}$/.test(h)) return [255, 255, 255];\n  const n = parseInt(h, 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction rgba(hex, a) {\n  const c = rgbOf(hex);\n  return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + Math.max(0, Math.min(1, a)) + ')';\n}\n\nfunction mix(a, b, t) {\n  const x = rgbOf(a), y = rgbOf(b);\n  const r = Math.round(x[0] + (y[0] - x[0]) * t);\n  const g = Math.round(x[1] + (y[1] - x[1]) * t);\n  const bl = Math.round(x[2] + (y[2] - x[2]) * t);\n  return '#' + ((1 << 24) + (r << 16) + (g << 8) + bl).toString(16).slice(1);\n}\n\n/* A soft round glow, rendered once per colour and blitted with drawImage\n   (much cheaper than shadowBlur on every particle). */\nconst __glows = {};\nfunction glowSprite(hex) {\n  if (__glows[hex]) return __glows[hex];\n  const s = 64;\n  const c = document.createElement('canvas');\n  c.width = s; c.height = s;\n  const g = c.getContext('2d');\n  const gr = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);\n  gr.addColorStop(0, rgba(hex, 1));\n  gr.addColorStop(0.18, rgba(hex, 0.55));\n  gr.addColorStop(0.45, rgba(hex, 0.14));\n  gr.addColorStop(1, rgba(hex, 0));\n  g.fillStyle = gr;\n  g.fillRect(0, 0, s, s);\n  __glows[hex] = c;\n  return c;\n}\n\nfunction glow(ctx, hex, x, y, r, a) {\n  if (r <= 0 || a <= 0) return;\n  ctx.globalAlpha = Math.min(1, a);\n  ctx.drawImage(glowSprite(hex), x - r, y - r, r * 2, r * 2);\n  ctx.globalAlpha = 1;\n}\n\n/* Frame clock: seconds since start and a clamped delta. */\nfunction tick(S) {\n  const now = performance.now();\n  if (!S.t0) { S.t0 = now; S.last = now; }\n  S.dt = Math.min(0.05, Math.max(0.001, (now - S.last) / 1000));\n  S.last = now;\n  S.age = (now - S.t0) / 1000;\n  return S.dt;\n}\n\n/* Deterministic pseudo-random so layouts are stable across resizes. */\nfunction rng(seed) {\n  let s = seed >>> 0 || 1;\n  return function () {\n    s = (s * 1664525 + 1013904223) >>> 0;\n    return s / 4294967296;\n  };\n}\n\nfunction ease(x) { return x < 0.5 ? 4 * x * x * x : 1 + (Math.pow(2 * x - 2, 3)) / 2; }\nfunction clamp(v, a, b) { return v < a ? a : v > b ? b : v; }\n\nconst SANS = 'Inter,\\u0020Helvetica,\\u0020Arial,\\u0020sans-serif';\nconst MONO = 'ui-monospace,\\u0020Menlo,\\u0020monospace';\n\nfunction roundRect(ctx, x, y, w, h, r) {\n  r = Math.min(r, w / 2, h / 2);\n  ctx.beginPath();\n  ctx.moveTo(x + r, y);\n  ctx.arcTo(x + w, y, x + w, y + h, r);\n  ctx.arcTo(x + w, y + h, x, y + h, r);\n  ctx.arcTo(x, y + h, x, y, r);\n  ctx.arcTo(x, y, x + w, y, r);\n  ctx.closePath();\n}\n\n/* Shared backdrop: flat colour, soft vignette and fine grain. */\nfunction backdrop(ctx, W, H, bg, tint, amt) {\n  ctx.fillStyle = bg;\n  ctx.fillRect(0, 0, W, H);\n  if (tint && amt > 0) {\n    const gr = ctx.createRadialGradient(W / 2, H * 0.45, 0, W / 2, H * 0.45, Math.max(W, H) * 0.7);\n    gr.addColorStop(0, rgba(tint, 0.10 * amt));\n    gr.addColorStop(1, rgba(tint, 0));\n    ctx.fillStyle = gr;\n    ctx.fillRect(0, 0, W, H);\n  }\n  const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.3, W / 2, H / 2, Math.max(W, H) * 0.8);\n  vg.addColorStop(0, 'rgba(0,0,0,0)');\n  vg.addColorStop(1, 'rgba(0,0,0,0.45)');\n  ctx.fillStyle = vg;\n  ctx.fillRect(0, 0, W, H);\n}\n\n/* Greedy word wrap with the current ctx.font. */\nfunction wrap(ctx, text, maxW) {\n  const out = [];\n  String(text).split('\\n').forEach(function (para) {\n    const words = para.split('\\u0020');\n    let cur = '';\n    for (let i = 0; i < words.length; i++) {\n      const next = cur ? cur + '\\u0020' + words[i] : words[i];\n      if (cur && ctx.measureText(next).width > maxW) { out.push(cur); cur = words[i]; } else cur = next;\n    }\n    out.push(cur);\n  });\n  return out;\n}\n\n/* Largest font size (up to max) at which text wraps into maxLines within maxW. */\nfunction fitFont(ctx, text, weight, family, maxW, maxLines, max, min) {\n  let fs = max;\n  while (fs > min) {\n    ctx.font = weight + '\\u0020' + fs + 'px\\u0020' + family;\n    if (wrap(ctx, text, maxW).length <= maxLines) break;\n    fs = fs * 0.92;\n  }\n  ctx.font = weight + '\\u0020' + fs + 'px\\u0020' + family;\n  return fs;\n}\n\n/* Smoothed pointer that idles along a slow figure-eight when the cursor is away. */\nfunction follow(S, env, W, H, k) {\n  const pt = env.pointer || {};\n  const t = S.age || 0;\n  const tx = pt.isHovered ? pt.localX : W / 2 + Math.sin(t * 0.37) * W * 0.3;\n  const ty = pt.isHovered ? pt.localY : H / 2 + Math.sin(t * 0.74) * H * 0.22;\n  if (S.px === undefined) { S.px = tx; S.py = ty; }\n  S.px += (tx - S.px) * Math.min(1, S.dt * (k || 6));\n  S.py += (ty - S.py) * Math.min(1, S.dt * (k || 6));\n  S.inside = !!pt.isHovered;\n  return pt;\n}\n\n/* Optional centred headline over a background effect. */\nfunction title(ctx, W, H, text, sub, ink, accent, bg) {\n  if (!text) return;\n  if (bg) {\n    /* Soft scrim so the headline reads over busy motion. */\n    const sc = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.min(W * 0.5, 560));\n    sc.addColorStop(0, rgba(bg, 0.72));\n    sc.addColorStop(1, rgba(bg, 0));\n    ctx.fillStyle = sc;\n    ctx.fillRect(0, 0, W, H);\n  }\n  ctx.textAlign = 'center';\n  ctx.textBaseline = 'alphabetic';\n  const maxW = Math.min(W * 0.86, 900);\n  const fs = fitFont(ctx, text, 600, SANS, maxW, 2, clamp(W * 0.065, 26, 72), 16);\n  const lines = wrap(ctx, text, maxW);\n  const lh = fs * 1.08;\n  const y0 = H / 2 - (lines.length * lh) / 2 + fs * 0.8 - (sub ? fs * 0.35 : 0);\n  ctx.fillStyle = ink;\n  lines.forEach(function (l, i) { ctx.fillText(l, W / 2, y0 + i * lh); });\n  if (sub) {\n    ctx.font = '500\\u0020' + clamp(fs * 0.24, 11, 15) + 'px\\u0020' + MONO;\n    ctx.fillStyle = rgba(accent, 0.85);\n    ctx.fillText(String(sub).toUpperCase(), W / 2, y0 + (lines.length - 1) * lh + fs * 0.9);\n  }\n}\n\nconst PIECE = { n: '37', total: '57' };\n/* Shared by every motion-graphics piece (prepended after motion-ui/_kit.js).\n   Same minifier rules as the kit: explicit semicolons, block comments only,\n   never a minus followed by a negative value. */\n\nconst SERIF = 'Georgia,\\u0020serif';\n\n/* Value noise and fractal noise, deterministic. */\nconst NZP = (function () {\n  const r = rng(1337);\n  const p = [];\n  for (let i = 0; i < 512; i++) p.push(Math.floor(r() * 256));\n  return p;\n})();\nfunction hash2(x, y) { return NZP[(x & 255) + NZP[y & 255]] / 255; }\nfunction noise2(x, y) {\n  const xi = Math.floor(x), yi = Math.floor(y);\n  const xf = x - xi, yf = y - yi;\n  const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);\n  const a = hash2(xi, yi), b = hash2(xi + 1, yi), c = hash2(xi, yi + 1), d = hash2(xi + 1, yi + 1);\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\n}\nfunction fbm(x, y) {\n  return noise2(x, y) * 0.55 + noise2(x * 2.03 + 17.1, y * 2.03 + 3.7) * 0.3 + noise2(x * 4.1 + 5.3, y * 4.1 + 41.9) * 0.15;\n}\n\n/* Film grain: one noise tile, re-offset every frame. */\nlet GRAIN = null;\nfunction grain(ctx, W, H, a) {\n  if (a <= 0) return;\n  if (!GRAIN) {\n    const c = document.createElement('canvas');\n    c.width = 160; c.height = 160;\n    const g = c.getContext('2d');\n    const im = g.createImageData(160, 160);\n    for (let i = 0; i < im.data.length; i += 4) {\n      const v = Math.random() * 255;\n      im.data[i] = v; im.data[i + 1] = v; im.data[i + 2] = v; im.data[i + 3] = 255;\n    }\n    g.putImageData(im, 0, 0);\n    GRAIN = ctx.createPattern(c, 'repeat');\n  }\n  const ox = Math.floor(Math.random() * 160), oy = Math.floor(Math.random() * 160);\n  ctx.save();\n  ctx.globalAlpha = a;\n  ctx.globalCompositeOperation = 'overlay';\n  ctx.translate(ox, oy);\n  ctx.fillStyle = GRAIN;\n  ctx.fillRect(0 - ox, 0 - oy, W, H);\n  ctx.restore();\n}\n\n/* Soft edge vignette. */\nfunction vignette(ctx, W, H, a) {\n  const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.75);\n  vg.addColorStop(0, 'rgba(0,0,0,0)');\n  vg.addColorStop(1, 'rgba(0,0,0,' + a + ')');\n  ctx.fillStyle = vg;\n  ctx.fillRect(0, 0, W, H);\n}\n\n/* Portfolio chrome: four mono captions and hairline registration marks. */\nfunction chrome(ctx, W, H, ink, name, caption) {\n  const s = Math.min(W, H);\n  const pad = clamp(s * 0.05, 14, 38);\n  const fs = clamp(s * 0.017, 9, 11);\n  ctx.save();\n  ctx.font = '500\\u0020' + fs + 'px\\u0020' + MONO;\n  ctx.textBaseline = 'middle';\n  ctx.fillStyle = rgba(ink, 0.62);\n  ctx.textAlign = 'left';\n  ctx.fillText(String(name).toUpperCase(), pad, pad);\n  ctx.textAlign = 'right';\n  ctx.fillText(PIECE.n + '\\u0020/\\u0020' + PIECE.total, W - pad, pad);\n  ctx.fillStyle = rgba(ink, 0.4);\n  ctx.textAlign = 'left';\n  if (W > 520) ctx.fillText(String(caption).toUpperCase(), pad, H - pad);\n  ctx.textAlign = 'right';\n  ctx.fillText('EXODE MOTION · 2026', W - pad, H - pad);\n  ctx.strokeStyle = rgba(ink, 0.22);\n  ctx.lineWidth = 1;\n  const m = pad * 0.45, k = 7;\n  ctx.beginPath();\n  ctx.moveTo(m, m + k); ctx.lineTo(m, m); ctx.lineTo(m + k, m);\n  ctx.moveTo(W - m - k, m); ctx.lineTo(W - m, m); ctx.lineTo(W - m, m + k);\n  ctx.moveTo(m, H - m - k); ctx.lineTo(m, H - m); ctx.lineTo(m + k, H - m);\n  ctx.moveTo(W - m - k, H - m); ctx.lineTo(W - m, H - m); ctx.lineTo(W - m, H - m - k);\n  ctx.stroke();\n  ctx.restore();\n}\n\n/* Spaced display text (letterSpacing where supported). */\nfunction spaced(ctx, text, x, y, em) {\n  ctx.letterSpacing = em + 'em';\n  ctx.fillText(text, x, y);\n  ctx.letterSpacing = '0px';\n}\n\n/* Backing-store scale of the canvas, for offscreen buffers (max 2). */\nfunction dprOf(ctx) {\n  const m = ctx.getTransform ? ctx.getTransform() : null;\n  return m ? clamp(Math.sqrt(m.a * m.a + m.b * m.b), 1, 2) : 1;\n}\n\n/* One pointer press per click. */\nfunction pressed(S, pt) {\n  const p = !!pt.isDown && !S.wasDown && !!pt.isHovered;\n  S.wasDown = !!pt.isDown;\n  return p;\n}\n\n/* ---------- Product launch helpers ---------- */\n\n/* Where the product sits: right of the copy on wide frames, above it on tall ones. */\nfunction productStage(W, H) {\n  const land = W > H * 1.05;\n  return land\n    ? { land: true, cx: W * 0.67, cy: H * 0.52, size: Math.min(W * 0.5, H * 0.78) }\n    : { land: false, cx: W / 2, cy: H * 0.32, size: Math.min(W * 0.88, H * 0.46) };\n}\n\n/* Studio sweep: spotlight behind the product and a soft floor. */\nfunction studio(ctx, W, H, bg, tint, st) {\n  ctx.fillStyle = bg;\n  ctx.fillRect(0, 0, W, H);\n  const r = st.size * 1.1;\n  const g = ctx.createRadialGradient(st.cx, st.cy, 0, st.cx, st.cy, r);\n  g.addColorStop(0, rgba(tint, 0.22));\n  g.addColorStop(0.5, rgba(tint, 0.06));\n  g.addColorStop(1, rgba(tint, 0));\n  ctx.fillStyle = g;\n  ctx.fillRect(0, 0, W, H);\n  const fy = st.cy + st.size * 0.42;\n  const fl = ctx.createLinearGradient(0, fy, 0, H);\n  fl.addColorStop(0, rgba(tint, 0.05));\n  fl.addColorStop(1, rgba(tint, 0));\n  ctx.fillStyle = fl;\n  ctx.fillRect(0, fy, W, H - fy);\n}\n\n/* Soft contact shadow. */\nfunction floorShadow(ctx, x, y, w, a) {\n  ctx.save();\n  ctx.translate(x, y);\n  ctx.scale(1, 0.16);\n  const g = ctx.createRadialGradient(0, 0, 0, 0, 0, w);\n  g.addColorStop(0, 'rgba(0,0,0,' + a + ')');\n  g.addColorStop(1, 'rgba(0,0,0,0)');\n  ctx.fillStyle = g;\n  ctx.fillRect(0 - w, 0 - w, w * 2, w * 2);\n  ctx.restore();\n}\n\n/* Launch copy block: eyebrow, name, tagline, three specs and a CTA pill.\n   Staggers in with `appear` (seconds since start) and records CTA hover on S. */\nfunction productCopy(ctx, W, H, o, S, env) {\n  const s = Math.min(W, H);\n  const land = W > H * 1.05;\n  const pad = clamp(s * 0.09, 20, 96);\n  const cw = land ? Math.min(W * 0.34, 500) : W - pad * 2;\n  /* Small frames (cards, thumbnails) drop the specs and shorten the tagline. */\n  const compact = cw < 220 || H < 300;\n  const fe = clamp(s * 0.016, 7, 12);\n  const nameFs = fitFont(ctx, o.name, 600, SANS, cw, 2, clamp(s * 0.085, 16, 78), 12);\n  const nl = wrap(ctx, o.name, cw);\n  const ft = clamp(s * 0.024, 9, 18);\n  ctx.font = '400\\u0020' + ft + 'px\\u0020' + SANS;\n  const tl = wrap(ctx, o.tagline, cw).slice(0, compact ? 2 : 3);\n  const fv = clamp(s * 0.034, 12, 26);\n  const specs = compact ? [] : (o.specs || []).slice(0, 3);\n  const ch = fe * 3.6;\n  const total = fe * 2.6 + nl.length * nameFs * 1.02 + ft * 0.9 + tl.length * ft * 1.5 + (specs.length ? fv * 3.4 : 0) + ch + fe;\n  let y = land ? H / 2 - total / 2 : H - pad - total;\n  const x = pad;\n  const reveal = function (i) {\n    const k = ease(clamp(((o.appear || 9) - 0.15 - i * 0.12) / 0.7, 0, 1));\n    ctx.globalAlpha = k;\n    return (1 - k) * 18;\n  };\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'alphabetic';\n\n  let d = reveal(0);\n  ctx.font = '600\\u0020' + fe + 'px\\u0020' + MONO;\n  ctx.fillStyle = o.accent;\n  spaced(ctx, String(o.eyebrow).toUpperCase(), x, y + fe + d, 0.22);\n  y += fe * 2.6;\n\n  d = reveal(1);\n  ctx.font = '600\\u0020' + nameFs + 'px\\u0020' + SANS;\n  ctx.fillStyle = o.ink;\n  ctx.letterSpacing = '-0.025em';\n  for (let i = 0; i < nl.length; i++) ctx.fillText(nl[i], x, y + nameFs * 0.86 + i * nameFs * 1.02 + d);\n  ctx.letterSpacing = '0px';\n  y += nl.length * nameFs * 1.02 + ft * 0.9;\n\n  d = reveal(2);\n  ctx.font = '400\\u0020' + ft + 'px\\u0020' + SANS;\n  ctx.fillStyle = rgba(o.ink, 0.62);\n  for (let i = 0; i < tl.length; i++) ctx.fillText(tl[i], x, y + ft + i * ft * 1.5 + d);\n  y += tl.length * ft * 1.5;\n\n  if (specs.length) {\n    d = reveal(3);\n    y += fv * 0.9;\n    ctx.fillStyle = rgba(o.ink, 0.14);\n    ctx.fillRect(x, y + d, cw, 1);\n    const colw = cw / 3;\n    for (let i = 0; i < specs.length; i++) {\n      ctx.font = '600\\u0020' + fv + 'px\\u0020' + SANS;\n      ctx.fillStyle = o.ink;\n      ctx.fillText(String(specs[i][0]), x + i * colw, y + fv * 1.5 + d);\n      ctx.font = '500\\u0020' + fe * 0.9 + 'px\\u0020' + MONO;\n      ctx.fillStyle = rgba(o.ink, 0.45);\n      ctx.fillText(String(specs[i][1]).toUpperCase(), x + i * colw, y + fv * 1.5 + fe * 1.7 + d);\n    }\n    y += fv * 2.5;\n  }\n\n  d = reveal(4);\n  ctx.font = '600\\u0020' + fe * 1.2 + 'px\\u0020' + SANS;\n  const label = String(o.cta);\n  const bw = ctx.measureText(label).width + fe * 4;\n  const by = y + d;\n  const pt = env.pointer || {};\n  const hot = !!pt.isHovered && pt.localX >= x && pt.localX <= x + bw && pt.localY >= by && pt.localY <= by + ch;\n  S.ctaHot = hot;\n  S.ctaK = (S.ctaK || 0) + ((hot ? 1 : 0) - (S.ctaK || 0)) * 0.2;\n  ctx.fillStyle = mix(o.accent, '#ffffff', S.ctaK * 0.18);\n  roundRect(ctx, x, by, bw, ch, ch / 2);\n  ctx.fill();\n  ctx.fillStyle = o.ctaInk;\n  ctx.textBaseline = 'middle';\n  ctx.fillText(label, x + fe * 2, by + ch / 2 + 1);\n  ctx.font = '500\\u0020' + fe * 1.1 + 'px\\u0020' + SANS;\n  ctx.fillStyle = rgba(o.ink, 0.7);\n  if (cw > bw + 120) ctx.fillText('Learn more\\u0020\\u0020→', x + bw + fe * 2, by + ch / 2 + 1);\n  ctx.textBaseline = 'alphabetic';\n  ctx.globalAlpha = 1;\n}\n\n/* Generative \"photographs\" for the portfolio templates. Each scene is painted\n   once into an offscreen canvas (cached by kind, size and seed); templates\n   then move, crop and cross-fade them. Same minifier rules as the kit. */\n\nconst PHOTO_CACHE = {};\nlet PHOTO_KEYS = 0;\nconst PHOTO_META = {\n  mountains: ['Vestrahorn,\\u002005:12', 'f/8 · 1/125 · ISO 64 · 35 mm'],\n  sea: ['Atlantic,\\u0020last light', 'f/5.6 · 1/500 · ISO 100 · 50 mm'],\n  dunes: ['Erg Chebbi,\\u0020noon', 'f/11 · 1/1000 · ISO 100 · 85 mm'],\n  city: ['Shinjuku,\\u002002:40', 'f/1.4 · 1/60 · ISO 3200 · 35 mm'],\n  forest: ['Black Forest,\\u0020fog', 'f/4 · 1/80 · ISO 400 · 70 mm'],\n  aurora: ['Tromsø,\\u002023:58', 'f/2 · 8 s · ISO 1600 · 14 mm'],\n  moon: ['Atacama,\\u0020moonrise', 'f/8 · 1/250 · ISO 200 · 600 mm'],\n  lake: ['Moraine Lake,\\u0020dawn', 'f/11 · 1/30 · ISO 64 · 24 mm'],\n};\n\nfunction skyGrad(g, w, h, stops) {\n  const gr = g.createLinearGradient(0, 0, 0, h);\n  for (let i = 0; i < stops.length; i++) gr.addColorStop(stops[i][0], stops[i][1]);\n  g.fillStyle = gr;\n  g.fillRect(0, 0, w, h);\n}\n\nfunction ridge(g, w, h, base, amp, rough, seed, fill) {\n  g.fillStyle = fill;\n  g.beginPath();\n  g.moveTo(0, h);\n  for (let x = 0; x <= w + 4; x += 4) {\n    const u = x / w;\n    const v = fbm(u * rough + seed, seed * 0.37) * 1.2 + Math.abs(Math.sin(u * rough * 0.9 + seed)) * 0.25;\n    g.lineTo(x, base - v * amp);\n  }\n  g.lineTo(w, h);\n  g.closePath();\n  g.fill();\n}\n\nfunction haze(g, w, y0, y1, col, a) {\n  const gr = g.createLinearGradient(0, y0, 0, y1);\n  gr.addColorStop(0, rgba(col, 0));\n  gr.addColorStop(0.6, rgba(col, a));\n  gr.addColorStop(1, rgba(col, 0));\n  g.fillStyle = gr;\n  g.fillRect(0, y0, w, y1 - y0);\n}\n\nfunction sunGlow(g, x, y, r, col, a) {\n  const gr = g.createRadialGradient(x, y, 0, x, y, r);\n  gr.addColorStop(0, rgba('#ffffff', a));\n  gr.addColorStop(0.08, rgba(col, a * 0.9));\n  gr.addColorStop(0.4, rgba(col, a * 0.25));\n  gr.addColorStop(1, rgba(col, 0));\n  g.fillStyle = gr;\n  g.fillRect(x - r, y - r, r * 2, r * 2);\n}\n\nfunction pines(g, w, base, hgt, dens, seed, fill) {\n  const r = rng(seed);\n  g.fillStyle = fill;\n  g.fillRect(0, base, w, 4000);\n  let x = 0 - 20;\n  while (x < w + 20) {\n    const th = hgt * (0.55 + r() * 0.6), tw = th * (0.22 + r() * 0.08);\n    g.beginPath();\n    g.moveTo(x, base - th);\n    const tiers = 6;\n    for (let i = 1; i <= tiers; i++) {\n      const y = base - th + (th / tiers) * i;\n      g.lineTo(x + tw * (i / tiers) * 1.1, y);\n      if (i < tiers) g.lineTo(x + tw * (i / tiers) * 0.55, y);\n    }\n    for (let i = tiers; i >= 1; i--) {\n      const y = base - th + (th / tiers) * i;\n      if (i < tiers) g.lineTo(x - tw * (i / tiers) * 0.55, y);\n      g.lineTo(x - tw * (i / tiers) * 1.1, y);\n    }\n    g.closePath();\n    g.fill();\n    x += tw * (0.9 + r() * dens);\n  }\n}\n\nconst PAINTERS = {\n  mountains: function (g, w, h, r) {\n    skyGrad(g, w, h, [[0, '#26324d'], [0.38, '#8f7f95'], [0.62, '#e7a98a'], [0.75, '#f6d6a8']]);\n    sunGlow(g, w * 0.66, h * 0.6, h * 0.9, '#ffd8a0', 0.75);\n    const cols = ['#b7a3b2', '#8d7f99', '#5f5a78', '#3a3a55', '#1d1f30'];\n    for (let i = 0; i < 5; i++) {\n      ridge(g, w, h, h * (0.6 + i * 0.09), h * (0.34 - i * 0.04), 3 + i * 1.3, r() * 50 + i * 9, cols[i]);\n      if (i < 4) haze(g, w, h * (0.52 + i * 0.09), h * (0.7 + i * 0.09), '#f3d2b5', 0.35 - i * 0.05);\n    }\n  },\n  sea: function (g, w, h, r) {\n    const hz = h * 0.6;\n    skyGrad(g, w, hz, [[0, '#1c2a4e'], [0.55, '#9c5a6e'], [0.85, '#ea8b5e'], [1, '#f7c98a']]);\n    sunGlow(g, w * 0.5, hz, h * 0.8, '#ffb070', 0.9);\n    g.fillStyle = '#ffe3b0';\n    g.beginPath(); g.arc(w * 0.5, hz + h * 0.01, h * 0.045, Math.PI, 0); g.fill();\n    const sea = g.createLinearGradient(0, hz, 0, h);\n    sea.addColorStop(0, '#5b4058'); sea.addColorStop(0.3, '#2e2a44'); sea.addColorStop(1, '#0f1222');\n    g.fillStyle = sea;\n    g.fillRect(0, hz, w, h - hz);\n    for (let i = 0; i < 900; i++) {\n      const y = hz + Math.pow(r(), 1.6) * (h - hz);\n      const d = (y - hz) / (h - hz);\n      const spread = w * (0.04 + d * 0.22);\n      const x = w * 0.5 + (r() - 0.5) * 2 * spread * Math.pow(r(), 0.7);\n      const len = 2 + r() * 14 * (0.3 + d);\n      g.fillStyle = rgba(r() < 0.6 ? '#ffd39a' : '#ff9a6a', (1 - d) * 0.7 * r());\n      g.fillRect(x - len / 2, y, len, 1 + d);\n    }\n    for (let i = 0; i < 60; i++) {\n      const y = hz + r() * (h - hz);\n      g.fillStyle = 'rgba(255,255,255,0.03)';\n      g.fillRect(0, y, w, 1);\n    }\n  },\n  dunes: function (g, w, h, r) {\n    skyGrad(g, w, h, [[0, '#9fc3d8'], [0.4, '#e9dcc8'], [0.55, '#f2d3ae']]);\n    const layers = [[0.5, 0.05, '#e7b37f', '#b56b3c'], [0.62, 0.07, '#e09a5e', '#8e4a26'], [0.76, 0.09, '#d27f45', '#6e3418'], [0.92, 0.1, '#c46a33', '#55260f']];\n    for (let L = 0; L < layers.length; L++) {\n      const ly = layers[L];\n      const f = 2 + L * 1.4 + r(), p = r() * 6;\n      const ys = [];\n      for (let x = 0; x <= w; x += 2) {\n        const u = x / w;\n        ys.push(h * ly[0] - h * ly[1] * (Math.sin(u * f * 3.1 + p) * 0.6 + fbm(u * 3 + L * 7, L) * 0.8));\n      }\n      g.fillStyle = ly[2];\n      g.beginPath();\n      g.moveTo(0, h);\n      for (let i = 0; i < ys.length; i++) g.lineTo(i * 2, ys[i]);\n      g.lineTo(w, h);\n      g.closePath();\n      g.fill();\n      /* Shadowed faces where the crest falls to the right. */\n      for (let i = 1; i < ys.length; i++) {\n        const slope = ys[i] - ys[i - 1];\n        if (slope > 0.05) {\n          g.fillStyle = rgba(ly[3], clamp(slope * 0.6, 0, 0.85));\n          g.fillRect(i * 2, ys[i], 2, h * 0.09);\n        }\n      }\n    }\n  },\n  city: function (g, w, h, r) {\n    skyGrad(g, w, h, [[0, '#070b1e'], [0.5, '#1b1838'], [0.72, '#4a2346']]);\n    haze(g, w, h * 0.45, h * 0.8, '#ff5f8a', 0.25);\n    const rows = [[0.72, '#151834', 0.35, 0.08], [0.86, '#0c0e22', 0.55, 0.14]];\n    for (let k = 0; k < rows.length; k++) {\n      let x = 0;\n      while (x < w) {\n        const bw = w * (0.03 + r() * rows[k][3]);\n        const bh = h * (0.15 + r() * rows[k][2]);\n        const top = h * rows[k][0] - bh;\n        g.fillStyle = rows[k][1];\n        g.fillRect(x, top, bw - 2, h);\n        for (let wy = top + 6; wy < h; wy += 7) {\n          for (let wx = x + 4; wx < x + bw - 6; wx += 6) {\n            if (r() < 0.32) { g.fillStyle = rgba(r() < 0.8 ? '#ffd28a' : '#9ad7ff', 0.35 + r() * 0.5); g.fillRect(wx, wy, 2.5, 3); }\n          }\n        }\n        x += bw;\n      }\n    }\n    g.globalCompositeOperation = 'lighter';\n    for (let i = 0; i < 70; i++) {\n      const x = r() * w, y = h * (0.78 + r() * 0.22), rr = h * (0.015 + r() * 0.05);\n      const c = ['#ff6a3d', '#ffd166', '#ff3d7f', '#7ad7ff'][Math.floor(r() * 4)];\n      const gr = g.createRadialGradient(x, y, 0, x, y, rr);\n      gr.addColorStop(0, rgba(c, 0.35)); gr.addColorStop(0.8, rgba(c, 0.22)); gr.addColorStop(1, rgba(c, 0));\n      g.fillStyle = gr;\n      g.beginPath(); g.arc(x, y, rr, 0, Math.PI * 2); g.fill();\n    }\n    g.globalCompositeOperation = 'source-over';\n  },\n  forest: function (g, w, h, r) {\n    skyGrad(g, w, h, [[0, '#c9d2cf'], [0.6, '#aebcb7'], [1, '#7f918b']]);\n    sunGlow(g, w * 0.3, h * 0.2, h * 0.8, '#fff6dc', 0.5);\n    const cols = ['#9fb0aa', '#7d918b', '#56685f', '#2d3a33', '#141c18'];\n    for (let i = 0; i < 5; i++) {\n      pines(g, w, h * (0.55 + i * 0.1), h * (0.25 + i * 0.08), 0.9 - i * 0.12, 30 + i * 13 + Math.floor(r() * 9), cols[i]);\n      if (i < 4) haze(g, w, h * (0.42 + i * 0.1), h * (0.7 + i * 0.1), '#dfe6e2', 0.5 - i * 0.08);\n    }\n    g.globalCompositeOperation = 'lighter';\n    for (let i = 0; i < 6; i++) {\n      const x = w * (0.15 + i * 0.07);\n      const gr = g.createLinearGradient(x, 0, x + w * 0.3, h);\n      gr.addColorStop(0, 'rgba(255,248,220,0.10)');\n      gr.addColorStop(1, 'rgba(255,248,220,0)');\n      g.fillStyle = gr;\n      g.beginPath(); g.moveTo(x, 0); g.lineTo(x + w * 0.03, 0); g.lineTo(x + w * 0.35, h); g.lineTo(x + w * 0.25, h); g.closePath(); g.fill();\n    }\n    g.globalCompositeOperation = 'source-over';\n  },\n  aurora: function (g, w, h, r) {\n    skyGrad(g, w, h, [[0, '#01040c'], [0.6, '#06192a'], [1, '#0b2533']]);\n    for (let i = 0; i < 400; i++) { g.fillStyle = rgba('#ffffff', r() * 0.8); g.fillRect(r() * w, r() * h * 0.7, r() < 0.95 ? 1 : 2, 1); }\n    g.globalCompositeOperation = 'lighter';\n    for (let x = 0; x < w; x += 2) {\n      const u = x / w;\n      const base = h * (0.5 - fbm(u * 2.5, 1) * 0.25);\n      const ht = h * (0.15 + fbm(u * 6 + 4, 2) * 0.35);\n      const gr = g.createLinearGradient(0, base - ht, 0, base);\n      gr.addColorStop(0, 'rgba(160,80,255,0)');\n      gr.addColorStop(0.55, 'rgba(60,255,170,' + (0.08 + fbm(u * 14, 3) * 0.12) + ')');\n      gr.addColorStop(1, 'rgba(60,255,170,0)');\n      g.fillStyle = gr;\n      g.fillRect(x, base - ht, 2, ht);\n    }\n    g.globalCompositeOperation = 'source-over';\n    ridge(g, w, h, h * 0.82, h * 0.16, 4, 11, '#0a1219');\n    g.fillStyle = 'rgba(200,230,255,0.08)';\n    g.fillRect(0, h * 0.86, w, h);\n  },\n  moon: function (g, w, h, r) {\n    skyGrad(g, w, h, [[0, '#0d1633'], [0.7, '#2f3a6a'], [1, '#8a6f8a']]);\n    const mx = w * 0.62, my = h * 0.42, mr = h * 0.2;\n    sunGlow(g, mx, my, mr * 3, '#d8dcff', 0.25);\n    const mg = g.createRadialGradient(mx - mr * 0.3, my - mr * 0.3, 0, mx, my, mr);\n    mg.addColorStop(0, '#fbf7ea'); mg.addColorStop(1, '#cfc7b4');\n    g.fillStyle = mg;\n    g.beginPath(); g.arc(mx, my, mr, 0, Math.PI * 2); g.fill();\n    for (let i = 0; i < 14; i++) {\n      const a = r() * 6.28, d = r() * mr * 0.8;\n      g.fillStyle = 'rgba(120,110,95,' + (0.1 + r() * 0.15) + ')';\n      g.beginPath(); g.arc(mx + Math.cos(a) * d, my + Math.sin(a) * d, mr * (0.04 + r() * 0.12), 0, Math.PI * 2); g.fill();\n    }\n    ridge(g, w, h, h * 0.86, h * 0.1, 2, 4, '#141729');\n    ridge(g, w, h, h * 0.96, h * 0.06, 5, 9, '#07080f');\n  },\n  lake: function (g, w, h, r) {\n    const hz = h * 0.56;\n    skyGrad(g, w, hz, [[0, '#4d6f8f'], [0.7, '#c9b8b0'], [1, '#f0cfae']]);\n    const cols = ['#9aa9b8', '#6c7f92', '#3d4c5c'];\n    for (let i = 0; i < 3; i++) ridge(g, w, hz, hz * (0.85 + i * 0.07), hz * (0.6 - i * 0.12), 3 + i * 2, 20 + i * 5, cols[i]);\n    pines(g, w, hz, h * 0.08, 0.6, 77, '#1f2a2c');\n    /* Mirror the top half into the lake with ripple lines. */\n    g.save();\n    g.translate(0, hz * 2);\n    g.scale(1, -1);\n    g.globalAlpha = 0.75;\n    g.drawImage(g.canvas, 0, 0, w, hz, 0, 0, w, hz);\n    g.restore();\n    const wg = g.createLinearGradient(0, hz, 0, h);\n    wg.addColorStop(0, 'rgba(40,90,110,0.25)'); wg.addColorStop(1, 'rgba(10,30,40,0.7)');\n    g.fillStyle = wg;\n    g.fillRect(0, hz, w, h - hz);\n    for (let i = 0; i < 120; i++) { g.fillStyle = 'rgba(255,255,255,' + r() * 0.08 + ')'; g.fillRect(r() * w, hz + r() * (h - hz), w * (0.05 + r() * 0.2), 1); }\n  },\n};\n\n/* A cached photograph of `kind` at w × h. */\nfunction photo(kind, w, h, seed) {\n  w = Math.max(32, Math.round(w)); h = Math.max(32, Math.round(h));\n  const key = kind + '|' + w + 'x' + h + '|' + (seed || 0);\n  if (PHOTO_CACHE[key]) return PHOTO_CACHE[key];\n  if (PHOTO_KEYS > 48) { for (const k in PHOTO_CACHE) delete PHOTO_CACHE[k]; PHOTO_KEYS = 0; }\n  const c = document.createElement('canvas');\n  c.width = w; c.height = h;\n  const g = c.getContext('2d');\n  (PAINTERS[kind] || PAINTERS.mountains)(g, w, h, rng(((seed || 0) + 3) * 7919));\n  const vg = g.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.3, w / 2, h / 2, Math.max(w, h) * 0.75);\n  vg.addColorStop(0, 'rgba(0,0,0,0)');\n  vg.addColorStop(1, 'rgba(0,0,0,0.35)');\n  g.fillStyle = vg;\n  g.fillRect(0, 0, w, h);\n  PHOTO_CACHE[key] = c;\n  PHOTO_KEYS++;\n  return c;\n}\n\n/* Draw a photo to cover a box, with optional zoom and pan (Ken Burns). */\nfunction drawPhoto(ctx, kind, x, y, w, h, zoom, px, py, seed) {\n  const k = dprOf(ctx);\n  const z = zoom || 1;\n  /* Fixed render size per box, so zooming crops the cache instead of repainting. */\n  const img = photo(kind, Math.round(w * k * 1.15 / 8) * 8, Math.round(h * k * 1.15 / 8) * 8, seed);\n  const sw = img.width / z, sh = img.height / z;\n  const sx = (img.width - sw) * (0.5 + (px || 0) * 0.5), sy = (img.height - sh) * (0.5 + (py || 0) * 0.5);\n  ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);\n}\n\n/* Portfolio site nav: name on the left, links on the right (Menu on phones). */\nfunction siteNav(ctx, W, H, o, env) {\n  const s = Math.min(W, H);\n  const pad = clamp(s * 0.06, 18, 56);\n  const fs = clamp(s * 0.019, 12, 15);\n  const y = pad + fs * 0.4;\n  const pt = env.pointer || {};\n  ctx.save();\n  ctx.textBaseline = 'middle';\n  ctx.textAlign = 'left';\n  ctx.font = (o.serif ? '400\\u0020' + fs * 1.35 + 'px\\u0020' + SERIF : '600\\u0020' + fs * 1.1 + 'px\\u0020' + SANS);\n  ctx.fillStyle = o.ink;\n  ctx.fillText(o.name, pad, y);\n  ctx.font = '500\\u0020' + fs + 'px\\u0020' + SANS;\n  ctx.textAlign = 'right';\n  if (W < 640) {\n    ctx.fillText('Menu', W - pad, y);\n  } else {\n    let x = W - pad;\n    for (let i = o.links.length - 1; i >= 0; i--) {\n      const lw = ctx.measureText(o.links[i]).width;\n      const hot = pt.isHovered && Math.abs(pt.localY - y) < fs && pt.localX > x - lw && pt.localX < x;\n      ctx.fillStyle = hot || i === 0 ? o.ink : rgba(o.ink, 0.6);\n      ctx.fillText(o.links[i], x, y);\n      if (hot || i === 0) ctx.fillRect(x - lw, y + fs * 0.75, lw, 1);\n      x -= lw + fs * 2.2;\n    }\n  }\n  ctx.restore();\n  return y + fs;\n}\n\n/* Portfolio · Illustrator: a botanical ink drawing that grows stroke by\n   stroke over watercolour washes on warm paper. Click to grow a new one.\n   Inputs: name, role, links, speed, accent, accent2, bg, text. */\nlet S = null;\n\nfunction plant(seed, W, H, cx, base, scale) {\n  const r = rng(seed * 977 + 13);\n  const strokes = [];\n  const washes = [];\n  const grow = function (x, y, ang, len, depth, t0) {\n    const pts = [];\n    let px = x, py = y, a = ang;\n    const steps = 18;\n    for (let i = 0; i <= steps; i++) {\n      pts.push([px, py]);\n      a += (r() - 0.5) * 0.12;\n      px += Math.cos(a) * len / steps;\n      py += Math.sin(a) * len / steps;\n    }\n    strokes.push({ pts: pts, t0: t0, dur: 0.5 + len / (scale * 2.2), w: Math.max(0.6, 2.4 - depth * 0.6), kind: 'stem' });\n    const end = pts[pts.length - 1];\n    const tEnd = t0 + 0.5 + len / (scale * 2.2);\n    /* Leaves along the stem. */\n    for (let i = 4; i < steps; i += 3 + Math.floor(r() * 3)) {\n      const side = (i % 2 ? 1 : -1);\n      const la = a + side * (0.7 + r() * 0.5);\n      strokes.push({ leaf: true, x: pts[i][0], y: pts[i][1], a: la, l: scale * (0.12 + r() * 0.1) * (1 - depth * 0.15), t0: t0 + (i / steps) * 0.8, dur: 0.6, w: 1, kind: 'leaf' });\n      if (r() < 0.5) washes.push({ x: pts[i][0] + Math.cos(la) * scale * 0.08, y: pts[i][1] + Math.sin(la) * scale * 0.08, r: scale * (0.08 + r() * 0.06), c: 1, t0: t0 + (i / steps) * 0.8 + 0.3 });\n    }\n    if (depth < 3) {\n      const kids = depth === 0 ? 3 : 2;\n      for (let k = 0; k < kids; k++) grow(end[0], end[1], a + (k - (kids - 1) / 2) * (0.5 + r() * 0.3), len * (0.55 + r() * 0.2), depth + 1, tEnd - 0.1);\n    } else {\n      /* A flower at each tip. */\n      strokes.push({ flower: true, x: end[0], y: end[1], r: scale * (0.05 + r() * 0.04), n: 5 + Math.floor(r() * 3), t0: tEnd, dur: 0.8, kind: 'flower' });\n      washes.push({ x: end[0], y: end[1], r: scale * (0.12 + r() * 0.06), c: 0, t0: tEnd + 0.2 });\n    }\n  };\n  grow(cx, base, 0 - Math.PI / 2 + (r() - 0.5) * 0.3, scale * 0.55, 0, 0);\n  return { strokes: strokes, washes: washes };\n}\n\nfunction render(ctx, env) {\n  const W = env.width, H = env.height;\n  if (!S) S = { seed: 1, t: 0, key: '' };\n  const dt = tick(S);\n  const speed = inp(env, 'speed', 1);\n  const name = String(inp(env, 'name', 'Iris Moreau'));\n  const role = String(inp(env, 'role', 'Illustrator\\u0020&\\u0020printmaker. Botanical drawings for books,\\u0020packaging and walls.'));\n  const links = String(inp(env, 'links', 'Portfolio,Prints,Commissions,Contact')).split(',');\n  const A = inp(env, 'accent', '#c8553d');\n  const B = inp(env, 'accent2', '#6b8f71');\n  const bg = inp(env, 'bg', '#f4ecdf');\n  const ink = inp(env, 'text', '#1d1a16');\n  const pt = env.pointer || {};\n  const s = Math.min(W, H);\n  const pad = clamp(s * 0.06, 18, 56);\n  const land = W > H * 1.05;\n  const cx = land ? W * 0.66 : W / 2, base = land ? H * 0.94 : H * 0.62;\n  const scale = land ? H * 0.85 : Math.min(W * 0.9, H * 0.5);\n  const key = S.seed + '|' + Math.round(W) + 'x' + Math.round(H);\n  if (S.key !== key) { S.P = plant(S.seed, W, H, cx, base, scale); S.key = key; }\n  if (pressed(S, pt)) { S.seed++; S.t = 0; S.key = ''; }\n  S.t += dt * speed;\n  if (S.t > 16) { S.seed++; S.t = 0; S.key = ''; }\n  const T = S.t;\n  const fadeOut = clamp((16 - T) / 1.2, 0, 1);\n\n  ctx.save();\n  ctx.translate(-W / 2, -H / 2);\n  ctx.fillStyle = bg;\n  ctx.fillRect(0, 0, W, H);\n  /* Paper fibres. */\n  ctx.strokeStyle = rgba(ink, 0.025);\n  ctx.beginPath();\n  for (let i = 0; i < 140; i++) { const x = hash2(i, 3) * W, y = hash2(i, 9) * H; ctx.moveTo(x, y); ctx.lineTo(x + 12 + hash2(i, 5) * 20, y + hash2(i, 7) * 4 - 2); }\n  ctx.stroke();\n\n  if (S.P) {\n    ctx.globalAlpha = fadeOut;\n    /* Watercolour washes. */\n    ctx.globalCompositeOperation = 'multiply';\n    for (let i = 0; i < S.P.washes.length; i++) {\n      const w = S.P.washes[i];\n      const k = ease(clamp((T - w.t0) / 1.4, 0, 1));\n      if (k <= 0) continue;\n      const col = w.c ? B : A;\n      for (let j = 0; j < 3; j++) {\n        const g = ctx.createRadialGradient(w.x + j * 3, w.y - j * 2, 0, w.x, w.y, w.r * k * (1 - j * 0.15));\n        g.addColorStop(0, rgba(col, 0.16));\n        g.addColorStop(0.8, rgba(col, 0.1));\n        g.addColorStop(1, rgba(col, 0));\n        ctx.fillStyle = g;\n        ctx.beginPath(); ctx.arc(w.x, w.y, w.r * k, 0, Math.PI * 2); ctx.fill();\n      }\n    }\n    ctx.globalCompositeOperation = 'source-over';\n    /* Ink strokes. */\n    ctx.strokeStyle = ink;\n    ctx.lineCap = 'round';\n    ctx.lineJoin = 'round';\n    for (let i = 0; i < S.P.strokes.length; i++) {\n      const st = S.P.strokes[i];\n      const k = clamp((T - st.t0) / st.dur, 0, 1);\n      if (k <= 0) continue;\n      if (st.kind === 'stem') {\n        ctx.lineWidth = st.w;\n        ctx.beginPath();\n        const m = Math.max(1, Math.floor(k * (st.pts.length - 1)));\n        ctx.moveTo(st.pts[0][0], st.pts[0][1]);\n        for (let j = 1; j <= m; j++) ctx.lineTo(st.pts[j][0], st.pts[j][1]);\n        ctx.stroke();\n      } else if (st.kind === 'leaf') {\n        const l = st.l * ease(k);\n        ctx.lineWidth = 0.9;\n        ctx.save();\n        ctx.translate(st.x, st.y);\n        ctx.rotate(st.a);\n        ctx.beginPath();\n        ctx.moveTo(0, 0);\n        ctx.quadraticCurveTo(l * 0.5, 0 - l * 0.32, l, 0);\n        ctx.quadraticCurveTo(l * 0.5, l * 0.32, 0, 0);\n        ctx.stroke();\n        ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(l * 0.85, 0); ctx.lineWidth = 0.5; ctx.stroke();\n        for (let v = 1; v < 4; v++) { ctx.beginPath(); ctx.moveTo(l * v * 0.22, 0); ctx.lineTo(l * (v * 0.22 + 0.1), 0 - l * 0.12); ctx.stroke(); }\n        ctx.restore();\n      } else {\n        const rr = st.r * ease(k);\n        ctx.lineWidth = 0.9;\n        for (let p = 0; p < st.n; p++) {\n          const a = (p / st.n) * Math.PI * 2 + st.x * 0.01;\n          ctx.save();\n          ctx.translate(st.x, st.y);\n          ctx.rotate(a);\n          ctx.beginPath();\n          ctx.moveTo(0, 0);\n          ctx.bezierCurveTo(rr * 0.6, 0 - rr * 0.5, rr * 1.1, 0 - rr * 0.2, rr, 0);\n          ctx.bezierCurveTo(rr * 1.1, rr * 0.2, rr * 0.6, rr * 0.5, 0, 0);\n          ctx.stroke();\n          ctx.restore();\n        }\n        ctx.fillStyle = ink;\n        ctx.beginPath(); ctx.arc(st.x, st.y, rr * 0.12, 0, Math.PI * 2); ctx.fill();\n      }\n    }\n    ctx.globalAlpha = 1;\n  }\n\n  siteNav(ctx, W, H, { name: name, links: links, ink: ink, serif: true }, env);\n\n  /* Copy. */\n  const cw = land ? W * 0.36 : W - pad * 2;\n  const fs = clamp(Math.min(W * 0.08, H * 0.13), 32, 110);\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'alphabetic';\n  ctx.font = 'italic 400\\u0020' + fs + 'px\\u0020' + SERIF;\n  ctx.fillStyle = ink;\n  const y0 = land ? H * 0.42 : H * 0.72;\n  ctx.fillText('Drawn', pad, y0);\n  ctx.font = '400\\u0020' + fs + 'px\\u0020' + SERIF;\n  ctx.fillText('by hand.', pad, y0 + fs * 0.95);\n  const rfs = clamp(s * 0.02, 12, 16);\n  ctx.font = '400\\u0020' + rfs + 'px\\u0020' + SANS;\n  ctx.fillStyle = rgba(ink, 0.65);\n  wrap(ctx, role, Math.min(cw, 380)).slice(0, 3).forEach(function (l, i) { ctx.fillText(l, pad, y0 + fs * 0.95 + rfs * 2.2 + i * rfs * 1.5); });\n  ctx.font = '500\\u0020' + clamp(s * 0.013, 9, 11) + 'px\\u0020' + MONO;\n  ctx.fillStyle = A;\n  spaced(ctx, 'PLATE N°\\u0020' + ('00' + S.seed).slice(-3) + '\\u0020\\u0020·\\u0020\\u0020CLICK TO DRAW ANOTHER', pad, H - pad, 0.15);\n\n  grain(ctx, W, H, 0.07);\n  ctx.restore();\n}\n"
          }
        ]
      },
      "layoutProps": {
        "layoutSizingHorizontal": "fill",
        "layoutSizingVertical": "fill"
      },
      "zIndex": 1,
      "interactions": []
    }
  ]
}
