Snapshot

Welcome

Snapshot builds a supported GitHub repository into static web output and hosts that output on Cloudflare.

Point Snapshot at a repo and it figures out what you have. There are three kinds of repo:

  • A notebook collection — a folder of Pluto notebooks. Each becomes a page in a sidebar site; supported interactions compile to WebAssembly and unsupported ones are labelled as static or limited.
  • A Therapy app — a Julia web app built with Therapy.jl. Your pages, your styling, hosted as-is.
  • A custom build — any toolchain. Drop a build.sh and Snapshot publishes whatever it produces — Node, Python, Hugo, a Makefile.

The loop is the same for all three: a push triggers a build in your own GitHub Actions. If the build and publish steps succeed, Snapshot replaces the hosted version. Supported notebook interactions become browser-side WebAssembly; apps and custom builds are hosted static files.

Why it's different — the exported site's Julia compute happens at build time and, where compilation succeeds, in the visitor's browser via WebAssembly. Snapshot does not provide an always-on Julia application process.

New here? Start with the quick start. Want an agent to build it for you? Jump to Build with an agent.

Quick start

The common path from a supported public repository to a hosted URL.

1 · Sign in

Open the dashboard and click Sign in. Authentication is handled by WorkOS — there's no password to manage.

2 · Connect a repo

Install the Snapshot GitHub App and choose which repos it can see. Snapshot adds one small workflow file to each connected repo and lists them under Repositories. No secrets are placed in your repo — publishing is authenticated with a short-lived GitHub OIDC token at build time.

3 · Enable, then push

Click Enable on a repo, then push to your default branch (or trigger the workflow manually). A successful workflow publishes the output. Your site shows up under Your work with an Open live link and build status.

Snapshot detects the supported notebook, Therapy app, and custom-build layouts described here. Add snapshot.toml when detection is not enough or you need explicit build settings.

Re-publish any time — a push to the configured branch triggers another workflow run. To retry without a code change, re-run the workflow from your repo's Actions tab.

Publish notebooks

A repo of Pluto notebooks becomes one cohesive sidebar site, with each export labelled live, limited, or static.

Drop your .jl Pluto notebooks anywhere in the repo. On push, Snapshot:

  • Attempts to compile supported notebook interactions to a browser-side WebAssembly bundle; unsupported interactions fall back as described below.
  • Builds a site with a left sidebar listing every notebook, each on its own page.
  • Labels each by the notebook's saved title (Pluto frontmatter), falling back to a tidied filename. Frontmatter chapter/section numbers set the order.

Live, static & limited

Each notebook is labelled in the sidebar so visitors know what to expect:

  • live — uses @bind and compiled to WebAssembly — it recomputes in the browser as you drag a slider.
  • static — no @bind; a clean, read-only notebook. Perfectly valid — not everything needs to be interactive.
  • limited — uses @bind, but some input isn't supported by the compiler yet, so it's published as a static snapshot with a clear "not interactive in this export" note. Expected on the frontier of what compiles — not a failure.

To make sure a notebook lands as live, see Make notebooks live.

Organize with folders → modules

Put notebooks in top-level subfolders and Snapshot groups them into collapsible sidebar sections — one per folder. Name and order the groups in snapshot.toml:

[[modules]]
dir   = "module1"
title = "Images & Abstractions"
open  = true        # group starts expanded (default)

[[modules]]
dir   = "module2"
title = "Data Science"
open  = false       # starts collapsed

Each viewer's expanded/collapsed choice is remembered, and the group holding the page you're on always opens. A flat repo (no subfolders) just renders one plain list.

Control which notebooks publish

By default every notebook publishes. To override per notebook, add [[notebooks]] blocks:

[[notebooks]]
file       = "research/deep.jl"
slug       = "deep-dive"     # its own clean URL: /deep-dive/
standalone = true            # its own page, no sidebar, hidden from the collection
title      = "Deep Dive"     # override the frontmatter title

[[notebooks]]
file    = "drafts/wip.jl"
publish = false              # never published

Set extract = "listed" at the top level to publish only the notebooks you list (everything else is ignored) — handy for a big repo where just a few notebooks should go public.

Pick the homepage

By default the sidebar opens on the first notebook. To choose the landing page:

  • name a notebook index.jl or home.jl — it becomes the default-shown notebook;
  • point home at any notebook in snapshot.toml (home = "intro.jl");
  • commit an index.html (hand-built or Therapy-rendered) — or set home = "landing.html" — to replace the sidebar shell with your own page.

Tip — hide a notebook's code cells in Pluto (the eye icon on each cell) for a clean, output-only published page. Snapshot keeps whatever you set.

Make notebooks live

A live notebook is one whose sliders recompute in the browser. Here's how to make sure yours compiles to WebAssembly cleanly.

Snapshot compiles each cell to WebAssembly with WasmTarget.jl. A lot of Julia compiles — numerics, @bind inputs, and plots — but it's a frontier, so a few habits keep a notebook reliably interactive. If a cell can't compile, it falls back to a static snapshot (labelled limited) and the rest of the notebook still ships.

Use a light, predictable toolbox

  • Depend on PlutoUI (for @bind sliders) and WasmMakie (for plots), plus your own plain-Julia math.
  • Hand-roll the numerics you need — a simple random-number generator, Euler steps for an ODE, finite differences for a gradient — rather than pulling heavy packages into the notebook.
  • Draw with the basics: Figure, Axis, and lines!. Build bars or histograms as vertical line segments and a curve as a polyline.

Patterns that compile cleanly

  • Prefer integer sliders and derive any float inside the cell (r = r_tenths / 10). Integer @bind values are rock-solid; a float bound directly into a deep or nested loop can quietly revert to its default.
  • Iterate integer ranges (for i in 0:n) and build any float axis by hand, rather than iterating a float range.
  • If a cell shows a number that should update with a slider, compute that number in its own bound cell and interpolate it into the Markdown — don't compute it inside the Markdown cell itself, or it bakes to the slider's default.

Let the dashboard tell you

You don't have to guess. After a build, each notebook's card shows honest coverage — e.g. "5 of 6 cells interactive" — and any cell that fell back is marked right on the page. Iterate, push, and watch the number climb. Static is fine too — a read-only notebook is a perfectly good page.

Live, multi-module example — the Computational Thinking course is dozens of notebooks compiled to live WebAssembly with exactly these patterns (source).

Apps & custom builds

Beyond notebooks, Snapshot hosts a full web app — built with Therapy.jl, or with any toolchain via build.sh.

Therapy apps

A Therapy.jl app is plain Julia rendered to static HTML at build time, with interactive "islands" compiled to WebAssembly. Snapshot treats a repo as an app when it finds an app.jl or a Therapy dependency. It instantiates your project and runs your build, then hosts the output folder.

# app.jl
using Therapy
cd(@__DIR__)
app = App(routes_dir = "src/routes", components_dir = "src/components",
          output_dir = "dist", layout = :Layout,
          base_path = get(ENV, "SNAPSHOT_BASE_PATH", ""))
Therapy.run(app)

Custom builds — any toolchain

You don't need Julia at all. Set type = "build" in snapshot.toml (or just drop a build.sh at the repo root with no notebooks present) and Snapshot runs your build.sh, then publishes whatever it writes to dist/. Use Node, Python, Hugo, Zola, a Makefile — anything.

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
node build.mjs        # ...or any commands: install tools, fetch data, render pages

The build runs in your GitHub Actions — Snapshot never executes your build code on its servers; it only ingests the output, with the platform's size and safety limits applied.

Sub-path hosting (important)

Your app is served under a path, so links and assets need that prefix. Snapshot passes it in as SNAPSHOT_BASE_PATH — read it for base_path and prefix any hand-written links and asset URLs, or they'll 404:

const BASE = get(ENV, "SNAPSHOT_BASE_PATH", "")
A(:href => "$(BASE)/about", "About")       # NOT "/about"
RawHtml("<link rel=stylesheet href=$(BASE)/styles.css>")

Pre-rendered notebooks (GPUs, instruments, credentials)

Some notebooks can't run on a CI machine at all — they need a GPU, lab hardware, private data, or credentials. The recipe: render them locally on the machine that can, commit the resulting HTML, and give Snapshot a build that mounts the committed output instead of re-running anything:

# snapshot.toml
type = "build"              # custom build — your build.sh owns everything
[app]
dir    = "docs"             # where the site build lives
output = "dist"             # the folder to publish

# docs/build.sh — CI never re-runs the notebooks; it just assembles the site
export MYPKG_SKIP_NB_EXPORT=1        # your app skips rendering when this is set
julia --project=. -e 'using Pkg; Pkg.Registry.add("General"); Pkg.resolve(); Pkg.instantiate()'
julia --project=. app.jl build       # mounts the committed docs/notebooks-static/

Locally you run the same build without the skip flag to re-render the notebooks, commit the updated HTML, and push — CI stays fast and never needs your hardware.

Examples — a full Therapy demo app with pages, themes, an embedded notebook and a database (source), a no-Julia build.sh demo (a Node build), and BasisSimulator.jl — the pre-rendered-notebooks recipe (Metal-GPU notebooks rendered locally, mounted by CI).

snapshot.toml

An optional file at the root of your repo. Skip it and Snapshot auto-detects everything; add it to customize.

# snapshot.toml
title = "My Project"        # the site / collection title
type  = "collection"        # "collection" | "app" | "build" (auto-detected if omitted)
theme = "fun-light"         # collections: default theme (also switchable live, no rebuild)
home  = "intro.jl"          # collections: which notebook (or .html) is the homepage

extract = "all"             # "all" (default) | "listed" (publish only [[notebooks]])
cache   = true              # false = ALWAYS re-export every notebook on every push

[[modules]]                 # collections: folders → grouped, collapsible sidebar
dir   = "module1"
title = "Getting Started"
open  = true

[[notebooks]]               # collections: per-notebook overrides
file       = "research/deep.jl"
slug       = "deep-dive"    # custom isolated URL
standalone = true           # own page, no sidebar, hidden from the collection
publish    = true           # set false to exclude entirely
title      = "Deep Dive"
cache      = false          # ALWAYS re-export just this notebook

[app]                       # apps / custom builds — all optional
command = "bash build.sh"   # custom build command (else build.sh, else app.jl build)
output  = "dist"            # the folder to publish (default dist)

Top-level fields

  • title — the project's display title.
  • type — collection, app, or build. Auto: an app.jl / Therapy dep ⇒ app; loose .jl notebooks ⇒ collection; a root build.sh with no notebooks ⇒ build (any toolchain).
  • theme — for collections, the default theme — fun-light (the default), fun-dark, or any DaisyUI theme (e.g. dracula, nord, cupcake). Also switchable live from the dashboard card with no rebuild. (Apps bring their own styling.)
  • home — collections only: the landing page. A notebook filename makes that notebook the default-shown one; an .html file replaces the sidebar shell with your own page. Auto-detected from index.jl / home.jl / index.html if omitted.
  • extract — "all" publishes every notebook; "listed" publishes only the ones in [[notebooks]].
  • cache — builds only re-export notebooks that actually changed (same notebook + same compiler ⇒ the previous export is reused — a big speedup for large collections). Set false to always re-export everything, e.g. if a notebook bakes in the build date or fetches live data. There's also a per-notebook cache override, and a "force" checkbox on the manual Run workflow button for one-off full rebuilds.

[[modules]] — sidebar groups

  • dir — the top-level subfolder.
  • title — the group's label.
  • open — whether it starts expanded (default true).

[[notebooks]] — per-notebook overrides

  • file — the notebook's path (required).
  • slug — a custom URL.
  • standalone — its own page with no sidebar, hidden from the collection.
  • publish — set false to exclude.
  • title — override the title.
  • cache — set false to always re-export this one notebook (skip the unchanged-notebook reuse).

[app] — apps & custom builds

  • command — override the build command.
  • output — the built folder to host (default dist).

Sharing & privacy

The limited community test accepts public GitHub repositories and publishes public sites only.

Private repositories and private, restricted, or secret-link sharing are not part of this test. Those controls are being evaluated as possible future paid features, but scope and availability are not decided. Ordinary test accounts should currently use only Public:

  • Public — anyone can view; it may appear in your public listing. This is the only supported visibility during the limited test.

Private repos

They are disabled for ordinary accounts during this test. Platform-owned test projects may exercise future access-control paths internally, but that does not make them part of the public offer.

Assume every test publication is public. Do not publish secrets, private data, confidential notebooks, or output that was not intended for anyone on the internet.

Add a database

A static Snapshot site can call a separately operated data service for features such as comments or saved state.

One option is direct browser access to a service such as Supabase. A public "anon" key ships in the page; row-level security (RLS) must restrict what a visitor can read or write. Supabase remains an external backend with its own operation and terms.

For a guestbook whose RLS policy intentionally permits public inserts, the browser request can look like this:

fetch(SUPABASE_URL + "/rest/v1/guestbook", {
  method: "POST",
  headers: {
    apikey: ANON_KEY,
    authorization: "Bearer " + ANON_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({ name, message }),
});

The anon key is meant to be public — RLS is what protects your data, so only safe reads and writes get through. The demo app's guestbook shows the full pattern.

Never commit a secret. Only the public anon key belongs in your repo; a service-role key or any private key must never ship in the page. Browser-direct access is appropriate only for operations your RLS and column grants intentionally expose. Snapshot's own authenticated dashboard sends privileged operations through a Cloudflare Worker, where service credentials remain encrypted server-side.

How it works

Build in your CI, host on the edge, run in the browser.

1 · Your repo

You push your notebooks, app, or custom build to GitHub as usual.

2 · Your GitHub Actions

The workflow Snapshot added builds the site — compiling notebooks to WebAssembly, or running your app's build — entirely in your repo's own GitHub Actions. Snapshot never runs your build code on its infrastructure; it only receives the finished output, authenticated by a short-lived GitHub OIDC token (no long-lived secret in your repo).

3 · Cloudflare's edge

The built site is uploaded to Cloudflare R2 and served through a Worker. Notebook interactions that compiled successfully then run as WebAssembly in each visitor's browser.

Where your site is served

Hosted content is served from a per-owner content origin such as owner.snapshot.show, separate from the dashboard and its sign-in at snapshot.show. This origin separation prevents hosted page code from acting with a visitor's Snapshot dashboard authority.

No project Julia process — Snapshot does not run an always-on Julia application process for the published site. You still maintain the repository, build configuration, dependencies, and any external services the site calls.

FAQ

Is it free?

The limited community test is free for up to 6 public notebooks. It is experimental hosting with no uptime or durability promise; keep GitHub as the source of record. Builds run in your own GitHub Actions.

Do private repos work?

No — ordinary accounts can publish only public repositories and public sites during this test.

What can I publish?

Pluto notebooks (a collection), Therapy.jl web apps, and custom builds (any toolchain via build.sh).

Does my build code run on Snapshot's servers?

No. It runs in your own GitHub Actions. Snapshot only ingests and hosts the output — applying size, zip-safety, and rate limits to what it receives.

Can I embed a published notebook in another page?

Yes — put its URL in an iframe. Apps can embed any published notebook that way.

Custom domain?

Not yet. A site's canonical address is owner.snapshot.show/repository/; bring-your-own custom domains are on the roadmap.

How do I take something down?

Disable the repo to stop publishing — Snapshot removes the one workflow file it added. Or Remove a published page from the dashboard. Your own code and notebooks are never modified; only that workflow file is.

How current is everything I read here?

The agent primer repeats the current configuration fields and workflow conventions in a form intended for coding agents. Check this documentation and your build logs when behavior differs.

Build with an agent

Snapshot is plain Julia + standard web tech, so an AI coding agent — Claude Code, Cursor, and the like — can build and publish your whole site for you.

Copy the primer below into your coding agent as a starting brief. It summarizes the current publishing model, snapshot.toml, Therapy apps, notebook exports, the optional database pattern, sharing constraints, and the publish workflow. Review the resulting changes and build logs before relying on them.

Snapshot (https://snapshot.show) turns a GitHub repo into a hosted site. A repo is
either a Therapy.jl APP (a pure-Julia signals web app) or a COLLECTION of Pluto
notebooks (each notebook a page compiled to live in-browser WebAssembly). The build
runs in YOUR OWN GitHub Actions (Snapshot never executes your build code); hosting is
a single Cloudflare Worker reading from an R2 bucket. This primer is your full brief:
everything you need to build AND publish such a site is below, in one place.

START by asking me:
  1. Are we building a Therapy.jl APP, a Pluto-notebook COLLECTION, or a hybrid?
  2. What is it for / what should it do (audience, pages, interactivity)?
  3. Does it need real data (sign-ups, comments, saved state) or sign-in / private
     sharing? (If yes I will use the Supabase-from-browser recipe + Snapshot's
     visibility model below.)
  4. Any theming or branding preferences?
Then propose a short plan and build it, following the rules below exactly.


===================================================================================
PART 1 - PUBLISHING MODEL (read this first; it shapes everything else)
===================================================================================

=== THE THREE REPO KINDS ===
  collection - a repo of Pluto .jl notebooks -> a sidebar site, each notebook a page
               compiled to live WASM islands (lean Therapy components, NOT the old
               heavy Pluto statefile). Hosted at /app/<owner>/<repo>/<slug>/...
  app        - a Therapy.jl app (an app.jl entry or a Therapy dep) -> built with its
               own Julia project and hosted as-is. Hosted at /app/<owner>/<repo>/
               (ONE site per repo, no trailing slug segment).
  build      - a CUSTOM build: snapshot.toml type="build" (or just a root build.sh).
               Snapshot runs your build.sh in YOUR CI and publishes whatever it writes
               to the output dir (default dist/). ANY toolchain (Node, Python, Hugo, a
               Makefile, a bash heredoc). See "CUSTOM BUILDS (build.sh)" below. Hosted
               like an app at /app/<owner>/<repo>/.

  HOSTS (origin split for safety):
    snapshot.show        the dashboard, /api/*, /auth/*, AND private/restricted bundles
                         (whose access gate needs the viewer's session cookie, which is
                         host-only on snapshot.show).
    apps.snapshot.show   the SANDBOX origin for PUBLIC + LINK bundles - isolated from the
                         dashboard + session cookie so a hosted page's JS can't ride a
                         viewer's session (state-changing /api/* rejects any non-dashboard
                         Origin). serve() 301-redirects each bundle to the correct origin
                         BY VISIBILITY and preserves ?key, so any /app/... link
                         self-corrects - you can link either form.
    snapshot.djblack.workers.dev   legacy Worker subdomain (still resolves).
  Per-bundle metadata lives at  app/<owner>/<repo>/_snapshot.json (serve() reads it).

  Live examples (public -> 301 to apps.snapshot.show, then serve):
    https://snapshot.show/app/Dale-Black/snapshot-demo/                  (collection)
    https://snapshot.show/app/Dale-Black/snapshot-computational-thinking/ (multi-module)
    https://snapshot.show/app/Dale-Black/snapshot-app-demo/             (Therapy app)
    https://snapshot.show/app/Dale-Black/snapshot-build-demo/           (custom build.sh)

=== INSTALL -> ENABLE -> PUSH -> OIDC-BUILD FLOW (the whole lifecycle) ===
  1. Install the "Snapshot" GitHub App on your account/repo (perms: contents:write,
     workflows:write, actions:read, metadata:read). Install URL the dashboard
     generates: https://github.com/apps/snapshot-publish/installations/new
  2. Enable a repo from the dashboard. This WRITES ONE FILE into your repo:
     .github/workflows/snapshot.yml  (the publish workflow; __INGEST_URL__ is
     substituted with the Worker origin at enable-time). NO Actions secrets are
     placed in your repo. (Enabling needs the owner's WorkOS dashboard session; it
     cannot be fully automated.)
  3. Push to main/master (or run the workflow manually: workflow_dispatch).
  4. The workflow builds, then authenticates to /ingest with a GitHub Actions OIDC
     token (audience=snapshot) - the Worker verifies the JWT via GitHub JWKS, binds
     repository==owner/repo, and checks the repo is enabled. No long-lived secret.
     Re-publish manually:
       gh workflow run snapshot.yml -R <owner>/<repo> --ref main
  Build runs in YOUR CI; Snapshot never runs your build code on its own infra.

=== HOW THE KIND IS CHOSEN (detect_kind, export.jl) ===
  1. snapshot.toml top-level `type` wins if valid:
       "collection" | "notebooks"                       -> :collection
       "app" | "build" | "site" | "custom" | "static"   -> the build path (:app)
  2. else find_app_dir(): if an app dir is found -> :app. An app dir = snapshot.toml
     [app] dir if set, else the first of these subdirs that "looks like an app":
       APP_DIR_CANDIDATES = ["", "docs", "app", "site", "web", "frontend"]
     "looks like an app" = contains the entry file (default app.jl), OR an app.jl,
     OR a Project.toml with a Therapy dep. (A Documenter docs/ with only make.jl +
     Documenter is NOT mistaken for an app - it needs app.jl or a Therapy dep.)
  3. else if a root build.sh exists AND there are NO Pluto notebooks -> :app (custom
     build). A stray build.sh never hijacks a notebook repo (notebooks present -> collection).
  4. else -> :collection.

=== URL SHAPE (exact - verified with curl) ===
  Site root:     https://snapshot.show/app/<owner>/<repo>/        (200; serves index.html)
  Notebook page: https://snapshot.show/app/<owner>/<repo>/<slug>/ (200; serves <slug>/index.html)
  Assets:        app/<owner>/<repo>/<slug>/...  (wasm/js/css are RELATIVE to the slug dir)

  RULE: a notebook serves at <slug>/ -> <slug>/index.html. It does NOT serve at
  <slug>.html or <slug>/<file>.html. The bundle root's index file comes from
  _snapshot.json `index` (default index.html). Only segments WITHOUT a "." get the
  index treatment (lastSeg.includes(".") test in serve()).
  Verified:
    /app/Dale-Black/snapshot-demo/                 -> 200  (collection root index)
    /app/Dale-Black/snapshot-demo/demo/            -> 200  (notebook "demo" -> demo/index.html)
    /app/Dale-Black/snapshot-demo/demo             -> 301 -> .../demo/   (trailing slash forced)
    /app/Dale-Black/snapshot-demo/demo.html        -> 404  (NOT how notebooks are addressed)
    /app/Dale-Black/snapshot-demo/demo/index.html  -> 200  (explicit index works too)
    /app/Dale-Black/snapshot-demo/nonexistent/     -> 404
    /app/Dale-Black/snapshot-app-demo/             -> 200  (a Therapy APP, single index)
  Trailing-slash redirect: GET .../<slug> (no slash, no ".") -> 301 to .../<slug>/ so
  the bundle's RELATIVE asset URLs resolve. The ?key=... capability token is PRESERVED
  across this redirect (url.search is carried into the Location).
  Legacy doubled path: old links app/<owner>/<repo>/<repo>/ (slug==repo, exactly 4 path
  segments) -> 301 to the collapsed app/<owner>/<repo>/.
  Origin redirect (by visibility): a PUBLIC or LINK bundle requested on snapshot.show
  301-redirects to apps.snapshot.show; a PRIVATE/RESTRICTED bundle requested on
  apps.snapshot.show 301-redirects back to snapshot.show (where the session gate works).
  So each bundle serves on exactly ONE origin; the verified examples above reach it via
  this redirect. ?key and the full path are carried across.

=== VISIBILITY / SHARING MODEL (Google-Docs style) ===
  Four levels in meta.visibility (R2 _snapshot.json is authoritative; serve() gates
  EVERY byte under the bundle off it, with ZERO db hits). VISIBILITIES =
  {public, link, restricted, private}.
    public      Anyone. Edge-cacheable. The only level that gets page-view AE analytics.
    link        Anyone holding the secret capability URL ?key=<secret>. NO login.
                Presenting a valid ?key sets a scoped cookie sn_acc_<owner>_<repo>
                (Path=/app/<owner>/<repo>, HttpOnly, Secure, SameSite=Lax, Max-Age=12h)
                so assets load without ?key on every URL.
    restricted  Signed-in repo OWNER, OR a signed-in user whose email is in shared_emails.
    private     Repo OWNER only (signed in). Default for a PRIVATE GitHub repo.
  DEFAULT on publish: a PRIVATE source repo -> "private"; a PUBLIC repo -> "public".
  The signal is the OIDC repository_visibility claim (GitHub-set, unspoofable). The
  owner's explicit Share choice is preserved across every republish (prevVisibility
  read from the prior _snapshot.json before overwrite).
  Private repos DO publish (owner-only by default); the owner opens access via Share.
  403 means the repo is NOT ENABLED for publishing - NOT "private repos are rejected".
  (The workflow's own 413/403 hint strings still say "only public repos for now" - that
  user-facing copy is stale relative to the Worker, which publishes private repos as
  owner-gated content.)

  OWNERSHIP for serving private content is STRICT: an installation row with a null
  workos_user_id is NOT treated as the owner (isRepoOwner fails closed). Invited emails
  are lower-cased; signed-in viewer email must match exactly. (The dashboard-EDIT gates
  use a looser check - they allow a null workos_user_id as a legacy valve - but the
  serve-path owner check does not.)
  DENY behavior:
    restricted/private + anonymous + an HTML-document request -> 302 redirect to
      /auth/login?next=<path+query>  (WorkOS sign-in, bounces back after login)
    link (or any sub-asset, or signed-in-but-not-granted) -> friendly 403 HTML page
      (sub-assets get a bare "forbidden" 403). Non-public responses are ALWAYS
      Cache-Control: private, no-store.

  OWNER SETS VISIBILITY from the dashboard Share dialog (per OWNED card):
    GET  /api/projects/share?owner=&repo=  -> {visibility, shared_emails, link_url}
         link_url is ONLY returned while visibility=link (a stale secret never leaks).
    POST /api/projects/share  body: {owner, repo, slug?, visibility?, addEmail?,
         removeEmail?, regenerateLink?}  (owner-gated via the App installation chain;
         slug only used to mirror the published DB row)
      - visibility must be one of the four (else 400).
      - addEmail/removeEmail edit shared_emails (validated email, lower-cased, <=254
        chars, max MAX_SHARES=100).
      - visibility=link mints link_secret (randomSecret = 18 random bytes -> 36 hex
        chars) on first use; regenerateLink:true ROTATES it (old ?key links 403
        immediately). Leaving link mode keeps the secret dormant (toggling back
        restores the same link).
    Capability link form:  https://snapshot.show/app/<owner>/<repo>/?key=<secret>
  A public repo that goes PRIVATE on GitHub is taken DOWN (webhook "privatized" ->
  takedownRepo deletes all content + writes a {taken_down:true} tombstone -> serve()
  returns a neutral 410 page; re-publishing after it goes public again restores it).

=== INGEST / SIZE LIMITS (publish can fail here even after a green build) ===
  Live caps (LIMITS const, worker/src/index.js; the actual deployed values - older
  memory citing 25/60/40MB is STALE):
  FREE-TIER (default, untrusted owners):
    maxCompressed   = 5 MB    request body (compressed)        -> 413
    maxDecompressed = 5 MB    total unzipped per site          -> 413
    maxFile         = 3 MB    single unzipped file             -> 413
    maxFiles        = 1500    entries per bundle               -> 413
    ownerBytes      = 5 MB    hosted storage per account       -> 413
    ownerProjects   = 10      distinct repo/slug projects/acct -> 409
    ingestPerMin    = 8       burst publishes/min/repo (RL)    -> 429
  PLATFORM circuit breakers (protect the free tier; LOUD 503, hosted sites keep serving):
    platformBytes    = 8 GB   soft cap (under R2's 10 GB free) -> 503 new publishes pause
    platformProjects = 5000                                    -> 503 (only blocks NEW projects)
    platformWarnPct  = 0.8    /health + /admin go "warn" past 80%
  TRUSTED owners (INGEST_OWNERS allowlist, incl. the operator): generous - maxCompressed
    90 MB, maxDecompressed 300 MB, maxFile 60 MB, maxFiles 20000, ownerBytes 4 GB,
    ownerProjects 200. (That is how the 27.7 MB Computational-Thinking collection is
    hosted; ordinary public users get the tight defaults above.)
  Defenses: boundedUnzip streams entry-by-entry and aborts (413) the moment any cap
    trips (zip-bomb safe; the whole bundle is held in memory before the R2 write so caps
    stay under the 128 MB Worker ceiling). AFTER unzip, each entry is path-checked
    (isSafeEntry): an absolute path, a backslash, or a ".." segment rejects the upload
    (400 "unsafe zip entry").
  Kill switch: secret INGEST_PAUSED (any value) -> all new ingests 503, /health 503,
    hosted sites stay live. Delete to resume.
  Heavy WASM islands are the usual size (413) culprit. Paid tiers are PLANNED, not built
  (no Stripe); the larger example numbers in pricing docs are a plan, not deployed.

=== TAKEDOWN / REMOVE / DISABLE (three different actions) ===
  Remove a published site:  POST /api/notebooks/remove  {owner, repo, slug} (owner-gated)
    -> deletes all R2 objects under app/<owner>/<repo>/ + the published row for that slug.
    Does NOT touch the GitHub repo or its workflow. (Despite the name, it removes the
    whole repo's hosted site via deletePrefix, not one notebook.)
  Disable a repo:  POST /api/github/disable  {owner, repo} (owner-gated)
    -> deletes .github/workflows/snapshot.yml + sets repos.enabled=false (enabled_at=null).
    BUILDS STOP; already-published content stays live.
  Auto-takedown (webhook, repository event):
    - "privatized" -> takedownRepo: content purged + {taken_down:true} tombstone ->
      serve() returns the neutral 410 page.
    - "deleted" -> content purged + repos row deleted, but NO tombstone -> serve()
      returns a plain 404 (not the 410 page).

=== CUSTOM DOMAINS - STATUS ===
  NOT a user-facing feature yet. snapshot.show is the Worker's own custom_domain route;
  snapshot.djblack.workers.dev is the legacy fallback (workers_dev=true). Per-user
  custom domains are PLANNED as a paid unlock; no code path exposes user-supplied domains.

=== DASHBOARD APIs an agent may touch (all owner-gated unless noted) ===
  GET  /api/bundles                          the signed-in user's published sites + {limits}
  GET  /api/projects/notebooks?owner=&repo=  per-notebook list from R2 manifest.json
  POST /api/projects/theme                   set/clear collection theme override
  GET/POST /api/projects/share               read/set visibility + emails + capability link
  POST /api/notebooks/remove                 remove a published site
  POST /api/github/{enable,disable,merge-pr} workflow lifecycle (enable needs WorkOS session)
  GET  /health                               "ok\n" (200) or "paused\n" (503 INGEST_PAUSED)


===================================================================================
PART 2 - snapshot.toml (the single config file; all keys optional, auto-detected if omitted)
===================================================================================

  # ---- project identity ----
  title = "Computational Thinking"     # site title (else humanized repo name)
  type  = "collection"                 # "app" | "collection"; else auto-detected
  theme = "snapshot"                   # default data-theme (dashboard can override);
                                       #   passed verbatim to <html data-theme>. Default
                                       #   if omitted: "snapshot". Ships snapshot /
                                       #   snapshot-dark + the DaisyUI v5 theme set;
                                       #   value space is NOT validated by the parser.
                                       #   COLLECTIONS ONLY - ignored for apps.

  # ---- homepage (pick ONE form) ----
  home  = "index.jl"                   # a notebook stem -> default-shown home;
                                       #   OR a *.html -> replaces the shell.
  # index = "index.jl"                 # alias for `home`
  # [collection]                       # nested alias form also works:
  #   home  = "index.jl"
  #   index = "index.html"

  # ---- extraction policy ----
  extract = "all"                      # "all" (default) | "listed"

  # ---- modules: top-level folders -> grouped collapsible sidebar ----
  [[modules]]
  dir   = "module1"                    # required: the top-level subfolder
  title = "Images, Transformations & Abstractions"   # optional label
  open  = true                         # default true (expanded)
  [[modules]]
  dir   = "module2"
  title = "Social Science & Data Science"
  open  = false                        # starts collapsed (auto-opens on its own pages)

  # ---- per-notebook overrides ----
  [[notebooks]]
  file       = "research/deep.jl"      # REQUIRED, repo-relative (aliases: path, notebook)
  slug       = "deep-dive"             # optional custom isolated URL (slugified)
  publish    = true                    # default true; false DROPS it (alias: extract)
  standalone = true                    # own page at its own URL, NO sidebar, hidden
                                       #   from the collection sidebar/landing
  title      = "Deep Dive"             # optional title override (beats frontmatter)

  # ---- app mode (only when type="app" / an app is detected) ----
  [app]
  dir     = "docs"                     # where the app lives, else auto-detected (root, docs/, app/, ...)
  entry   = "app.jl"                   # entry script (default app.jl)
  output  = "dist"                     # build output dir to host (default "dist")
  command = "julia --project=. app.jl build"   # full custom build (bash -lc); else build.sh else app.jl build

=== NOTEBOOK DISCOVERY (collection mode) ===
  - A file is a Pluto notebook iff it ends in .jl AND its FIRST line starts with:
      ### A Pluto.jl notebook ###          (the PLUTO_MAGIC header)
    (matched via startswith, so a trailing version comment on that line is fine.)
  - find_notebooks() does a RECURSIVE walkdir of the whole repo (skips .git).
    Subfolders are walked - that is what makes folders=modules work.
  - Order = notebook_order() reads each notebook's frontmatter for course order:
      sort key = (chapter, section-or-order, lowercase filename)
    Notebooks without chapter/section get a large default (1e6) -> they sort AFTER
    ordered ones, alphabetical among themselves. Frontmatter fields accept numbers OR
    numeric strings. `order` and `section` are aliases (order checked first).

=== PLUTO FRONTMATTER (read by collection.jl) ===
  Pluto stores frontmatter as a contiguous block of  #> ...  comment lines near the top
  (a TOML doc with a [frontmatter] table). Snapshot reads:
    title       -> sidebar/page label (else humanized filename: "newton-method" -> "Newton method")
    description -> card description on the landing grid
    chapter / section / order -> sidebar ordering (see above)
  Example block:
    #> [frontmatter]
    #> title = "Newton's Method"
    #> description = "Root-finding, interactively"
    #> chapter = 1
    #> section = 3
  Snapshot deliberately does NOT guess titles from `#` heading lines.

=== SLUG DERIVATION (collection_plan, the single source of truth) ===
  - slugify(s) = lowercase, non-[a-z0-9] runs -> "-", trimmed.
  - base = slugify(filename stem) (empty -> "notebook").
  - FLAT, module-prefixed slugs: a notebook in subfolder <mod> gets
      "<slugify(mod)>-<base>"   e.g. module2/random_walks.jl -> "module2-random-walks"
    (FLAT with a hyphen, NOT nested module2/random-walks - nesting broke CI artifact
    names). A root-level notebook (no subfolder) keeps its bare base slug, so flat repos
    are byte-for-byte unchanged.
  - A [[notebooks]] slug= override replaces the whole slug (its own isolated URL).
  - Uniqueness: collisions get "-1", "-2", ... appended.
  - URL of a page = /app/<owner>/<repo>/<slug>/  (index.html inside).

=== FOLDERS = MODULES -> GROUPED COLLAPSIBLE SIDEBAR ([[modules]]) ===
  A notebook's module = the FIRST path component of its repo-relative path (top-level
  subfolder); root-level notebooks have module "" and sit ABOVE the groups with no
  header. Declaring [[modules]] turns the sidebar into grouped, collapsible sections.
  Rendering (collection.jl): each group is a native <details class="sn-group"> +
  <summary> with a rotating caret - NO JS needed for collapse. Heading text:
  numeric dir "module2" -> "Module 2 . <title>"; non-numeric "intro" -> "Intro";
  if title already starts with "Module" it is used verbatim.
  Group order: root notebooks ("") first, then declared [[modules]] in declared order,
  then any undeclared subfolders (alphabetical, default-open).
  open logic per group: is_open = author_default(open=) OR group-contains-active. The
  group holding the CURRENT page ALWAYS auto-expands (active notebook never hidden),
  even with open=false. A tiny vanilla <script> persists per-viewer expand/collapse in
  localStorage keyed sn:groups:app/<owner>/<repo>; genuine <summary> clicks persist, the
  programmatic auto-open does not. Flat repos (no module folders) render one plain list.

=== PER-NOTEBOOK EXTRACTION CONTROL ([[notebooks]] + extract=) ===
  Top-level policy:
    extract = "all"      # DEFAULT: every discovered notebook publishes
    extract = "listed"   # only [[notebooks]] entries publish (aliases: "manual", "explicit")
  Per-notebook overrides keyed by repo-relative path (forward slashes) - see the
  [[notebooks]] block in the snapshot.toml above for the full key list.
  Interaction:
    extract="all"   : everything publishes EXCEPT entries with publish=false.
    extract="listed": ONLY [[notebooks]] entries with publish (default true) publish.
  The home/index notebook is ALWAYS kept (it is the landing), regardless of policy.

=== HOMEPAGE SELECTION (resolution order for the collection root) ===
  1. A COMMITTED index.html at the repo root (or [collection]/top-level home=/index=
     pointing to a *.html) REPLACES the shell entirely; its sibling asset dirs
     (assets, public, static, _assets, img, images) are copied alongside.
  2. A notebook named index.jl or home.jl (case-insensitive stem) becomes the
     default-shown home - sorts first in the sidebar, inlined at the root.
  3. Explicit home= / index= pointing to a *.jl names which notebook is the home
     (top-level home="..."/index="...", or nested under [collection] home/index).
  4. Else a generated landing cover grid (one card per notebook, kind badge + desc).
  Notebook "kind" badge per page is derived from real exported coverage.json:
    limited (>=1 @bind cell fell back to static - WASM can't compile it yet) >
    interactive (>=1 working interactive cell, none fell back) > static.
  Badge text in the rendered HTML: "live" for interactive, "limited", "static".

=== THEMES (per-collection, live, NO rebuild) - COLLECTIONS ONLY ===
  Therapy apps bring their own styling (no theme picker). The theme skins the collection
  SHELL chrome (sidebar/topbar/bg); notebook fragments inherit the shell's
  <html data-theme> via DaisyUI --color-* vars.
  Available: all ~35 DaisyUI v5 themes (from CDN daisyui@5/themes.css) PLUS two warm
  brand themes defined inline: snapshot (light, default) and snapshot-dark.
  Precedence (serve() injects whichever is set):  theme_override ?? theme_default ?? "snapshot"
    theme_default  = snapshot.toml theme="..." (baked into manifest.json at build).
    theme_override = dashboard override (sticky across republishes).
  Set the default in snapshot.toml:  theme = "dracula"
  Set/clear the override (owner-gated; instant, no rebuild):
    POST /api/projects/theme  body: {owner, repo, slug, theme}   (all three required)
      theme="" or "default"  -> clears override (falls back to snapshot.toml default)
      else must match  ^[a-z][a-z0-9-]{1,30}$
    -> {ok, theme, effective}     (effective = theme || theme_default || "snapshot")
  Applied at serve time by an HTMLRewriter stamping <html data-theme="<theme>"> onto the
  collection index HTML only (no asset rewrite, instant reskin). The SERVED page has NO
  visitor-facing theme switcher - the theme is the owner's curated choice; the dashboard
  picker (.sn-theme <select> pill on collection cards) is the only way to change it.

=== CI: PER-NOTEBOOK CACHE KEY + PIPELINE (snapshot.yml) ===
  Pipeline jobs: discover (detect kind + notebook matrix, WARM the Julia depot via
  julia-actions/cache, precompile once) -> export (collection: one parallel matrix job
  per notebook, fail-fast:false so one bad notebook can't sink the rest) ->
  collection-publish (gather per-notebook dirs, DEDUPLICATE the WasmMakie font atlas,
  zip -> attest -> OIDC ingest). Apps skip the matrix: a single app-publish job.
  Per-notebook actions/cache@v4 key (a HIT skips Julia entirely - only CHANGED notebooks
  recompile):
    snap-nb-v4-<slug>-<hashFiles(notebook source)>-wt<WasmTarget HEAD SHA>-eng<hashFiles(engine src/assets/Project.toml)>-<SNAPSHOT_OPTIMIZE>-o<SNAPSHOT_ORACLE_SAMPLES>-b<BINARYEN_VERSION>-t<WASMTOOLS_VERSION>
  CACHE OVERRIDES (always re-export): snapshot.toml top-level `cache = false` (whole
  repo) or per-[[notebooks]] `cache = false` (one notebook) - discover emits a `cache`
  flag per matrix entry and the workflow's cache step is `if:`-skipped; plus a `force`
  checkbox on workflow_dispatch (Run workflow) for one-off full rebuilds. Custom
  build.sh / app builds never use this cache (always full).
  Workflow env knobs: SNAPSHOT_WT_REF / SNAPSHOT_ENGINE_REF (engine refs, default main),
  SNAPSHOT_OPTIMIZE (size|speed|debug|off, default size), SNAPSHOT_ORACLE_SAMPLES (oracle
  verify samples, default 5 - in the yml env AND the cache key).
  Build pins node 22, Binaryen version_125, wasm-tools 1.244.0, Julia 1.12.
  IMPORTANT: snapshot.toml, collection.jl and export.jl are NOT in the per-notebook cache
  key. Changing module config / sidebar / extraction busts NO notebook caches - only the
  always-run gather/publish job re-emits shell HTML. (Platform-side: the Worker
  base64-embeds ci/* via gen-templates; repos curl ci/export.jl + ci/collection.jl fresh
  from the Worker each run.)
  Do NOT push while a build is in progress (concurrency: cancel-in-progress kills it).
  Watch a build:
    gh run list -R <owner>/<repo> --workflow=snapshot.yml --limit 1


===================================================================================
PART 3 - THERAPY.JL APPS
===================================================================================

=== WHAT THERAPY.JL IS ===
  Pure-Julia signals web framework. Islands architecture (NOT a SPA): pages render to
  static HTML on the server (Julia), only @island components ship a tiny per-island WASM
  module (1-5 KB) compiled by WasmTarget.jl. Requires Julia 1.12 (WasmTarget IR compat).
  Repo: github.com/GroupTherapyOrg/Therapy.jl  Docs: grouptherapyorg.github.io/Therapy.jl/

=== PROJECT LAYOUT + RUN ===
  A site is a directory with an app.jl, a routes dir, a components dir, an input.css.
  Canonical (app.jl does cd(@__DIR__)):
    docs/
      app.jl
      input.css                 # Tailwind v4 entry (@import "tailwindcss"; ...)
      Project.toml              # deps: Therapy (+ WasmMakie etc.)
      src/routes/               # file-based routes
        index.jl                -> /
        getting-started.jl      -> /getting-started
        examples/index.jl       -> /examples
        users/[id].jl           -> /users/:id        (dynamic - SKIPPED by static build)
        blog/[...slug].jl       -> /blog/*           (catch-all - SKIPPED by static build)
      src/components/           # auto-loaded (recursively) BEFORE routes; islands self-register
        Layout.jl
        InteractiveCounter.jl
  RUN (from the dir containing app.jl, or cd into it inside app.jl):
    julia +1.12 --project=. app.jl dev      # dev server, HMR, http://127.0.0.1:8080
    julia +1.12 --project=. app.jl build    # static site -> output_dir (default "dist")
    julia +1.12 --project=. app.jl build --optim   # wasm-tools size optimization
  Therapy.run(app) dispatches on ARGS[1] ("build" is the default when no arg; "dev" =>
  dev server; "--optim" anywhere in ARGS sets optimize_wasm=true for either).
  Local toolchain here: `~/.juliaup/bin/julia +1.12` (channel 1.12.6).

=== App() CONFIG (all kwargs, exact names from src/App/App.jl) ===
  app = App(
    routes_dir     = "src/routes",      # file-based routes root (default "src/routes")
    components_dir = "src/components",  # auto-loaded recursively; islands register here
    title          = "Therapy.jl App", # used in <title>; non-root pages get "Page - Title"
    output_dir     = "dist",            # static build target (wiped each build)
    layout         = nothing,           # Symbol resolved to a loaded fn, OR a Function (default none)
    base_path      = "",                # e.g. "/Therapy.jl" for GH Pages sub-path hosting
    tailwind       = true,              # true => run Tailwind CLI (auto-downloads v4.1.18)
    dark_mode      = true,              # emits the .dark class init + theme localStorage script
    middleware     = Function[],        # Oxygen-style middleware (dev server only)
    interactive    = [],                # usually unnecessary - islands auto-discover
    routes         = Pair{String,Function}[],  # optional explicit routes (skips discovery if set)
    prebaked_dir   = nothing,           # dir of pre-baked island .js + manifest.toml (skip compile)
  )
  NOTES:
  - base_path is rstrip'd of trailing '/'. Empty string = root hosting.
  - `layout` as a Symbol is resolved AFTER components load (so define Layout(content) in
    components_dir). As a Function it is used directly.
  - The layout fn takes ONE arg: the page content, passed as a RawHtml of the already-SSR'd
    route. Signature: function Layout(content) ... end.
  - A ROUTE FILE must EVALUATE TO a Function (the last value). Either:
      () -> begin ... end   (index.jl style)   OR   function Page() ... end
    The route fn takes no args and returns a VNode tree (page content only, NOT the Layout).
    If a route file returns a non-Function, load_app! @warns and DROPS it.
  - `interactive` manual config takes "Name" => "#selector" pairs (rarely needed; islands
    auto-discover into therapy-island[data-component="<lowercase-name>"] selectors).

=== HTML HELPERS (Capitalized like JSX) ===
  Each is make_element(:tag) returning a VNode. Call: Tag(props_and_children...).
  EXPORTED (available from bare `using Therapy`):
  Layout/text: Div Span P Br Hr H1 H2 H3 H4 H5 H6 Strong Em Code Pre Blockquote
  Links/media: A Img Video Audio Source Iframe
  Forms:       Form Input Button Textarea Select Option Label Fieldset Legend
  Lists:       Ul Ol Li Dl Dt Dd
  Tables:      Table Thead Tbody Tfoot Tr Th Td Caption
  Semantic:    Header Footer Nav MainEl Section Article Aside Details Summary Figure Figcaption
  Other:       Script Style Meta Canvas
  SVG:         Svg Path Circle Rect Line Polygon Polyline Text G Defs Use
  Special:     RawHtml(str)  Fragment(...)  Show(cond) do..end  For(items) do item,i..end
  NOT EXPORTED: `Link` (HTML <link>) exists as Therapy.Link but is NOT in the export list -
    qualify it or use RawHtml/Meta. (Script, Style, Meta ARE exported; Link is not.)
  NOTE: <main> is MainEl (avoids clash with Julia's Main). There is NO Main/Link exported alias.

=== ELEMENT SYNTAX: props vs children ===
  Args are split by parse_element_args: a Symbol => value Pair (Pair{Symbol,<:Any}) or a
  Dict is a PROP; anything else is a CHILD. Keyword args (Tag(...; class="x")) also fold
  into props. Order-independent, mix freely:
    Div(:class => "card", :id => "x", H3("Title"), P("body"))
  - CLASS: :class => "flex gap-4". Helper: tw("flex", cond && "bg-blue-500") filters
    nothing/""/false then space-joins (exported as tw).
  - ATTRIBUTES: underscores in the key become hyphens at render: :data_foo => "1" ->
    data-foo="1". For literal hyphen attr names use Symbol("data-state") => "...".
  - BOOLEAN attrs (BOOLEAN_ATTRIBUTES: disabled, checked, selected, hidden, open, ...)
    render bare when value === true, omitted otherwise.
  - EVENT HANDLERS: key starts with on_. Two forms:
      :on_click => "jsFunc()"          (String) -> onclick="jsFunc()" in SSR
      :on_click => () -> set_x(x()+1)  (closure) -> SKIPPED in SSR, COMPILED to WASM (islands only)
    ALL underscores are stripped from the event key: on_click -> onclick, on_input -> oninput.
  - A signal/memo getter passed as a child or attr value is CALLED at SSR to render its
    current value (e.g. Span(count) prints count()). Inside an island it also becomes reactive.
  - RawHtml(str) injects unescaped HTML (XSS risk with untrusted input). All text/number
    children are HTML-escaped except inside script/style (RAW_TEXT_ELEMENTS). Bool children
    render NOTHING.

=== SIGNALS / REACTIVITY (src/Reactivity) ===
  count, set_count = create_signal(0)        # (getter, setter) callable structs
  count()                                     # read (tracks dep inside an effect)
  set_count(5)                                # write; no-op if new == old (guarded by !=)
  doubled = create_memo(() -> count() * 2)    # derived; read via doubled()
  create_effect(() -> ...)                    # runs now + on dep change
  batch() do; set_a(1); set_b(2); end         # coalesce notifications (glitch-free)
  on_mount(() -> ...)                          # post-mount lifecycle (islands); on_cleanup also exists
  Signals/memos/effects are MEANINGFUL ONLY INSIDE @island bodies (they compile to WASM
  there). In plain SSR components they run once server-side and are static.

=== @island (interactive WASM components) ===
  @island function Counter(; initial::Int = 0)
      count, set_count = create_signal(initial)
      doubled = create_memo(() -> count() * 2)
      create_effect(() -> js("console.log('c', $1, 'd', $2)", count(), doubled()))
      return Div(:class => "flex gap-4",
          Button(:on_click => () -> set_count(count() - 1), "-"),
          Span(count),
          Button(:on_click => () -> set_count(count() + 1), "+"),
          Span("doubled ", Span(doubled)))
  end
  - SSR emits <therapy-island data-component="counter" data-props="{&quot;initial&quot;:0}">
    ...</therapy-island> - data-component is ALWAYS LOWERCASED, and data-props is a
    DOUBLE-QUOTED, HTML-escaped JSON string with keys sorted ALPHABETICALLY (JS reads
    props by index = alpha order). WasmTarget compiles signals->globals,
    handlers/effects/memos->wasm fns; the browser hydrates via a cursor (no new DOM
    nodes). Use the island like a normal component: Counter(initial=5).
  - HARD RULE: every kwarg MUST be type-annotated (initial::Int = 0, title::String). A
    bare count = 0 is a macro-expansion ERROR: "@island <Name>: kwarg `count` needs a
    type annotation".
  - PROPS ARE COMPILE-TIME CONSTANTS. Whatever prop values exist when the page is
    pre-rendered get BAKED into the WASM module (e.g. SearchableList(items_data=["Julia",
    ...]) embeds that vector). The build pre-renders every static route to populate
    ISLAND_PROPS_CACHE before compiling islands. Islands cannot read arbitrary
    runtime/localStorage state directly - seed via kwargs and patch dataset.props from a
    pre-hydration <script>; Therapy writes props into signals before first flush.
  - js("code", $1, $2) is the raw-JS escape hatch (no-op in Julia; emits JS in WASM). $N
    interpolates the compiled value of the Nth extra arg.
  - on_click/on_input handlers can read `children`; child slot via Wrapper() do; P("x"); end
    (renders as <therapy-children>; island must reference the bare symbol children in its body).

=== ISLAND GOTCHA: js("...",$N) goes in create_effect, NEVER in on_click ===
  Inside :on_click/:on_input closures call SETTERS ONLY: () -> set_x(1 - x()). Do NOT call
  js(...) (interpolated or not) from a handler closure - WasmTarget mis-indexes the
  handler export into an import slot (the export points at the last js.js_hN import), so
  the click routes to the wrong wasm fn: signals silently never update, NO console error.
  Put ALL DOM writes / localStorage / console.log in create_effect(() -> js("...", sig())).
  Effects interpolate $N correctly; handlers do not. (Re-evaluate if the underlying
  WasmTarget codegen bug is fixed.)

=== @island SIGNALS ARE COMPILE-TIME CONSTANTS - PERSIST VIA JS ===
  @island signal initial values are BAKED at compile time; compiled_get_prop_i32 returns
  the prop INDEX during SSR, and hydration may not read patched data-props reliably. Do
  NOT try to drive a signal's init value from localStorage via data-props patching. For
  persisted UI state (panel open/close, prefs): SSR the element hidden (display:none),
  restore on load from localStorage with plain JS, toggle display + localStorage.setItem
  on click, style with [data-state="on"]. Reserve @island for truly reactive state that
  needn't survive a page load.

=== SSR + HYDRATION MODEL ===
  - Pages render server-side to HTML. Body wrapper is <div id="therapy-content">. The
    Layout's inner content wrapper MUST carry id="page-content" (the ClientRouter swap target).
  - Each island ships an inline IIFE (<script>) + inline base64 WASM; the signal runtime
    script (window.__therapy reg/set/get pub/sub) loads first. Hydration walks existing DOM
    (data-hk cursor; island content resets its hk counter to 0), attaches reactivity,
    creates zero nodes.
  - Static build writes <route>/index.html (root -> index.html), plus 404.html, .nojekyll,
    styles.css. DYNAMIC routes (:param, *) are SKIPPED by build (dev server serves them).

=== CLIENT ROUTER (Astro-style View Transitions, NOT an SPA bundle) ===
  Auto-injected into every page by App.jl via client_router_script(content_selector=
  "#page-content", base_path=<base_path in build / "" in dev>). (The function's OWN default
  content_selector is "#therapy-content", but App.jl always overrides it to "#page-content".)
  Intercepts internal <a> clicks, fetch()es the next page, swaps #page-content via
  document.startViewTransition, diffs <head>, re-hydrates new islands, handles popstate.
  Singleton-guarded: if (window.TherapyRouter) return;.
  CRITICAL RULES:
  - The Layout's content container MUST be id="page-content" or loadPage can't find it and
    falls back to a full window.location.href reload (white flicker). Canonical:
        MainEl(:id => "page-content", :class => "flex-1 ...", content)
  - Inline non-island <script>s do NOT re-run after a swap UNLESS their textContent
    .includes one of: therapy-island, TherapyHydrate, __therapy, __tw, wasmmakie. For a
    hand-written client script add one of those markers (e.g. a /* __therapy */ comment) so
    the router re-executes it, AND make it idempotent + guard async loops with a generation
    token:
        /* __therapy */ (function(){ var GEN=(window.__snGen=(window.__snGen||0)+1);
          /* in any setTimeout/poll: if (GEN!==window.__snGen) return; */ })();
    The router fires NO lifecycle event (init only adds click + popstate listeners) - the
    marker is the only hook. (__snGen is a Snapshot-app convention, not a Therapy built-in.)
  - Layout scripts OUTSIDE #page-content (theme init, auth) persist across swaps and
    correctly don't re-run.
  - NavLink(href::String, children...; class="", active_class="active", inactive_class="",
    exact=false) renders an <a> with data-navlink="true" + data-active-class (and
    data-inactive-class / data-exact only when set); the router toggles active/inactive
    classes on nav. NavLink does NOT auto-prefix base_path - pass the full href. Hash-only
    (#anchor) same-page links are left to the browser.

=== HTML5 TAG-NAME COLLISION GOTCHA ===
  Therapy bulk-exports HTML5 constructors INCLUDING Figure, Section, Header, Footer,
  Details, Summary, Canvas, Line, Table, Text, etc. `using WasmPlot` (or WasmMakie) after
  `using Therapy` makes both export Figure -> Julia refuses to pick -> UndefVarError:
  Figure not defined / "two or more modules export this name". In any module/codegen
  pulling Therapy + a plotting/data lib:
    import WasmMakie as WM                      # then WM.Figure, WM.Axis, WM.lines!
    using Therapy: Div, RawHtml, create_signal, @island, ...   # narrow `using`, not bare `using Therapy`
  Inside auto-loaded components_dir files this rarely bites (the TherapyApp module does
  `using Therapy`), but ANY file that also `using`s a colliding dep must qualify.

=== THEMES (Tailwind v4 CSS-first; DaisyUI path is INFERRED, see caveat) ===
  - Tailwind v4 is CSS-first: configuration lives in input.css, NOT a JS config object.
  - tailwind=true runs the Tailwind CLI (auto-downloaded v4.1.18, cached in
    <DEPOT_PATH>/tailwind/v4.1.18/, i.e. ~/.julia/tailwind/v4.1.18/), finds input.css in
    ./dirname(routes_dir)/dirname(components_dir), outputs styles.css. If the CLI can't run
    it falls back to the CDN script (with darkMode:'class' in tailwind.config). input.css
    is auto-created if missing.
  - Dark mode is CLASS-based: put @custom-variant dark (&:where(.dark, .dark *)); in
    input.css (NOT a JS darkMode:'class'). App injects a script that toggles .dark on <html>
    from localStorage key therapy-theme (or therapy-theme:<base_path>) / prefers-color-scheme.
  - Minimal input.css (matches the actual docs/input.css):
        @import "tailwindcss";
        @source "./src/**/*.jl";       /* scan .jl for class names */
        @custom-variant dark (&:where(.dark, .dark *));
        @theme { --color-accent-500: #389826; --font-sans: 'Inter', sans-serif; }
  - data-theme switching: the dark_mode script ALSO reads localStorage key
    suite-active-theme (or :<base_path>) and, when its value is non-empty and != "default",
    sets data-theme on <html>. Therapy.jl's OWN docs site implements themes (ocean/minimal/
    nature) as hand-rolled CSS custom-property blocks ([data-theme="ocean"] {
    --color-accent-500: ... }) in input.css - it does NOT use DaisyUI. The DaisyUI route
    (App(tailwind=false) + your own npm Tailwind+DaisyUI build + @plugin "daisyui"; in
    input.css, themes via data-theme) is UNVERIFIED inside Therapy.jl; confirm exact plugin
    syntax against a real DaisyUI site (Suite.jl / Snapshot) before relying on it.

=== base_path SUB-PATH HOSTING (the Snapshot-app CRITICAL rule) ===
  Apps are hosted at /app/<owner>/<repo>/, so the app MUST read SNAPSHOT_BASE_PATH
  (Snapshot sets ENV["SNAPSHOT_BASE_PATH"]="/app/<owner>/<repo>"):
    app = App(base_path = get(ENV, "SNAPSHOT_BASE_PATH", ""), ...)
  Therapy does NOT auto-prefix raw A() hrefs - prefix EVERY internal link AND asset URL
  yourself or they 404:
    A(:href => "$(base)/getting-started/", ...)   Img(:src => "$(base)/logo.png")
    NavLink hrefs likewise must include the prefix (NavLink does not add it).
  - Therapy does NOT emit <base href> (it breaks #hash links). In BUILD mode the
    router/asset paths are prefixed with base_path; the page carries data-base-path and
    styles.css is linked at <base_path>/styles.css. The router strips base_path
    (normalizePath) when matching active links.
  - DEV mode uses base_path="" for the router so /getting-started/ resolves locally;
    hard-coded prefixed hrefs still resolve in dev because the router normalizes. Keep
    links absolute-with-prefix.

=== HMR (dev) ===
  FileWatching (kqueue/inotify) -> surgical per-island recompile (~2-3s) -> WS push.
  Signal state is preserved across an island swap if signal count+types match (counter
  stays at 7); else fresh start. Component edit = island re-hydrate (state kept); route
  edit = full page reload; CSS edit = hot inject.

=== app MODE BUILD: [app] TABLE + build_app! ===
  Build step precedence: snapshot.toml [app] command  >  a build.sh next to the app  >
  `julia --project=. <entry> build`. Snapshot first runs Pkg.instantiate() in the app dir
  AND best-effort instantiates any immediate sub-dir that is its own project (sidecar
  envs; failures only WARN). The build must produce the `output` dir (default dist/) or it
  errors.
  Use build.sh (chmod +x) when you need a second Julia env or system pkgs (npm for
  Tailwind/DaisyUI):
    #!/usr/bin/env bash
    set -euo pipefail
    cd "$(dirname "$0")"
    julia --project=. app.jl build
    npm i
    npx @tailwindcss/cli -i input.css -o dist/styles.css --minify
  Or the equivalent via [app] command:
    command = "julia --project=build_env -e 'using Pkg; Pkg.instantiate()' && julia --project=. app.jl build"

=== CUSTOM BUILDS (build.sh) - ANY TOOLCHAIN, NO JULIA REQUIRED ===
  The build path is NOT Julia-only. A repo with NO Julia project still builds: set
  type="build" (aliases site/custom/static) OR just drop a root build.sh with no
  notebooks. Snapshot runs build.sh in YOUR GitHub Actions and publishes whatever it
  writes to the output dir (snapshot.toml [app] output, default dist/). When there is no
  Project.toml in the build dir, Snapshot SKIPS Julia entirely (no instantiate) and lets
  build.sh own the whole build - so Node, Python, Hugo, Zola, a Makefile, plain bash all
  work. Contract:
    # snapshot.toml
    type = "build"
    [app]
    output = "dist"        # the folder build.sh writes (default; can omit)
    # build.sh  (chmod +x)
    #!/usr/bin/env bash
    set -euo pipefail
    cd "$(dirname "$0")"
    node build.mjs         # ...or any commands; install tools, fetch data, render pages
  SAFETY: the build runs in your own CI (Snapshot never executes build.sh on its infra);
  its output is ingested with the same size/zip/path-traversal limits as everything else,
  and PUBLIC build outputs are served on the sandbox origin apps.snapshot.show (so a
  hosted page's JS is cross-origin to the dashboard/session). [app] output must be a
  relative in-repo path (no leading "/" or ".."). If no build step is found (no command,
  no build.sh, no app.jl) the build errors clearly.
  Live example: github.com/Dale-Black/snapshot-build-demo (a Node build, no deps).

=== MINIMAL END-TO-END THERAPY APP ===
  # app.jl
  using Therapy
  cd(@__DIR__)
  app = App(routes_dir="src/routes", components_dir="src/components",
            title="Demo", output_dir="dist",
            base_path=get(ENV,"SNAPSHOT_BASE_PATH",""), layout=:Layout)
  Therapy.run(app)

  # src/components/Layout.jl
  function Layout(content)
      Div(:class => "min-h-screen flex flex-col",
          Nav(:class => "h-16 px-6 flex items-center", NavLink("/", "Home"; class="font-bold")),
          MainEl(:id => "page-content", :class => "flex-1 max-w-3xl mx-auto px-6 py-8", content))
  end

  # src/components/Counter.jl   (island auto-registers on load)
  @island function Counter(; initial::Int = 0)
      n, set_n = create_signal(initial)
      return Div(:class => "flex gap-3",
          Button(:on_click => () -> set_n(n() - 1), "-"), Span(n),
          Button(:on_click => () -> set_n(n() + 1), "+"))
  end

  # src/routes/index.jl
  () -> Div(:class => "space-y-6", H1("Hello"), Counter(initial=3))


===================================================================================
PART 4 - PLUTO NOTEBOOKS -> LIVE WASM ISLANDS  (the highest-value, gotcha-dense part)
===================================================================================

=== WHAT IT IS (Snapshot.jl) ===
  A Pluto .jl notebook compiled to a serverless interactive page: each group of
  co-dependent @bind variables is extracted to pure Julia, compiled to a tiny WasmGC
  module via WasmTarget.jl, and run in-browser. No slider server, no Julia backend, no
  precomputed staterequest files. Ships on any static host.
  Repo: github.com/GroupTherapyOrg/Snapshot.jl  License: MIT
  Docs/gallery (also the public scoreboard of which notebooks ship how many islands and
  why the rest don't): grouptherapyorg.github.io/Snapshot.jl/
  (Snapshot calls export_notebook(...; therapy=true, fragment=true) for you per-notebook;
  you usually only WRITE notebooks following the rules below, and let CI compile them.)

=== THE TWO EXPORT MODES (one entry point) ===
  using Snapshot
  # RECOMMENDED - lean Therapy component: SSR'd cells + wasm islands, NO Pluto frontend,
  # NO baked statefile. Themeable, drops into any static host or Therapy.jl app. This is
  # what Snapshot + the docs gallery serve.
  export_notebook("notebook.jl"; therapy=true)
  # CLASSIC - full Pluto static export (heavy: baked statefile + Pluto frontend via
  # jsdelivr CDN). Same islands under the hood. Emits notebook.html + notebook.islands/
  # (the group_N.wasm + shim.js + islands.json manifest).
  export_notebook("notebook.jl")

  export_notebook(path; ...) - EXACT KWARGS (src/exporter.jl:243):
    output_dir=dirname(abspath(path))   # where .html + <name>.islands/ go
    islands::Bool=true                  # false -> plain static, no wasm
    verify::Bool=true                   # run the Node byte-exact + oracle checks
    oracle_samples::Integer=5           # sampled bond values per group the oracle compares
    max_wasm_size_per_group=5_000_000   # bytes; bigger group -> judged fallback
    optimize=false                      # WasmTarget -O level on EVERY island, then verify/
                                        #   oracle run on the OPTIMIZED bytes:
                                        #   false=off | true/:size=-Os | :speed=-O3 | :debug=-O1
    baked_state::Bool=true              # classic only: inline pluto_statefile
    baked_notebookfile::Bool=true       # classic only: inline the .jl source
    disable_ui::Bool=true               # classic only: hide Pluto editor chrome
    pluto_cdn_root=nothing              # classic only: override jsdelivr frontend root
    session=nothing                     # reuse a Pluto.ServerSession
    env_dir=nothing                     # Pkg.activate escape hatch for UNREGISTERED pkgs
                                        #   (WasmMakie etc.); must STAY active through the
                                        #   oracle's bond re-runs
    shared_fonts_path=nothing           # font dedup: write the Makie atlas ONCE per
                                        #   collection; islands load it by URL
    therapy::Bool=false                 # the lean mode (above)
    theme_picker::Bool=true             # therapy only: floating DaisyUI theme picker.
                                        #   pass FALSE when embedding in a host with its own picker
    fragment::Bool=false                # therapy only: ALSO write <name>.fragment.html - a
                                        #   native-inline component (no <html>/<body>, CSS
                                        #   @scope-isolated to .pi-notebook) for inlining
                                        #   into a host page WITHOUT an iframe
    assets_base=""                      # therapy+fragment only: base the host rewrites into
                                        #   the fragment's asset URLs
  Returns the export .html path.
  Integrator API (you already have a running notebook / a PSS-style pipeline):
    islands_dirname = generate_wasm_islands(session, notebook, original_state;
        output_dir, url_path, verify=true, oracle_samples=5,
        max_wasm_size_per_group=5_000_000, fallback_warnings=true,
        shared_fonts_path=nothing, optimize=false, env_dir=nothing)
        # (src/exporter.jl:33; original_state::Dict, url_path required)
    html = inject_islands_script(html, islands_dirname)   # classic Pluto html only

=== THE 2-LAYER RENDERING MANDATE (non-negotiable; therapy=true path) ===
  Every cell renders in TWO layers; an island is reactive OR loudly-warned, NEVER blank,
  NEVER a baking hack masking a broken island.
  LAYER 1 - BASE (always): each cell's Pluto-captured output.body SSR'd 1-to-1.
    - Use Pluto's decisions verbatim; do NOT reinvent. cell.output.body empty (trailing ;
      suppressed it) => no output div. code_folded=true => code ABSENT from DOM (Therapy
      Show(false)), not CSS-hidden. md"..." cells => rendered prose, never the source.
    - String bodies baked as-is; tree+object bodies (Vector/Tuple/struct =
      application/vnd.pluto.tree+object, a Dict) rendered via _tree_html to Pluto's own
      <pluto-tree>/<p-r>/<p-k>/<p-v> DOM so the ported treeview.css styles it.
    - Each cell mount is <pluto-output class="rich_output" id="out-<cellid>">. An island
      REPLACES this base content on a successful wasm render.
    - Optimization: an island whose baked base body > 8192 bytes is emitted empty (the
      island re-renders it; avoids redundant bloat). Relies on the island + the loud
      warning on failure (src/exporter.jl:471).
  LAYER 2 - ISLANDS (on top): reactive cells re-render from wasm. If the island THROWS at
    runtime, shim.js prepends a loud Pluto-native `!!! warning` admonition (warning_node,
    class "pss-island-fallback-warning") INTO each of the group's cell mounts. Groups that
    couldn't COMPILE at export time ship static + the same warning naming the exact reason.
    Never silently blank.

=== WHAT A CELL BECOMES (cell "kind" in islands.json - src/compile.jl:235) ===
  "string"  -> text/html/markdown output => reactive DOM (innerHTML / set_text)
  "tree"    -> tuple/struct/array => Pluto tree DOM (shared tree renderer)
  "canvas"  -> a WasmMakie.Figure cell (duck-typed: return type named Figure from a module
               named WasmMakie). FIGURES RENDER TO A BASE64 IMG, NOT a live <canvas>: the
               wasm draws into an offscreen canvas then shim.js does cv.toDataURL() ->
               <img class="wasmmakie-island">. Its oracle is command-stream equality, not
               byte equality. (image/png matrix-of-color cells also ship as kind "canvas".)

=== HOW IT RUNS IN-BROWSER (therapy=true wiring) ===
  - A tiny inline script delegates `input` events on the document to bond elements:
    collectBonds() does querySelectorAll("bond[def]") -> reads the inner input/select/
    textarea -> builds {bondName: value} keyed by the `def` attribute -> calls
    window.__pi_renderAll(bondValues).
  - Value encoding MUST match the shim's value_tree contract per input type:
      range/number -> Number ;  checkbox -> Boolean ;
      date/datetime-local/month/week/time -> {__pluto_date_ms: inp.valueAsNumber}
        (NOT a raw string - a raw string makes value_tree decode the DateTime struct leaf
         as undefined => BigInt(undefined) throws => the whole group blanks) ;
      everything else -> string.
  - assets/shim.js exposes window.__pi_renderAll as the single shared marshalling core;
    both the lean path and the classic fetch-interception path call render_group_cells.

  islands.json MANIFEST (in <name>.islands/, src/compile.jl ~L562):
    { "fallback_warnings": <bool>,
      "bond_graph": {...},   // FULL graph: island + fallback groups
      "groups": [ { "wasm": "group_0.wasm",
                    "bonds":  [ {"name","desc","initial","transform"}, ... ],
                    "cells":  [ {"id","fn","kind":"string|tree|canvas","desc"}, ... ],
                    "canvas_glue":  <js|null>,    // canvas2d imports + load_fonts for figures
                    "canvas_fonts": <json|null> } ], // inline atlas, OR null when deduped
      "fonts_url": <relpath> }  // present only when shared_fonts_path was used

=== EMBEDDING A PUBLISHED NOTEBOOK ===
  Classic / generic: iframe the published notebook's URL.
    <iframe src="https://<your-snapshot-site>/<slug>/" loading="lazy"
            style="width:100%;border:0"></iframe>
  Native inline (no iframe), in a Therapy host: export with
    export_notebook("nb.jl"; therapy=true, fragment=true, theme_picker=false,
                    assets_base="https://host/notebooks-static")
  then RawHtml-inject <slug>.fragment.html. The fragment is a <div class="pi-notebook">
  with all its CSS wrapped in @scope (.pi-notebook){...} so it doesn't leak to/from the
  host; the host's <html data-theme=...> reskins chrome AND the notebook's cells together
  (one DaisyUI theme swap; the serving Worker can override data-theme at serve time with
  zero rebuild).


-----------------------------------------------------------------------------------
PART 4B - AUTHORING NOTEBOOKS THAT COMPILE TO LIVE WASM (THE HARD RULES)
-----------------------------------------------------------------------------------
Snapshot publishes each Pluto notebook via the Snapshot.jl WASM export (WasmTarget 0.4+).
Every @bind-reactive cell is compiled cell-by-cell to a WebAssembly island. A differential
ORACLE compares the WASM output against native Julia on a few bond samples; ANY mismatch
silently drops that cell back to a static snapshot (with a loud build warning) - the
notebook still "works" but interactivity is gone. The rules below are what make cells
compile and match first try; each violation has caused a real, slow-to-find regression.
Reference notebooks (copy their shape verbatim) live in
Dale-Black/snapshot-computational-thinking: module1/newton_method.jl,
module2/monte_carlo_pi.jl, module2/random_walks.jl.

--- RULE 0: DEPS = PlutoUI + WasmMakie + hand-rolled numerics ONLY ---
  The embedded Project has EXACTLY two deps. Nothing else compiles reliably:
    [deps]
    PlutoUI    = "7f904dfe-b85e-4ff6-b463-dae2292396a8"
    WasmMakie  = "782397d3-b2e0-4093-86f4-3070b4a5c6bd"
    [sources]
    WasmMakie  = {url = "https://github.com/GroupTherapyOrg/WasmMakie.jl"}
    [compat]
    PlutoUI    = "~0.7.83"
    WasmMakie  = "~0.1.0"
  Copy the FULL Manifest cell verbatim from newton_method.jl (it pins julia_version =
  "1.12.6", project_hash 528a15ccaaea2a8f2cfb8a3b8ef12bd3bc6e7ee4, PlutoUI 0.7.83,
  WasmMakie 0.1.0). Identical deps -> identical resolved env -> matches a known-good island
  build. Do NOT add Distributions / DifferentialEquations / Plots / ForwardDiff / Images /
  Colors / ImageShow / Random - they inflate manifest resolution or fail to compile.
  Hand-roll instead:
    randomness:  Park-Miller LCG (s = (s*16807) % 2147483647; u = Float64(s)/2147483647.0)
    seeding:     s = (seed*2654435761 + 12345) % 2147483647; if s==0; s=1; end
    ODEs:        explicit Euler steps (x += dt*f(x))
    gradients:   finite differences ((f(x+h)-f(x-h))/2h)
    derivatives: hand-written analytic fprime (newton_method.jl supplies f' per case)

--- RULE 1: Float @bind values miscompile at depth>=2 / through a helper / mismatched bond-set ---
  A Float bound variable silently reverts to the slider DEFAULT (e.g. prob=0.5) when:
    - it is used at loop-nesting depth >= 2 (a loop inside a loop), OR
    - it is threaded through a HELPER FUNCTION, OR
    - the island's bond-SET differs from a sibling island's.
  Integer bonds are rock-solid; Float bonds are fragile. (A known WasmTarget gap as of
  0.3.20; current builds use 0.4+ - re-test on newer WT before assuming the workarounds.) FIXES:
    (a) make the slider an INTEGER and derive the Float IN-CELL:
          @bind r0x10 Slider(0:30; default=20)   # then r0 = Float64(r0x10)/10.0
    (b) use the Float bond DIRECTLY in the island cell at single-loop depth (see
        random_walks.jl `path` cell: prob is read inline, never via a helper).
    (c) to run "many sub-simulations", FLATTEN to ONE loop - a single RNG stream chopped
        into chunks - so the bond stays at depth 1. random_walks.jl runs nwalks*nsteps as
        ONE flat loop with `if j == nsteps` chunk boundaries, NOT a walk-loop nested in a
        walks-loop. (That nested form was the multi-day time-sink.)
        NOTE: random_walks.jl's flat histogram/stats loops use a FIXED seed (s=777) and a
        FAIR coin (u<0.5), so prob is not even read there. If your flattened many-sims loop
        must use the Float bond, keep it at single-loop depth and inline (never via a helper).

--- RULE 2: live (bond-dependent) markdown must be ASCII-ONLY ---
  Multibyte unicode in a bond-dependent md cell renders as &#NNNN; entities in WASM but
  literal in native -> oracle mismatch -> fallback. Banned in LIVE md: the approx sign,
  sqrt symbol, em-dash, division sign, arrow, degree, pi, superscript-2 and friends. Write
  "is about", "sqrt(steps)", "--", "pi", "^2".
  STATIC md cells (NO bond interpolation) are SSR'd by native Julia - unicode/LaTeX is FINE
  there. newton_method.jl's prose cells use $\sqrt 2$, $x_{n+1}$ freely BECAUSE they are
  static; its live readout `**Final estimate of the root:** $(root_estimate)` is plain
  ASCII. Keep whole numbers/ASCII in any cell that interpolates a bond.

--- RULE 3: iterate INTEGER ranges, never StepRangeLen ---
  StepRangeLen iteration (for t in 0.0:0.1:1.0) is not WASM-compilable. Iterate an Int
  range and build float axes by hand:
    for k in 0:steps; t = lo + (hi-lo)*k/steps; ...; end
  (newton_method.jl figure cell; random_walks.jl steps_axis cell.)

--- RULE 4: WasmMakie = bare Figure / Axis / lines! only (in WASM) ---
  Proven WASM-safe: lines!, scatter!, image! (also barplot!/heatmap! - see PART 5).
  DEFINED + exported in WasmMakie but UNPROVEN in WASM: hist! surface! contour! stairs!
  density! errorbars! hlines! vlines! scatterlines! ... - AVOID for new notebooks. No
  labels, legends, titles, hidedecorations assumed for a generic figure.
  Minimal pattern:
    fig = Figure(size = (560, 320)); ax = Axis(fig[1, 1])
    lines!(ax, xs, ys); fig
  Build histograms/bars as vertical lines! SEGMENTS (one bar = lines!(ax,[c,c],[0,h]));
  build a convergence/running-estimate as a single polyline lines!(ax, xs, ys).
  (random_walks.jl draws its bell-curve histogram as 41 vertical lines! bars.)
  For images use the gray_figure pattern (flat Vector{Float64} -> Vector{NTuple{4,Float64}}
  -> image!(ax,(0.0,Float64(nc)),(0.0,Float64(nr)),pix,Int64(nc),Int64(nr); interpolate=false)).
  image! signature: image!(ax::Axis, xspan::Tuple{Real,Real}, yspan::Tuple{Real,Real}, ...).

--- RULE 5: live numeric READOUT must be a SEPARATE bond-dependent tuple cell (the 2-cell split) ---
  PI renders the md skeleton ONCE (native, default bonds) and only RECOMPILES interpolation
  slots that reference "reactive names" = bonds + OTHER CELLS' cell-level definitions. A
  value computed as the md cell's OWN let-local is NOT a reactive name -> the slot is deemed
  constant -> BAKED AT THE SLIDER DEFAULT. Symptom: readout text never changes when you drag
  the slider; oracle fails.
  WRONG (bakes to defaults):
    md"""estimate = $(let s=...; loop over bonds; est end)"""
  RIGHT (the 2-cell split - verified, monte_carlo_pi.jl):
    mc_stats = let s=(seed*...)%...; ...loop over ndarts...; (floor(est*1e5)/1e5, floor(err*1e5)/1e5) end;
    md"""**Estimate from $(ndarts) darts: $(mc_stats[1])** ... off by $(mc_stats[2])"""
  The TUPLE cell is a cell-level (reactive) name, so it gets real bonds and its slots
  recompile live; the md cell is PURE interpolation. Do NOT use a single
  `begin; a,b = let..end; md... end` cell - the cell-level destructure compiles to a runtime
  WebAssembly.Exception (keep the tuple as one name and index it: mc_stats[1]).

--- RULE 6: a readout must depend on the SAME bond set as its figure (Firefox trap) ---
  A readout that uses a STRICT SUBSET of its figure's bonds can pass in Chrome + the node
  oracle but FALL BACK only in Firefox/Zen ("Not interactive in this export", and the Float
  tuple renders as raw i64 bit-patterns). FIX: make the readout genuinely USE all the
  figure's bonds (run the same simulation). coverage.json / the LIVE badge come from the
  node build-oracle and do NOT catch browser-runtime failures - a Firefox-only break is
  mislabeled "LIVE". Spot-check the DEPLOYED page in a real browser. (A real bit-pattern is
  a 15+ digit INTEGER with no decimal point; a legit long float like sqrt(2)=
  1.4142135623730951 is FINE - don't chase that false positive.)

--- RULE 7: NEVER show a raw Float tuple/vector compute cell - suppress with `;` OR stringify ---
  A SHOWN cell whose value is Tuple{Float64,...} or Vector{Float64} displays each float as
  its i64 BIT PATTERN (e.g. (4624746457346762342, ...) instead of (15.2, ...)). This is a PI
  shim limitation (the "bits" tree node is reused for both display and feeding values back
  into WASM; it cannot be fixed shim-side without breaking the feed). TWO fixes, both used in
  the reference notebooks:
    (a) end the plumbing cell with `;` so the value stays defined + reactive but hidden:
          rw_stats = let ...; (floor(m*100)/100, floor(sd*100)/100, ...) end;   # trailing ;
        (random_walks.jl rw_stats / path; monte_carlo_pi.jl mc_stats)
    (b) if you WANT to display the values, convert to strings first so they render identically
        to native: iterates = string.(newton_sequence(which, x0, n))  (newton_method.jl SHOWS
        this Vector because string.() sidesteps the bit path).
  A single SCALAR Float shows fine (no tree); only raw Float tuples/vectors hit the bit path.

--- RULE 8: let CI verify; do not bisect slowly locally ---
  Each export_notebook compiles every island via the node oracle (~13 min per WasmMakie
  notebook for a full local compile). Author with these rules, push, and let the parallel
  Snapshot CI compile+verify; graceful loud-warning fallback covers misses. Only export
  locally to debug a specific confirmed failure. The TRUTH signal is the collection
  coverage.json (verified live):
    https://snapshot.show/app/Dale-Black/snapshot-computational-thinking/coverage.json
      -> {"cells":{"fallback":0,"interactive":83,"total":83},
          "notebooks":{"interactive":24,"limited":0,"static":1}}
  cells.fallback MUST be 0. Per-notebook coverage lives at
    <page-slug>/<notebook-stem>.islands/coverage.json   (verified live)
  where the page slug is module-prefixed + hyphenated but the islands dir keeps the
  underscore stem, e.g.:
    .../module1-newton-method/newton_method.islands/coverage.json
       -> {"cells":{"fallback":0,"interactive":6,"total":6},"groups":{...,"island":1,...}}
  Local one-notebook compile (~13 min; kwargs verified against PI source):
    julia --project=Snapshot.jl -e 'using Snapshot; export_notebook("nb.jl"; output_dir="out", verify=true, oracle_samples=5, optimize=:size)'
  -> read out/<stem>.islands/coverage.json (cells.fallback must be 0). Valid optimize values:
  false | true/:size | :speed | :debug.

--- ASSORTED NOTEBOOK GOTCHAS (each one silently kills a notebook) ---
  - Cell-UUID prefixes MUST be valid hex [0-9a-f]. `pc0000...` throws in Base.UUID() -> the
    WHOLE notebook fails to load and renders fully static, masking every other error. Use hex
    pairs (newton uses real Pluto UUIDs; monte_carlo: c3a0xxxx; random_walks: b1a0xxxx).
  - NO Matrix literals - they trap inside WASM kernels. Use flat column-major Vectors + an
    (nr,nc) shape; index out[(j-1)*nrows + i].
  - Discrete uniform sampling: use integer state mod, NOT floor/trunc: face = (s % 6) + 1.
    Gaussian: Box-Muller from two unif() (sqrt/log/cos all compile).
  - One @bind per concept; keep the bound expression WASM-friendly (no heavy deps in the
    reactive path). Sliders: good range + sensible default + show_value=true.
  - Must serve over HTTP, not file:// - the runtime fetch()es the wasm/manifest/fonts;
    file:// blocks it (the classic "nothing is reactive" red herring).
  - Cells inside Pluto run with currentScript/invalidation bound; the lean export wraps each
    inline output <script> in an IIFE binding those + deferring to DOMContentLoaded. Benign
    console noise: "currentScript is not defined", "input_el already declared" from WasmMakie
    figure embeds.
  - PlutoUI.TableOfContents cells are skipped (its widget needs Pluto's DOM); the lean shell
    renders a faithful vanilla aside ToC instead.
  - A wiring/encoding fix does NOT need an island recompile - a "reuse-islands re-emit" (open
    nb -> generate_therapy_html over the committed <slug>.islands/) is the fast path; a full
    rebuild force-recompiles (wasteful).
  - Per-owner ingest size is limited; very large multi-WasmMakie collections can OOM the
    Worker (128 MB) -> publish 503. Keep collections modest or per-module.

--- COMPLETE MINIMAL WORKING SKELETON (copy, fill in your numerics) ---
  Save as e.g. demo.jl. Replace the Manifest body with newton_method.jl's verbatim.

  ### A Pluto.jl notebook ###
  # v0.20.28

  #> [frontmatter]
  #> chapter = "2"
  #> section = "9"
  #> title = "My Live WASM Demo"
  #> tags = ["lecture", "module2", "interactive"]
  #> layout = "layout.jlhtml"
  #> description = "One-line summary; runs in your browser as WebAssembly."
  #> license = "MIT"

  using Markdown
  using InteractiveUtils

  macro bind(def, element)
      #! format: off
      return quote
          local iv = try Base.loaded_modules[Base.PkgId(Base.UUID("6e696c72-6542-2067-7265-42206c756150"), "AbstractPlutoDingetjes")].Bonds.initial_value catch; b -> missing; end
          local el = $(esc(element))
          global $(esc(def)) = Core.applicable(Base.get, el) ? Base.get(el) : iv(el)
          el
      end
      #! format: on
  end

  # CB aa000002-0000-4000-8000-000000000002
  begin
      using PlutoUI, WasmMakie
  end

  # CB aa000003-0000-4000-8000-000000000003
  PlutoUI.TableOfContents(aside = true)

  # CB aa000001-0000-4000-8000-000000000001
  md"""
  # My Live WASM Demo
  Static prose may use unicode/LaTeX freely: error shrinks like 1/sqrt(N), root is sqrt(2).
  """

  # CB aa000004-0000-4000-8000-000000000004
  md"""
  samples = $(@bind nsamp Slider(500:500:20000, show_value=true, default=4000))

  seed = $(@bind seed Slider(1:50, show_value=true, default=7))
  """

  # CB aa000005-0000-4000-8000-000000000005
  let
      s = (seed * 2654435761 + 12345) % 2147483647
      if s == 0; s = 1; end
      xs = Vector{Float64}(undef, nsamp)
      ys = Vector{Float64}(undef, nsamp)
      inside = 0
      for k in 1:nsamp
          s = (s * 16807) % 2147483647; x = Float64(s) / 2147483647.0
          s = (s * 16807) % 2147483647; y = Float64(s) / 2147483647.0
          if x*x + y*y <= 1.0; inside = inside + 1; end
          xs[k] = Float64(k); ys[k] = 4.0 * Float64(inside) / Float64(k)
      end
      fig = Figure(size = (600, 340)); ax = Axis(fig[1, 1])
      lines!(ax, [1.0, Float64(nsamp)], [3.14159265, 3.14159265])
      lines!(ax, xs, ys); fig
  end

  # CB aa000006-0000-4000-8000-000000000006
  demo_stats = let
      s = (seed * 2654435761 + 12345) % 2147483647
      if s == 0; s = 1; end
      inside = 0
      for k in 1:nsamp
          s = (s * 16807) % 2147483647; x = Float64(s) / 2147483647.0
          s = (s * 16807) % 2147483647; y = Float64(s) / 2147483647.0
          if x*x + y*y <= 1.0; inside = inside + 1; end
      end
      est = 4.0 * Float64(inside) / Float64(nsamp); err = est - 3.14159265
      if err < 0.0; err = -err; end
      (floor(est * 100000.0) / 100000.0, floor(err * 100000.0) / 100000.0)
  end;

  # CB aa000007-0000-4000-8000-000000000007
  md"""**Estimate from $(nsamp) samples: $(demo_stats[1])** (true 3.14159...).
  Off by about **$(demo_stats[2])**. Error shrinks like 1/sqrt(N).
  """

  # CB 00000000-0000-0000-0000-000000000001
  PLUTO_PROJECT_TOML_CONTENTS = """
  [deps]
  PlutoUI = "7f904dfe-b85e-4ff6-b463-dae2292396a8"
  WasmMakie = "782397d3-b2e0-4093-86f4-3070b4a5c6bd"

  [sources]
  WasmMakie = {url = "https://github.com/GroupTherapyOrg/WasmMakie.jl"}

  [compat]
  PlutoUI = "~0.7.83"
  WasmMakie = "~0.1.0"
  """

  # CB 00000000-0000-0000-0000-000000000002
  PLUTO_MANIFEST_TOML_CONTENTS = """
  ...PASTE the FULL Manifest body verbatim from module1/newton_method.jl
  (julia_version = "1.12.6", project_hash = "528a15ccaaea2a8f2cfb8a3b8ef12bd3bc6e7ee4")...
  """

  # Cell order block: every cell's UUID MUST appear once. The order marker prefix (shown
  # in the real file as box-drawing glyphs) chooses code visibility:
  #   code-shown  prefix  -> the cell's code IS in the DOM (used for plotting/code cells)
  #   code-folded prefix  -> code folded/absent (used for md + the Project/Manifest cells)
  # In the real .jl these are the "# (cell-order)" header lines after "# Cell order:".
  # The two Project/Manifest cells ALWAYS keep UUIDs 0000...0001 / 0000...0002.

  NOTE: "CB" above stands in for the real Pluto cell-begin marker (a box-drawing glyph
  line); copy a reference notebook's exact byte sequence rather than retyping it.


===================================================================================
PART 5 - WASMTARGET (what compiles to WASM) + WASMMAKIE (plotting)
===================================================================================

=== WASMTARGET.JL: WHAT COMPILES, WHAT DOES NOT ===
  WasmTarget is the Julia->WebAssembly (WasmGC) compiler under every island. It replaces
  Julia's final codegen stage: Julia parses/lowers/infers/optimizes to fully-typed IR,
  WasmTarget emits WasmGC bytecode from that IR. No LLVM, no runtime, no server. Pure
  numeric kernels compile to IMPORT-FREE .wasm (modules that touch print/show or string
  interop import the standardized wasm:js-string builtins + a small io module). You do NOT
  call it directly (Snapshot.jl does), but every reactive cell must stay inside its subset
  or the island silently degrades to static. This section is the subset.
  Version: v0.4.2 (Apache-2.0). Requires Julia 1.12 OR 1.13 (typed-IR is version-specific).
  NOTE: the SciML SimpleDiffEq/StaticArrays integrations below landed after the 0.3.20
  tag (included in 0.4+) - verify the installed version if an ODE/SVector island fails to compile.

  CORE PRINCIPLE - CORRECT-OR-LOUD: strict=true is the DEFAULT. If codegen hits something
  it cannot lower faithfully, it raises WasmCompileError naming the construct + source
  location - it does NOT silently emit a wrong value. validate=true (default) runs
  wasm-tools on every module -> WasmValidationError on bad bytes. The differential fuzzer
  (~600 Base operation signatures) reports 0 silent divergences and 0 open gaps:
  unsupported constructs fail LOUDLY (compile error or trap), never miscompile.
  compile(f,(T,); strict=false) = permissive stub-and-trap (do NOT use for notebooks).

  WHAT COMPILES WELL
  Language: Int 8/16/32/64/128-bit (Julia wrap + over-shift semantics; 128-bit CHECKED
  arithmetic is a strict reject), Float32/Float64 (IEEE 754, correctly-rounded ^),
  if/else/while/for, mutable+immutable structs, Tuple/NamedTuple, Vector + Matrix, UTF-8
  String, closures (incl. over Dicts/Vectors, passed to higher-order fns),
  try/catch/finally + nested + catchable Base errors (BoundsError/DivideError/DomainError/
  OverflowError/InexactError), Union{Nothing,T} + small unions, Dict/Set, splatting
  f(args...), keyword args, multi-function modules, externref JS interop.
  Base: most of Base compiles from its REAL implementation because Julia inlines
  aggressively (sum(v) arrives as a flat loop in the caller). Discovery is closed-world
  trim collection (discovery=:trim default); ~100 core @overlay methods patch Base fns that
  reach GC internals / ccall / pointer math (push!/pop!/filter on WasmGC arrays, split/join/
  replace, string(::Float64) via Ryu, sinh/hypot/pow_body, reinterpret->Core.bitcast).

  Stdlib (zero-dep weakdep extensions - `using Statistics` activates it, nothing else):
    Statistics 100%   mean/var/std/cor/median/quantile (+ !-forms), bit-exact
    LinearAlgebra 97% det/inv/\/norm/dot/cross, lu/cholesky/eigen/svd OBJECTS, Diagonal/
                      Symmetric/Triangular, mul!/ldiv!. OUT: qr/schur/lq, general/complex eigen
    Random 100%       (Julia <=1.12 ONLY) seeded Xoshiro: rand/randn/randexp, shuffle,
                      randperm, seed!. GATED on 1.13. OUT: rand!/randn! array fills (SIMD
                      llvmcall), bitrand, OS entropy
    SparseArrays 100% SparseMatrixCSC, nnz/findnz, sparse matmul, +/-/transpose, spdiagm.
                      OUT: sparse \ / factorizations, sprand/sprandn
    Dates 96%         Date/DateTime/Time, arithmetic, accessors, dayname. OUT: format DSL,
                      canonicalize, now/today (need host time)
  SciML (each runs in a FROZEN offline wasm module):
    ForwardDiff 100%  derivative/gradient/jacobian/hessian (+ !-forms). EXACT derivatives.
                      OUT: Config/Chunk preallocation API (use the standard API)
    StaticArrays 100% the SVector{N,T} surface only: construct/getindex/iterate/reductions/
                      arithmetic/dot/broadcast. OUT: SMatrix, MArray, MVector
    SimpleDiffEq 100% solve ODEs in wasm: fixed-step SimpleEuler/SimpleRK4/SimpleTsit5/
                      LoopEuler/LoopRK4 over scalar/Vector/SVector state, incl.
                      ODEProblem(f,u0,tspan,p) with p an NTuple. OUT: adaptive SimpleATsit5,
                      Vector-typed p. PORTABILITY GATE: multi-stage Vector-STATE ODEs compile
                      on Julia <=1.12 but are SKIPPED on 1.13 (a tracked stack-threading
                      codegen bug; LOUD validation error, not a miscompile). Scalar,
                      SVector-state, and parameterized ODEs work on BOTH - author multi-dim
                      state as SVector, not Vector, to stay portable.

  WHY FLOAT TUPLES DISPLAY AS INTEGERS (the Bridge):
  WasmTarget.Bridge does BIT-EXACT JS<->wasm value transport; Snapshot.jl consumes it to
  marshal slider bonds. Floats NEVER cross as decimal text - they cross as their i64 BIT
  PATTERN: read side reinterpret(Int64, ::Float64) (Float32 transported as the
  exactly-widened Float64 bits because reinterpret(Int32,::Float32) currently miscompiles),
  JS decodes via DataView.getFloat64. Char crosses as Int64(codepoint(c)). Every numeric
  leaf leaves the module as an exact integer string (stringified BigInt) tagged by a
  descriptor tree.
    CONSEQUENCE: if you read/log a Float return WITHOUT its Bridge descriptor (raw wasm
    export, or a tuple whose float field's descriptor was dropped), you see the raw i64
    bits - a giant integer like 4607182418800017408, NOT 1.0. That is the f64 bit pattern,
    not a bug. A "Float tuple showing as integers" symptom = a value crossed the boundary
    without the matching descriptor decode. (This is the root cause behind RULE 6 / RULE 7
    above.)
  Bridge universe (both directions): Int8-64, UInt8-64, Bool, Float32/64, Char, String,
  Tuple, NamedTuple, (im)mutable/parametric structs, Vector{T}, Matrix{T}, recursively.
  Outside it (Dict/Set/ranges/abstract) the descriptor returns nothing -> caller falls back
  (a cell returning a Dict can't round-trip a value to JS, even though Dict compiles internally).

  KNOWN GAPS THAT BITE NOTEBOOK AUTHORS (anything whose INFERRED TYPE IS ABSTRACT is rejected/traps):
  - Heterogeneous-key Dict literals: Dict(Int32(0)=>0, some_int64=>0) promotes to an
    unparameterized Dict. FIX: promote keys so all pairs share one concrete type.
  - Mixed Char/String/SubString varargs beyond 2 args -> the vararg tuple widens to a Union.
    Only 2-arg combos are covered.
  - Matrix literals of tuples [(a,b) (c,d); ...] -> hvncat recurses in compilation. FIX:
    build with Matrix{T}(undef,m,n) + explicit stores.
  - Type-unstable / boxed / dynamically-dispatched code is REJECTED, not guessed. Pre-flight
    author-side with JET (@report_call), AllocCheck (@check_allocs), or DispatchDoctor
    (@stable). WasmTarget ships none of these - they are linters that make code strict the
    way the compiler wants.
  - Vector{String} as a reactive SIGNAL global needs externref globals; plain numeric
    signals are i64.

  PRACTICAL AUTHORING RULES:
  - Write type-stable cells. If JET/@code_warntype shows a Union or Any in a hot return, the
    island will fail to compile (loudly) - restructure rather than hope.
  - Use Int bonds and DERIVE floats inside the cell (cheaper, dodges Float-bond edge cases).
    Prefer SVector over allocating Vectors in ODE state - also keeps multi-dim ODEs portable.
  - Reach for the supported stdlib/SciML names above; avoid the OUT items. For randomness,
    seed Xoshiro explicitly and stay on Julia <=1.12 semantics.
  - A green build that renders STATIC (no live recompute on slider move) means the island
    degraded - the cell left the subset. Treat it as a compile-subset miss, not a CI issue.

=== WASMMAKIE.JL (plotting) ===
  Makie's plotting API rendered through HTML Canvas2D, with the plotting logic (ticks,
  layout, color, draw) compiled to a WasmGC module by WasmTarget.jl. No Julia server, no
  precomputed state at run time. Repo: github.com/GroupTherapyOrg/WasmMakie.jl  Docs:
  grouptherapyorg.github.io/WasmMakie.jl/  Pre-alpha (v0.1.2). One stdlib dep (Base64).
  Julia 1.12/1.13. Makie pin 0.24.11. HOST-AGNOSTIC by mandate: src/ contains NO
  Therapy/Pluto code; do NOT add such imports inside WasmMakie.
  Install (not yet in General):
    using Pkg; Pkg.add(url = "https://github.com/GroupTherapyOrg/WasmMakie.jl")

  TWO RENDER MODES - know which one you are in (the ctx you pass to render!):
    RecordingCtx  - host-side (native Julia). Records a Canvas2D command stream.
                    html_snippet(fig) uses this. FULL API works here.
    WasmCtx       - the compiled path. render!(fig, WasmCtx()) emits wasm import calls. Only
                    a SUBSET is proven to compile (PROVEN-IN-WASM below).
  Static export (RecordingCtx, no wasm - figure already computed):
    import WasmMakie as WM
    fig = WM.Figure(size = (600.0, 400.0))
    ax  = WM.Axis(fig[1, 1]; title = "waves", xlabel = "x", ylabel = "sin")
    xs  = collect(0.0:0.05:6.3)
    WM.lines!(ax, xs, [sin(x) for x in xs]; linewidth = 2.0)
    write("plot.html", WM.html_snippet(fig))   # self-contained: canvas+glue+fonts+stream
  show(io, MIME"text/html", fig) calls html_snippet - so a Figure displays inline in ANY
  notebook/docs system that honors text/html.

  CORE API (exact identifiers). Floats (not Ints) REQUIRED for sizes/data if the code may be
  wasm-compiled; host-side RecordingCtx tolerates Ints (the README's Int example is host-only):
    Figure(; size=(600.0,450.0), backgroundcolor=(1.,1.,1.,1.), figure_padding=16.0)
      (size/backgroundcolor/figure_padding are the ONLY Figure kwargs)
    fig[row, col] -> GridPosition ; fig[1, 1:2] spans columns (UnitRange ok)
    Axis(fig[1,1]; title="", xlabel="", ylabel="", subtitle="", titlealign=:center)
      (those 5 are the ONLY Axis constructor kwargs)
  Plot mutators (all take ax first, return the plot struct):
    lines!(ax, x, y; color, linewidth=1.5, linestyle=:solid, label="")
      lines!(ax, x, f::Function) allowed host-side (applies f over x via Float64(f(v))).
    scatter!(ax, x, y; color, markersize=9, marker=:circle, strokecolor=:black, strokewidth=0, label="")
    barplot!(ax, x, y; color, gap=0.2, dodge, n_dodge, dodge_gap=0.03, stack, strokecolor, strokewidth, label)
    heatmap!(ax, xs, ys, values::AbstractMatrix; colorrange=(NaN,NaN))   # auto if NaN
    heatmap!(ax, values::AbstractMatrix)                                  # 1:n edges
    image!(ax, (x0,x1), (y0,y1), pixels::AbstractMatrix{NTuple{4,Float64}}; interpolate=true)
  Also exported (host-side proven, NOT in the wasm corpus): hlines! vlines! hspan! vspan!
    ablines! linesegments! scatterlines! stairs! hist! errorbars! rangebars! stem! density!
    band! pie! boxplot! violin! crossbar! series! waterfall! contour! contourf! mesh!
    surface! meshscatter!
  Layout / decoration: colsize!(fig,i,sz) rowsize!(fig,i,sz) with Relative(f)/Fixed(px)/
    Auto(); hidedecorations!/hidexdecorations!/hideydecorations!/hidespines!(ax,:l,:r,:t,:b);
    axislegend(ax; position=:rt, nbanks=1); Colorbar(fig[1,2], hm; label="", vertical=true).

  COLORS - restricted. _color accepts an (r,g,b,a) NTuple{4,Float64} (0..1), OR one of these
    13 Symbol names: :black :white :red :green :blue :orange :purple :gray :grey :cyan
    :magenta :yellow :transparent (:gray/:grey are aliases). ANY OTHER -> error("unknown
    named color"). There is NO 3-tuple/RGB form and NO Colorant/named-CSS support - use a 4-tuple:
      lines!(ax, xs, ys; color = (0.2, 0.4, 0.9, 1.0))   # not (0.2,0.4,0.9)
    Unset color -> Wong 7-color cycle per axis (wraps mod1 7).
  MARKERS - only :circle :rect :utriangle ; else error().
  LINESTYLE - :solid :dash :dot :dashdot (those 4; else error()). Dashes compile.

  PROVEN IN WASM vs PROVEN HOST-SIDE ONLY (the load-bearing distinction):
  The wasm corpus (13 scenes, Chromium-rendered, scored against real Makie) covers ONLY
  these compiling through WasmCtx:  lines! scatter! barplot! heatmap! image!  + 2x2 grid +
  titles/labels/axis decor. The wasm path is additionally GATED on command-stream equality
  with the host-side RecordingCtx run of the same program. Everything else in the export
  list (legend, Colorbar, violin, boxplot, pie, band, density, stem, contour, mesh, surface,
  series, ...) is proven ONLY host-side via RecordingCtx - it renders in html_snippet but is
  NOT yet demonstrated to AOT-compile to wasm. For a WASM ISLAND, stay inside the proven five
  primitives unless you verify the kernel compiles. (Bars/hist/stem/errorbars are built from
  lines!/segments/scatter under the hood, so simple histogram-as-bars and stem-as-segments
  are reachable.)

  WASM-KERNEL AUTHORING RULES (when render!(fig, WasmCtx()) must compile):
  - Concrete numeric types only: Float64 positions/sizes (size=(400.0,300.0)),
    Vector{Float64} data. No Int where a Float is expected, no Any, no Dict{Symbol,Any}.
  - NO Matrix construction inside a wasm kernel - Matrix literals trap. Use the FLAT
    column-major overloads:
      heatmap!(ax, xs, ys, values::Vector, nx::Int64, ny::Int64)   # col-major flat
      image!(ax,(x0,x1),(y0,y1), pixels::Vector{NTuple{4,Float64}}, ni::Int64, nj::Int64)
      series!(ax, flat::Vector, nseries::Int64, npoints::Int64)
    Example kernel (matches shipping corpus k10_heatmap_viridis):
      function k10()
        fig = WM.Figure(size=(400.0,300.0))
        WM.heatmap!(WM.Axis(fig[1,1]), [0.,1.,2.,3.], [0.,1.,2.],
                    [1.,2.,3.,4.,5.,6.], Int64(3), Int64(2))   # 3x2 col-major
        WM.render!(fig, WM.WasmCtx()); return Int64(0)
      end
  - Build vectors with push! loops, NOT comprehensions; pass args positionally; build
    strings byte-wise. (Filed WasmTarget gaps.)
  - axislegend uses direct Symbol comparison (string(::Symbol)+char-indexing traps in wasm).

  EMBEDDING CONTRACT (what a HOST consumes - generic, no framework refs). Exported surface:
    import_specs()  -> Canvas2D wasm import surface (generated from the 65-row ops table;
                       hosts NEVER hand-copy the import list)
    js_glue()       -> canonical JS import-object factory (canvas2d_imports) + canvas2d_load_fonts
    js_specs()      -> op signature table (JSON) for the replayer
    replay_js()     -> command-stream replayer source (assets/replay.js)
    font_faces_json() / FONT_FACES -> bundled-font FontFace payload (data: URLs)
    html_snippet(fig; id, fonts=true)  -> self-contained STATIC fragment (RecordingCtx)
    wasm_html_snippet(bytes, export_name; width, height, id, fonts) -> fragment around a
       HOST-COMPILED wasm module (width/height REQUIRED; WasmMakie NEVER compiles - the host
       owns WasmTarget)
  Completion signal: the <canvas> gets data-wasmmakie-done="1" once drawn - hosts/tests await
  this. The wasm instance is instantiated with imports {canvas2d: canvas2d_imports(canvas),
  Math:{pow: Math.pow}, io: noop-Proxy} and { builtins:["js-string"] }; the host keeps the
  instance and re-calls the export to re-render on state change (stateless recompute - no Observables).

  THERAPY ISLAND PATTERN (host-side; register_canvas_provider!/Canvas live in THERAPY, not WasmMakie):
    Therapy.register_canvas_provider!(name = "WasmMakie",
        import_specs = WasmMakie.import_specs, js_glue = WasmMakie.js_glue)
    @island function LivePlot()
      freq, set_freq = create_signal(Int64(3))
      create_effect(() -> begin
        fig = WM.Figure(size=(600.0,300.0)); ax = WM.Axis(fig[1,1]; title="live")
        # build from freq() using the 5 proven primitives ...
        WM.render!(fig, WM.WasmCtx())     # draws to the island's <canvas>
      end)
      Div(Canvas(:width=>600, :height=>300),
          Button(:on_click => () -> set_freq(freq()+Int64(1)), "+"))
    end
  Always `import WasmMakie as WM` and call WM.Figure (Therapy also exports Figure - collision).


===================================================================================
PART 6 - DATA WITHOUT A BACKEND + JULIA/PKG/BUILD GOTCHAS
===================================================================================

=== DATA WITHOUT A BACKEND (Supabase from the browser) ===
  A Snapshot site is static (edge-hosted HTML + WASM, no server you run). To get real data
  - sign-ups, comments, saved state - the page talks DIRECTLY to a free edge DB (Supabase /
  PostgREST) from the browser using the PUBLIC anon/publishable key. Row-Level Security
  (RLS) in Postgres is the actual protection, NOT key secrecy. Snapshot's own dashboard uses
  this same recipe.
    CORE RULE: the anon/publishable key is MEANT to be public and ships in the page. NEVER
    commit/ship a service_role key or any other secret. Safety = RLS + column GRANTs +
    locked-down SQL functions in Postgres, never the key. (Real demo ships a
    sb_publishable_... key in the page; service_role is never in the browser.)

  READ (GET) - direct table select, no server:
    fetch(SB.url + "/rest/v1/demo_guestbook?select=id,name,message,created_at&order=created_at.desc&limit=50",
          { headers: { apikey: SB.key, authorization: "Bearer " + SB.key } })
    // returns JSON array; PostgREST params: select=, order=col.desc, limit=, eq.<val>, etc.
    // RLS SELECT policy + column-level GRANT decide what anon can read.

  WRITE (POST) - two valid shapes:
  1) Simplest (the docs.jl snippet): insert straight into the table. An RLS INSERT policy
     gates it - you MUST add that policy + grant or it 401/403s. (The real demo's table has
     NO insert policy, so raw-POST works only on a table you set up for it.)
    fetch(SUPABASE_URL + "/rest/v1/guestbook", {
      method: "POST",
      headers: { apikey: ANON_KEY, authorization: "Bearer " + ANON_KEY, "content-type": "application/json" },
      body: JSON.stringify({ name, message }),
    })
    // add header  prefer: "return=representation"  to get the inserted row(s) back.
  2) HARDENED (what the real demo ships): anon has NO direct insert/update/delete; ALL writes
     go through SECURITY DEFINER Postgres functions called via /rest/v1/rpc/<fn>. This lets
     Postgres enforce length caps, per-IP rate limits, total-row caps, per-owner delete - a
     crafted request to the raw table can't bypass them.
    await fetch(SB.url + "/rest/v1/rpc/add_guestbook", {
      method: "POST",
      headers: { apikey: SB.key, authorization: "Bearer " + SB.key, "content-type": "application/json",
                 prefer: "return=representation" },
      body: JSON.stringify({ p_name: name, p_message: message, p_token: TOK }) })
    // DELETE only your own row (p_token = per-browser uuid in localStorage 'gb-token'; server stores only its hash)
    await fetch(SB.url + "/rest/v1/rpc/delete_guestbook", {
      method: "POST", headers: {...},
      body: JSON.stringify({ p_id: Number(id), p_token: TOK }) })

  The Postgres side (db.sql at repo root, idempotent, re-runnable):
    alter table demo_guestbook enable row level security;
    revoke all on demo_guestbook from anon, authenticated;
    grant select (id, name, message, created_at) on demo_guestbook to anon, authenticated; -- hides ip/owner hashes
    create policy gb_read on demo_guestbook for select to anon, authenticated using (true);
    -- NO insert/update/delete policy -> writes only possible via the functions below
    create or replace function add_guestbook(p_name text, p_message text, p_token text)
      returns bigint language plpgsql security definer set search_path = public as $$ ... $$;
    create or replace function delete_guestbook(p_id bigint, p_token text)
      returns boolean language plpgsql security definer set search_path = public as $$ ... $$;
    revoke all on function add_guestbook(text,text,text) from public;
    grant execute on function add_guestbook(text,text,text) to anon, authenticated;
    -- IPs/tokens stored ONLY as salted sha256 (built-in sha256()/convert_to(), no pgcrypto needed)
  Full recipe: github.com/Dale-Black/snapshot-app-demo (src/routes/guestbook.jl + db.sql).
  Live: snapshot.show/app/Dale-Black/snapshot-app-demo/

  EMBEDDING THE CONFIG + Therapy client-router gotcha (CRITICAL):
  The demo injects the public config via a <script> in a RawHtml():
    RawHtml("""<script>/* __therapy ... */
    window.DEMO_SB = { url: "https://<proj>.supabase.co", key: "<PUBLIC anon/publishable key>" };
    </script>""")
  Therapy's client router (View Transitions) swaps ONLY #page-content on nav - the browser
  does NOT execute <script> tags arriving via that DOM swap. So an inline page script
  silently never runs on the FIRST client-side nav into the page (looks dead until a full
  refresh). FIX: put the literal token __therapy in the script body - the Therapy client
  router scans swapped-in content for a script containing __therapy and re-executes it. Also
  use a generation counter so in-flight async from a prior run bails before rendering into a
  detached DOM node:
    var GEN = (window.__gbGen = (window.__gbGen || 0) + 1);
    ... if (GEN !== window.__gbGen) return; ...   // after each await, before touching DOM
  Related: if a fetch-driven list shows N duplicate rows DURING a run but snaps to 1 on
  completion, it's a duplicated addEventListener inside an on_mount/IIFE setup re-run on nav
  - guard setup with a singleton flag (if(window._x)return;window._x=1), NOT the listeners.
  It is NOT a streaming/buffer race.

=== JULIA / PKG / BUILD GOTCHAS (these silently break agent builds) ===
  - STDLIBS STILL NEED [deps] ENTRIES. Downloads, SHA, etc. are stdlib but you must add an
    explicit [deps] = "uuid" line in Project.toml to import/using them from a package.
    Omitting it = "package X not found".
  - MANIFEST.toml IS GITIGNORED. Don't git add it; don't rely on it existing in CI. CI
    re-resolves via Pkg.instantiate from Project.toml + [sources].
  - [sources] ONLY WORKS FOR THE ROOT PROJECT. Every unregistered transitive dep must be
    listed in the ROOT Project.toml's [deps] + [sources]. A [sources] in a dependency is ignored.
  - NODE 22 IS REQUIRED for WASM (WasmGC). The export oracle / WasmTarget emit WasmGC (type
    bytes 0x50/0x5f). node <=20 needs --experimental-wasm-gc, and that flag is REMOVED on
    node 25 (`node --experimental-wasm-gc` errors "bad option", so you cannot just add it);
    node 22+ has WasmGC ON by default. On old node, EVERY island silently degrades to static
    with GREEN CI and a live page showing "0/N interactive cells live across 0 notebooks" -
    one env misconfig silently nukes the whole corpus. In CI pin:
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
    Two follow-on traps: (1) if you cache _nbout/export output, node-version is NOT in the
    cache key - bump a cache-version suffix (pi-nbexport-vN) when changing node, or you hit a
    poisoned static cache. (2) Add a WasmGC preflight that fails LOUD: node -e compiling the
    minimal module  new Uint8Array([0,97,115,109,1,0,0,0,1,3,1,0x5f,0])  and exit 1 if it can't.
  - HTTP EXPORTS `run` -> AMBIGUITY. In any module doing `using HTTP`, call Base.run(cmd)
    explicitly for shelling out; bare run() collides with HTTP.run.
  - `@assert x !== y` IS A PARSEERROR on Julia 1.12. Use `@assert !isnothing(x)`; never !==
    inside @assert.
  - pipeline() HAS NO `env` KWARG. Set env on the Cmd first: setenv(cmd, env) then
    Base.run(pipeline(cmd, ...)).
  - include() RESOLVES RELATIVE TO THE SCRIPT'S DIR, not cwd. In app.jl start with
    cd(@__DIR__) (the demo does) so build paths are stable.
  - Local toolchain here: `~/.juliaup/bin/julia +1.12` (channel 1.12.6).


===================================================================================
PART 7 - PUBLISH (the end-to-end steps)
===================================================================================
  1. Build it. Verify it works locally:
       app:        julia +1.12 --project=. app.jl dev   (then ... app.jl build -> dist/)
       collection: author notebooks per PART 4B; let CI compile, or one local
                   export_notebook(...; verify=true) to debug a confirmed failure.
  2. Push to a GitHub repo (branch first if you are on the default branch; commit/push only
     when the user asks).
  3. Sign in at https://snapshot.show, install the "Snapshot" GitHub App on the repo, and
     Enable the repo from the dashboard (writes .github/workflows/snapshot.yml).
  4. Push to main/master (or `gh workflow run snapshot.yml -R <owner>/<repo> --ref main`).
     The build runs in YOUR Actions and publishes via OIDC; the dashboard shows the live URL:
       app:        https://snapshot.show/app/<owner>/<repo>/
       collection: https://snapshot.show/app/<owner>/<repo>/<slug>/
  5. Confirm: GET the URL (expect 200). For collections, GET
     /app/<owner>/<repo>/coverage.json and check cells.fallback == 0; spot-check a live page
     in a real browser (the node oracle does not catch Firefox-only runtime breaks).
  6. (Optional) Set visibility/sharing via the dashboard Share dialog, or theme via the
     dashboard picker (collections only) - both instant, no rebuild.
  Watch a build:  gh run list -R <owner>/<repo> --workflow=snapshot.yml --limit 1
  If publish 413s after a green build, the bundle exceeds the free-tier size caps (PART 1) -
  trim islands or split per-module. 403 = repo not enabled.

Now ask me the scoping questions at the top, propose a short plan, and build it - following
every rule above exactly.

Paste it into your coding agent, then answer its questions. It will scaffold the repo, build your site, and tell you how to publish on Snapshot.

Sign out?

You will need to sign in again to get back to your dashboard.

Sign out