Update secure site and Forgejo release integration

ober

a940a7c88952e3a890835576917bc356141221ae

diff --git a/README.md b/README.md
index b1df70f..3b8e9e0 100644
--- a/README.md
+++ b/README.md
@@ -21,11 +21,11 @@ LLM-assisted documentation guardrails.
 
 The signed v0.2.5 release is live. `/install.sh` returns HTTP 200 with a
 bootstrap script that downloads the SSH-signed installer from the canonical
-SourceHut repo. The installer verifies an Ed25519-signed manifest before
+Forgejo repo. The installer verifies an Ed25519-signed manifest before
 trusting any artifact.
 
-The public signer key (`ssh-ed25519 AAAAC3NzaC1...zeUQ`, identity `~lisp`)
-is documented in the [release notes](https://git.sr.ht/~lisp/jerboa/refs/v0.2.5)
+The public signer key (`ssh-ed25519 AAAAC3NzaC1...zeUQ`, identity `ober`)
+is documented in the [release notes](https://git.jerboa.sh/ober/jerboa/releases/tag/v0.2.5)
 and pinned in [`support/installer-release.lock`](support/installer-release.lock).
 
 See [`docs/installer-trust.md`](docs/installer-trust.md) for the full trust
@@ -34,11 +34,9 @@ reproducibility evidence.
 
 ## Package Registry
 
-The site documents the built-in `@lisp` TUF registry at `/packages/`.
-The registry lives at [git.sr.ht/~lisp/jerboa-registry](https://git.sr.ht/~lisp/jerboa-registry)
-and is served via the SourceHut `/blob/main/<path>` endpoint (the
-`/refs/download/` endpoint is blocked by the go-away anti-bot proxy for
-anonymous scripted clients).
+The site documents the built-in `ober` TUF registry at `/packages/`.
+The registry lives at [git.jerboa.sh/ober/jerboa-registry](https://git.jerboa.sh/ober/jerboa-registry)
+and is served via Forgejo raw file endpoints.
 
 ## Build
 
diff --git a/assets/favicon.ico b/assets/favicon.ico
new file mode 100644
index 0000000..37570a1
Binary files /dev/null and b/assets/favicon.ico differ
diff --git a/assets/repl/SHA256SUMS b/assets/repl/SHA256SUMS
new file mode 100644
index 0000000..c0babc1
--- /dev/null
+++ b/assets/repl/SHA256SUMS
@@ -0,0 +1 @@
+2e5b07eb2c6f388cd2fb941a32482d8a07af5fcdefe43049bf0d1dfb95bf7ca6  jerboa-repl.wasm
diff --git a/assets/repl/jerboa-repl.wasm b/assets/repl/jerboa-repl.wasm
new file mode 100755
index 0000000..7f0415d
Binary files /dev/null and b/assets/repl/jerboa-repl.wasm differ
diff --git a/assets/repl/manifest.json b/assets/repl/manifest.json
new file mode 100644
index 0000000..c013359
--- /dev/null
+++ b/assets/repl/manifest.json
@@ -0,0 +1,14 @@
+{
+  "schema_version": 1,
+  "abi_version": 1,
+  "subset_revision": "browser-subset-137",
+  "jerboa_version": "0.2.4",
+  "jerboa_commit": "05264127a90185d78efeb5cca33f05ae0bad6752",
+  "rust_toolchain": "1.94.1",
+  "rust_target": "wasm32-unknown-unknown",
+  "wasm_filename": "jerboa-repl.wasm",
+  "wasm_sha256": "2e5b07eb2c6f388cd2fb941a32482d8a07af5fcdefe43049bf0d1dfb95bf7ca6",
+  "wasm_bytes": 1146780,
+  "wasm_import_count": 0,
+  "wasm_memory_max_bytes": 33554432
+}
diff --git a/assets/repl/repl-worker.js b/assets/repl/repl-worker.js
new file mode 100644
index 0000000..d1d375f
--- /dev/null
+++ b/assets/repl/repl-worker.js
@@ -0,0 +1,86 @@
+let wasm = null;
+let memory = null;
+let ready = false;
+
+const decoder = new TextDecoder();
+const encoder = new TextEncoder();
+
+function resultEnvelope() {
+  const ptr = wasm.repl_result_ptr();
+  const len = wasm.repl_result_len();
+  const view = new Uint8Array(memory.buffer, ptr, len);
+  return JSON.parse(decoder.decode(view));
+}
+
+async function init(id, wasmUrl, expectedAbi) {
+  const response = await fetch(wasmUrl, { cache: "no-store" });
+  if (!response.ok) {
+    throw new Error(`engine fetch failed: ${response.status}`);
+  }
+  const contentType = response.headers.get("content-type") || "";
+  if (contentType.split(";")[0].trim() !== "application/wasm") {
+    throw new Error("engine response is not application/wasm");
+  }
+  const instanceResult = await WebAssembly.instantiateStreaming(response, {});
+  wasm = instanceResult.instance.exports;
+  memory = wasm.memory;
+  const abi = wasm.repl_abi_version();
+  if (abi !== expectedAbi) {
+    throw new Error(`engine ABI ${abi} does not match ${expectedAbi}`);
+  }
+  wasm.repl_engine_info();
+  const info = resultEnvelope();
+  ready = true;
+  postMessage({
+    kind: "ready",
+    id,
+    abi,
+    engineVersion: "0.1.0",
+    subsetRevision: "browser-subset-137",
+    info,
+  });
+}
+
+function evalSource(id, source) {
+  if (!ready || !wasm) {
+    throw new Error("engine is not ready");
+  }
+  const bytes = encoder.encode(source);
+  const ptr = wasm.repl_input_alloc(bytes.length);
+  if (ptr === 0) {
+    throw new Error("engine rejected input allocation");
+  }
+  new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes);
+  const started = performance.now();
+  wasm.repl_eval(bytes.length, 250000);
+  memory = wasm.memory;
+  postMessage({
+    kind: "result",
+    id,
+    result: resultEnvelope(),
+    elapsedMs: Math.round(performance.now() - started),
+  });
+}
+
+onmessage = (event) => {
+  const message = event.data || {};
+  Promise.resolve()
+    .then(() => {
+      if (message.kind === "init") {
+        return init(message.id, message.wasmUrl, message.expectedAbi);
+      }
+      if (message.kind === "eval") {
+        return evalSource(message.id, message.source || "");
+      }
+      if (message.kind === "info") {
+        wasm.repl_engine_info();
+        postMessage({ kind: "result", id: message.id, result: resultEnvelope(), elapsedMs: 0 });
+        return undefined;
+      }
+      throw new Error("unknown worker message");
+    })
+    .catch((error) => {
+      ready = false;
+      postMessage({ kind: "fatal", id: message.id, message: String(error && error.message || error) });
+    });
+};
diff --git a/assets/repl/repl.css b/assets/repl/repl.css
new file mode 100644
index 0000000..106552c
--- /dev/null
+++ b/assets/repl/repl.css
@@ -0,0 +1 @@
+.repl-shell{max-width:1280px;margin:0 auto;padding:1rem clamp(1rem,3vw,2rem) 2rem}.repl-head{display:flex;align-items:center;justify-content:space-between;gap:1rem;border-bottom:1px solid var(--line);padding:.8rem 0 1rem}.repl-head h1{font-size:1.7rem;line-height:1.1;margin:0;color:#101923}.repl-subset{font-size:.8rem;font-weight:850;color:#0b6b5c;border:1px solid #9bd1c6;border-radius:999px;padding:.18rem .55rem;background:#f2fffb}.repl-status{color:#526578;font-size:.92rem}.repl-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:1rem;min-height:calc(100vh - 12rem);padding-top:1rem}.repl-pane{display:flex;flex-direction:column;min-width:0}.repl-toolbar{display:flex;align-items:center;gap:.55rem;min-height:2.6rem}.repl-toolbar button,.repl-toolbar select{border:1px solid #c9d5de;background:#fff;color:#172230;border-radius:6px;min-height:2.25rem;font:inherit;font-weight:800}.repl-toolbar button{padding:.5rem .72rem;cursor:pointer}.repl-toolbar select{max-width:12.5rem;padding:.46rem .55rem}.repl-toolbar button:disabled{cursor:not-allowed;opacity:.55}.repl-toolbar button[data-repl-run]{background:#172230;color:#fff;border-color:#172230}.repl-editor-shell,.repl-transcript{width:100%;min-height:30rem;flex:1;border:1px solid #cbd8e2;border-radius:8px;background:#fff;color:#172230;font:14px/1.55 SFMono-Regular,ui-monospace,Consolas,monospace;overflow:hidden}.repl-editor-shell{position:relative;background:#fbfdff}.repl-highlight,.repl-editor{position:absolute;inset:0;width:100%;height:100%;margin:0;border:0;border-radius:8px;background:transparent;font:inherit;line-height:inherit;letter-spacing:0;padding:1rem;tab-size:2;white-space:pre;overflow:auto}.repl-highlight{pointer-events:none;color:#172230;z-index:1}.repl-editor{z-index:2;resize:none;color:transparent;caret-color:#172230;outline:none;-webkit-text-fill-color:transparent}.repl-editor::selection{background:rgba(36,87,197,.25)}.repl-editor:focus{box-shadow:inset 0 0 0 2px #2457c5}.tok-comment{color:#7a8792;font-style:italic}.tok-string{color:#a94d00}.tok-number{color:#c12652;font-weight:760}.tok-form{color:#7a26b8;font-weight:850}.tok-keyword{color:#0a7770;font-weight:760}.tok-module{color:#0069b8;font-weight:760}.tok-operator{color:#0e5fb0;font-weight:850}.tok-paren,.tok-quote{color:#495d70;font-weight:760}.tok-symbol{color:#172230}.repl-transcript{padding:1rem;overflow:auto;white-space:pre-wrap}.repl-transcript pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.repl-source,.repl-value,.repl-stdout,.repl-error,.repl-note{border-bottom:1px solid #edf1f4;padding:.6rem 0}.repl-source{color:#637487}.repl-value{color:#113a73;font-weight:750}.repl-stdout{color:#186247}.repl-error{color:#b53624;font-weight:760}.repl-note{color:#606f7e}.repl-noscript{display:block;margin:1rem 0;padding:.8rem;border:1px solid #e0a25a;border-radius:8px;background:#fff4df;color:#603d0d}@media(max-width:800px){.repl-head{align-items:flex-start;flex-direction:column}.repl-grid{grid-template-columns:1fr;min-height:auto}.repl-editor-shell,.repl-transcript{min-height:20rem}.repl-toolbar{flex-wrap:wrap}.repl-toolbar select{max-width:100%;flex:1 1 10rem}}
diff --git a/assets/repl/repl.js b/assets/repl/repl.js
new file mode 100644
index 0000000..b4fc60c
--- /dev/null
+++ b/assets/repl/repl.js
@@ -0,0 +1,821 @@
+const config = {
+  abi: 1,
+  workerUrl: "/assets/repl/v1/repl-worker.js",
+  wasmUrl: "/assets/repl/v1/jerboa-repl.wasm",
+};
+const examples = [
+  {
+    name: "Core Data",
+    source: `(import (jerboa prelude))
+
+(defstruct point (x y))
+(def p (make-point 3 4))
+
+(match '(jerboa scheme wasm)
+  ((lang family target)
+   (list (point? p) (point-x p) (point-y p) lang family target))
+  (_ 'no-match))
+`,
+  },
+  {
+    name: "Numbers",
+    source: `(import (jerboa prelude))
+
+(list
+  (integer-part 42.75)
+  (fractional-part 42.75)
+  (floor-align 37 8)
+  (ceiling-align 37 8)
+  (expt-mod 2 16 17)
+  (uint-length-in-u8 65535))
+`,
+  },
+  {
+    name: "Bytes",
+    source: `(import (jerboa prelude))
+
+(def bv #vu8(0 1 2 255))
+
+(list
+  (u8vector-length bv)
+  (u8vector-ref bv 3)
+  (u8vector->uint #vu8(1 0) big)
+  (u8vector->uint #vu8(1 0) little)
+  (uint->u8vector 65535 big 2)
+  (u8vector-reverse bv)
+  (u8vector->bytestring #vu8(222 173 190 239)))
+`,
+  },
+  {
+    name: "Strings",
+    source: `(import (jerboa prelude))
+
+(def words (string-split "jerboa,scheme,wasm" ","))
+
+(list
+  words
+  (string-join words "/")
+  (string-prefix? "jer" "jerboa")
+  (string-suffix? "wasm" "browser-wasm")
+  (string-contains "browser-wasm" "wasm")
+  (string-trim "  padded  "))
+`,
+  },
+  {
+    name: "Core Lists",
+    source: `(import (jerboa prelude))
+
+(def nums '(1 2 3 4 5))
+
+(list
+  (append-map (lambda (x) (list x (* x 2))) '(1 2 3))
+  (take-last 2 nums)
+  (drop-last 2 nums)
+  (flatten '(1 (2 (3)) (4 5)))
+  (distinct '(a b a c b))
+  (interpose 'sep '(a b c))
+  (interleave '(a b c) '(1 2 3))
+  (zip '(a b c) '(1 2 3))
+  (assoc 'a (frequencies '(a b a c b a)))
+  (assoc #t (group-by odd? nums))
+  (keep (lambda (x) (and (odd? x) (* x 10))) nums)
+  (take-while odd? '(1 3 4 5))
+  (drop-while odd? '(1 3 4 5))
+  (take-until even? '(1 3 4 5))
+  (drop-until even? '(1 3 4 5))
+  (butlast nums)
+  (assoc 'a (duplicates '(a b a c b a)))
+  (group-same '(a a b b b c a))
+  (group-consecutive '(1 1 2 3 3 2))
+  (group-n-consecutive 2 nums))
+`,
+  },
+  {
+    name: "Functional Core",
+    source: `(import (jerboa prelude))
+
+(def add-shift
+  (compose 1+ (lambda (x) (* x 2))))
+
+(def summarize
+  (juxt 1+ 1- (lambda (x) (* x x))))
+
+(list
+  (add-shift 5)
+  ((partial + 1 2) 3 4)
+  ((curry list 'a 'b) 'c 'd)
+  ((complement odd?) 2)
+  ((negate odd?) 3)
+  ((constantly 'stable) 'ignored)
+  ((flip cons) '(tail) 'head)
+  (summarize 5)
+  ((conjoin integer? positive?) 5)
+  ((disjoin string? symbol?) 'jerboa)
+  ((every-pred integer? positive?) -1)
+  ((some-fn string? symbol?) 'jerboa)
+  ((fnil + 0 10) #f #f))
+`,
+  },
+  {
+    name: "Threading",
+    source: `(import (jerboa prelude))
+
+(def values '(1 2 3 4 5))
+
+(list
+  (-> 5 (1+) (* 2))
+  (->> values
+       (map (lambda (x) (* x 2)))
+       (filter even?))
+  (as-> 5 x
+    (+ x 1)
+    (list x (* x 2)))
+  (some-> 5 (1+) (* 2))
+  (some-> #f (1+) (* 2))
+  (cond-> 5
+    #t (1+)
+    #f (* 100)
+    #t (* 2))
+  (unwrap (->? (ok 10)
+            (+ 5)
+            (* 2)))
+  (unwrap (->>? (ok values)
+            (map (lambda (x) (* x 2)))
+            (filter even?))))
+`,
+  },
+  {
+    name: "Conditional Binding",
+    source: `(import (jerboa prelude))
+
+(def maybe-score 91)
+(def missing-score #f)
+
+(list
+  (when-let (score maybe-score)
+    (if (>= score 90) 'honors 'pass))
+  (void? (when-let (score missing-score)
+           (+ score 1)))
+  (if-let (score maybe-score)
+    (str "score=" score)
+    "missing")
+  (if-let (score missing-score)
+    (str "score=" score)
+    "missing")
+  (awhen maybe-score
+    (+ it 9))
+  (aif missing-score
+    (+ it 9)
+    'fallback)
+  (when/list #t
+    'alpha
+    'beta
+    (+ 1 2))
+  (when/list #f
+    'alpha
+    'beta))
+`,
+  },
+  {
+    name: "Results",
+    source: `(import (jerboa prelude))
+
+(def (safe-div x y)
+  (if (= y 0)
+      (err "division by zero")
+      (ok (/ x y))))
+
+(def results
+  (list (safe-div 10 2)
+        (safe-div 4 0)
+        (safe-div 9 3)))
+
+(list
+  (map-ok (lambda (x) (* x 10)) (safe-div 10 2))
+  (and-then (safe-div 10 2)
+            (lambda (x) (ok (+ x 1))))
+  (unwrap-or (safe-div 4 0) 'fallback)
+  (sequence-results (list (ok 1) (ok 2) (ok 3)))
+  (results-partition results)
+  (filter-ok results)
+  (filter-err results)
+  (call-with-values
+    (lambda () (result->values (safe-div 4 0)))
+    list)
+  (try-result (/ 1 0))
+(try-result* (error "boom")))
+`,
+  },
+  {
+    name: "Iteration",
+    source: `(import (jerboa prelude))
+
+(def scores '#(72 81 94 67 88))
+(def table (make-hash-table))
+
+(hash-put! table 'alpha 1)
+(hash-put! table 'beta 2)
+
+(list
+  (for/collect ((x (in-range 5)))
+    (* x x))
+  (for/collect ((score (in-vector scores)))
+    (>= score 80))
+  (for/collect ((ch (in-string "wasm")))
+    ch)
+  (for/collect ((pair (in-indexed '(a b c))))
+    pair)
+  (for/collect ((letter (in-list '(a b c)))
+                (number (in-list '(1 2))))
+    (list letter number))
+  (for/fold ((sum 0)) ((x (in-range 1 6)))
+    (+ sum x))
+  (for/or ((x (in-list '(#f #f ready))))
+    x)
+  (for/and ((x (in-list '(#t ok yes))))
+    x)
+  (for/collect ((k (in-hash-keys table)))
+    k))
+`,
+  },
+  {
+    name: "Port Iteration",
+    source: `(import (jerboa prelude))
+
+(def datum-port
+  (open-input-string "1 two three"))
+
+(def byte-port
+  (open-input-u8vector #u8(65 66 67)))
+
+(list
+  (for/collect ((ch (in-chars (open-input-string "ab\\nc"))))
+    ch)
+  (for/collect ((line (in-lines (open-input-string "alpha\\nbeta\\r\\ngamma"))))
+    line)
+  (for/collect ((datum (in-port datum-port)))
+    datum)
+  (for/collect ((byte (in-bytes byte-port)))
+    byte)
+  (take (in-naturals 10) 5)
+  (let ((n 0))
+    (for/collect
+      ((x (in-producer
+            (lambda ()
+              (set! n (+ n 1))
+              (if (> n 3) (eof-object) n)))))
+      x)))
+`,
+  },
+  {
+    name: "Alists",
+    source: `(import (jerboa prelude))
+
+(def person
+  (alist (name "Alice") (age 30) (city "Boise")))
+
+(def settings
+  '(mode "debug" port 8080))
+
+(list
+  person
+  (let-alist person (name age city)
+    (list name age city))
+  (agetq 'age person 'missing)
+  (asetq 'age 31 person)
+  (aremq 'city person)
+  (alist->plist* person)
+  (plist->alist* settings)
+  (pgetq 'mode settings 'missing)
+  (psetq 'port 9090 settings)
+  (premq 'mode settings)
+  (let ((out '()))
+    (dotimes (i 4)
+      (set! out (cons i out)))
+    (reverse out)))
+`,
+  },
+  {
+    name: "Data Interchange",
+    source: `(import (jerboa prelude))
+
+(def payload
+  (string->json-object
+    (json-object->string
+      (list->hash-table
+        '(("name" . "Alice")
+          ("age" . 30)
+          ("tags" . ("ops" "wasm"))
+          ("active" . #t))))))
+
+(def rows
+  '(("name" "age") ("Alice" "30") ("Bob" "25")))
+
+(def people
+  '(((name . "Alice") (age . "30"))
+    ((name . "Bob") (age . "25"))))
+
+(list
+  (hash-ref payload "name")
+  (hash-ref payload "tags")
+  (json-object->string (list->hash-table '(("ok" . #t) ("count" . 2))))
+  (read-csv "name,age\\nAlice,30\\n")
+  (csv->alists "name,age\\nAlice,30\\nBob,25")
+  (alists->csv people)
+  (rows->csv-string rows)
+  (call-with-output-string
+    (lambda (p)
+      (write-json payload p))))
+`,
+  },
+  {
+    name: "Datetime",
+    source: `(import (jerboa prelude))
+
+(def started
+  (parse-datetime "2024-03-25T10:30:05.123Z"))
+
+(def local
+  (make-datetime 2024 3 25 10 30 5 123000000 -420))
+
+(def wait
+  (duration 90 250))
+
+(list
+  (datetime->iso8601 started)
+  (date->string local)
+  (time->string local)
+  (datetime->epoch local)
+  (datetime->julian local)
+  (datetime->alist local)
+  (duration-seconds wait)
+  (duration-nanoseconds wait)
+  (datetime->iso8601 (datetime-add local 3600))
+  (datetime-diff (datetime-add local 60) local)
+  (datetime<? local (datetime-add local 1))
+  (datetime->iso8601 (datetime-floor-day local))
+  (day-of-week 2024 3 25)
+  (day-of-year 2024 3 25)
+  (days-in-month 2024 2)
+  (leap-year? 2024))
+`,
+  },
+  {
+    name: "Data Definitions",
+    source: `(import (jerboa prelude))
+
+(define-enum status (queued running complete failed))
+(defrecord job (id owner state))
+
+(def current
+  (make-job 42 "ops" status-running))
+
+(list
+  status-queued
+  status-running
+  (status? status-complete)
+  (status? 'running)
+  (status->name (job-state current))
+  (job? current)
+  (job-id current)
+  (job-owner current)
+  (job->alist current))
+`,
+  },
+  {
+    name: "Paths",
+    source: `(import (jerboa prelude))
+
+(def path
+  (path-join "/home" "jerboa" "notes.ss"))
+
+(list
+  path
+  (path-directory path)
+  (path-strip-directory path)
+  (path-extension path)
+  (path-strip-extension path)
+  (path-absolute? path)
+  (path-absolute? "relative.ss"))
+`,
+  },
+  {
+    name: "Sequence Tools",
+    source: `(import (jerboa prelude))
+
+(list
+  (sort '(3 1 2) <)
+  (partition-all 2 '(1 2 3 4 5))
+  (partition-by odd? '(1 3 2 4 5 7))
+  (split-with odd? '(1 3 2 4))
+  (reductions + 0 '(1 2 3))
+  (iterate-n 5 1+ 0)
+  (slice '(a b c d e) 1 3)
+  (map/car 1+ '(1 2 3))
+  (list
+    (length=? '(a b c) '(1 2 3))
+    (length<n? '(a b c) 4)
+    (length>n? '(a b c) 2)
+    (first-and-only '(done))))
+`,
+  },
+  {
+    name: "State Cells",
+    source: `(import (jerboa prelude))
+
+(def counter (atom 0))
+(def shared-score (make-shared 10))
+(def scratch (volatile! 'draft))
+
+(list
+  (atom? counter)
+  (deref counter)
+  (reset! counter 5)
+  (swap! counter + 3)
+  (compare-and-set! counter 8 13)
+  (deref counter)
+  (shared-ref shared-score)
+  (shared-set! shared-score 11)
+  (shared-swap! shared-score 1+)
+  (volatile? scratch)
+  (vderef scratch)
+  (vreset! scratch 'ready)
+  (vswap! scratch (lambda (x) (list x 'done))))
+`,
+  },
+  {
+    name: "Metadata And Nested",
+    source: `(import (jerboa prelude))
+
+(def wrapped
+  (with-meta '(1 2) (hash 'source "demo")))
+
+(def state
+  (hash "user" (hash "name" "Alice" "count" 1)))
+
+(def updated
+  (assoc-in state '("user" "name") "Bob"))
+
+(assoc-in! state '("user" "count") 2)
+(update-in! state '("user" "count") + 40)
+
+(def copied
+  (update-in updated '("user" "name") string-upcase))
+
+(def v
+  (vector (hash "x" 1) 2))
+
+(assoc-in! v '(0 "x") 9)
+
+(defstruct point (x y))
+(def p (make-point 3 4))
+
+(list
+  (meta-wrapped? wrapped)
+  (hash-ref (meta wrapped) 'source)
+  (strip-meta wrapped)
+  (equal? wrapped '(1 2))
+  (get-in state '("user" "count"))
+  (get-in updated '("user" "name"))
+  (get-in copied '("user" "name"))
+  (get-in v '(0 "x"))
+  (nested-get p 'x 'missing)
+  (get-in p '(y)))
+`,
+  },
+  {
+    name: "Clojure Style",
+    source: `(import (jerboa prelude) (std clojure))
+
+(defn passing? [score]
+  (>= score 80))
+
+(defn bump [score]
+  (inc score))
+
+(def scores '(72 81 94 67 88))
+
+(displayln "passing after bump")
+(filter passing? (map bump scores))
+(reduce (lambda [acc score] (+ acc score)) 0 scores)
+`,
+  },
+  {
+    name: "Runtime",
+    source: `(import (jerboa prelude))
+
+(list
+  (jerboa-system)
+  (jerboa-version-string)
+  (jerboa-home)
+  (features)
+  (current-second)
+  (system-type))
+`,
+  },
+];
+const editor = document.querySelector("[data-repl-editor]");
+const exampleSelect = document.querySelector("[data-repl-example]");
+const loadButton = document.querySelector("[data-repl-load]");
+const runButton = document.querySelector("[data-repl-run]");
+const resetButton = document.querySelector("[data-repl-reset]");
+const clearButton = document.querySelector("[data-repl-clear]");
+const transcript = document.querySelector("[data-repl-transcript]");
+const status = document.querySelector("[data-repl-status]");
+
+let worker = null;
+let nextId = 1;
+let activeId = null;
+let hardTimer = null;
+let highlightLayer = null;
+
+const highlightKeywords = new Set([
+  "and",
+  "begin",
+  "case",
+  "cond",
+  "def",
+  "defclass",
+  "defmethod",
+  "defstruct",
+  "define",
+  "defn",
+  "display",
+  "displayln",
+  "do",
+  "else",
+  "filter",
+  "foldl",
+  "for",
+  "if",
+  "import",
+  "lambda",
+  "let",
+  "let*",
+  "match",
+  "map",
+  "or",
+  "receive",
+  "quote",
+  "reduce",
+  "try",
+  "unless",
+  "when",
+]);
+
+function escapeHtml(text) {
+  return text
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;");
+}
+
+function span(className, text) {
+  return `<span class="${className}">${escapeHtml(text)}</span>`;
+}
+
+function highlightSource(source) {
+  let html = "";
+  let index = 0;
+  while (index < source.length) {
+    const ch = source[index];
+    if (ch === ";") {
+      const end = source.indexOf("\n", index);
+      const stop = end === -1 ? source.length : end;
+      html += span("tok-comment", source.slice(index, stop));
+      index = stop;
+      continue;
+    }
+    if (ch === '"') {
+      let cursor = index + 1;
+      while (cursor < source.length) {
+        if (source[cursor] === "\\") {
+          cursor += 2;
+        } else if (source[cursor] === '"') {
+          cursor += 1;
+          break;
+        } else {
+          cursor += 1;
+        }
+      }
+      html += span("tok-string", source.slice(index, cursor));
+      index = cursor;
+      continue;
+    }
+    if ("()[]{}".includes(ch)) {
+      html += span("tok-paren", ch);
+      index += 1;
+      continue;
+    }
+    if (ch === "'") {
+      html += span("tok-quote", ch);
+      index += 1;
+      continue;
+    }
+    if (/\s/.test(ch)) {
+      html += ch === "\n" ? "\n" : escapeHtml(ch);
+      index += 1;
+      continue;
+    }
+    let cursor = index;
+    while (cursor < source.length && !/\s/.test(source[cursor]) && !"()[]{}\";".includes(source[cursor])) {
+      cursor += 1;
+    }
+    const token = source.slice(index, cursor);
+    if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(token)) {
+      html += span("tok-number", token);
+    } else if (token.startsWith("#:") || token.endsWith(":")) {
+      html += span("tok-keyword", token);
+    } else if (token.startsWith(":")) {
+      html += span("tok-module", token);
+    } else if (highlightKeywords.has(token)) {
+      html += span("tok-form", token);
+    } else if (/^[*/+<>=!?$%_&~^.-]+$/.test(token)) {
+      html += span("tok-operator", token);
+    } else {
+      html += span("tok-symbol", token);
+    }
+    index = cursor;
+  }
+  return html + (source.endsWith("\n") ? " " : "\n");
+}
+
+function refreshHighlight() {
+  if (!highlightLayer) {
+    return;
+  }
+  highlightLayer.innerHTML = highlightSource(editor.value);
+}
+
+function syncHighlightScroll() {
+  if (!highlightLayer) {
+    return;
+  }
+  highlightLayer.scrollTop = editor.scrollTop;
+  highlightLayer.scrollLeft = editor.scrollLeft;
+}
+
+function installHighlighter() {
+  const shell = document.createElement("div");
+  shell.className = "repl-editor-shell";
+  highlightLayer = document.createElement("pre");
+  highlightLayer.className = "repl-highlight";
+  editor.parentNode.insertBefore(shell, editor);
+  shell.appendChild(highlightLayer);
+  shell.appendChild(editor);
+  refreshHighlight();
+  syncHighlightScroll();
+}
+
+function installExamples() {
+  for (const example of examples) {
+    const option = document.createElement("option");
+    option.value = example.name;
+    option.textContent = example.name;
+    exampleSelect.appendChild(option);
+  }
+  exampleSelect.value = examples[0].name;
+  editor.value = examples[0].source;
+  refreshHighlight();
+}
+
+function selectedExample() {
+  return examples.find((example) => example.name === exampleSelect.value) || examples[0];
+}
+
+function appendLine(className, text) {
+  const line = document.createElement("div");
+  line.className = className;
+  const pre = document.createElement("pre");
+  pre.textContent = text;
+  line.appendChild(pre);
+  transcript.appendChild(line);
+  transcript.scrollTop = transcript.scrollHeight;
+}
+
+function setStatus(text) {
+  status.textContent = text;
+}
+
+function setRunning(running) {
+  runButton.disabled = running || !worker;
+  resetButton.disabled = false;
+}
+
+function killWorker(message) {
+  if (worker) {
+    worker.terminate();
+  }
+  worker = null;
+  activeId = null;
+  clearTimeout(hardTimer);
+  setRunning(true);
+  appendLine("repl-error", message);
+  startWorker();
+}
+
+function startWorker() {
+  setStatus("Loading engine");
+  runButton.disabled = true;
+  worker = new Worker(config.workerUrl, { type: "module" });
+  const id = nextId++;
+  activeId = id;
+  worker.onmessage = (event) => {
+    const message = event.data || {};
+    if (message.id && message.id !== activeId && message.kind !== "ready") {
+      return;
+    }
+    if (message.kind === "ready") {
+      activeId = null;
+      setStatus(`Ready: ABI ${message.abi}, ${message.subsetRevision}`);
+      runButton.disabled = false;
+      return;
+    }
+    if (message.kind === "result") {
+      clearTimeout(hardTimer);
+      activeId = null;
+      setRunning(false);
+      renderResult(message.result, message.elapsedMs);
+      return;
+    }
+    if (message.kind === "fatal") {
+      clearTimeout(hardTimer);
+      killWorker(`Engine restarted: ${message.message}`);
+    }
+  };
+  worker.onerror = () => {
+    killWorker("Engine restarted after a worker failure");
+  };
+  worker.postMessage({
+    kind: "init",
+    id,
+    wasmUrl: config.wasmUrl,
+    expectedAbi: config.abi,
+  });
+}
+
+function renderResult(result, elapsedMs) {
+  setStatus(`${result.status} in ${elapsedMs} ms`);
+  for (const event of result.events || []) {
+    appendLine(event.kind === "stdout" ? "repl-stdout" : "repl-value", event.text);
+  }
+  if (result.error) {
+    const where = result.error.line ? ` at ${result.error.line}:${result.error.column}` : "";
+    appendLine("repl-error", `${result.status}${where}: ${result.error.message}`);
+  }
+}
+
+function run() {
+  if (!worker || activeId) {
+    return;
+  }
+  const source = editor.value;
+  appendLine("repl-source", `> ${source}`);
+  const id = nextId++;
+  activeId = id;
+  setRunning(true);
+  setStatus("Running");
+  hardTimer = setTimeout(() => {
+    killWorker("Engine timed out after 2000 ms; session state was reset");
+  }, 2000);
+  worker.postMessage({ kind: "eval", id, source });
+}
+
+runButton.addEventListener("click", run);
+loadButton.addEventListener("click", () => {
+  editor.value = selectedExample().source;
+  refreshHighlight();
+  syncHighlightScroll();
+  editor.focus();
+});
+resetButton.addEventListener("click", () => {
+  if (worker) {
+    worker.terminate();
+  }
+  worker = null;
+  activeId = null;
+  clearTimeout(hardTimer);
+  appendLine("repl-note", "Session reset");
+  startWorker();
+});
+clearButton.addEventListener("click", () => {
+  transcript.textContent = "";
+});
+editor.addEventListener("keydown", (event) => {
+  if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
+    event.preventDefault();
+    run();
+  }
+});
+editor.addEventListener("input", refreshHighlight);
+editor.addEventListener("scroll", syncHighlightScroll);
+
+if (!("Worker" in window) || !("WebAssembly" in window)) {
+  setStatus("Browser does not support this REPL");
+  runButton.disabled = true;
+  resetButton.disabled = true;
+} else {
+  installHighlighter();
+  installExamples();
+  startWorker();
+}
diff --git a/data/jerboa-source.json b/data/jerboa-source.json
index 4d6b0b9..c2f3a07 100644
--- a/data/jerboa-source.json
+++ b/data/jerboa-source.json
@@ -2,14 +2,14 @@
   "schema": "jerboa.site-source.v1",
   "repo": {
     "path": "jerboa",
-    "remote": "https://git.sr.ht/~lisp/jerboa",
-    "branch": "master",
-    "commit": "1cf9afb74a3d5da7ff9ee76e477dae2332364f74",
-    "version": "0.2.3",
+    "remote": "https://git.jerboa.sh/ober/jerboa",
+    "branch": "HEAD",
+    "commit": "ae716feb376ce9adefcad65e923ebfd866131947",
+    "version": "0.2.8",
     "source_tree": "HEAD",
     "working_tree_dirty": false
   },
-  "generated_at": "2026-06-19T21:47:35Z",
+  "generated_at": "2026-07-30T17:45:56Z",
   "site_contract": {
     "human_docs": "docs/",
     "machine_data": "data/",
@@ -18,128 +18,116 @@
   },
   "canonical_sources": {
     "docs": [
-      {"path": "docs/Chez-changes.md", "sha256": "f8866866fe2eac24b969470b41c5e89ed63703b831b6c43ae37053dcae97facf", "bytes": 4500},
-      {"path": "docs/Future-proofing.md", "sha256": "927b8f6ec50d338e570c90e0f1decf7cfd8847824646ceae6b3079910841a310", "bytes": 17232},
-      {"path": "docs/JERBOA-LANG.md", "sha256": "60acc5ef32957b9e4608a65c237993e5699bcead31e570ad377175931573f49f", "bytes": 27440},
+      {"path": "docs/Chez-changes.md", "sha256": "1192280f33100fd1fecac75260d2d36bc8a783d5aeb7218a4e457621eeb03027", "bytes": 4901},
+      {"path": "docs/JERBOA-LANG.md", "sha256": "57ee01608e3bf8f2e8dc6cb257ef206ca515fc0cf5d956fe99430432fe279b7e", "bytes": 28382},
       {"path": "docs/Philosophy.md", "sha256": "8d388cb1162837f0a53311f71bcc4e98dbbd184832ca83c649f58b2170c70669", "bytes": 13970},