diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index b00bfd0..55c3c99 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -8,3 +8,14 @@ updates:
       actions:
         patterns:
           - "*"
+  # Rust core crate. NOTE: deliberately no entry for /desktop — its path
+  # dependency on an external sibling checkout (../../gossamer) would make
+  # dependabot error.
+  - package-ecosystem: "cargo"
+    directory: "/core"
+    schedule:
+      interval: "weekly"
+    groups:
+      cargo:
+        patterns:
+          - "*"
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
new file mode 100644
index 0000000..6ae3b52
--- /dev/null
+++ b/.github/workflows/README.md
@@ -0,0 +1,33 @@
+<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
+# Workflows
+
+## Product CI (required checks)
+
+These build and test the actual product and are self-contained — they run
+anywhere, including forks:
+
+| Workflow | What it verifies |
+|---|---|
+| `rust-ci.yml` | `cargo fmt --check`, `clippy -D warnings`, `cargo test` (unit + golden contract + property tests), wasm32 build + wasm-bindgen |
+| `ui-ci.yml` | ReScript compile under Deno, wasm core build, `deno test` (TEA update tests + golden-fixture contract tests), esbuild bundle, `deno lint` |
+
+The same commands run locally: `just test`, `just build`, `just check`
+(or the underlying `deno task …` / `cargo …` equivalents).
+
+## Estate workflows (expected to no-op or fail outside hyperpolymath)
+
+Everything else in this directory is governance/scanning plumbing tied to
+private or sibling `hyperpolymath` repos (`standards` reusable workflows,
+`hypatia`, `casket-ssg`, `a2ml`/`k9` validate actions, `.git-private-farm`,
+`boj-server`). On forks or in isolated environments they cannot run and are
+**not** indicators of product health:
+
+`boj-build.yml`, `casket-pages.yml`, `dogfood-gate.yml`, `governance.yml`,
+`hypatia-scan.yml`, `instant-sync.yml`, `mirror.yml`,
+`push-email-notify.yml`, `secret-scanner.yml`
+
+`codeql.yml` and `scorecard.yml` are standard GitHub scanning and run
+anywhere.
+
+Branch protection should require `rust-ci` and `ui-ci`; estate workflows
+should stay non-required.
diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml
new file mode 100644
index 0000000..74da032
--- /dev/null
+++ b/.github/workflows/rust-ci.yml
@@ -0,0 +1,66 @@
+# SPDX-License-Identifier: MPL-2.0
+# Product CI (Rust): the load-bearing build/test workflow for core/.
+# desktop/ is intentionally NOT built here — it needs a sibling checkout of
+# hyperpolymath/gossamer (see desktop/README.md) and is excluded from the
+# workspace in the root Cargo.toml.
+name: rust-ci
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+concurrency:
+  group: rust-ci-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  test:
+    name: fmt + clippy + test
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+      - name: Install stable toolchain
+        run: |
+          rustup toolchain install stable --profile minimal --component rustfmt --component clippy
+          rustup default stable
+      - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+        with:
+          path: |
+            ~/.cargo/registry
+            target
+          key: rust-test-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
+      - name: Format check
+        run: cargo fmt --check
+      - name: Clippy (including the wasm feature, compiled for host)
+        run: cargo clippy --all-targets --features wasm -- -D warnings
+      - name: Tests (unit + golden contract + property)
+        run: cargo test
+
+  wasm:
+    name: wasm build
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+      - name: Install stable toolchain with wasm target
+        run: |
+          rustup toolchain install stable --profile minimal
+          rustup default stable
+          rustup target add wasm32-unknown-unknown
+      - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+        with:
+          path: |
+            ~/.cargo/registry
+            ~/.cargo/bin
+            target
+          key: rust-wasm-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
+      - name: Install wasm-bindgen-cli
+        run: command -v wasm-bindgen >/dev/null || cargo install wasm-bindgen-cli --version 0.2.126 --locked
+      - name: Build core for wasm32
+        run: cargo build --release --target wasm32-unknown-unknown --features wasm -p nexia-core
+      - name: Generate JS bindings
+        run: wasm-bindgen target/wasm32-unknown-unknown/release/nexia_core.wasm --target web --no-typescript --out-dir web/wasm
diff --git a/.github/workflows/ui-ci.yml b/.github/workflows/ui-ci.yml
new file mode 100644
index 0000000..5b91fb1
--- /dev/null
+++ b/.github/workflows/ui-ci.yml
@@ -0,0 +1,64 @@
+# SPDX-License-Identifier: MPL-2.0
+# Product CI (UI/web): compiles the ReScript UI under Deno, builds the wasm
+# core, runs the UI + contract tests, and produces the web bundle.
+name: ui-ci
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+concurrency:
+  group: ui-ci-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  build-test:
+    name: rescript + wasm + deno test + bundle
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+      - name: Install Deno
+        run: |
+          curl -fsSL https://deno.land/install.sh | DENO_INSTALL="$HOME/.deno" sh -s v2.9.1
+          echo "$HOME/.deno/bin" >> "$GITHUB_PATH"
+
+      - name: Install stable Rust with wasm target
+        run: |
+          rustup toolchain install stable --profile minimal
+          rustup default stable
+          rustup target add wasm32-unknown-unknown
+
+      - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+        with:
+          path: |
+            ~/.cargo/registry
+            ~/.cargo/bin
+            target
+          key: rust-wasm-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
+
+      - name: Install wasm-bindgen-cli
+        run: command -v wasm-bindgen >/dev/null || cargo install wasm-bindgen-cli --version 0.2.126 --locked
+
+      - name: Install JS dependencies (Deno-only, npm registry)
+        run: deno task setup
+
+      - name: Compile ReScript
+        run: deno task build:res
+
+      - name: Build wasm core
+        run: deno task build:wasm
+
+      - name: UI + contract tests
+        run: deno task test:ui
+
+      - name: Bundle web app
+        run: deno task build:web
+
+      - name: Lint
+        run: deno lint
diff --git a/.gitignore b/.gitignore
index cc9fc67..43bf943 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,6 +40,13 @@ erl_crash.dump
 # ReScript
 /lib/bs/
 /.bsb.lock
+ui/lib/
+ui/src/**/*.res.js
+ui/tests/**/*.res.js
+
+# Generated web artifacts
+web/dist/
+web/wasm/
 
 # Python (SaltStack only)
 __pycache__/
diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml
index 129038b..6212667 100644
--- a/.machine_readable/6a2/STATE.a2ml
+++ b/.machine_readable/6a2/STATE.a2ml
@@ -5,10 +5,11 @@
 [metadata]
 project = "nexia-list"
 version = "0.1.0"
-last-updated = "2026-03-15"
+last-updated = "2026-07-02"
 status = "active"
 
 [project-context]
 name = "nexia-list"
-completion-percentage = 0
-phase = "In development"
+completion-percentage = 35
+phase = "active development — web-first build resurrection"
+focus = "wasm core integration (wasm-bindgen bridge to browser), then product CI, tests, canvas drag-and-drop, PWA (service worker)"
diff --git a/.machine_readable/INTENT.contractile b/.machine_readable/INTENT.contractile
index 6d63819..7c86422 100644
--- a/.machine_readable/INTENT.contractile
+++ b/.machine_readable/INTENT.contractile
@@ -35,38 +35,36 @@
 
   ; === Purpose (what this repo IS) ===
   (purpose
-    "{{ONE_PARAGRAPH_PURPOSE}}"
+    "Nexia is a local-first, spatial personal knowledge management tool: notes arranged on a spatial canvas, first-class bidirectional links between them, and (planned) agents implemented as persistent queries that continuously organise the notebook. It is web-first — a ReScript TEA-style UI drives a Rust core compiled to WASM in the browser — and all user data lives on the user's device in human-readable JSON."
   )
 
   ; === Anti-Purpose (what this repo is NOT — prevents scope creep) ===
   (anti-purpose
-    "{{ONE_PARAGRAPH_ANTI_PURPOSE}}"
-    ; Examples:
-    ; "This is NOT a general-purpose database — it solves one specific problem."
-    ; "This is NOT a framework — it is a library with a focused API."
-    ; "This does NOT handle authentication — that is delegated to [other repo]."
+    "This is NOT a cloud or SaaS product — no server-side component, no account, no required network. It does NOT do real-time collaboration — local-first means single-user focus. It must NOT introduce vendor lock-in — data is always exportable in open formats. It is NOT an outliner clone — the spatial canvas and link graph are the primary organising metaphors, not a hierarchy."
   )
 
   ; === Key Architectural Decisions That Must Not Be Reversed ===
   (architectural-invariants
-    ; *REMINDER: List the foundational decisions*
-    ; ("Idris2 for ABI definitions — dependent types prove interface correctness")
-    ; ("Zig for FFI — zero-cost C ABI compatibility")
-    ; ("Elixir for supervision — OTP fault tolerance")
+    ("Web-first: the browser is the primary target; desktop (Gossamer) is an optional external shell over the same bundle")
+    ("The Rust core is the single source of truth for note/notebook semantics — the UI must not fork the data model")
+    ("Rust core is compiled to WASM (wasm-bindgen) so the browser runs the real engine")
+    ("Deno is the only JS runtime/package manager — no npm/bun/yarn/pnpm CLIs")
+    ("Local-first: no core feature may require network access")
+    ("User data stored in human-readable JSON")
   )
 
   ; === Sensitive Areas (if in doubt, ask) ===
   (ask-before-touching
-    ; *REMINDER: List areas where LLMs should check before modifying*
-    ; "src/abi/ — formal proofs, changes require re-verification"
-    ; "ffi/zig/ — C ABI boundary, changes affect all language bindings"
-    ; ".machine_readable/ — checkpoint files, format is specified"
+    ".machine_readable/ — checkpoint files, format is specified"
+    ".github/workflows/ — estate governance workflows; removal requires approval"
+    "LICENSE and SPDX headers — never remove or modify"
+    "core/src/ data model — semantics mirrored by the UI; changes ripple across the WASM boundary"
   )
 
   ; === Ecosystem Position ===
   (ecosystem
-    (belongs-to "{{MONOREPO_OR_STANDALONE}}")
-    (depends-on ("{{DEP1}}" "{{DEP2}}"))
-    (depended-on-by ("{{CONSUMER1}}" "{{CONSUMER2}}"))
+    (belongs-to "standalone repo within the hyperpolymath estate")
+    (depends-on ("gossamer (optional — desktop shell only, external sibling checkout)"))
+    (depended-on-by ())
   )
 )
diff --git a/.machine_readable/agent_instructions/methodology.a2ml b/.machine_readable/agent_instructions/methodology.a2ml
index 754f357..71e5ed1 100644
--- a/.machine_readable/agent_instructions/methodology.a2ml
+++ b/.machine_readable/agent_instructions/methodology.a2ml
@@ -55,7 +55,7 @@ perfective = 10   # % for SPDX headers, doc updates, formatting, style
 # Customise this per project — the template default is generic.
 
 [methodology.unique-strength]
-description = "{{PROJECT_UNIQUE_STRENGTH}}"
+description = "Typed end-to-end: the Rust core compiled to WASM is the single source of truth for note/notebook semantics, driven by an exhaustively pattern-matched TEA-style ReScript UI — no type drift between engine and interface"
 deepen-not-broaden = true
 
 # ============================================================================
diff --git a/.tool-versions b/.tool-versions
index 185ff65..908d1c9 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1 +1,2 @@
 just 1.36.0
+deno 2.9.1
diff --git a/.well-known/ai.txt b/.well-known/ai.txt
index 0bfbd37..bc5a8a3 100644
--- a/.well-known/ai.txt
+++ b/.well-known/ai.txt
@@ -12,9 +12,9 @@ Allow: /
 Manifest: /0-AI-MANIFEST.a2ml
 
 # Machine-readable metadata
-Metadata: /.machine_readable/STATE.scm
-Metadata: /.machine_readable/META.scm
-Metadata: /.machine_readable/ECOSYSTEM.scm
+Metadata: /.machine_readable/6a2/STATE.a2ml
+Metadata: /.machine_readable/6a2/META.a2ml
+Metadata: /.machine_readable/6a2/ECOSYSTEM.a2ml
 
 # License
 License: MPL-2.0
diff --git a/0-AI-MANIFEST.a2ml b/0-AI-MANIFEST.a2ml
index ccf3db0..e68b16c 100644
--- a/0-AI-MANIFEST.a2ml
+++ b/0-AI-MANIFEST.a2ml
@@ -48,22 +48,26 @@ Bot-specific instructions for:
 
 ## REPOSITORY STRUCTURE
 
-Nexia is a cross-platform personal knowledge management tool inspired by
-Tinderbox. It provides spatial canvas note arrangement, bidirectional linking,
-intelligent agents, and prototype inheritance. Built with Rust core, ReScript
-TEA UI, Tauri 2.0 desktop/mobile shell, and WASM/PWA web deployment.
+Nexia is a local-first, web-first personal knowledge management tool inspired
+by Tinderbox. It provides spatial canvas note arrangement, bidirectional
+linking, and (planned) intelligent agents and prototype inheritance. Built
+with a Rust core compiled to WASM (wasm-bindgen), a ReScript TEA-style UI on
+@rescript/react, and Deno 2 tooling. Desktop is an OPTIONAL Gossamer shell
+requiring an external sibling checkout; it is not built in this repo's CI.
 
 ```
 nexia-list/
 ├── 0-AI-MANIFEST.a2ml         # THIS FILE (start here)
 ├── README.adoc                 # Project overview
-├── core/                       # Rust core engine (graph, search, storage)
+├── core/                       # Rust core engine (notes, backlinks, search, JSON storage)
 │   └── src/
-├── ui/                         # ReScript TEA application
+├── ui/                         # ReScript TEA-style application
 │   └── src/
-├── desktop/                    # Tauri 2.0 desktop shell
+├── scripts/                    # Deno build/dev scripts (esbuild)
+├── desktop/                    # OPTIONAL Gossamer shell (external; not built in CI)
 │   └── src/
-├── web/                        # Browser/PWA build
+├── web/                        # Browser build (bundle output in dist/)
+├── docs/adr/                   # Architecture decision records
 ├── .machine_readable/          # SCM files (6 files)
 │   ├── .machine_readable/6a2/STATE.a2ml
 │   ├── .machine_readable/6a2/META.a2ml
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 134411e..b651e0e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -3,54 +3,65 @@
 
 ## Development Setup
 
+Prerequisites: [Deno](https://deno.land/) 2.x and [Rust](https://www.rust-lang.org/tools/install)
+stable (plus the `wasm32-unknown-unknown` target for WASM builds). Deno is the
+only JS toolchain — do not use npm/bun/yarn/pnpm. A Guix environment is
+provided via `guix.scm` (`guix shell`) if you prefer reproducible shells.
+
 ```bash
 # Clone the repository
 git clone https://github.com/hyperpolymath/nexia-list.git
 cd nexia-list
 
-# Using Nix (recommended for reproducibility)
-nix develop
+# Install dependencies
+deno task setup
+
+# Run the development server (http://localhost:5173)
+deno task dev
 
-# Or using toolbox/distrobox
-toolbox create nexia-list-dev
-toolbox enter nexia-list-dev
-# Install dependencies manually
+# Build (ReScript + web bundle)
+deno task build
 
 # Verify setup
-just check   # or: cargo check / mix compile / etc.
-just test    # Run test suite
+deno task lint
+deno task test    # Rust core tests + UI tests
 ```
 
+Equivalent `just` recipes exist: `just setup`, `just build`, `just test`,
+`just run`, `just check`.
+
 ### Repository Structure
 ```
 nexia-list/
-├── src/                 # Source code (Perimeter 1-2)
-├── lib/                 # Library code (Perimeter 1-2)
-├── extensions/          # Extensions (Perimeter 2)
-├── plugins/             # Plugins (Perimeter 2)
-├── tools/               # Tooling (Perimeter 2)
-├── docs/                # Documentation (Perimeter 3)
-│   ├── architecture/    # ADRs, specs (Perimeter 2)
-│   └── proposals/       # RFCs (Perimeter 3)
-├── examples/            # Examples (Perimeter 3)
-├── spec/                # Spec tests (Perimeter 3)
-├── tests/               # Test suite (Perimeter 2-3)
-├── .well-known/         # Protocol files (Perimeter 1-3)
-├── .github/             # GitHub config (Perimeter 1)
-│   ├── ISSUE_TEMPLATE/
-│   └── workflows/
+├── core/                # Rust core — notes, backlinks, search, JSON storage
+├── ui/                  # ReScript TEA-style UI (@rescript/react)
+├── scripts/             # Deno build/dev scripts (esbuild)
+├── web/                 # Browser entry + bundle output (dist/)
+├── desktop/             # OPTIONAL Gossamer shell (external sibling checkout;
+│                        # not built in this repo's CI)
+├── docs/                # ADRs, reports
+│   └── adr/             # Architecture decision records
+├── tests/               # Cross-cutting tests
+├── .well-known/         # Protocol files (ai.txt, security.txt, humans.txt)
+├── .machine_readable/   # Contractiles, STATE/META/ECOSYSTEM checkpoints,
+│                        # and governance metadata (see below)
+├── .github/             # GitHub config and workflows
 ├── CHANGELOG.md
 ├── CODE_OF_CONDUCT.md
 ├── CONTRIBUTING.md      # This file
-├── GOVERNANCE.md
 ├── LICENSE
-├── MAINTAINERS.md
+├── MAINTAINERS.adoc
 ├── README.adoc
+├── ROADMAP.adoc
 ├── SECURITY.md
-├── flake.nix            # Nix flake (Perimeter 1)
-└── Justfile             # Task runner (Perimeter 1)
+├── deno.json            # Deno tasks and import map
+└── Justfile             # Task runner recipes
 ```
 
+Governance and invariants are machine-readable: see
+[`.machine_readable/`](.machine_readable/) (in particular `MUST.contractile`
+and `INTENT.contractile`) and [`0-AI-MANIFEST.a2ml`](0-AI-MANIFEST.a2ml).
+
 ---
 
 ## How to Contribute
@@ -75,7 +86,7 @@ Use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md) and include:
 ### Suggesting Features
 
 **Before suggesting**:
-1. Check the [roadmap](ROADMAP.md) if available
+1. Check the [roadmap](ROADMAP.adoc)
 2. Search existing issues and discussions
 3. Consider which perimeter the feature belongs to
 
@@ -120,3 +131,11 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/):
 [optional body]
 
 [optional footer]
+```
+
+---
+
+## Questions?
+
+See [MAINTAINERS.adoc](MAINTAINERS.adoc) for who to contact, and
+[SECURITY.md](SECURITY.md) for reporting vulnerabilities.
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..8fa28a5
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,727 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "cc"
+version = "1.2.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "diff"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "nexia-core"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "console_error_panic_hook",
+ "pretty_assertions",
+ "proptest",
+ "serde",
+ "serde-wasm-bindgen",
+ "serde_json",
+ "tempfile",
+ "thiserror",
+ "uuid",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "pretty_assertions"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
+dependencies = [
+ "diff",
+ "yansi",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "proptest"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+dependencies = [
+ "bit-set",
+ "bit-vec",
+ "bitflags",
+ "num-traits",
+ "rand",
+ "rand_chacha",
+ "rand_xorshift",
+ "regex-syntax",
+ "rusty-fork",
+ "tempfile",
+ "unarray",
+]
+
+[[package]]
+name = "quick-error"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_xorshift"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "rusty-fork"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
+dependencies = [
+ "fnv",
+ "quick-error",
+ "tempfile",
+ "wait-timeout",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-wasm-bindgen"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
+dependencies = [
+ "js-sys",
+ "serde",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "unarray"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "uuid"
+version = "1.23.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..de15700
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,12 @@
+# SPDX-License-Identifier: MPL-2.0
+# Workspace root. desktop/ is excluded on purpose: it depends on a sibling
+# checkout of hyperpolymath/gossamer (../gossamer) that is not part of this
+# repo — see desktop/README.md. Root builds must never break on its absence.
+[workspace]
+resolver = "2"
+members = ["core"]
+exclude = ["desktop"]
+
+[profile.release]
+opt-level = "z"
+lto = true
diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc
index 43f4c4a..b6e2d70 100644
--- a/EXPLAINME.adoc
+++ b/EXPLAINME.adoc
@@ -7,7 +7,7 @@ The README makes claims. This file backs them up.
 
 [quote, README]
 ____
-Nexia is an open-source, cross-platform knowledge management tool inspired by https://www.eastgate.com/Tinderbox/[Tinderbox]. It provides:
+Nexia is an open-source, local-first knowledge management tool inspired by https://www.eastgate.com/Tinderbox/[Tinderbox].
 ____
 
 == Technology Choices
@@ -16,7 +16,10 @@ ____
 |===
 | Technology | Learn More
 
-| **Deno** | https://deno.land
+| **Deno 2** (only JS toolchain) | https://deno.land
+| **ReScript 11** (TEA-style UI on @rescript/react) | https://rescript-lang.org
+| **Rust** (core engine, compiled to WASM via wasm-bindgen) | https://www.rust-lang.org
+| **esbuild** (bundling, via Deno) | https://esbuild.github.io
 |===
 
 == File Map
@@ -25,7 +28,13 @@ ____
 |===
 | Path | What's There
 
-| `test(s)/` | Test suite
+| `core/` | Rust core: Note/Notebook, backlinks, substring search, JSON storage
+| `ui/` | ReScript TEA-style UI (Model/Msg/Update/View + DOM bindings)
+| `scripts/` | Deno build/dev scripts
+| `web/` | Browser entry + bundle output (`dist/`)
+| `desktop/` | Optional Gossamer shell (external sibling checkout; not built in CI)
+| `docs/adr/` | Architecture decision records (web-first WASM, Deno-only)
+| `tests/` | Cross-cutting test suite
 |===
 
 == Questions?
diff --git a/Justfile b/Justfile
index 599f086..c70a343 100644
--- a/Justfile
+++ b/Justfile
@@ -7,12 +7,40 @@ import? "contractile.just"
 default:
     @just --list
 
+# Install dependencies (Deno packages + wasm target for the core)
+setup:
+    deno task setup
+    rustup target add wasm32-unknown-unknown || true
+
+# Build the project (ReScript compile + esbuild web bundle)
+build:
+    deno task build
+
+# Build the Rust core to WASM for the browser
+build-wasm:
+    deno task build:wasm
+
+# Run all tests (Rust core + UI)
+test:
+    deno task test
+
+# Run the development server (http://localhost:5173)
+run:
+    deno task dev
+
+# Static checks — Deno lint, rustfmt, clippy
+check:
+    deno lint
+    cd core && cargo fmt --check && cargo clippy -- -D warnings
+
 # Self-diagnostic — checks dependencies, permissions, paths
 doctor:
     @echo "Running diagnostics for nexia-list..."
     @echo "Checking required tools..."
     @command -v just >/dev/null 2>&1 && echo "  [OK] just" || echo "  [FAIL] just not found"
     @command -v git >/dev/null 2>&1 && echo "  [OK] git" || echo "  [FAIL] git not found"
+    @command -v deno >/dev/null 2>&1 && echo "  [OK] deno" || echo "  [FAIL] deno not found (need Deno 2.x)"
+    @command -v cargo >/dev/null 2>&1 && echo "  [OK] cargo" || echo "  [FAIL] cargo not found (need Rust stable)"
     @echo "Checking for hardcoded paths..."
     @grep -rn '/var/mnt/eclipse' --include='*.rs' --include='*.ex' --include='*.res' --include='*.gleam' --include='*.sh' --include='*.toml' . 2>/dev/null | grep -v 'Justfile' | head -5 || echo "  [OK] No hardcoded paths in source"
     @echo "Diagnostics complete."
diff --git a/QUICKSTART-DEV.adoc b/QUICKSTART-DEV.adoc
index 76e6eef..e4dd9a3 100644
--- a/QUICKSTART-DEV.adoc
+++ b/QUICKSTART-DEV.adoc
@@ -1,13 +1,18 @@
 // SPDX-License-Identifier: CC-BY-SA-4.0
-// Template: QUICKSTART-DEV.adoc — clone → build → test → PR
-// Replace nexia-list, {{BUILD_CMD}}, {{TEST_CMD}}, {{LANG_STACK}} with actuals
+// QUICKSTART-DEV.adoc — clone → build → test → PR
 = nexia-list — Quick Start for Developers
 :toc:
 :toclevels: 2
 
 == Tech Stack
 
-{{LANG_STACK}}
+* **Deno 2** — the only JS toolchain (package management via `deno install`,
+  tasks, dev server; no npm/bun/yarn/pnpm CLIs)
+* **ReScript 11** — TEA-style UI on `@rescript/react`, bundled with esbuild
+* **Rust stable** — core engine (notes, backlinks, search, JSON storage),
+  compiled to WASM via wasm-bindgen for the browser
+* Optional: **Gossamer** desktop shell (external sibling checkout of
+  `hyperpolymath/gossamer`; not built in this repo's CI)
 
 == Set Up Development Environment
 
@@ -18,34 +23,35 @@
 guix shell
 ----
 
-=== Option B: Nix (fallback)
+=== Option B: Manual
 
 [source,bash]
 ----
-nix develop
+git clone https://github.com/hyperpolymath/nexia-list.git
+cd nexia-list
+just setup    # deno task setup + rustup wasm32 target
 ----
 
-=== Option C: Manual
+== Build
 
 [source,bash]
 ----
-git clone https://github.com/hyperpolymath/nexia-list.git
-cd nexia-list
-just setup-dev
+deno task build       # ReScript compile + esbuild web bundle
+deno task build:wasm  # Rust core → WASM for the browser
 ----
 
-== Build
+== Test
 
 [source,bash]
 ----
-{{BUILD_CMD}}
+deno task test        # Rust core (cargo test) + UI (deno test)
 ----
 
-== Test
+== Run
 
 [source,bash]
 ----
-{{TEST_CMD}}
+deno task dev         # dev server on http://localhost:5173
 ----
 
 == Project Structure
@@ -53,15 +59,18 @@ just setup-dev
 [source]
 ----
 nexia-list/
-├── src/                  # Source code
-├── src/abi/              # Idris2 ABI definitions (if applicable)
-├── ffi/zig/              # Zig FFI bridge (if applicable)
-├── tests/                # Test suite
-├── docs/                 # Documentation
-├── .machine_readable/    # Checkpoint files (STATE, META, ECOSYSTEM)
-├── Justfile              # Task runner recipes
+├── core/                 # Rust core engine (12 passing unit tests)
+├── ui/                   # ReScript TEA-style UI
+│   └── src/bindings/     # DOM bindings
+├── scripts/              # Deno build/dev scripts (esbuild)
+├── web/                  # Browser entry + bundle output (dist/)
+├── desktop/              # OPTIONAL Gossamer shell (external dependency)
+├── docs/adr/             # Architecture decision records
+├── tests/                # Cross-cutting tests
+├── .machine_readable/    # Checkpoint files (STATE, META, ECOSYSTEM) + contractiles
+├── Justfile              # Task runner recipes (delegate to deno tasks)
+├── deno.json             # Deno tasks and import map
 ├── guix.scm              # Guix environment
-├── flake.nix             # Nix environment (fallback)
 └── 0-AI-MANIFEST.a2ml    # AI agent entry point
 ----
 
@@ -69,11 +78,14 @@ nexia-list/
 
 [source,bash]
 ----
+just setup          # Install dependencies (+ wasm target)
 just build          # Build the project
+just build-wasm     # Build the WASM core
 just test           # Run tests
+just run            # Start the dev server
+just check          # Deno lint + rustfmt --check + clippy
 just doctor         # Self-diagnostic
-just lint           # Lint and format
-just panic-scan     # Security scan via panic-attacker
+just assail         # Security scan via panic-attacker (if installed)
 just tour           # Guided tour of the codebase
 ----
 
@@ -81,9 +93,9 @@ just tour           # Guided tour of the codebase
 
 [source,bash]
 ----
-just lint           # Format and lint
+just check          # Lint and static checks
 just test           # All tests pass
-just panic-scan     # No new security issues
+deno task fmt       # Format (deno fmt + cargo fmt)
 ----
 
 == Contractile Invariants
@@ -91,7 +103,18 @@ just panic-scan     # No new security issues
 Read `.machine_readable/MUST.contractile` before making changes.
 Key invariants that must never be violated:
 
-{{MUST_INVARIANTS}}
+* **Deno only** — no npm/bun/yarn/pnpm dependencies or CLIs
+* **No new TypeScript, Python, or Go files**
+* **SPDX-License-Identifier header on every source file** (MPL-2.0 for code,
+  CC-BY-SA-4.0 for docs); never remove or modify LICENSE
+* **No hardcoded absolute paths** (`/home/*`, `/mnt/*`, `/var/mnt/*`) — use
+  env vars, XDG dirs, or relative references
+* **No `unsafe {}` Rust blocks without a safety comment**
+* **`.machine_readable/` and `0-AI-MANIFEST.a2ml` preserved**; no SCM files in
+  repo root
+* **No removal of CI workflows without explicit approval**; all GitHub Actions
+  SHA-pinned
+* **Tests must not be deleted or weakened**
 
 == LLM/AI Agent Development
 
@@ -102,10 +125,10 @@ If using an AI assistant, load the warmup context first:
 just llm-context    # Outputs role-appropriate context
 ----
 
-Or read `0-AI-MANIFEST.a2ml` and `.claude/CLAUDE.md` directly.
+Or read `0-AI-MANIFEST.a2ml` directly.
 
 == Get Help
 
-* **Architecture**: link:EXPLAINME.adoc[EXPLAINME.adoc]
+* **Architecture**: link:EXPLAINME.adoc[EXPLAINME.adoc] and link:docs/adr/[docs/adr/]
 * **Wiki**: https://github.com/hyperpolymath/nexia-list/wiki
 * **Report issue**: `just help-me`
diff --git a/QUICKSTART-MAINTAINER.adoc b/QUICKSTART-MAINTAINER.adoc
index 2ba7ac2..3b8fa85 100644
--- a/QUICKSTART-MAINTAINER.adoc
+++ b/QUICKSTART-MAINTAINER.adoc
@@ -1,6 +1,5 @@
 // SPDX-License-Identifier: CC-BY-SA-4.0
-// Template: QUICKSTART-MAINTAINER.adoc — packaging, deploying, and maintaining
-// Replace nexia-list, {{PACKAGE_NAME}}, {{DEPS}} with actuals
+// QUICKSTART-MAINTAINER.adoc — packaging, deploying, and maintaining
 = nexia-list — Quick Start for Platform Maintainers
 :toc:
 :toclevels: 2
@@ -10,9 +9,22 @@
 This guide covers packaging, deploying, and maintaining nexia-list for
 distribution on your platform.
 
+nexia-list is a **web-first** application: the build output is a static web
+bundle (HTML/JS/CSS + a WASM core) that can be served by any static file
+server or opened locally. There is no server-side component.
+
+== Build Dependencies
+
+* **Deno 2.x** — the only JS toolchain (no npm/bun/yarn/pnpm)
+* **Rust stable** with the `wasm32-unknown-unknown` target (for the WASM core)
+* **just** — task runner (optional; recipes delegate to `deno task ...`)
+* Optional, desktop shell only: a sibling checkout of
+  https://github.com/hyperpolymath/gossamer[hyperpolymath/gossamer] — the
+  desktop shell is NOT built in this repo's CI and is not required
+
 == Runtime Dependencies
 
-{{DEPS}}
+None. The product is a static web bundle; users need only a modern browser.
 
 == Build from Source
 
@@ -20,10 +32,12 @@ distribution on your platform.
 ----
 git clone https://github.com/hyperpolymath/nexia-list.git
 cd nexia-list
-just build-release
+just setup
+just build          # ReScript compile + esbuild bundle
+just build-wasm     # Rust core → WASM
 ----
 
-Output: `{{BUILD_OUTPUT_PATH}}`
+Output: `web/dist/` (static bundle).
 
 == Packaging
 
@@ -34,63 +48,24 @@ Output: `{{BUILD_OUTPUT_PATH}}`
 guix build -f guix.scm
 ----
 
-=== Nix
-
-[source,bash]
-----
-nix build
-----
-
-=== Container (Stapeln)
-
-[source,bash]
-----
-just stapeln-export    # Generates Containerfile
-podman build -t nexia-list .
-----
-
 === Manual Package
 
-[source,bash]
-----
-just install --prefix=/usr/local
-----
-
-Files installed:
-
-[cols="1,2"]
-|===
-| Path | Contents
-
-| `$PREFIX/bin/`
-| Executables
-
-| `$PREFIX/share/{{PACKAGE_NAME}}/`
-| Data files, assets
-
-| `$PREFIX/share/doc/{{PACKAGE_NAME}}/`
-| Documentation
-
-| `$PREFIX/share/applications/`
-| .desktop file (Linux, if GUI)
-
-| `$PREFIX/share/man/man1/`
-| Man pages
-|===
+Copy the built `web/dist/` bundle (plus `web/index.html` and `web/styles.css`)
+to your platform's static-assets location, e.g.
+`$PREFIX/share/nexia-list/`.
 
 == Configuration
 
-Default config location: `$XDG_CONFIG_HOME/{{PACKAGE_NAME}}/config.toml`
-
-Fallback: `$HOME/.config/{{PACKAGE_NAME}}/config.toml`
+There is currently no system configuration file. User data is stored
+browser-side (IndexedDB) and via JSON file download/upload.
 
 == Health Checks
 
 [source,bash]
 ----
-just doctor            # Full diagnostic
-just run --version   # Version check
-just run --selftest  # Built-in self-test
+just doctor            # Tooling diagnostic (just, git, deno, cargo)
+just check             # Deno lint + rustfmt --check + clippy
+just test              # Rust core tests + UI tests
 ----
 
 == Updating
@@ -98,30 +73,18 @@ just run --selftest  # Built-in self-test
 [source,bash]
 ----
 git pull
-just build-release
-just install --prefix=/usr/local
+just build
+just build-wasm
 ----
 
-Or via OPSM: `opsm update {{PACKAGE_NAME}}`
+Then redeploy `web/dist/`.
 
 == Security Notes
 
-* License: MPL-2.0 (Palimpsest License)
-* All dependencies SHA-pinned
-* `panic-attacker` scan results: link:INSTALL-SECURITY-REPORT.adoc[]
-* OpenSSF Scorecard: see badge in README
-
-== Multi-Instance Deployment
-
-For deploying multiple instances (e.g., different users or tenants):
-
-[source,bash]
-----
-just install --prefix=/opt/{{PACKAGE_NAME}}-instance1 --config=/etc/{{PACKAGE_NAME}}/instance1.toml
-just install --prefix=/opt/{{PACKAGE_NAME}}-instance2 --config=/etc/{{PACKAGE_NAME}}/instance2.toml
-----
-
-Each instance has isolated config, data, and logs.
+* License: MPL-2.0 (code), CC-BY-SA-4.0 (docs)
+* JS dependencies pinned via `deno.lock`; Rust dependencies via `Cargo.lock`
+* GitHub Actions are SHA-pinned (see `.machine_readable/MUST.contractile`)
+* Optional pre-commit scan: `just assail` (panic-attacker, if installed)
 
 == Reporting Issues
 
diff --git a/QUICKSTART-USER.adoc b/QUICKSTART-USER.adoc
index 7eca4ac..d5f70c1 100644
--- a/QUICKSTART-USER.adoc
+++ b/QUICKSTART-USER.adoc
@@ -1,39 +1,28 @@
 // SPDX-License-Identifier: CC-BY-SA-4.0
-// Template: QUICKSTART-USER.adoc — 5-minute path to working software
-// Replace nexia-list, Nexia List — See README.adoc for details., just run, Nexia List started successfully. with actuals
+// QUICKSTART-USER.adoc — 5-minute path to working software
 = nexia-list — Quick Start for Users
 :toc:
 :toclevels: 2
 
 == What is nexia-list?
 
-Nexia List — See README.adoc for details.
+Nexia is a local-first personal knowledge management tool: notes on a spatial
+canvas with bidirectional links and (planned) intelligent agents. It runs in
+your browser — your data stays on your device as human-readable JSON. See
+link:README.adoc[README.adoc] for the full pitch.
 
 == Prerequisites
 
 Before you begin, ensure you have:
 
 * **just** — task runner (https://github.com/casey/just[install guide])
-* Platform-specific requirements listed below
-
-[cols="1,3"]
-|===
-| Platform | Additional Requirements
-
-| Linux
-| See README.adoc
-
-| macOS
-| See README.adoc
-
-| Windows
-| See README.adoc
-|===
+* **Deno 2.x** — https://deno.land/[install guide]
+* **Rust stable** — https://www.rust-lang.org/tools/install[install guide]
+  (needed to build the core; `just setup` adds the WASM target)
+* A modern web browser
 
 == Install
 
-=== Option 1: Standard Install (recommended)
-
 [source,bash]
 ----
 # Clone and set up
@@ -42,27 +31,10 @@ cd nexia-list
 just setup
 ----
 
-The setup script will:
-
-* Detect your platform and shell
-* Install missing dependencies (with your permission)
-* Configure the application
-* Offer install location choices
-* Run a self-diagnostic to verify everything works
-
-=== Option 2: Container (via Stapeln)
+The setup recipe will:
 
-[source,bash]
-----
-just stapeln-run
-----
-
-=== Option 3: Portable (no system changes)
-
-[source,bash]
-----
-just install --portable --prefix=./nexia-list-portable
-----
+* Install JS dependencies via Deno (no npm required)
+* Add the `wasm32-unknown-unknown` Rust target for the WASM core
 
 == First Run
 
@@ -71,12 +43,12 @@ just install --portable --prefix=./nexia-list-portable
 just run
 ----
 
-Expected output:
+Then open http://localhost:5173 in your browser.
 
-[source]
-----
-Nexia List started successfully.
-----
+NOTE: Nexia is pre-release (v0.1, in active development). You can create,
+edit, delete, and search notes; the spatial canvas supports pan/zoom and
+double-click note creation. Drag-and-drop and a desktop app are not available
+yet.
 
 == Self-Diagnostic
 
@@ -87,35 +59,21 @@ If something isn't working:
 just doctor
 ----
 
-This checks all dependencies, permissions, paths, and connectivity.
-If it finds issues, it will suggest fixes.
-
-To attempt automatic repair:
-
-[source,bash]
-----
-just heal
-----
+This checks that the required tools (just, git, deno, cargo) are available
+and suggests fixes if not.
 
 == Get Help
 
-* **In-app**: `just run --help`
 * **Guided tour**: `just tour`
 * **Report a problem**: `just help-me` (pre-fills diagnostic context)
 * **Wiki**: https://github.com/hyperpolymath/nexia-list/wiki
 
 == Uninstall
 
-[source,bash]
-----
-just uninstall
-----
-
-You will be asked:
-
-1. Which uninstall tier (Bennett reversible, parameter-based, standard, or secure)
-2. Whether to include or exclude your data
-3. Whether to clear caches and LLM models
+Nexia makes no system-wide changes. To remove it, delete the cloned
+directory. Your notebooks are plain JSON files — export/download them first
+if you want to keep them (browser data lives in IndexedDB for the site until
+you clear it).
 
 == Next Steps
 
diff --git a/READINESS.md b/READINESS.md
new file mode 100644
index 0000000..39ab077
--- /dev/null
+++ b/READINESS.md
@@ -0,0 +1,36 @@
+<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
+<!-- READINESS.md — Component Readiness Grade (CRG) for nexia-list -->
+<!-- Last updated: 2026-07-02 -->
+
+# Readiness
+
+**Current Grade:** D
+
+Graded per the hyperpolymath
+[Component Readiness Grades](https://github.com/hyperpolymath/standards/tree/main/component-readiness-grades)
+standard. Grade assigned in the
+[2026-04-15 post-audit report](docs/reports/audit/audit-2026-04-15-post.md)
+(promoted from E/X).
+
+## What D means here
+
+| Aspect | Status |
+| --- | --- |
+| Builds from source | Yes — `deno task build` (ReScript + esbuild bundle); Rust core builds and its 12 unit tests pass |
+| Lockfiles | Yes — `deno.lock`, `Cargo.lock` |
+| CI | Estate governance/scanning workflows only; product CI (rust-ci.yml, ui-ci.yml) landing in a parallel workstream |
+| Tests | Rust core unit tests only; no UI or integration tests yet |
+| Docs | Truth-reset 2026-07-02; roadmap and topology reflect actual state |
+| Known debt | unwrap/expect calls in core and desktop; unsafe `get` in View.res (see audit report) |
+
+## Path to C
+
+- Product CI running on every PR (Rust build+test, ReScript build, lint)
+- Tests beyond the core crate: UI unit tests exercised in CI
+- WASM bridge built and smoke-tested in CI
+
+## Path to B
+
+- Integration tests covering the UI → WASM core → persistence path
+- Canvas interaction coverage (pan/zoom, note creation, drag-and-drop once implemented)
+- Known-debt items from the audit resolved or explicitly waived
diff --git a/README.adoc b/README.adoc
index 7c82e75..f493b55 100644
--- a/README.adoc
+++ b/README.adoc
@@ -6,25 +6,25 @@
 :icons: font
 :source-highlighter: rouge
 
-**Cross-platform personal knowledge management with spatial notes, relationships, and intelligent agents.**
+**Local-first personal knowledge management with spatial notes, relationships, and intelligent agents.**
 
-image:https://img.shields.io/badge/License-MPL--2.0-blue.svg[License: PMPL-1.0,link="https://github.com/hyperpolymath/palimpsest-license"]
-image:https://img.shields.io/badge/Platform-Desktop%20%7C%20Mobile%20%7C%20Web-green.svg[Platform]
-image:https://img.shields.io/badge/ReScript-TEA-orange.svg[ReScript TEA]
-image:https://img.shields.io/badge/Backend-Rust-red.svg[Rust]
+image:https://img.shields.io/badge/License-MPL--2.0-blue.svg[License: MPL-2.0,link="https://www.mozilla.org/en-US/MPL/2.0/"]
+image:https://img.shields.io/badge/Platform-Web%20%7C%20Desktop%20(optional)-green.svg[Platform]
+image:https://img.shields.io/badge/ReScript-TEA--style-orange.svg[ReScript TEA-style]
+image:https://img.shields.io/badge/Core-Rust%20%2B%20WASM-red.svg[Rust + WASM]
 
 toc::[]
 
 == What is Nexia?
 
-Nexia is an open-source, cross-platform knowledge management tool inspired by https://www.eastgate.com/Tinderbox/[Tinderbox]. It provides:
+Nexia is an open-source, local-first knowledge management tool inspired by https://www.eastgate.com/Tinderbox/[Tinderbox]. It provides:
 
 * **Spatial Canvas** - Arrange notes visually, see relationships at a glance
 * **Linked Notes** - First-class bidirectional links between ideas
-* **Intelligent Agents** - Persistent queries that continuously organize your knowledge
-* **Prototype Inheritance** - Notes inherit attributes from templates
+* **Intelligent Agents** (planned) - Persistent queries that continuously organize your knowledge
+* **Prototype Inheritance** (planned) - Notes inherit attributes from templates
 * **Local-First** - Your data lives on your device, in readable formats
-* **True Cross-Platform** - Same app on Desktop, Mobile, and Web
+* **Web-First** - Runs in the browser; an optional desktop shell can wrap the same app
 
 == Philosophy
 
@@ -33,34 +33,43 @@ Your knowledge is yours. Not in a cloud you don't control. Not locked in a propr
 
 Nexia follows the **local-first** philosophy:
 
-. Data stored as human-readable files (JSON/XML)
+. Data stored in human-readable formats (JSON)
 . Works offline by default
 . Optional sync is additive, never required
-. Export to open formats (HTML, Markdown, OPML)
+. Export to open formats (HTML, Markdown, OPML — planned)
 
 == Architecture
 
+Nexia is **web-first**. The primary target is the browser: the Rust core is
+compiled to WebAssembly so the real engine runs client-side, with no server
+required. A desktop shell is optional and external.
+
 [source]
 ----
-┌─────────────────────────────────────────────────────────┐
-│                    ReScript + TEA                        │
-│              (rescript-tea, cadre-tea-router)            │
-│                   Single UI Codebase                     │
-└─────────────────────┬───────────────────────────────────┘
-                      │
-        ┌─────────────┼─────────────┐
-        ▼             ▼             ▼
-   ┌──────────┐ ┌──────────┐ ┌─────────────┐
-   │ Gossamer │ │ Gossamer │ │   Browser   │
-   │ Desktop  │ │  Mobile  │ │  (WASM/JS)  │
-   └────┬─────┘ └────┬─────┘ └──────┬──────┘
-        │            │              │
-        └────────────┼──────────────┘
-                     ▼
-         ┌───────────────────────┐
-         │      Rust Core        │
-         │  Graph · Search · IO  │
-         └───────────────────────┘
+┌──────────────────────────────────────────────────────────┐
+│              ReScript UI (TEA-style)                     │
+│   Model / Msg / Update / View — hand-rolled TEA loop     │
+│   on @rescript/react; bundled by esbuild → web/dist/     │
+└─────────────────────────┬────────────────────────────────┘
+                          │
+                          ▼
+┌──────────────────────────────────────────────────────────┐
+│                  Browser (primary target)                │
+│                                                          │
+│   ┌──────────────────────────────────────────────┐       │
+│   │   Rust Core → WASM (wasm-bindgen)            │       │
+│   │   Notes · Backlinks · Search · JSON storage  │       │
+│   └──────────────────────────────────────────────┘       │
+│                                                          │
+│   Persistence: IndexedDB + file download/upload          │
+└─────────────────────────┬────────────────────────────────┘
+                          │ optional
+                          ▼
+┌──────────────────────────────────────────────────────────┐
+│   Gossamer desktop shell (OPTIONAL, EXTERNAL)            │
+│   Requires a sibling checkout of hyperpolymath/gossamer; │
+│   intentionally not built in this repo's CI              │
+└──────────────────────────────────────────────────────────┘
 ----
 
 === Tech Stack
@@ -70,76 +79,74 @@ Nexia follows the **local-first** philosophy:
 |Layer |Technology |Purpose
 
 |UI Framework
-|ReScript + TEA
-|Type-safe functional UI with The Elm Architecture
-
-|Routing
-|cadre-tea-router
-|URL-based navigation, deep linking
-
-|Desktop/Mobile
-|Gossamer (gossamer-rs)
-|Lightweight webview shell for Linux, macOS, Windows, iOS, Android
-
-|Web
-|WASM + PWA
-|Browser deployment, offline support
+|ReScript 11 + hand-rolled TEA on `@rescript/react`
+|Type-safe functional UI with The Elm Architecture pattern
 
 |Core Engine
-|Rust
-|Graph operations, search indexing, file I/O
+|Rust (serde, serde_json, uuid, chrono, thiserror)
+|Note/Notebook model, backlinks reverse index, substring search, JSON storage
 
-|Configuration
-|Nickel (bunsenite)
-|Type-safe configuration and schema definitions
+|Browser Bridge
+|wasm-bindgen (in progress)
+|Compiles the Rust core to WASM so the browser runs the real engine
+
+|Tooling
+|Deno 2 tasks + esbuild
+|Package management, dev server, bundling — no npm/bun/yarn/pnpm CLIs
 
 |Storage
-|Local files + IndexedDB
-|Platform-adaptive persistence
+|JSON via IndexedDB + file download/upload
+|Local-first persistence in the browser
+
+|Desktop (optional)
+|Gossamer (external sibling checkout)
+|Thin webview shell wrapping the same web bundle
 |===
 
 === Why This Stack?
 
-**ReScript + TEA** gives us:
+**ReScript + TEA-style architecture** gives us:
 
 * Exhaustive pattern matching (compiler catches missed states)
 * Pure update functions (easy testing, time-travel debugging)
 * Single source of truth (no stale UI bugs)
 * Type-safe message passing
 
-**Gossamer** gives us:
+**Rust compiled to WASM** gives us:
 
-* True cross-platform (desktop AND mobile from one codebase)
-* Minimal binary sizes (smaller than Tauri, far smaller than Electron)
-* Native webview performance via Zig FFI
-* Capability-based security model (no implicit permissions)
-* Idris2 ABI-verified interface contracts
+* Memory safety without GC pauses
+* One core implementation shared by browser and any future shell
+* No type drift between "desktop core" and "web core"
 
-**Rust** gives us:
+=== Future directions (not yet implemented)
 
-* Memory safety without GC pauses
-* Excellent graph libraries (petgraph)
-* Fast full-text search (tantivy)
-* WASM compilation for browser
+These appear in older docs as if they existed — they do not, yet:
+
+* Graph engine via petgraph
+* Full-text search via tantivy (current search is substring matching)
+* Nickel schemas for configuration/validation
+* Service worker / offline PWA install
+* Mobile targets
+* A dedicated TEA library and URL router (the current TEA loop is hand-rolled;
+  `rescript-tea` and `cadre-tea-router` were removed as unused/unresolvable)
 
 == Features
 
 === MVP (v0.1)
 
-* [ ] Create, edit, delete notes
-* [ ] Spatial canvas with drag-and-drop
-* [ ] Bidirectional linking between notes
-* [ ] Basic search
-* [ ] Local file storage (JSON)
-* [ ] Desktop app (Linux, macOS, Windows)
+* [x] Create, edit, delete notes
+* [ ] Spatial canvas with drag-and-drop (pan/zoom and double-click create work; drag-and-drop pending)
+* [x] Bidirectional linking between notes (in core; UI surfacing in progress)
+* [x] Basic search
+* [x] Local file storage (JSON)
+* [ ] Optional desktop shell (Gossamer, external)
 
 === Future
 
 * [ ] Agents (persistent queries)
 * [ ] Prototype inheritance
 * [ ] Timeline view
-* [ ] Mobile apps (iOS, Android)
-* [ ] Web app (PWA)
+* [ ] Web app installable as PWA (service worker)
 * [ ] Rich text editing
 * [ ] Entity extraction (names, dates, places)
 * [ ] Export (HTML, Markdown, OPML)
@@ -149,46 +156,44 @@ Nexia follows the **local-first** philosophy:
 
 [source]
 ----
-nexia/
-├── core/                 # Rust - graph engine, search, storage
+nexia-list/
+├── core/                  # Rust — note/notebook engine (~623 LOC, 12 unit tests)
 │   ├── Cargo.toml
 │   └── src/
-│       ├── lib.rs
-│       ├── graph.rs      # Note relationships
-│       ├── search.rs     # Full-text indexing
-│       └── storage.rs    # File I/O
-├── ui/                   # ReScript - TEA application
+│       ├── lib.rs         # Crate root
+│       ├── note.rs        # Note data model (serde)
+│       ├── notebook.rs    # CRUD, backlinks reverse index, substring search
+│       ├── storage.rs     # JSON save/load
+│       └── wasm.rs        # wasm-bindgen bridge for the browser
+├── ui/                    # ReScript — TEA-style app (~880 LOC)
 │   ├── rescript.json
 │   └── src/
-│       ├── Main.res      # Entry point
-│       ├── Model.res     # Application state
-│       ├── Update.res    # Message handling
-│       ├── View.res      # Rendering
-│       ├── Canvas.res    # Spatial view
-│       └── Note.res      # Note component
-├── desktop/              # Gossamer desktop shell
-│   ├── gossamer.conf.json
-│   └── src/
-│       └── main.rs       # Gossamer command handlers
-├── mobile/               # Gossamer mobile configuration
-│   ├── ios/
-│   └── android/
-├── web/                  # Browser/PWA build
-│   ├── index.html
-│   └── service-worker.js
-├── config/               # Nickel schemas
-│   └── note-schema.ncl
-└── docs/
-    └── ROADMAP.adoc
+│       ├── Main.res       # Entry point
+│       ├── Model.res      # Application state
+│       ├── Msg.res        # Message type
+│       ├── Types.res      # Shared types
+│       ├── Update.res     # Message handling
+│       ├── View.res       # Rendering (list, editor, canvas)
+│       └── bindings/
+│           └── DomBindings.res
+├── scripts/               # Deno build/dev scripts (esbuild bundling, dev server)
+├── web/                   # Browser entry (index.html, styles) + bundle output (dist/)
+├── desktop/               # OPTIONAL Gossamer shell — requires external sibling
+│                          # checkout of hyperpolymath/gossamer; not built in CI
+├── docs/                  # ADRs, reports
+└── tests/                 # Cross-cutting tests
 ----
 
 == Getting Started
 
 === Prerequisites
 
-* https://deno.land/[Deno] (package management, scripts)
-* https://www.rust-lang.org/tools/install[Rust] (core engine)
-* https://github.com/hyperpolymath/gossamer[Gossamer] (webview shell)
+* https://deno.land/[Deno] 2.x (only JS toolchain used — no npm/bun/yarn/pnpm)
+* https://www.rust-lang.org/tools/install[Rust] stable
+  (plus the `wasm32-unknown-unknown` target for WASM builds:
+  `rustup target add wasm32-unknown-unknown`)
+* Optional, desktop only: a sibling checkout of
+  https://github.com/hyperpolymath/gossamer[Gossamer] next to this repo
 
 === Development
 
@@ -201,11 +206,17 @@ cd nexia-list
 # Install dependencies
 deno task setup
 
-# Run development server
+# Run development server (http://localhost:5173)
 deno task dev
 
-# Build for production
+# Build for production (ReScript + web bundle)
 deno task build
+
+# Build the WASM core
+deno task build:wasm
+
+# Run tests (Rust core + UI)
+deno task test
 ----
 
 == Contributing
@@ -215,14 +226,14 @@ Contributions welcome! Please read our link:CONTRIBUTING.md[Contributing Guide]
 === Development Principles
 
 . **Local-first always** - Never require network for core functionality
-. **Platform parity** - Features should work on all platforms
+. **Web-first** - The browser is the primary target; shells are additive
 . **Type safety** - Leverage ReScript's type system fully
 . **Performance** - Keep UI responsive with 10,000+ notes
 . **Simplicity** - Resist feature creep, do fewer things well
 
 == License
 
-MPL-2.0. See link:LICENSE[LICENSE] for details.
+MPL-2.0 for code, CC-BY-SA-4.0 for documentation. See link:LICENSE[LICENSE] for details.
 
 Your knowledge is yours. The code to manage it should be too.
 
@@ -231,9 +242,9 @@ Your knowledge is yours. The code to manage it should be too.
 * https://www.eastgate.com/Tinderbox/[Tinderbox] - The inspiration for spatial note-taking
 * https://rescript-lang.org/[ReScript] - Type-safe language compiling to JS
 * https://github.com/hyperpolymath/gossamer[Gossamer] - Lightweight webview shell with Zig FFI
-* https://github.com/hyperpolymath[hyperpolymath] ecosystem - ReScript-TEA, gossamer-rs, bunsenite
+* https://github.com/hyperpolymath[hyperpolymath] ecosystem
 
 
-== Architecture
+== Architecture Map
 
 See link:TOPOLOGY.md[TOPOLOGY.md] for a visual architecture map and completion dashboard.
diff --git a/ROADMAP.adoc b/ROADMAP.adoc
index c87f6de..a54c62d 100644
--- a/ROADMAP.adoc
+++ b/ROADMAP.adoc
@@ -7,6 +7,21 @@
 
 This document outlines the development phases for Nexia, from MVP to full-featured knowledge management platform.
 
+.Revised sequencing (2026-07)
+[NOTE]
+====
+Nexia is now **web-first**: the Rust core is compiled to WASM and the browser
+is the primary target (see link:docs/adr/0001-wasm-core-web-first.md[ADR-0001]).
+The desktop shell (Gossamer) is optional and external. Current sequencing:
+
+. Build resurrection (Deno 2 tasks, ReScript compile, esbuild bundle) — ✅ done
+. WASM core integration (wasm-bindgen bridge, IndexedDB/file persistence) — in progress
+. Product CI (rust-ci.yml, ui-ci.yml — landing in a parallel workstream)
+. Tests beyond the core unit suite
+. Canvas drag-and-drop + accessibility + PWA (service worker)
+. Wiki-links, graph view, agents, import/export
+====
+
 toc::[]
 
 == Phase 0: Foundation
@@ -15,10 +30,10 @@ Establish the cross-platform development infrastructure.
 
 === Objectives
 
-* Set up monorepo structure
-* Configure Rust workspace for core engine
-* Initialize ReScript project with TEA
-* Set up Tauri for desktop builds
+* Set up repo structure
+* Configure Rust crate for core engine
+* Initialize ReScript project with TEA-style architecture
+* (Optional) Gossamer desktop shell — external, not built in this repo's CI
 * Establish CI/CD pipeline
 
 === Deliverables
@@ -28,37 +43,37 @@ Establish the cross-platform development infrastructure.
 |Item |Description |Status
 
 |Project scaffold
-|Monorepo with core/, ui/, desktop/, web/ directories
-|[ ]
+|Repo with core/, ui/, web/, scripts/, desktop/ directories
+|[x]
 
 |Rust core crate
-|Empty library with Cargo.toml configured
-|[ ]
+|Library with Cargo.toml configured
+|[x]
 
 |ReScript setup
-|rescript.json with rescript-tea, cadre-tea-router deps
-|[ ]
+|rescript.json with @rescript/core + @rescript/react (hand-rolled TEA loop)
+|[x]
 
-|Tauri shell
-|Basic Tauri app that loads ReScript UI
+|Gossamer shell (optional, external)
+|Thin shell wrapping the web bundle; requires sibling ../gossamer checkout
 |[ ]
 
 |Deno tasks
-|dev, build, test, lint commands
-|[ ]
+|setup, dev, build, test, lint, fmt commands (Deno 2, no npm)
+|[x]
 
 |GitHub Actions
-|CI for Rust, ReScript, cross-platform builds
+|Product CI for Rust + ReScript (rust-ci.yml, ui-ci.yml — parallel workstream)
 |[ ]
 
 |MPL-2.0 license
 |LICENSE file
-|[ ]
+|[x]
 |===
 
 == Phase 1: MVP - Basic Note Management
 
-A working desktop app that can create, view, and link notes.
+A working app (browser-first) that can create, view, and link notes.
 
 === Objectives
 
@@ -117,38 +132,42 @@ type msg =
 
 |Note struct
 |Rust data model with serde serialization
-|[ ]
+|[x]
 
 |Notebook operations
-|CRUD operations in Rust core
-|[ ]
+|CRUD operations in Rust core (with backlinks reverse index)
+|[x]
 
 |TEA Model
 |ReScript model type mirroring Rust structs
-|[ ]
+|[x]
 
 |TEA Update
 |Message handlers for all note operations
-|[ ]
+|[x]
 
 |Note editor
 |Basic textarea for note content
-|[ ]
+|[x]
 
 |Note list view
 |Sidebar showing all notes
-|[ ]
+|[x]
 
 |JSON storage
-|Save/load notebook as JSON file
-|[ ]
+|Save/load notebook as JSON
+|[x]
+
+|Basic search
+|Substring search across titles/content in Rust core
+|[x]
 
-|Tauri commands
-|Bridge between UI and Rust core
+|WASM bridge
+|wasm-bindgen bridge so the browser UI calls the Rust core
 |[ ]
 
-|Desktop build
-|Working Linux/macOS/Windows binaries
+|Gossamer commands (optional, external)
+|Bridge between UI and Rust core in the optional desktop shell
 |[ ]
 |===
 
@@ -197,12 +216,12 @@ The core differentiator - visual arrangement of notes.
 |Item |Description |Status
 
 |Canvas component
-|ReScript wrapper for HTML canvas
-|[ ]
+|Canvas view rendering note cards
+|[x]
 
 |Pan/zoom
-|Mouse drag to pan, scroll to zoom
-|[ ]
+|Mouse drag to pan, scroll to zoom (double-click creates a note)
+|[x]
 
 |Note cards
 |DOM elements positioned on canvas
@@ -359,8 +378,8 @@ True cross-platform deployment.
 
 === Objectives
 
-* iOS app via Tauri mobile
-* Android app via Tauri mobile
+* iOS app via Gossamer (optional, external)
+* Android app via Gossamer (optional, external)
 * Progressive Web App
 * Offline-first with service worker
 * Optional sync between devices
@@ -398,7 +417,7 @@ True cross-platform deployment.
 |===
 |Item |Description |Status
 
-|Tauri mobile config
+|Gossamer mobile config (optional, external)
 |iOS and Android build setup
 |[ ]
 
@@ -630,7 +649,7 @@ Integration with the wider world.
 * Can create 100+ notes without performance issues
 * Can link notes bidirectionally
 * Can save and reload notebook
-* Works on Linux, macOS, Windows
+* Works in modern browsers on Linux, macOS, Windows
 * Note content survives app restart
 
 === Full Product Success Criteria
diff --git a/TEST-NEEDS.md b/TEST-NEEDS.md
index 619e545..b64b294 100644
--- a/TEST-NEEDS.md
+++ b/TEST-NEEDS.md
@@ -1,28 +1,40 @@
+<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
 # TEST-NEEDS.md — nexia-list
 
-## CRG Grade: C — ACHIEVED 2026-04-04
+## CRG Grade: D (see READINESS.md; last audited 2026-04-15, test matrix updated 2026-07-02)
 
 ## Current Test State
 
-| Category | Count | Notes |
-|----------|-------|-------|
-| Test files | 0 | Current state |
+| Category | Where | Count | Notes |
+|----------|-------|-------|-------|
+| Rust unit tests | `core/src/*.rs` (`#[cfg(test)]`) | 14 | note, notebook (incl. self-link rejection, backlink rebuild), storage |
+| Golden contract (Rust) | `core/tests/golden.rs` | 2 | on-disk JSON format, shared fixture `tests/fixtures/notebook.golden.json` |
+| Property tests | `core/tests/invariants.rs` (proptest) | 2 | backlinks == exact inverse of links after any op sequence; serde roundtrip. Found a real self-link index-corruption bug on first run. |
+| Golden contract (JS/wasm) | `ui/tests/contract.test.js` | 2 | same fixture decoded through the wasm bindings; camelCase view shape; snake_case disk format |
+| TEA update tests | `ui/tests/UpdateTests.res` + `update.test.js` | 1 suite (~25 assertions) | CRUD/link/search/zoom/delete-guard against the real wasm core |
 
 ## What's Covered
 
-- [ ] No test files found
+- [x] Core unit tests
+- [x] Property-based tests (backlink invariant, serde roundtrip)
+- [x] Cross-language contract tests (Rust ⇄ wasm/JS golden fixture)
+- [x] UI update-function tests against the wasm core
+- [x] CI/CD test automation (`rust-ci.yml`, `ui-ci.yml`)
 
-## Still Missing (for CRG B+)
+## Still Missing (for CRG C/B)
 
-- [ ] Add unit tests
-- [ ] Add integration tests
-- [ ] Zig FFI tests (if applicable)
-- [ ] CI/CD test automation
-- [ ] Property-based tests
-- [ ] Edge case coverage
+- [ ] Browser E2E in CI (Astral smoke flows: create/edit/link/reload-restore,
+      keyboard-only session) — verified manually, not yet a CI job
+- [ ] Real fuzz target (`fuzz_load_notebook`: arbitrary bytes →
+      `serde_json::from_str::<Notebook>` must never panic) replacing
+      `tests/fuzz/placeholder.txt`
+- [ ] 10k-note performance benchmark (search <100 ms target)
+- [ ] Accessibility gate (axe-core in E2E)
 
 ## Run Tests
 
 ```bash
-# Tests needed
+deno task test        # cargo test (core) + deno test (UI/contract)
+deno task test:rust   # Rust only
+deno task test:ui     # UI/contract only (needs build:res + build:wasm first)
 ```
diff --git a/TOPOLOGY.md b/TOPOLOGY.md
index c13c085..5c5460d 100644
--- a/TOPOLOGY.md
+++ b/TOPOLOGY.md
@@ -1,95 +1,104 @@
 <!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
 <!-- TOPOLOGY.md — Project architecture map and completion dashboard -->
-<!-- Last updated: 2026-02-19 -->
+<!-- Last updated: 2026-07-02 -->
 
 # Nexia — Project Topology
 
 ## System Architecture
 
+Nexia is **web-first**: the browser is the primary target, running the real
+Rust core compiled to WebAssembly. The desktop shell is optional and depends
+on an **external** sibling checkout of `hyperpolymath/gossamer`; it is
+intentionally not built in this repo's CI.
+
 ```
                         ┌─────────────────────────────────────────┐
                         │              USER INTERFACE             │
-                        │        (Spatial Canvas / Notes)         │
+                        │     (Note list / Editor / Canvas)       │
                         └───────────────────┬─────────────────────┘
                                             │
                                             ▼
                         ┌─────────────────────────────────────────┐
                         │           UI LAYER (RESCRIPT)           │
-                        │    (TEA Architecture, cadre-tea-router)  │
-                        └──────────┬───────────────────┬──────────┘
-                                   │                   │
-                                   ▼                   ▼
-                        ┌───────────────────┐  ┌───────────────────┐
-                        │   TAURI SHELL     │  │   BROWSER / PWA   │
-                        │ (Desktop/Mobile)  │  │   (WASM/JS)       │
-                        └──────────┬────────┘  └──────────┬────────┘
-                                   │                      │
-                                   └──────────┬───────────┘
-                                              │
-                                              ▼
+                        │  Hand-rolled TEA on @rescript/react     │
+                        │  Model / Msg / Update / View            │
+                        │  esbuild bundle → web/dist/             │
+                        └───────────────────┬─────────────────────┘
+                                            │
+                                            ▼
                         ┌─────────────────────────────────────────┐
-                        │           RUST CORE (CRATES)            │
+                        │        BROWSER (PRIMARY TARGET)         │
                         │                                         │
-                        │  ┌───────────┐  ┌───────────────────┐  │
-                        │  │  Graph    │  │  Search           │  │
-                        │  │ (Petgraph)│  │ (Tantivy)         │  │
-                        │  └─────┬─────┘  └────────┬──────────┘  │
-                        │        │                 │              │
-                        │  ┌─────▼─────┐  ┌────────▼──────────┐  │
-                        │  │ Storage   │  │  Nickel           │  │
-                        │  │ (FS/IDB)  │  │  Schemas          │  │
-                        │  └─────┬─────┘  └────────┬──────────┘  │
-                        └────────│─────────────────│──────────────┘
-                                 │                 │
-                                 ▼                 ▼
+                        │  ┌───────────────────────────────────┐  │
+                        │  │   RUST CORE → WASM (wasm-bindgen) │  │
+                        │  │   Note · Notebook · Backlinks     │  │
+                        │  │   Substring search · JSON storage │  │
+                        │  └────────────────┬──────────────────┘  │
+                        │                   │                     │
+                        │                   ▼                     │
+                        │  ┌───────────────────────────────────┐  │
+                        │  │          DATA LAYER               │  │
+                        │  │  IndexedDB + file download/upload │  │
+                        │  │  (human-readable JSON)            │  │
+                        │  └───────────────────────────────────┘  │
+                        └───────────────────┬─────────────────────┘
+                                            │ optional
+                                            ▼
                         ┌─────────────────────────────────────────┐
-                        │             DATA LAYER                  │
-                        │  ┌───────────┐  ┌───────────────────┐  │
-                        │  │ Local JSON│  │  IndexedDB        │  │
-                        │  │ (Files)   │  │  (Web Storage)    │  │
-                        │  └───────────┘  └───────────────────┘  │
+                        │  GOSSAMER DESKTOP SHELL (OPTIONAL)      │
+                        │  EXTERNAL: requires sibling checkout    │
+                        │  ../gossamer — NOT built in this CI     │
                         └─────────────────────────────────────────┘
 
                         ┌─────────────────────────────────────────┐
                         │          REPO INFRASTRUCTURE            │
-                        │  Justfile Automation  .machine_readable/  │
-                        │  Deno Tooling         0-AI-MANIFEST.a2ml  │
+                        │  Deno 2 tasks   Justfile   scripts/     │
+                        │  .machine_readable/  0-AI-MANIFEST.a2ml │
                         └─────────────────────────────────────────┘
 ```
 
+Future (not yet implemented, kept out of the diagram deliberately): petgraph
+graph engine, tantivy full-text search, Nickel schemas, service worker / PWA
+install, mobile targets.
+
 ## Completion Dashboard
 
 ```
 COMPONENT                          STATUS              NOTES
 ─────────────────────────────────  ──────────────────  ─────────────────────────────────
-USER INTERFACES
-  ReScript UI (TEA)                 ██████░░░░  60%    Spatial canvas prototyping
-  Tauri Desktop Shell               ████████░░  80%    Linux/macOS integration verified
-  Tauri Mobile Shell                ████░░░░░░  40%    Initial Android stubs
-  Web / PWA Deployment              ██████░░░░  60%    Offline service worker verified
-
-CORE ENGINE (RUST)
-  Graph Engine (petgraph)           ██████████ 100%    Bidirectional links stable
-  Search Indexing (tantivy)         ████████░░  80%    FTS integration refining
-  File I/O (JSON/XML)               ██████████ 100%    Local-first storage verified
-  Nickel Schemas                    ██████████ 100%    Note validation active
-
-REPO INFRASTRUCTURE
-  Justfile Automation               ██████████ 100%    Standard build/setup tasks
-  .machine_readable/                ██████████ 100%    STATE tracking active
-  Deno Task Runner                  ██████████ 100%    Package management verified
+PRODUCT
+  Rust Core Engine                  ████████░░  80%    Of MVP scope: Note/Notebook, back-
+                                                       links, substring search, JSON
+                                                       storage; 12 passing unit tests
+  ReScript UI (TEA-style)           ██████░░░░  60%    Compiles; list/editor work; canvas
+                                                       pan/zoom + dbl-click create; no
+                                                       drag-and-drop; GraphView placeholder
+  WASM Bridge (core → browser)      ████░░░░░░  40%    In progress (wasm-bindgen)
+  Web / PWA                         ███░░░░░░░  30%    Builds and runs via esbuild bundle;
+                                                       no service worker yet (planned)
+  Desktop Shell (Gossamer)          ░░░░░░░░░░   0%    Blocked-external: needs sibling
+                                                       ../gossamer checkout; not buildable
+                                                       in this repo
+
+INFRASTRUCTURE
+  CI Product Coverage               █░░░░░░░░░  10%    In progress: rust-ci.yml + ui-ci.yml
+                                                       landing in a parallel workstream;
+                                                       existing 11 workflows are estate
+                                                       governance/scanning only
+  Governance / Meta                 ███████░░░  70%    Contractiles, STATE, manifest, docs
 
 ─────────────────────────────────────────────────────────────────────────────
-OVERALL:                            ███████░░░  ~70%   Core engine stable, UI refining
+OVERALL:                            ████░░░░░░  ~35%   Build resurrected; WASM integration
+                                                       is the current critical path
 ```
 
 ## Key Dependencies
 
 ```
-Nickel Schema ───► Rust Core ──────► Graph Engine ──────► Spatial UI
-     │               │                 │                   │
-     ▼               ▼                 ▼                   ▼
-Storage Logic ──► Local Files ──────► Search Index ───► Query Agent
+Rust Core ──► WASM bundle ──► ReScript UI ──► Web bundle (web/dist/)
+    │                                              │
+    ▼                                              ▼
+JSON storage ──► IndexedDB / file download   (optional) Gossamer shell
 ```
 
 ## Update Protocol
diff --git a/contractiles/intend/Intentfile.a2ml b/contractiles/intend/Intentfile.a2ml
index ec1f5da..baecd39 100644
--- a/contractiles/intend/Intentfile.a2ml
+++ b/contractiles/intend/Intentfile.a2ml
@@ -8,7 +8,7 @@ Declared intent and purpose for Nexia List.
 
 ## Purpose
 
-Nexia List — // SPDX-License-Identifier: MPL-2.0
+Nexia List — local-first spatial knowledge management: notes on a spatial canvas with bidirectional links, web-first (Rust core compiled to WASM, ReScript TEA-style UI), data stored as user-readable JSON.
 
 ## Anti-Purpose
 
diff --git a/core/Cargo.toml b/core/Cargo.toml
index d3caea0..ff61b8a 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -13,7 +13,13 @@ crate-type = ["cdylib", "rlib"]
 
 [features]
 default = []
-wasm = ["wasm-bindgen", "console_error_panic_hook"]
+wasm = [
+    "wasm-bindgen",
+    "console_error_panic_hook",
+    "serde-wasm-bindgen",
+    "chrono/wasmbind",
+    "uuid/js",
+]
 
 [dependencies]
 serde = { version = "1.0", features = ["derive"] }
@@ -25,7 +31,9 @@ thiserror = "1.0"
 # Optional WASM support
 wasm-bindgen = { version = "0.2", optional = true }
 console_error_panic_hook = { version = "0.1", optional = true }
+serde-wasm-bindgen = { version = "0.6", optional = true }
 
 [dev-dependencies]
 pretty_assertions = "1.0"
 tempfile = "3.0"
+proptest = "1"
diff --git a/core/src/lib.rs b/core/src/lib.rs
index 7590d89..b8ae9a8 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -2,21 +2,26 @@
 
 //! Nexia Core — Knowledge Graph and Note Engine.
 //!
-//! This crate provides the foundational data structures for the Nexia 
-//! ecosystem. It treats notes as nodes in a multi-dimensional graph, 
+//! This crate provides the foundational data structures for the Nexia
+//! ecosystem. It treats notes as nodes in a multi-dimensional graph,
 //! supporting bidirectional linking and spatial arrangement.
 //!
 //! ARCHITECTURE:
 //! - `note`: Individual atomic unit of information.
 //! - `notebook`: A logical collection/subgraph of notes.
-//! - `storage`: Content-addressable persistence layer.
+//! - `storage`: File-based JSON persistence.
+//! - `wasm` (feature "wasm"): browser bindings; the web UI runs this crate
+//!   compiled to WebAssembly as its single source of truth.
 
 #![forbid(unsafe_code)]
 pub mod note;
 pub mod notebook;
 pub mod storage;
 
-// PUBLIC API: Re-export primary types for use in Desktop (Tauri) and Web consumers.
+#[cfg(feature = "wasm")]
+pub mod wasm;
+
+// PUBLIC API: Re-export primary types for the desktop shell and web consumers.
 pub use note::{Note, NoteId, Point2D};
 pub use notebook::Notebook;
 pub use storage::Storage;
diff --git a/core/src/notebook.rs b/core/src/notebook.rs
index 95ab5e3..906e0ba 100644
--- a/core/src/notebook.rs
+++ b/core/src/notebook.rs
@@ -65,10 +65,7 @@ impl Notebook {
 
         // Update backlinks for any links this note has
         for target_id in &note.links {
-            self.backlinks
-                .entry(*target_id)
-                .or_default()
-                .insert(id);
+            self.backlinks.entry(*target_id).or_default().insert(id);
         }
 
         self.notes.insert(id, note);
@@ -121,6 +118,11 @@ impl Notebook {
 
     /// Create a link between two notes
     pub fn link_notes(&mut self, from: NoteId, to: NoteId) -> Result<(), NotebookError> {
+        // A self-link would corrupt the backlink index: Note::add_link
+        // refuses it silently, so the backlink below must not be recorded.
+        if from == to {
+            return Err(NotebookError::CircularLink);
+        }
         // Verify both notes exist
         if !self.notes.contains_key(&from) {
             return Err(NotebookError::NoteNotFound(from));
@@ -205,6 +207,20 @@ impl Notebook {
             .collect()
     }
 
+    /// Rebuild the backlinks index from the notes' outgoing links.
+    ///
+    /// The index is persisted alongside the notes, but a hand-edited or
+    /// older file may carry a stale one — loading always rebuilds instead
+    /// of trusting it.
+    pub fn rebuild_backlinks(&mut self) {
+        self.backlinks.clear();
+        for (id, note) in &self.notes {
+            for target_id in &note.links {
+                self.backlinks.entry(*target_id).or_default().insert(*id);
+            }
+        }
+    }
+
     /// Update the modified timestamp
     fn touch(&mut self) {
         self.modified_at = chrono::Utc::now();
@@ -275,6 +291,35 @@ mod tests {
         assert!(notebook.get_backlinks(&id3).is_empty());
     }
 
+    #[test]
+    fn test_self_link_rejected() {
+        let mut notebook = Notebook::new("Test");
+        let id = notebook.create_note("Note");
+
+        assert!(matches!(
+            notebook.link_notes(id, id),
+            Err(NotebookError::CircularLink)
+        ));
+        // The backlink index must stay untouched.
+        assert!(notebook.get_backlinks(&id).is_empty());
+    }
+
+    #[test]
+    fn test_rebuild_backlinks() {
+        let mut notebook = Notebook::new("Test");
+        let id1 = notebook.create_note("Note 1");
+        let id2 = notebook.create_note("Note 2");
+        notebook.link_notes(id1, id2).unwrap();
+
+        // Corrupt the index, then rebuild
+        notebook.backlinks.clear();
+        assert!(notebook.get_backlinks(&id2).is_empty());
+
+        notebook.rebuild_backlinks();
+        assert_eq!(notebook.get_backlinks(&id2), vec![id1]);
+        assert!(notebook.get_backlinks(&id1).is_empty());
+    }
+
     #[test]
     fn test_search() {
         let mut notebook = Notebook::new("Test");
diff --git a/core/src/storage.rs b/core/src/storage.rs
index d8e5cbd..9751086 100644
--- a/core/src/storage.rs
+++ b/core/src/storage.rs
@@ -55,7 +55,9 @@ impl Storage for JsonStorage {
         }
 
         let json = std::fs::read_to_string(path)?;
-        let notebook = serde_json::from_str(&json)?;
+        let mut notebook: Notebook = serde_json::from_str(&json)?;
+        // The stored index may be stale (hand-edited or older files).
+        notebook.rebuild_backlinks();
         Ok(notebook)
     }
 }
@@ -63,7 +65,6 @@ impl Storage for JsonStorage {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use std::fs;
     use tempfile::tempdir;
 
     #[test]
diff --git a/core/src/wasm.rs b/core/src/wasm.rs
new file mode 100644
index 0000000..bd2ec41
--- /dev/null
+++ b/core/src/wasm.rs
@@ -0,0 +1,345 @@
+// SPDX-License-Identifier: MPL-2.0
+//! WASM bindings — the browser-facing API of the core.
+//!
+//! The UI keeps a read model and applies the values returned here; every
+//! mutation returns only what changed (a single note view, or a delta for
+//! operations that touch link topology), never the whole notebook.
+//!
+//! View types serialize with camelCase keys to match the UI's records; the
+//! on-disk JSON format (snake_case, see `storage`) is unaffected.
+
+use crate::note::{Note, Point2D};
+use crate::notebook::Notebook;
+use serde::Serialize;
+use std::collections::HashMap;
+use uuid::Uuid;
+use wasm_bindgen::prelude::*;
+
+#[wasm_bindgen(start)]
+pub fn start() {
+    console_error_panic_hook::set_once();
+}
+
+fn to_js<T: Serialize>(value: &T) -> Result<JsValue, JsValue> {
+    // json_compatible(): maps become plain JS objects, not Map instances.
+    value
+        .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
+        .map_err(|e| JsValue::from_str(&e.to_string()))
+}
+
+fn err(msg: impl std::fmt::Display) -> JsValue {
+    // A real JS Error (not a bare string) so callers can read .message.
+    JsValue::from(JsError::new(&msg.to_string()))
+}
+
+fn parse_id(id: &str) -> Result<Uuid, JsValue> {
+    Uuid::parse_str(id).map_err(|_| err(format!("Invalid note id: {id}")))
+}
+
+/// UI-facing shape of a note. Optional fields are omitted when absent
+/// (ReScript reads absent as None); `links`/`attributes` are always present.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct NoteView {
+    id: String,
+    title: String,
+    content: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    position: Option<Point2D>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    size: Option<(f64, f64)>,
+    created_at: String,
+    modified_at: String,
+    links: Vec<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    prototype: Option<String>,
+    attributes: HashMap<String, serde_json::Value>,
+}
+
+impl From<&Note> for NoteView {
+    fn from(note: &Note) -> Self {
+        Self {
+            id: note.id.to_string(),
+            title: note.title.clone(),
+            content: note.content.clone(),
+            position: note.position,
+            size: note.size,
+            created_at: note.created_at.to_rfc3339(),
+            modified_at: note.modified_at.to_rfc3339(),
+            links: note.links.iter().map(Uuid::to_string).collect(),
+            prototype: note.prototype.map(|id| id.to_string()),
+            attributes: note.attributes.clone(),
+        }
+    }
+}
+
+/// Result of a mutation that touches more than one note. The UI merges it:
+/// upsert `changed`, drop `removed` (notes and their backlink entries),
+/// replace each key present in `backlinks`.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct DeltaView {
+    changed: Vec<NoteView>,
+    removed: Vec<String>,
+    backlinks: HashMap<String, Vec<String>>,
+}
+
+/// Full notebook snapshot, used on init/new/load.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct NotebookView {
+    notes: HashMap<String, NoteView>,
+    backlinks: HashMap<String, Vec<String>>,
+    name: String,
+    created_at: String,
+    modified_at: String,
+}
+
+impl From<&Notebook> for NotebookView {
+    fn from(nb: &Notebook) -> Self {
+        let notes = nb
+            .all_notes()
+            .map(|note| (note.id.to_string(), NoteView::from(note)))
+            .collect();
+        let backlinks = nb
+            .all_note_ids()
+            .filter_map(|id| {
+                let sources = nb.get_backlinks(id);
+                if sources.is_empty() {
+                    None
+                } else {
+                    Some((
+                        id.to_string(),
+                        sources.iter().map(Uuid::to_string).collect(),
+                    ))
+                }
+            })
+            .collect();
+        Self {
+            notes,
+            backlinks,
+            name: nb.name.clone(),
+            created_at: nb.created_at.to_rfc3339(),
+            modified_at: nb.modified_at.to_rfc3339(),
+        }
+    }
+}
+
+#[wasm_bindgen]
+pub struct WasmNotebook {
+    inner: Notebook,
+}
+
+#[wasm_bindgen]
+impl WasmNotebook {
+    #[wasm_bindgen(constructor)]
+    pub fn new(name: String) -> WasmNotebook {
+        WasmNotebook {
+            inner: Notebook::new(name),
+        }
+    }
+
+    /// Deserialize from the on-disk JSON format. Backlinks are rebuilt
+    /// rather than trusted.
+    pub fn from_json(json: &str) -> Result<WasmNotebook, JsValue> {
+        let mut inner: Notebook =
+            serde_json::from_str(json).map_err(|e| err(format!("Invalid notebook JSON: {e}")))?;
+        inner.rebuild_backlinks();
+        Ok(WasmNotebook { inner })
+    }
+
+    /// Serialize to the on-disk JSON format (pretty-printed, snake_case).
+    pub fn to_json(&self) -> Result<String, JsValue> {
+        serde_json::to_string_pretty(&self.inner).map_err(err)
+    }
+
+    pub fn snapshot(&self) -> Result<JsValue, JsValue> {
+        to_js(&NotebookView::from(&self.inner))
+    }
+
+    pub fn name(&self) -> String {
+        self.inner.name.clone()
+    }
+
+    pub fn set_name(&mut self, name: String) {
+        self.inner.name = name;
+    }
+
+    pub fn len(&self) -> usize {
+        self.inner.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.inner.is_empty()
+    }
+
+    pub fn create_note(&mut self, title: &str) -> Result<JsValue, JsValue> {
+        let id = self.inner.create_note(title);
+        self.note_view(&id.to_string())
+    }
+
+    pub fn create_note_at(&mut self, title: &str, x: f64, y: f64) -> Result<JsValue, JsValue> {
+        let note = Note::new(title).with_position(x, y);
+        let id = self.inner.add_note(note);
+        self.note_view(&id.to_string())
+    }
+
+    pub fn get_note(&self, id: &str) -> Result<JsValue, JsValue> {
+        let id = parse_id(id)?;
+        match self.inner.get_note(&id) {
+            Some(note) => to_js(&NoteView::from(note)),
+            None => Ok(JsValue::UNDEFINED),
+        }
+    }
+
+    pub fn update_title(&mut self, id: &str, title: &str) -> Result<JsValue, JsValue> {
+        self.with_note(id, |note| {
+            note.title = title.to_string();
+            note.touch();
+        })
+    }
+
+    pub fn update_content(&mut self, id: &str, content: &str) -> Result<JsValue, JsValue> {
+        self.with_note(id, |note| {
+            note.content = content.to_string();
+            note.touch();
+        })
+    }
+
+    pub fn move_note(&mut self, id: &str, x: f64, y: f64) -> Result<JsValue, JsValue> {
+        self.with_note(id, |note| {
+            note.position = Some(Point2D::new(x, y));
+            note.touch();
+        })
+    }
+
+    pub fn resize_note(&mut self, id: &str, width: f64, height: f64) -> Result<JsValue, JsValue> {
+        self.with_note(id, |note| {
+            note.size = Some((width, height));
+            note.touch();
+        })
+    }
+
+    pub fn set_attribute(&mut self, id: &str, key: &str, value: &str) -> Result<JsValue, JsValue> {
+        let parsed: serde_json::Value =
+            serde_json::from_str(value).map_err(|e| err(format!("Invalid attribute JSON: {e}")))?;
+        self.with_note(id, |note| note.set_attribute(key, parsed))
+    }
+
+    /// Delete a note. The delta carries the notes that lost an outgoing
+    /// link and the backlink entries of the deleted note's targets.
+    pub fn delete_note(&mut self, id: &str) -> Result<JsValue, JsValue> {
+        let uuid = parse_id(id)?;
+        let sources = self.inner.get_backlinks(&uuid);
+        let removed_note = self
+            .inner
+            .remove_note(&uuid)
+            .ok_or_else(|| err(format!("Note not found: {id}")))?;
+
+        let changed = sources
+            .iter()
+            .filter_map(|source_id| self.inner.get_note(source_id))
+            .map(NoteView::from)
+            .collect();
+        let backlinks = removed_note
+            .links
+            .iter()
+            .map(|target| {
+                (
+                    target.to_string(),
+                    self.inner
+                        .get_backlinks(target)
+                        .iter()
+                        .map(Uuid::to_string)
+                        .collect(),
+                )
+            })
+            .collect();
+
+        to_js(&DeltaView {
+            changed,
+            removed: vec![uuid.to_string()],
+            backlinks,
+        })
+    }
+
+    pub fn link(&mut self, from: &str, to: &str) -> Result<JsValue, JsValue> {
+        let from_id = parse_id(from)?;
+        let to_id = parse_id(to)?;
+        self.inner.link_notes(from_id, to_id).map_err(err)?;
+        self.link_delta(from_id, to_id)
+    }
+
+    pub fn unlink(&mut self, from: &str, to: &str) -> Result<JsValue, JsValue> {
+        let from_id = parse_id(from)?;
+        let to_id = parse_id(to)?;
+        self.inner.unlink_notes(from_id, to_id).map_err(err)?;
+        self.link_delta(from_id, to_id)
+    }
+
+    pub fn backlinks(&self, id: &str) -> Result<Vec<String>, JsValue> {
+        let id = parse_id(id)?;
+        Ok(self
+            .inner
+            .get_backlinks(&id)
+            .iter()
+            .map(Uuid::to_string)
+            .collect())
+    }
+
+    /// Case-insensitive substring search over titles and content.
+    pub fn search(&self, query: &str) -> Vec<String> {
+        if query.is_empty() {
+            return Vec::new();
+        }
+        self.inner
+            .search(query)
+            .iter()
+            .map(|note| note.id.to_string())
+            .collect()
+    }
+}
+
+impl WasmNotebook {
+    fn note_view(&self, id: &str) -> Result<JsValue, JsValue> {
+        let uuid = parse_id(id)?;
+        let note = self
+            .inner
+            .get_note(&uuid)
+            .ok_or_else(|| err(format!("Note not found: {id}")))?;
+        to_js(&NoteView::from(note))
+    }
+
+    fn with_note(&mut self, id: &str, mutate: impl FnOnce(&mut Note)) -> Result<JsValue, JsValue> {
+        let uuid = parse_id(id)?;
+        let note = self
+            .inner
+            .get_note_mut(&uuid)
+            .ok_or_else(|| err(format!("Note not found: {id}")))?;
+        mutate(note);
+        self.note_view(id)
+    }
+
+    fn link_delta(&self, from: Uuid, to: Uuid) -> Result<JsValue, JsValue> {
+        let changed = self
+            .inner
+            .get_note(&from)
+            .map(NoteView::from)
+            .into_iter()
+            .collect();
+        let mut backlinks = HashMap::new();
+        backlinks.insert(
+            to.to_string(),
+            self.inner
+                .get_backlinks(&to)
+                .iter()
+                .map(Uuid::to_string)
+                .collect(),
+        );
+        to_js(&DeltaView {
+            changed,
+            removed: Vec::new(),
+            backlinks,
+        })
+    }
+}
diff --git a/core/tests/golden.rs b/core/tests/golden.rs
new file mode 100644
index 0000000..b9abdf7
--- /dev/null
+++ b/core/tests/golden.rs
@@ -0,0 +1,61 @@
+// SPDX-License-Identifier: MPL-2.0
+//! Golden-fixture contract test.
+//!
+//! Guards the on-disk JSON format shared with the web UI: the same fixture
+//! is decoded by ui/tests/contract.test.js through the WASM bindings. If a
+//! serde shape change breaks either side, CI fails on both.
+
+use nexia_core::{NoteId, Notebook};
+
+const FIXTURE: &str = include_str!("../../tests/fixtures/notebook.golden.json");
+
+fn ids() -> (NoteId, NoteId) {
+    (
+        NoteId::parse_str("11111111-1111-4111-8111-111111111111").unwrap(),
+        NoteId::parse_str("22222222-2222-4222-8222-222222222222").unwrap(),
+    )
+}
+
+#[test]
+fn golden_fixture_loads_with_rebuilt_backlinks() {
+    let mut nb: Notebook = serde_json::from_str(FIXTURE).unwrap();
+    nb.rebuild_backlinks();
+    let (alpha, beta) = ids();
+
+    assert_eq!(nb.len(), 2);
+    assert_eq!(nb.name, "Golden");
+    let alpha_note = nb.get_note(&alpha).unwrap();
+    assert_eq!(alpha_note.title, "Alpha");
+    assert!(alpha_note.links_to(&beta));
+    assert_eq!(alpha_note.position.map(|p| (p.x, p.y)), Some((10.0, 20.0)));
+    assert_eq!(alpha_note.size, Some((200.0, 150.0)));
+    assert_eq!(
+        alpha_note.get_attribute("status"),
+        Some(&serde_json::json!("todo"))
+    );
+
+    // The fixture intentionally omits `backlinks`: loading must rebuild it.
+    assert_eq!(nb.get_backlinks(&beta), vec![alpha]);
+    assert!(nb.get_backlinks(&alpha).is_empty());
+
+    // Search reaches only the matching note.
+    assert_eq!(nb.search("alpha").len(), 1);
+}
+
+#[test]
+fn golden_fixture_roundtrips() {
+    let mut nb: Notebook = serde_json::from_str(FIXTURE).unwrap();
+    nb.rebuild_backlinks();
+    let (alpha, beta) = ids();
+
+    let json = serde_json::to_string(&nb).unwrap();
+    let nb2: Notebook = serde_json::from_str(&json).unwrap();
+
+    assert_eq!(nb2.len(), 2);
+    assert_eq!(nb2.name, "Golden");
+    assert!(nb2.get_note(&alpha).unwrap().links_to(&beta));
+    assert_eq!(
+        nb2.get_note(&beta).unwrap().created_at,
+        nb.get_note(&beta).unwrap().created_at
+    );
+}
diff --git a/core/tests/invariants.proptest-regressions b/core/tests/invariants.proptest-regressions
new file mode 100644
index 0000000..c34ae8d
--- /dev/null
+++ b/core/tests/invariants.proptest-regressions
@@ -0,0 +1,7 @@
+# Seeds for failure cases proptest has generated in the past. It is
+# automatically read and these particular cases re-run before any
+# novel cases are generated.
+#
+# It is recommended to check this file in to source control so that
+# everyone who runs the test benefits from these saved cases.
+cc cb24a0c5c22b211cff4cdf261d639958ac18b0a52ec1d3b2b1e4b43f94ba89cd # shrinks to ops = [Create, Create, Create, Create, Create, Create, Create, Create, Create, Create, Create, Create, Link(3731237569635921641, 10808007827563886585)]
diff --git a/core/tests/invariants.rs b/core/tests/invariants.rs
new file mode 100644
index 0000000..38fc351
--- /dev/null
+++ b/core/tests/invariants.rs
@@ -0,0 +1,104 @@
+// SPDX-License-Identifier: MPL-2.0
+//! Property tests for the notebook's structural invariants.
+
+use nexia_core::{NoteId, Notebook};
+use proptest::prelude::*;
+use std::collections::HashSet;
+
+#[derive(Debug, Clone)]
+enum Op {
+    Create,
+    Link(usize, usize),
+    Unlink(usize, usize),
+    Remove(usize),
+}
+
+fn op_strategy() -> impl Strategy<Value = Op> {
+    prop_oneof![
+        2 => Just(Op::Create),
+        3 => (any::<usize>(), any::<usize>()).prop_map(|(a, b)| Op::Link(a, b)),
+        1 => (any::<usize>(), any::<usize>()).prop_map(|(a, b)| Op::Unlink(a, b)),
+        1 => any::<usize>().prop_map(Op::Remove),
+    ]
+}
+
+fn pick(ids: &[NoteId], index: usize) -> Option<NoteId> {
+    if ids.is_empty() {
+        None
+    } else {
+        Some(ids[index % ids.len()])
+    }
+}
+
+proptest! {
+    /// After any sequence of operations, the backlinks index is exactly the
+    /// inverse of the union of the notes' outgoing links.
+    #[test]
+    fn backlinks_are_exact_inverse(ops in prop::collection::vec(op_strategy(), 1..80)) {
+        let mut nb = Notebook::new("prop");
+        // Ids ever created — removed ones stay to exercise error paths.
+        let mut ids: Vec<NoteId> = Vec::new();
+
+        for op in ops {
+            match op {
+                Op::Create => ids.push(nb.create_note("note")),
+                Op::Link(a, b) => {
+                    if let (Some(from), Some(to)) = (pick(&ids, a), pick(&ids, b)) {
+                        let _ = nb.link_notes(from, to);
+                    }
+                }
+                Op::Unlink(a, b) => {
+                    if let (Some(from), Some(to)) = (pick(&ids, a), pick(&ids, b)) {
+                        let _ = nb.unlink_notes(from, to);
+                    }
+                }
+                Op::Remove(a) => {
+                    if let Some(id) = pick(&ids, a) {
+                        nb.remove_note(&id);
+                    }
+                }
+            }
+        }
+
+        for id in nb.all_note_ids() {
+            let expected: HashSet<NoteId> =
+                nb.all_notes().filter(|n| n.links_to(id)).map(|n| n.id).collect();
+            let actual: HashSet<NoteId> = nb.get_backlinks(id).into_iter().collect();
+            prop_assert_eq!(expected, actual, "backlink index diverged for {}", id);
+        }
+
+        // No dangling targets: every link points at a live note.
+        for note in nb.all_notes() {
+            for target in &note.links {
+                prop_assert!(nb.get_note(target).is_some(), "dangling link to {}", target);
+            }
+        }
+    }
+
+    /// Serialization round-trips without losing notes or links.
+    #[test]
+    fn serde_roundtrip(ops in prop::collection::vec(op_strategy(), 1..40)) {
+        let mut nb = Notebook::new("prop");
+        let mut ids: Vec<NoteId> = Vec::new();
+        for op in ops {
+            match op {
+                Op::Create => ids.push(nb.create_note("note")),
+                Op::Link(a, b) => {
+                    if let (Some(from), Some(to)) = (pick(&ids, a), pick(&ids, b)) {
+                        let _ = nb.link_notes(from, to);
+                    }
+                }
+                _ => {}
+            }
+        }
+
+        let json = serde_json::to_string(&nb).unwrap();
+        let nb2: Notebook = serde_json::from_str(&json).unwrap();
+        prop_assert_eq!(nb.len(), nb2.len());
+        for note in nb.all_notes() {
+            let restored = nb2.get_note(&note.id).unwrap();
+            prop_assert_eq!(&note.links, &restored.links);
+            prop_assert_eq!(&note.title, &restored.title);
+        }
+    }
+}
diff --git a/deno.json b/deno.json
index 21fc2e3..1846ae9 100644
--- a/deno.json
+++ b/deno.json
@@ -1,31 +1,39 @@
 {
   "$schema": "https://deno.land/x/deno/cli/schemas/config-file.v1.json",
-  "name": "nexia",
-  "version": "0.1.0",
+  "nodeModulesDir": "auto",
   "tasks": {
-    "dev": "cd ui && deno task dev",
-    "build": "cd ui && deno task build",
+    "setup": "deno install --allow-scripts=npm:rescript@11.1.4,npm:esbuild@0.28.1",
+    "setup:rust": "cd core && cargo build",
+    "dev": "deno run -A scripts/dev.js",
+    "build": "deno task build:res && deno task build:web",
+    "build:res": "cd ui && deno run -A npm:rescript@11.1.4",
+    "build:res:watch": "cd ui && deno run -A npm:rescript@11.1.4 build -w",
+    "build:web": "deno run -A scripts/build.js",
     "build:rust": "cd core && cargo build --release",
+    "build:wasm": "deno run -A scripts/build_wasm.js",
     "gossamer:dev": "cd desktop && gossamer dev",
     "gossamer:build": "cd desktop && gossamer build",
-    "test": "cd core && cargo test",
+    "test": "deno task test:rust && deno task test:ui",
+    "test:rust": "cd core && cargo test",
+    "test:ui": "deno test -A ui/tests/",
     "lint": "deno lint && cd core && cargo clippy",
     "fmt": "deno fmt && cd core && cargo fmt",
-    "setup": "deno task setup:ui && deno task setup:rust",
-    "setup:ui": "cd ui && npm install",
-    "setup:rust": "cd core && cargo build"
+    "clean": "cd ui && deno run -A npm:rescript@11.1.4 clean"
   },
   "imports": {
-    "@rescript/core": "npm:@rescript/core@^1.0.0",
-    "@rescript/react": "npm:@rescript/react@^0.12.0",
-    "rescript-tea": "npm:rescript-tea@^0.10.0"
+    "rescript": "npm:rescript@11.1.4",
+    "@rescript/core": "npm:@rescript/core@1.6.1",
+    "@rescript/react": "npm:@rescript/react@0.13.1",
+    "react": "npm:react@18.3.1",
+    "react-dom": "npm:react-dom@18.3.1",
+    "esbuild": "npm:esbuild@0.28.1"
   },
   "lint": {
-    "include": ["ui/", "web/"],
-    "exclude": ["ui/src/**/*.res.js"]
+    "include": ["ui/", "web/", "scripts/"],
+    "exclude": ["ui/src/**/*.res.js", "ui/tests/**/*.res.js", "ui/lib/", "web/dist/", "web/wasm/"]
   },
   "fmt": {
-    "include": ["ui/", "web/"],
-    "exclude": ["ui/src/**/*.res.js"]
+    "include": ["ui/", "web/", "scripts/"],
+    "exclude": ["ui/src/**/*.res.js", "ui/tests/**/*.res.js", "ui/lib/", "web/dist/", "web/wasm/"]
   }
 }
diff --git a/deno.lock b/deno.lock
index e69de29..54a8384 100644
--- a/deno.lock
+++ b/deno.lock
@@ -0,0 +1,234 @@
+{
+  "version": "5",
+  "specifiers": {
+    "npm:@rescript/core@1.6.1": "1.6.1_rescript@11.1.4",
+    "npm:@rescript/react@0.13.1": "0.13.1_react@18.3.1_react-dom@18.3.1__react@18.3.1",
+    "npm:esbuild@0.28.1": "0.28.1",
+    "npm:react-dom@18.3.1": "18.3.1_react@18.3.1",
+    "npm:react@18.3.1": "18.3.1",
+    "npm:rescript@11.1.4": "11.1.4"
+  },
+  "npm": {
+    "@esbuild/aix-ppc64@0.28.1": {
+      "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+      "os": ["aix"],
+      "cpu": ["ppc64"]
+    },
+    "@esbuild/android-arm64@0.28.1": {
+      "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+      "os": ["android"],
+      "cpu": ["arm64"]
+    },
+    "@esbuild/android-arm@0.28.1": {
+      "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+      "os": ["android"],
+      "cpu": ["arm"]
+    },
+    "@esbuild/android-x64@0.28.1": {
+      "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+      "os": ["android"],
+      "cpu": ["x64"]
+    },
+    "@esbuild/darwin-arm64@0.28.1": {
+      "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+      "os": ["darwin"],
+      "cpu": ["arm64"]
+    },
+    "@esbuild/darwin-x64@0.28.1": {
+      "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+      "os": ["darwin"],
+      "cpu": ["x64"]
+    },
+    "@esbuild/freebsd-arm64@0.28.1": {
+      "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+      "os": ["freebsd"],
+      "cpu": ["arm64"]
+    },
+    "@esbuild/freebsd-x64@0.28.1": {
+      "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+      "os": ["freebsd"],
+      "cpu": ["x64"]
+    },
+    "@esbuild/linux-arm64@0.28.1": {
+      "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+      "os": ["linux"],
+      "cpu": ["arm64"]
+    },
+    "@esbuild/linux-arm@0.28.1": {
+      "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+      "os": ["linux"],
+      "cpu": ["arm"]
+    },
+    "@esbuild/linux-ia32@0.28.1": {
+      "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+      "os": ["linux"],
+      "cpu": ["ia32"]
+    },
+    "@esbuild/linux-loong64@0.28.1": {
+      "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+      "os": ["linux"],
+      "cpu": ["loong64"]
+    },
+    "@esbuild/linux-mips64el@0.28.1": {
+      "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+      "os": ["linux"],
+      "cpu": ["mips64el"]
+    },
+    "@esbuild/linux-ppc64@0.28.1": {
+      "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+      "os": ["linux"],
+      "cpu": ["ppc64"]
+    },
+    "@esbuild/linux-riscv64@0.28.1": {
+      "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+      "os": ["linux"],
+      "cpu": ["riscv64"]
+    },
+    "@esbuild/linux-s390x@0.28.1": {
+      "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+      "os": ["linux"],
+      "cpu": ["s390x"]
+    },
+    "@esbuild/linux-x64@0.28.1": {
+      "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+      "os": ["linux"],
+      "cpu": ["x64"]
+    },
+    "@esbuild/netbsd-arm64@0.28.1": {
+      "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+      "os": ["netbsd"],
+      "cpu": ["arm64"]
+    },
+    "@esbuild/netbsd-x64@0.28.1": {
+      "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+      "os": ["netbsd"],
+      "cpu": ["x64"]
+    },
+    "@esbuild/openbsd-arm64@0.28.1": {
+      "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+      "os": ["openbsd"],
+      "cpu": ["arm64"]
+    },
+    "@esbuild/openbsd-x64@0.28.1": {
+      "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+      "os": ["openbsd"],
+      "cpu": ["x64"]
+    },
+    "@esbuild/openharmony-arm64@0.28.1": {
+      "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+      "os": ["openharmony"],
+      "cpu": ["arm64"]
+    },
+    "@esbuild/sunos-x64@0.28.1": {
+      "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+      "os": ["sunos"],
+      "cpu": ["x64"]
+    },
+    "@esbuild/win32-arm64@0.28.1": {
+      "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+      "os": ["win32"],
+      "cpu": ["arm64"]
+    },
+    "@esbuild/win32-ia32@0.28.1": {
+      "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+      "os": ["win32"],
+      "cpu": ["ia32"]
+    },
+    "@esbuild/win32-x64@0.28.1": {
+      "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+      "os": ["win32"],
+      "cpu": ["x64"]
+    },
+    "@rescript/core@1.6.1_rescript@11.1.4": {
+      "integrity": "sha512-vyb5k90ck+65Fgui+5vCja/mUfzKaK3kOPT4Z6aAJdHLH1eljEi1zKhXroCiCtpNLSWp8k4ulh1bdB5WS0hvqA==",
+      "dependencies": [
+        "rescript"
+      ]
+    },
+    "@rescript/react@0.13.1_react@18.3.1_react-dom@18.3.1__react@18.3.1": {
+      "integrity": "sha512-VIWtu/sAJyYmDVoAhit0LHDYQrW6RqZ6z8sh8san5cjEAT4klv8JWkiaSK3FGUfooUDkGUXXgKTkqyj8zRR21w==",
+      "dependencies": [
+        "react",
+        "react-dom"
+      ]
+    },
+    "esbuild@0.28.1": {
+      "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+      "optionalDependencies": [
+        "@esbuild/aix-ppc64",
+        "@esbuild/android-arm",
+        "@esbuild/android-arm64",
+        "@esbuild/android-x64",
+        "@esbuild/darwin-arm64",
+        "@esbuild/darwin-x64",
+        "@esbuild/freebsd-arm64",
+        "@esbuild/freebsd-x64",
+        "@esbuild/linux-arm",
+        "@esbuild/linux-arm64",
+        "@esbuild/linux-ia32",
+        "@esbuild/linux-loong64",
+        "@esbuild/linux-mips64el",
+        "@esbuild/linux-ppc64",
+        "@esbuild/linux-riscv64",
+        "@esbuild/linux-s390x",
+        "@esbuild/linux-x64",
+        "@esbuild/netbsd-arm64",
+        "@esbuild/netbsd-x64",
+        "@esbuild/openbsd-arm64",
+        "@esbuild/openbsd-x64",
+        "@esbuild/openharmony-arm64",
+        "@esbuild/sunos-x64",
+        "@esbuild/win32-arm64",
+        "@esbuild/win32-ia32",
+        "@esbuild/win32-x64"
+      ],
+      "scripts": true,
+      "bin": true
+    },
+    "js-tokens@4.0.0": {
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+    },
+    "loose-envify@1.4.0": {
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "dependencies": [
+        "js-tokens"
+      ],
+      "bin": true
+    },
+    "react-dom@18.3.1_react@18.3.1": {
+      "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+      "dependencies": [
+        "loose-envify",
+        "react",
+        "scheduler"
+      ]
+    },
+    "react@18.3.1": {
+      "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+      "dependencies": [
+        "loose-envify"
+      ]
+    },
+    "rescript@11.1.4": {
+      "integrity": "sha512-0bGU0bocihjSC6MsE3TMjHjY0EUpchyrREquLS8VsZ3ohSMD+VHUEwimEfB3kpBI1vYkw3UFZ3WD8R28guz/Vw==",
+      "scripts": true,
+      "bin": true
+    },
+    "scheduler@0.23.2": {
+      "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+      "dependencies": [
+        "loose-envify"
+      ]
+    }
+  },
+  "workspace": {
+    "dependencies": [
+      "npm:@rescript/core@1.6.1",
+      "npm:@rescript/react@0.13.1",
+      "npm:esbuild@0.28.1",
+      "npm:react-dom@18.3.1",
+      "npm:react@18.3.1",
+      "npm:rescript@11.1.4"
+    ]
+  }
+}
diff --git a/desktop/README.md b/desktop/README.md
new file mode 100644
index 0000000..6992b29
--- /dev/null
+++ b/desktop/README.md
@@ -0,0 +1,40 @@
+<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
+# Nexia Desktop Shell (optional, external dependency)
+
+This crate is the Gossamer webview shell for Nexia. **It does not build from
+this repository alone** and that is intentional:
+
+- `Cargo.toml` depends on `gossamer-rs = { path = "../../gossamer/bindings/rust" }`,
+  i.e. a checkout of [hyperpolymath/gossamer](https://github.com/hyperpolymath/gossamer)
+  as a **sibling directory** of this repo.
+- The root workspace `Cargo.toml` lists `desktop` under `exclude`, so
+  `cargo build`/`cargo test` at the repo root never touch it, and CI
+  (`rust-ci.yml`, `ui-ci.yml`) deliberately skips it.
+
+## Building (with the sibling checkout)
+
+```
+parent/
+├── nexia-list/   (this repo)
+└── gossamer/     (git clone https://github.com/hyperpolymath/gossamer)
+```
+
+Then: `cd desktop && cargo build`.
+
+## How the desktop shell relates to the web app
+
+The web app is the primary target: the ReScript UI calls the Rust core
+compiled to WebAssembly (see `docs/adr/0001-wasm-core-web-first.md`). The
+UI reaches the engine only through the store seam
+(`ui/src/store/WasmStore.res`), whose operations deliberately mirror the
+command handlers registered in `src/main.rs` here.
+
+To resume desktop work without UI changes, implement a `GossamerStore` with
+the same signatures over `gossamer-bridge.js` (`invoke()`), and select it at
+startup when the bridge is present. Known gaps to backfill in `src/main.rs`
+at that point:
+
+- commands not yet registered: `unlink_notes`, `move_note`, `resize_note`,
+  `set_attribute`
+- the `.lock().unwrap()` calls on the shared state should use
+  `unwrap_or_else(std::sync::PoisonError::into_inner)`
diff --git a/docs/adr/0001-wasm-core-web-first.md b/docs/adr/0001-wasm-core-web-first.md
new file mode 100644
index 0000000..fdd36d5
--- /dev/null
+++ b/docs/adr/0001-wasm-core-web-first.md
@@ -0,0 +1,45 @@
+<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
+# ADR-0001: Compile the Rust core to WASM; the browser is the primary target
+
+- **Status:** Accepted
+- **Date:** 2026-07-02
+
+## Context
+
+- The desktop shell depends on `hyperpolymath/gossamer` as a path dependency
+  (`../../gossamer/bindings/rust`) — an external sibling checkout that is not
+  available in this repo or its CI. The desktop layer is therefore not
+  buildable here.
+- The three layers (Rust core, ReScript UI, shell) were disconnected: the UI
+  did not actually call the core, and nothing exercised the seam between them.
+- Maintaining separate "desktop core" and "web core" paths invites type drift
+  between engine and interface.
+
+## Decision
+
+- Compile `nexia-core` to WebAssembly with wasm-bindgen (`deno task
+  build:wasm`).
+- The **browser is the primary target**: the bundled app in `web/dist/` runs
+  the real Rust core client-side, with persistence via IndexedDB and JSON
+  file download/upload.
+- The UI delegates all note/notebook semantics to the core through a store
+  seam; the ReScript layer holds view state only and never forks the data
+  model.
+- Desktop becomes an **optional thin shell** added later: Gossamer wraps the
+  same web bundle. It stays out of this repo's CI and requires the external
+  sibling checkout.
+
+## Consequences
+
+- One engine, one data model — no type drift between platforms; every target
+  ships the same tested core.
+- The product is buildable and testable entirely within this repo (Deno 2 +
+  Rust stable + `wasm32-unknown-unknown` target); CI can cover the real
+  product.
+- Browser persistence limits apply (IndexedDB quotas; explicit file
+  download/upload instead of transparent filesystem access) until a shell
+  provides native file I/O.
+- WASM boundary costs: data crossing the bridge is serialized, so the API
+  surface must stay coarse-grained.
+- The desktop experience is deferred; anything desktop-only (native menus,
+  file watching) waits for the optional shell.
diff --git a/docs/adr/0002-deno-only-interpretation.md b/docs/adr/0002-deno-only-interpretation.md
new file mode 100644
index 0000000..18c94b4
--- /dev/null
+++ b/docs/adr/0002-deno-only-interpretation.md
@@ -0,0 +1,36 @@
+<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
+# ADR-0002: Interpretation of the "Deno only, no npm/bun/yarn/pnpm" invariant
+
+- **Status:** Accepted
+- **Date:** 2026-07-02
+
+## Context
+
+`.machine_readable/MUST.contractile` requires: "no npm/bun/yarn/pnpm
+dependencies — Deno only". The UI needs packages that are published to the
+npm registry (`rescript`, `@rescript/core`, `@rescript/react`, `react`,
+`esbuild`), and Deno 2 natively resolves such packages via `npm:` specifiers.
+A literal reading ("nothing from the npm registry") would make the ReScript
+toolchain unusable; the invariant needed a precise interpretation.
+
+## Decision
+
+- **Deno is the only JS package manager and runtime.** Dependencies are
+  declared in `deno.json` (import map with `npm:` specifiers), installed with
+  `deno install` (`deno task setup`), and locked in `deno.lock`.
+- **The npm registry as a package *source* is allowed.** `npm:` specifiers
+  resolved by Deno do not violate the invariant.
+- **The npm CLI (and bun/yarn/pnpm) is not used** — no `package.json`
+  workflows, no `package-lock.json`, no `npx`.
+
+## Consequences
+
+- The previous `setup:ui` task, which shelled out to the npm CLI, violated
+  this interpretation and was removed; `deno task setup` replaces it.
+- Reproducibility comes from `deno.lock`; there is exactly one JS lockfile
+  and one toolchain to install in CI.
+- Registry-published tools (ReScript compiler, esbuild) run via
+  `deno run -A npm:<pkg>` rather than through node_modules `.bin` scripts.
+- Future tooling must follow the same rule: if a JS dependency is needed, add
+  it to `deno.json` — never introduce `package.json` or another package
+  manager.
diff --git a/llm-warmup-dev.md b/llm-warmup-dev.md
index b2fe640..c1304d0 100644
--- a/llm-warmup-dev.md
+++ b/llm-warmup-dev.md
@@ -8,7 +8,7 @@ See README.adoc for overview.
 - `just build` — build the project
 - `just test` — run tests
 - `just doctor` — diagnose issues
-- `just heal` — attempt auto-repair
+- `just run` — start the dev server (http://localhost:5173)
 
 ## Quick Context
 - License: MPL-2.0
diff --git a/llm-warmup-user.md b/llm-warmup-user.md
index 32b83dc..0fc0107 100644
--- a/llm-warmup-user.md
+++ b/llm-warmup-user.md
@@ -8,7 +8,7 @@ See README.adoc for overview.
 - `just build` — build the project
 - `just test` — run tests
 - `just doctor` — diagnose issues
-- `just heal` — attempt auto-repair
+- `just run` — start the dev server (http://localhost:5173)
 
 ## Quick Context
 - License: MPL-2.0
diff --git a/scripts/build.js b/scripts/build.js
new file mode 100644
index 0000000..49a5cf0
--- /dev/null
+++ b/scripts/build.js
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: MPL-2.0
+/// Production bundle: ui/src/Main.res.js -> web/dist/
+/// Run via: deno task build:web  (after deno task build:res)
+
+import * as esbuild from "esbuild";
+
+const root = new URL("..", import.meta.url).pathname;
+const dist = `${root}web/dist`;
+
+await Deno.mkdir(dist, { recursive: true });
+
+await esbuild.build({
+  entryPoints: [`${root}ui/src/Main.res.js`],
+  bundle: true,
+  format: "esm",
+  minify: true,
+  sourcemap: true,
+  outfile: `${dist}/app.js`,
+  loader: { ".wasm": "file" },
+  logLevel: "info",
+});
+
+// Static assets. index.html references ./app.js and ./styles.css.
+for (
+  const asset of [
+    "index.html",
+    "styles.css",
+    "manifest.webmanifest",
+    "service-worker.js",
+  ]
+) {
+  try {
+    await Deno.copyFile(`${root}web/${asset}`, `${dist}/${asset}`);
+  } catch (err) {
+    if (!(err instanceof Deno.errors.NotFound)) throw err;
+  }
+}
+
+// WASM core artifacts (present after deno task build:wasm).
+try {
+  await Deno.mkdir(`${dist}/wasm`, { recursive: true });
+  for await (const entry of Deno.readDir(`${root}web/wasm`)) {
+    if (entry.isFile) {
+      await Deno.copyFile(
+        `${root}web/wasm/${entry.name}`,
+        `${dist}/wasm/${entry.name}`,
+      );
+    }
+  }
+} catch (err) {
+  if (!(err instanceof Deno.errors.NotFound)) throw err;
+}
+
+esbuild.stop();
diff --git a/scripts/build_wasm.js b/scripts/build_wasm.js
new file mode 100644
index 0000000..1766d34
--- /dev/null
+++ b/scripts/build_wasm.js
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: MPL-2.0
+/// Build the Rust core to WebAssembly with JS glue in web/wasm/.
+/// Run via: deno task build:wasm
+/// Requires: rustup target wasm32-unknown-unknown, wasm-bindgen-cli
+/// (cargo install wasm-bindgen-cli --version <matching Cargo.lock>).
+
+const root = new URL("..", import.meta.url).pathname;
+
+async function run(cmd, args, cwd) {
+  const status = await new Deno.Command(cmd, {
+    args,
+    cwd,
+    stdout: "inherit",
+    stderr: "inherit",
+  }).spawn().status;
+  if (!status.success) {
+    console.error(`${cmd} ${args.join(" ")} failed (exit ${status.code})`);
+    Deno.exit(status.code);
+  }
+}
+
+await run("cargo", [
+  "build",
+  "--release",
+  "--target",
+  "wasm32-unknown-unknown",
+  "--features",
+  "wasm",
+  "-p",
+  "nexia-core",
+], root);
+
+await run("wasm-bindgen", [
+  `${root}target/wasm32-unknown-unknown/release/nexia_core.wasm`,
+  "--target",
+  "web",
+  "--no-typescript",
+  "--out-dir",
+  `${root}web/wasm`,
+], root);
+
+console.log("WASM core built: web/wasm/nexia_core.js + nexia_core_bg.wasm");
diff --git a/scripts/dev.js b/scripts/dev.js
new file mode 100644
index 0000000..be1d574
--- /dev/null
+++ b/scripts/dev.js
@@ -0,0 +1,71 @@
+// SPDX-License-Identifier: MPL-2.0
+/// Dev server: rescript -w + esbuild serve with rebuild on request.
+/// Run via: deno task dev
+
+import * as esbuild from "esbuild";
+
+const root = new URL("..", import.meta.url).pathname;
+
+// ReScript watcher (compiles .res -> .res.js in-source)
+const rescript = new Deno.Command(Deno.execPath(), {
+  args: ["run", "-A", "npm:rescript@11.1.4", "build", "-w"],
+  cwd: `${root}ui`,
+  stdout: "inherit",
+  stderr: "inherit",
+}).spawn();
+
+const ctx = await esbuild.context({
+  entryPoints: [`${root}ui/src/Main.res.js`],
+  bundle: true,
+  format: "esm",
+  sourcemap: true,
+  outfile: `${root}web/dist/app.js`,
+  loader: { ".wasm": "file" },
+  logLevel: "info",
+});
+
+await ctx.watch();
+
+// Copy static assets once; edit them with the server running and reload.
+await Deno.mkdir(`${root}web/dist`, { recursive: true });
+for (
+  const asset of [
+    "index.html",
+    "styles.css",
+    "manifest.webmanifest",
+    "service-worker.js",
+  ]
+) {
+  try {
+    await Deno.copyFile(`${root}web/${asset}`, `${root}web/dist/${asset}`);
+  } catch (err) {
+    if (!(err instanceof Deno.errors.NotFound)) throw err;
+  }
+}
+try {
+  await Deno.mkdir(`${root}web/dist/wasm`, { recursive: true });
+  for await (const entry of Deno.readDir(`${root}web/wasm`)) {
+    if (entry.isFile) {
+      await Deno.copyFile(
+        `${root}web/wasm/${entry.name}`,
+        `${root}web/dist/wasm/${entry.name}`,
+      );
+    }
+  }
+} catch (err) {
+  if (!(err instanceof Deno.errors.NotFound)) throw err;
+}
+
+const { hosts, port } = await ctx.serve({
+  servedir: `${root}web/dist`,
+  port: 5173,
+});
+console.log(`Nexia dev server: http://${hosts[0] ?? "localhost"}:${port}/`);
+
+// Keep the process alive until interrupted; clean up the watcher on exit.
+Deno.addSignalListener("SIGINT", () => {
+  rescript.kill();
+  esbuild.stop();
+  Deno.exit(0);
+});
+await rescript.status;
diff --git a/tests/fixtures/notebook.golden.json b/tests/fixtures/notebook.golden.json
new file mode 100644
index 0000000..c4e5ef0
--- /dev/null
+++ b/tests/fixtures/notebook.golden.json
@@ -0,0 +1,25 @@
+{
+  "notes": {
+    "11111111-1111-4111-8111-111111111111": {
+      "id": "11111111-1111-4111-8111-111111111111",
+      "title": "Alpha",
+      "content": "First golden note",
+      "position": { "x": 10.0, "y": 20.0 },
+      "size": [200.0, 150.0],
+      "created_at": "2026-01-01T00:00:00Z",
+      "modified_at": "2026-01-02T00:00:00Z",
+      "links": ["22222222-2222-4222-8222-222222222222"],
+      "attributes": { "status": "todo" }
+    },
+    "22222222-2222-4222-8222-222222222222": {
+      "id": "22222222-2222-4222-8222-222222222222",
+      "title": "Beta",
+      "content": "Second golden note",
+      "created_at": "2026-01-01T00:00:00Z",
+      "modified_at": "2026-01-01T00:00:00Z"
+    }
+  },
+  "name": "Golden",
+  "created_at": "2026-01-01T00:00:00Z",
+  "modified_at": "2026-01-02T00:00:00Z"
+}
diff --git a/ui/rescript.json b/ui/rescript.json
index 53c2e90..c0f7380 100644
--- a/ui/rescript.json
+++ b/ui/rescript.json
@@ -4,21 +4,24 @@
     {
       "dir": "src",
       "subdirs": true
+    },
+    {
+      "dir": "tests",
+      "type": "dev"
     }
   ],
   "package-specs": [
     {
-      "module": "es6",
+      "module": "esmodule",
       "in-source": true
     }
   ],
   "suffix": ".res.js",
   "bs-dependencies": [
     "@rescript/core",
-    "@rescript/react",
-    "rescript-tea",
-    "cadre-tea-router"
+    "@rescript/react"
   ],
+  "bsc-flags": ["-open RescriptCore"],
   "jsx": {
     "version": 4,
     "mode": "automatic"
diff --git a/ui/src/Dispatcher.res b/ui/src/Dispatcher.res
new file mode 100644
index 0000000..a966447
--- /dev/null
+++ b/ui/src/Dispatcher.res
@@ -0,0 +1,8 @@
+// SPDX-License-Identifier: MPL-2.0
+/// Late-bound dispatch for effects (e.g. the file picker) that complete
+/// after update() has returned. Main.res registers the live dispatcher on
+/// mount.
+
+let dispatchRef: ref<Msg.msg => unit> = ref(_ => ())
+
+let dispatch = (msg: Msg.msg) => dispatchRef.contents(msg)
diff --git a/ui/src/Main.res b/ui/src/Main.res
index b0fd3c1..d5c3fe0 100644
--- a/ui/src/Main.res
+++ b/ui/src/Main.res
@@ -1,71 +1,97 @@
 // SPDX-License-Identifier: MPL-2.0
-/// Main entry point for Nexia
+/// Main entry point for Nexia: loads the WASM core, restores the autosaved
+/// notebook from IndexedDB, then mounts the app.
 
 module App = {
   @react.component
-  let make = () => {
-    let (model, setModel) = React.useState(() => Model.initial())
+  let make = (~initialNotebook: Types.notebook) => {
+    let (model, setModel) = React.useState(() => {
+      ...Model.initial(),
+      notebook: initialNotebook,
+    })
 
     let dispatch = (msg: Msg.msg) => {
       setModel(currentModel => Update.update(currentModel, msg))
     }
 
-    // Keyboard shortcuts
     React.useEffect0(() => {
-      let handleKeyDown = (e: Dom.keyboardEvent) => {
-        let key = Webapi.Dom.KeyboardEvent.key(e)
-        let ctrlKey = Webapi.Dom.KeyboardEvent.ctrlKey(e)
-        let metaKey = Webapi.Dom.KeyboardEvent.metaKey(e)
-        let modKey = ctrlKey || metaKey
+      Dispatcher.dispatchRef := dispatch
+      None
+    })
+
+    // Any notebook change (edits, load, new) refreshes the IndexedDB copy
+    // after a debounce, so the working set survives reloads.
+    React.useEffect1(() => {
+      Persist.scheduleAutosave()
+      None
+    }, [model.notebook])
+
+    // Keyboard shortcuts. The handler is registered once; guards that need
+    // current state (e.g. "don't delete while editing") live in Update.update,
+    // which always sees the latest model.
+    React.useEffect0(() => {
+      let handleKeyDown = (e: DomBindings.keyboardEvent) => {
+        let key = DomBindings.key(e)
+        let modKey = DomBindings.ctrlKey(e) || DomBindings.metaKey(e)
 
         switch (modKey, key) {
         | (true, "n") => {
-            Webapi.Dom.KeyboardEvent.preventDefault(e)
+            DomBindings.preventDefault(e)
             dispatch(Msg.CreateNote)
           }
         | (true, "s") => {
-            Webapi.Dom.KeyboardEvent.preventDefault(e)
+            DomBindings.preventDefault(e)
             dispatch(Msg.SaveNotebook)
           }
-        | (true, "f") => {
-            Webapi.Dom.KeyboardEvent.preventDefault(e)
-            // Focus search - would need a ref
-          }
         | (false, "Escape") => {
             dispatch(Msg.ClearSelection)
             dispatch(Msg.StopEditingNote)
           }
-        | (false, "Delete") | (false, "Backspace") =>
-          // Only delete if not editing
-          switch model.editingNote {
-          | None => dispatch(Msg.DeleteSelectedNotes)
-          | Some(_) => ()
-          }
+        | (false, "Delete") | (false, "Backspace") => dispatch(Msg.DeleteSelectedNotes)
         | _ => ()
         }
       }
 
-      let handler = %raw(`
-        function(e) {
-          handleKeyDown(e);
-        }
-      `)
-
-      Webapi.Dom.window->Webapi.Dom.Window.addEventListener("keydown", handler)
-
-      Some(
-        () => {
-          Webapi.Dom.window->Webapi.Dom.Window.removeEventListener("keydown", handler)
-        },
-      )
+      DomBindings.addKeydownListener(handleKeyDown)
+      Some(() => DomBindings.removeKeydownListener(handleKeyDown))
     })
 
     <View model dispatch />
   }
 }
 
-// Mount the app
-switch ReactDOM.querySelector("#root") {
-| Some(root) => ReactDOM.Client.createRoot(root)->ReactDOM.Client.Root.render(<App />)
-| None => Js.Console.error("Could not find #root element")
+let start = async () => {
+  try {
+    await WasmStore.init("./wasm/nexia_core_bg.wasm")
+
+    let initialNotebook = switch await Persist.loadAutosave() {
+    | Some(json) =>
+      switch WasmStore.loadFromJson(json) {
+      | Ok(snapshot) => snapshot
+      | Error(_) => WasmStore.snapshot() // unreadable autosave: start fresh
+      }
+    | None => WasmStore.snapshot()
+    }
+
+    switch ReactDOM.querySelector("#root") {
+    | Some(root) =>
+      ReactDOM.Client.createRoot(root)->ReactDOM.Client.Root.render(<App initialNotebook />)
+    | None => Js.Console.error("Could not find #root element")
+    }
+  } catch {
+  | e => {
+      Js.Console.error2("Nexia failed to start", e->Exn.anyToExnInternal)
+      switch ReactDOM.querySelector("#root") {
+      | Some(root) =>
+        ReactDOM.Client.createRoot(root)->ReactDOM.Client.Root.render(
+          <div role="alert" className="error-banner">
+            {React.string("Nexia failed to start. Check the browser console for details.")}
+          </div>,
+        )
+      | None => ()
+      }
+    }
+  }
 }
+
+start()->ignore
diff --git a/ui/src/Types.res b/ui/src/Types.res
index 023bc1d..b9ffc9f 100644
--- a/ui/src/Types.res
+++ b/ui/src/Types.res
@@ -52,29 +52,9 @@ type viewport = {
   zoom: float,
 }
 
-/// Helper functions for creating types
-module Note = {
-  let make = (~title: string): note => {
-    let now = Js.Date.toISOString(Js.Date.make())
-    {
-      id: Js.Math.random()->Float.toString,
-      title,
-      content: "",
-      position: None,
-      size: None,
-      createdAt: now,
-      modifiedAt: now,
-      links: [],
-      prototype: None,
-      attributes: Js.Dict.empty(),
-    }
-  }
-
-  let withPosition = (note: note, x: float, y: float): note => {
-    ...note,
-    position: Some({x, y}),
-  }
-}
+// Note construction lives in the Rust core (WasmStore.createNote) — the UI
+// never fabricates ids or timestamps, which is what kept these types from
+// drifting apart before.
 
 module Point2D = {
   let make = (x: float, y: float): point2D => {x, y}
diff --git a/ui/src/Update.res b/ui/src/Update.res
index ce094ba..aa3dde8 100644
--- a/ui/src/Update.res
+++ b/ui/src/Update.res
@@ -1,223 +1,143 @@
 // SPDX-License-Identifier: MPL-2.0
-/// Update function - handles all state transitions
+/// Update function — handles all state transitions.
+///
+/// All notebook mutations delegate to the Rust core (WasmStore); the
+/// model's notebook is a read model patched with what the core returns.
+/// The dicts are mutated in place — a new notebook record is produced per
+/// transition so React re-renders, but older model values must not be
+/// treated as immutable history.
 
 open Types
 open Model
 open Msg
 
-/// Helper to update a note in the notebook
-let updateNoteInNotebook = (notebook: notebook, id: noteId, updater: note => note): notebook => {
-  switch Js.Dict.get(notebook.notes, id) {
-  | Some(note) =>
-    let now = Js.Date.toISOString(Js.Date.make())
-    let updatedNote = {...updater(note), modifiedAt: now}
-    let newNotes = Js.Dict.fromArray(
-      Js.Dict.entries(notebook.notes)->Array.map(((k, v)) =>
-        if k == id {
-          (k, updatedNote)
-        } else {
-          (k, v)
-        }
-      ),
-    )
-    {...notebook, notes: newNotes, modifiedAt: now}
-  | None => notebook
-  }
-}
-
-/// Add a note to the notebook
-let addNoteToNotebook = (notebook: notebook, note: note): notebook => {
-  let now = Js.Date.toISOString(Js.Date.make())
-  let newNotes = Js.Dict.fromArray(
-    Array.concat(Js.Dict.entries(notebook.notes), [(note.id, note)]),
-  )
-  {...notebook, notes: newNotes, modifiedAt: now}
-}
+%%private(
+  let deleteKey: (Js.Dict.t<'a>, string) => unit = %raw(`(dict, key) => { delete dict[key] }`)
+)
 
-/// Remove a note from the notebook
-let removeNoteFromNotebook = (notebook: notebook, id: noteId): notebook => {
-  let now = Js.Date.toISOString(Js.Date.make())
-  let newNotes = Js.Dict.fromArray(
-    Js.Dict.entries(notebook.notes)->Array.filter(((k, _)) => k != id),
-  )
-  // Also remove from backlinks and remove links to this note
-  let newBacklinks = Js.Dict.fromArray(
-    Js.Dict.entries(notebook.backlinks)
-    ->Array.filter(((k, _)) => k != id)
-    ->Array.map(((k, v)) => (k, v->Array.filter(linkId => linkId != id))),
-  )
-  {...notebook, notes: newNotes, backlinks: newBacklinks, modifiedAt: now}
-}
-
-/// Add a link between notes
-let addLinkToNotebook = (notebook: notebook, fromId: noteId, toId: noteId): notebook => {
-  // Add forward link
-  let notebook = updateNoteInNotebook(notebook, fromId, note => {
-    if !Array.includes(note.links, toId) {
-      {...note, links: Array.concat(note.links, [toId])}
-    } else {
-      note
-    }
-  })
-
-  // Add backlink
-  let currentBacklinks = switch Js.Dict.get(notebook.backlinks, toId) {
-  | Some(links) => links
-  | None => []
-  }
-  if !Array.includes(currentBacklinks, fromId) {
-    let newBacklinks = Js.Dict.fromArray(
-      Array.concat(
-        Js.Dict.entries(notebook.backlinks)->Array.filter(((k, _)) => k != toId),
-        [(toId, Array.concat(currentBacklinks, [fromId]))],
-      ),
-    )
-    {...notebook, backlinks: newBacklinks}
-  } else {
-    notebook
-  }
+/// Upsert a single note view returned by the core.
+let setNote = (notebook: notebook, note: note): notebook => {
+  Js.Dict.set(notebook.notes, note.id, note)
+  {...notebook, modifiedAt: note.modifiedAt}
 }
 
-/// Remove a link between notes
-let removeLinkFromNotebook = (notebook: notebook, fromId: noteId, toId: noteId): notebook => {
-  // Remove forward link
-  let notebook = updateNoteInNotebook(notebook, fromId, note => {
-    {...note, links: note.links->Array.filter(id => id != toId)}
+/// Apply a topology delta returned by the core.
+let applyDelta = (notebook: notebook, delta: WasmStore.delta): notebook => {
+  delta.changed->Array.forEach(note => Js.Dict.set(notebook.notes, note.id, note))
+  delta.removed->Array.forEach(id => {
+    deleteKey(notebook.notes, id)
+    deleteKey(notebook.backlinks, id)
   })
-
-  // Remove backlink
-  let currentBacklinks = switch Js.Dict.get(notebook.backlinks, toId) {
-  | Some(links) => links
-  | None => []
-  }
-  let newBacklinks = Js.Dict.fromArray(
-    Array.concat(
-      Js.Dict.entries(notebook.backlinks)->Array.filter(((k, _)) => k != toId),
-      [(toId, currentBacklinks->Array.filter(id => id != fromId))],
-    ),
+  Js.Dict.entries(delta.backlinks)->Array.forEach(((id, sources)) =>
+    Js.Dict.set(notebook.backlinks, id, sources)
   )
-  {...notebook, backlinks: newBacklinks}
+  // Spread with a no-op override: a fresh record identity so React re-renders.
+  {...notebook, name: notebook.name}
 }
 
-/// Simple search implementation
-let searchNotes = (notebook: notebook, query: string): array<noteId> => {
-  if query == "" {
-    []
-  } else {
-    let queryLower = String.toLowerCase(query)
-    Js.Dict.entries(notebook.notes)
-    ->Array.filter(((_, note)) => {
-      String.toLowerCase(note.title)->String.includes(queryLower) ||
-        String.toLowerCase(note.content)->String.includes(queryLower)
-    })
-    ->Array.map(((id, _)) => id)
+let patchNote = (model: model, result: result<note, string>): model =>
+  switch result {
+  | Ok(note) => {...model, notebook: setNote(model.notebook, note), dirty: true}
+  | Error(message) => {...model, error: Some(message)}
   }
-}
 
 /// The main update function
-let update = (model: model, msg: msg): model => {
+let rec update = (model: model, msg: msg): model => {
   switch msg {
   // Note CRUD
   | CreateNote =>
-    let note = Note.make(~title="New Note")
-    {
-      ...model,
-      notebook: addNoteToNotebook(model.notebook, note),
-      selection: SingleNote(note.id),
-      editingNote: Some(note.id),
-      dirty: true,
+    switch WasmStore.createNote("New Note") {
+    | Ok(note) => {
+        ...model,
+        notebook: setNote(model.notebook, note),
+        selection: SingleNote(note.id),
+        editingNote: Some(note.id),
+        dirty: true,
+      }
+    | Error(message) => {...model, error: Some(message)}
     }
 
   | CreateNoteAt(position) =>
-    let note = Note.make(~title="New Note")->Note.withPosition(position.x, position.y)
-    {
-      ...model,
-      notebook: addNoteToNotebook(model.notebook, note),
-      selection: SingleNote(note.id),
-      editingNote: Some(note.id),
-      dirty: true,
+    switch WasmStore.createNoteAt("New Note", position.x, position.y) {
+    | Ok(note) => {
+        ...model,
+        notebook: setNote(model.notebook, note),
+        selection: SingleNote(note.id),
+        editingNote: Some(note.id),
+        dirty: true,
+      }
+    | Error(message) => {...model, error: Some(message)}
     }
 
-  | DeleteNote(id) => {
-      ...model,
-      notebook: removeNoteFromNotebook(model.notebook, id),
-      selection: switch model.selection {
-      | SingleNote(selectedId) if selectedId == id => NoSelection
-      | MultipleNotes(ids) => {
-          let remaining = ids->Array.filter(i => i != id)
-          switch remaining {
-          | [] => NoSelection
-          | [single] => SingleNote(single)
-          | multiple => MultipleNotes(multiple)
+  | DeleteNote(id) =>
+    switch WasmStore.deleteNote(id) {
+    | Ok(delta) => {
+        ...model,
+        notebook: applyDelta(model.notebook, delta),
+        selection: switch model.selection {
+        | SingleNote(selectedId) if selectedId == id => NoSelection
+        | MultipleNotes(ids) => {
+            let remaining = ids->Array.filter(i => i != id)
+            switch remaining {
+            | [] => NoSelection
+            | [single] => SingleNote(single)
+            | multiple => MultipleNotes(multiple)
+            }
           }
-        }
-      | other => other
-      },
-      editingNote: switch model.editingNote {
-      | Some(editId) if editId == id => None
-      | other => other
-      },
-      dirty: true,
+        | other => other
+        },
+        editingNote: switch model.editingNote {
+        | Some(editId) if editId == id => None
+        | other => other
+        },
+        dirty: true,
+      }
+    | Error(message) => {...model, error: Some(message)}
     }
 
   | DeleteSelectedNotes =>
-    switch model.selection {
-    | NoSelection => model
-    | SingleNote(id) => update(model, DeleteNote(id))
-    | MultipleNotes(ids) =>
-      ids->Array.reduce(model, (m, id) => update(m, DeleteNote(id)))
+    // Guarded here rather than in the keyboard handler so the check always
+    // sees current state (the handler is registered once and would capture a
+    // stale model).
+    switch model.editingNote {
+    | Some(_) => model
+    | None =>
+      switch model.selection {
+      | NoSelection => model
+      | SingleNote(id) => update(model, DeleteNote(id))
+      | MultipleNotes(ids) => ids->Array.reduce(model, (m, id) => update(m, DeleteNote(id)))
+      }
     }
 
   // Note editing
-  | UpdateNoteTitle(id, title) => {
-      ...model,
-      notebook: updateNoteInNotebook(model.notebook, id, note => {...note, title}),
-      dirty: true,
-    }
+  | UpdateNoteTitle(id, title) => patchNote(model, WasmStore.updateTitle(id, title))
 
-  | UpdateNoteContent(id, content) => {
-      ...model,
-      notebook: updateNoteInNotebook(model.notebook, id, note => {...note, content}),
-      dirty: true,
-    }
+  | UpdateNoteContent(id, content) => patchNote(model, WasmStore.updateContent(id, content))
 
   | StartEditingNote(id) => {...model, editingNote: Some(id)}
 
   | StopEditingNote => {...model, editingNote: None}
 
   // Note positioning
-  | MoveNote(id, position) => {
-      ...model,
-      notebook: updateNoteInNotebook(model.notebook, id, note => {
-        {...note, position: Some(position)}
-      }),
-      dirty: true,
-    }
+  | MoveNote(id, position) => patchNote(model, WasmStore.moveNote(id, position.x, position.y))
 
-  | ResizeNote(id, width, height) => {
-      ...model,
-      notebook: updateNoteInNotebook(model.notebook, id, note => {
-        {...note, size: Some((width, height))}
-      }),
-      dirty: true,
-    }
+  | ResizeNote(id, width, height) => patchNote(model, WasmStore.resizeNote(id, width, height))
 
   // Links
   | LinkNotes(fromId, toId) =>
     if fromId == toId {
       model
     } else {
-      {
-        ...model,
-        notebook: addLinkToNotebook(model.notebook, fromId, toId),
-        dirty: true,
+      switch WasmStore.link(fromId, toId) {
+      | Ok(delta) => {...model, notebook: applyDelta(model.notebook, delta), dirty: true}
+      | Error(message) => {...model, error: Some(message)}
       }
     }
 
-  | UnlinkNotes(fromId, toId) => {
-      ...model,
-      notebook: removeLinkFromNotebook(model.notebook, fromId, toId),
-      dirty: true,
+  | UnlinkNotes(fromId, toId) =>
+    switch WasmStore.unlink(fromId, toId) {
+    | Ok(delta) => {...model, notebook: applyDelta(model.notebook, delta), dirty: true}
+    | Error(message) => {...model, error: Some(message)}
     }
 
   // Selection
@@ -281,7 +201,7 @@ let update = (model: model, msg: msg): model => {
   | SetSearchQuery(query) => {
       ...model,
       searchQuery: query,
-      searchResults: searchNotes(model.notebook, query),
+      searchResults: query == "" ? [] : WasmStore.search(query),
     }
 
   | ClearSearch => {...model, searchQuery: "", searchResults: []}
@@ -289,15 +209,46 @@ let update = (model: model, msg: msg): model => {
   // File operations
   | NewNotebook => {
       ...initial(),
+      notebook: WasmStore.reset("Untitled Notebook"),
       viewMode: model.viewMode,
       sidebarOpen: model.sidebarOpen,
     }
 
-  | SaveNotebook => model // Handled by command in full TEA setup
+  | SaveNotebook =>
+    switch WasmStore.toJson() {
+    | Ok(json) => {
+        Persist.saveToFile(model.notebook.name, json)
+        {...model, dirty: false}
+      }
+    | Error(message) => {...model, error: Some(message)}
+    }
 
-  | SaveNotebookAs(_path) => model // Handled by command
+  | SaveNotebookAs(name) =>
+    switch WasmStore.toJson() {
+    | Ok(json) => {
+        Persist.saveToFile(name, json)
+        {...model, dirty: false}
+      }
+    | Error(message) => {...model, error: Some(message)}
+    }
 
-  | LoadNotebook(_path) => model // Handled by command
+  | LoadNotebook(_path) => {
+      // Async: the picker resolves after update() returns; the result comes
+      // back through Dispatcher as NotebookLoaded / SetError.
+      Persist.openFile()
+      ->Promise.thenResolve(content =>
+        switch content {
+        | Some(json) =>
+          switch WasmStore.loadFromJson(json) {
+          | Ok(snapshot) => Dispatcher.dispatch(NotebookLoaded(snapshot))
+          | Error(message) => Dispatcher.dispatch(SetError(message))
+          }
+        | None => ()
+        }
+      )
+      ->ignore
+      model
+    }
 
   | NotebookLoaded(notebook) => {
       ...model,
@@ -305,6 +256,9 @@ let update = (model: model, msg: msg): model => {
       dirty: false,
       selection: NoSelection,
       editingNote: None,
+      searchQuery: "",
+      searchResults: [],
+      error: None,
     }
 
   | NotebookSaved => {...model, dirty: false}
diff --git a/ui/src/View.res b/ui/src/View.res
index 5bb8667..8ea48f8 100644
--- a/ui/src/View.res
+++ b/ui/src/View.res
@@ -233,6 +233,7 @@ module Toolbar = {
           ? <span className="dirty-indicator"> {React.string("Unsaved")} </span>
           : React.null}
         <button onClick={_ => dispatch(NewNotebook)}> {React.string("New")} </button>
+        <button onClick={_ => dispatch(LoadNotebook(""))}> {React.string("Load")} </button>
         <button onClick={_ => dispatch(SaveNotebook)}> {React.string("Save")} </button>
       </div>
     </div>
diff --git a/ui/src/bindings/DomBindings.res b/ui/src/bindings/DomBindings.res
new file mode 100644
index 0000000..0878014
--- /dev/null
+++ b/ui/src/bindings/DomBindings.res
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: MPL-2.0
+/// Minimal DOM externals for global keyboard handling.
+/// Local bindings instead of a rescript-webapi dependency: only these six
+/// operations are needed.
+
+type keyboardEvent
+
+@val @scope("window")
+external addKeydownListener: (@as("keydown") _, keyboardEvent => unit) => unit = "addEventListener"
+
+@val @scope("window")
+external removeKeydownListener: (@as("keydown") _, keyboardEvent => unit) => unit =
+  "removeEventListener"
+
+@get external key: keyboardEvent => string = "key"
+@get external ctrlKey: keyboardEvent => bool = "ctrlKey"
+@get external metaKey: keyboardEvent => bool = "metaKey"
+@send external preventDefault: keyboardEvent => unit = "preventDefault"
diff --git a/ui/src/store/Persist.res b/ui/src/store/Persist.res
new file mode 100644
index 0000000..8b88614
--- /dev/null
+++ b/ui/src/store/Persist.res
@@ -0,0 +1,49 @@
+// SPDX-License-Identifier: MPL-2.0
+/// Persistence: debounced autosave to IndexedDB plus explicit file
+/// save (download) and load (picker). The autosave key always mirrors the
+/// current working notebook so it survives reloads.
+
+@module("./idb.js") external idbSet: (string, string) => promise<unit> = "idbSet"
+@module("./idb.js") external idbGet: string => promise<Js.Nullable.t<string>> = "idbGet"
+@module("./fileio.js") external downloadText: (string, string) => unit = "downloadText"
+@module("./fileio.js")
+external openTextFile: unit => promise<Js.Nullable.t<string>> = "openTextFile"
+
+let autosaveKey = "nexia.autosave"
+let autosaveDelayMs = 800
+
+let pending: ref<option<timeoutId>> = ref(None)
+
+let scheduleAutosave = () => {
+  switch pending.contents {
+  | Some(id) => clearTimeout(id)
+  | None => ()
+  }
+  pending :=
+    Some(setTimeout(() => {
+        pending := None
+        switch WasmStore.toJson() {
+        | Ok(json) => idbSet(autosaveKey, json)->ignore
+        | Error(_) => ()
+        }
+      }, autosaveDelayMs))
+}
+
+let loadAutosave = async (): option<string> => {
+  switch await idbGet(autosaveKey) {
+  | value => value->Js.Nullable.toOption
+  | exception _ => None
+  }
+}
+
+let saveToFile = (name: string, json: string) => {
+  let safeName = name == "" ? "notebook" : name
+  downloadText(`${safeName}.nexia.json`, json)
+}
+
+let openFile = async (): option<string> => {
+  switch await openTextFile() {
+  | value => value->Js.Nullable.toOption
+  | exception _ => None
+  }
+}
diff --git a/ui/src/store/WasmStore.res b/ui/src/store/WasmStore.res
new file mode 100644
index 0000000..ffe2e9a
--- /dev/null
+++ b/ui/src/store/WasmStore.res
@@ -0,0 +1,111 @@
+// SPDX-License-Identifier: MPL-2.0
+/// Bindings to the WASM-compiled Rust core plus the current-notebook handle.
+///
+/// This module is the seam between the TEA UI and the engine: Update.res
+/// mutates through it and patches its read model with the returned values.
+/// A GossamerStore implementing the same operations over the desktop bridge
+/// can replace it in a webview shell without UI changes.
+
+open Types
+
+type t
+
+type delta = {
+  changed: array<note>,
+  removed: array<noteId>,
+  backlinks: Js.Dict.t<array<noteId>>,
+}
+
+@module("../../../web/wasm/nexia_core.js")
+external initWasm: string => promise<unit> = "default"
+
+@module("../../../web/wasm/nexia_core.js") @new
+external makeNotebook: string => t = "WasmNotebook"
+
+@module("../../../web/wasm/nexia_core.js") @scope("WasmNotebook")
+external fromJsonRaw: string => t = "from_json"
+
+@send external createNoteRaw: (t, string) => note = "create_note"
+@send external createNoteAtRaw: (t, string, float, float) => note = "create_note_at"
+@send external updateTitleRaw: (t, noteId, string) => note = "update_title"
+@send external updateContentRaw: (t, noteId, string) => note = "update_content"
+@send external moveNoteRaw: (t, noteId, float, float) => note = "move_note"
+@send external resizeNoteRaw: (t, noteId, float, float) => note = "resize_note"
+@send external deleteNoteRaw: (t, noteId) => delta = "delete_note"
+@send external linkRaw: (t, noteId, noteId) => delta = "link"
+@send external unlinkRaw: (t, noteId, noteId) => delta = "unlink"
+@send external searchRaw: (t, string) => array<noteId> = "search"
+@send external snapshotRaw: t => notebook = "snapshot"
+@send external toJsonRaw: t => string = "to_json"
+
+let current: ref<option<t>> = ref(None)
+
+exception StoreNotInitialized
+
+let instance = (): t =>
+  switch current.contents {
+  | Some(store) => store
+  | None => raise(StoreNotInitialized)
+  }
+
+/// Load the wasm module and start with an empty notebook.
+let init = async (wasmUrl: string) => {
+  await initWasm(wasmUrl)
+  current := Some(makeNotebook("Untitled Notebook"))
+}
+
+/// Replace the current notebook with a fresh one; returns its snapshot.
+let reset = (name: string): notebook => {
+  let nb = makeNotebook(name)
+  current := Some(nb)
+  snapshotRaw(nb)
+}
+
+let errorMessage = (e: exn, fallback: string): string =>
+  switch e {
+  | Exn.Error(jsError) => jsError->Exn.message->Option.getOr(fallback)
+  | _ => fallback
+  }
+
+/// Replace the current notebook from on-disk JSON; returns its snapshot.
+let loadFromJson = (json: string): result<notebook, string> =>
+  try {
+    let nb = fromJsonRaw(json)
+    current := Some(nb)
+    Ok(snapshotRaw(nb))
+  } catch {
+  | e => Error(errorMessage(e, "Could not load notebook"))
+  }
+
+let snapshot = (): notebook => snapshotRaw(instance())
+
+let toJson = (): result<string, string> =>
+  try Ok(toJsonRaw(instance())) catch {
+  | e => Error(errorMessage(e, "Could not serialize notebook"))
+  }
+
+let tryNote = (operation: unit => note): result<note, string> =>
+  try Ok(operation()) catch {
+  | e => Error(errorMessage(e, "Note operation failed"))
+  }
+
+let tryDelta = (operation: unit => delta): result<delta, string> =>
+  try Ok(operation()) catch {
+  | e => Error(errorMessage(e, "Link operation failed"))
+  }
+
+let createNote = (title: string) => tryNote(() => createNoteRaw(instance(), title))
+let createNoteAt = (title: string, x: float, y: float) =>
+  tryNote(() => createNoteAtRaw(instance(), title, x, y))
+let updateTitle = (id: noteId, title: string) => tryNote(() => updateTitleRaw(instance(), id, title))
+let updateContent = (id: noteId, content: string) =>
+  tryNote(() => updateContentRaw(instance(), id, content))
+let moveNote = (id: noteId, x: float, y: float) => tryNote(() => moveNoteRaw(instance(), id, x, y))
+let resizeNote = (id: noteId, width: float, height: float) =>
+  tryNote(() => resizeNoteRaw(instance(), id, width, height))
+
+let deleteNote = (id: noteId) => tryDelta(() => deleteNoteRaw(instance(), id))
+let link = (fromId: noteId, toId: noteId) => tryDelta(() => linkRaw(instance(), fromId, toId))
+let unlink = (fromId: noteId, toId: noteId) => tryDelta(() => unlinkRaw(instance(), fromId, toId))
+
+let search = (query: string): array<noteId> => searchRaw(instance(), query)
diff --git a/ui/src/store/fileio.js b/ui/src/store/fileio.js
new file mode 100644
index 0000000..25da422
--- /dev/null
+++ b/ui/src/store/fileio.js
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: MPL-2.0
+// File save/load via download link and file picker. Works in every browser;
+// the File System Access API can layer on top later.
+
+export function downloadText(filename, text) {
+  const blob = new Blob([text], { type: "application/json" });
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement("a");
+  a.href = url;
+  a.download = filename;
+  document.body.appendChild(a);
+  a.click();
+  a.remove();
+  URL.revokeObjectURL(url);
+}
+
+export function openTextFile() {
+  return new Promise((resolve) => {
+    const input = document.createElement("input");
+    input.type = "file";
+    input.accept = ".json,application/json";
+    input.onchange = () => {
+      const file = input.files && input.files[0];
+      if (!file) return resolve(null);
+      file.text().then(resolve, () => resolve(null));
+    };
+    input.oncancel = () => resolve(null);
+    input.click();
+  });
+}
diff --git a/ui/src/store/idb.js b/ui/src/store/idb.js
new file mode 100644
index 0000000..903be7c
--- /dev/null
+++ b/ui/src/store/idb.js
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: MPL-2.0
+// Minimal IndexedDB key-value helpers for autosave.
+
+const DB = "nexia";
+const STORE = "kv";
+
+function open() {
+  return new Promise((resolve, reject) => {
+    const req = indexedDB.open(DB, 1);
+    req.onupgradeneeded = () => req.result.createObjectStore(STORE);
+    req.onsuccess = () => resolve(req.result);
+    req.onerror = () => reject(req.error);
+  });
+}
+
+export async function idbSet(key, value) {
+  const db = await open();
+  return new Promise((resolve, reject) => {
+    const tx = db.transaction(STORE, "readwrite");
+    tx.objectStore(STORE).put(value, key);
+    tx.oncomplete = () => {
+      db.close();
+      resolve(undefined);
+    };
+    tx.onerror = () => {
+      db.close();
+      reject(tx.error);
+    };
+  });
+}
+
+export async function idbGet(key) {
+  const db = await open();
+  return new Promise((resolve, reject) => {
+    const tx = db.transaction(STORE, "readonly");
+    const req = tx.objectStore(STORE).get(key);
+    req.onsuccess = () => {
+      db.close();
+      resolve(req.result ?? null);
+    };
+    req.onerror = () => {
+      db.close();
+      reject(req.error);
+    };
+  });
+}
diff --git a/ui/tests/UpdateTests.res b/ui/tests/UpdateTests.res
new file mode 100644
index 0000000..beca730
--- /dev/null
+++ b/ui/tests/UpdateTests.res
@@ -0,0 +1,104 @@
+// SPDX-License-Identifier: MPL-2.0
+/// Pure TEA update tests, run against the real WASM core.
+/// The JS wrapper (update.test.js) initializes the wasm module before
+/// calling runAll; every assertion failure raises with its label.
+
+open Types
+
+exception AssertionFailed(string)
+
+let check = (cond: bool, label: string) =>
+  if !cond {
+    raise(AssertionFailed(label))
+  }
+
+let selectedId = (model: Model.model, label: string): noteId =>
+  switch model.selection {
+  | SingleNote(id) => id
+  | _ => raise(AssertionFailed(label))
+  }
+
+let runAll = () => {
+  let snapshot = WasmStore.reset("Test")
+  let model = {...Model.initial(), notebook: snapshot}
+
+  // Create
+  let model = Update.update(model, Msg.CreateNote)
+  check(Model.noteCount(model) == 1, "create adds a note")
+  let alpha = selectedId(model, "note selected after create")
+  check(model.editingNote == Some(alpha), "editing starts after create")
+  check(model.dirty, "dirty after create")
+
+  // Ids come from the core: UUID shape, not a random float
+  check(String.length(alpha) == 36, "core-issued UUID id")
+
+  // Edit through the core
+  let model = Update.update(model, Msg.UpdateNoteTitle(alpha, "Alpha"))
+  check((Model.getNote(model, alpha)->Option.getExn).title == "Alpha", "title updated")
+  let model = Update.update(model, Msg.UpdateNoteContent(alpha, "Spatial hypertext"))
+  check(
+    (Model.getNote(model, alpha)->Option.getExn).content == "Spatial hypertext",
+    "content updated",
+  )
+
+  // Second note, then link
+  let model = Update.update(model, Msg.CreateNote)
+  let beta = selectedId(model, "note selected after second create")
+  let model = Update.update(model, Msg.LinkNotes(alpha, beta))
+  check(
+    (Model.getNote(model, alpha)->Option.getExn).links->Array.includes(beta),
+    "forward link recorded",
+  )
+  check(Model.getBacklinks(model, beta)->Array.includes(alpha), "backlink recorded")
+
+  // Self-link is a no-op
+  let selfLinked = Update.update(model, Msg.LinkNotes(alpha, alpha))
+  check(
+    (Model.getNote(selfLinked, alpha)->Option.getExn).links->Array.length == 1,
+    "self link ignored",
+  )
+
+  // Unlink
+  let unlinked = Update.update(model, Msg.UnlinkNotes(alpha, beta))
+  check(
+    !((Model.getNote(unlinked, alpha)->Option.getExn).links->Array.includes(beta)),
+    "unlink removes forward link",
+  )
+  check(!(Model.getBacklinks(unlinked, beta)->Array.includes(alpha)), "unlink removes backlink")
+
+  // Search through the core
+  let model = Update.update(model, Msg.SetSearchQuery("alpha"))
+  check(model.searchResults == [alpha], "search finds the matching note")
+  let model = Update.update(model, Msg.SetSearchQuery(""))
+  check(model.searchResults == [], "empty query yields no results")
+
+  // Zoom clamping
+  let model = Update.update(model, Msg.ZoomCanvas(100.0))
+  check(model.viewport.zoom <= 5.0, "zoom clamped high")
+  let model = Update.update(model, Msg.ZoomCanvas(0.00001))
+  check(model.viewport.zoom >= 0.1, "zoom clamped low")
+
+  // Move on canvas
+  let model = Update.update(model, Msg.MoveNote(alpha, {x: 42.0, y: 7.0}))
+  check(
+    switch (Model.getNote(model, alpha)->Option.getExn).position {
+    | Some(p) => p.x == 42.0 && p.y == 7.0
+    | None => false
+    },
+    "move sets position",
+  )
+
+  // Delete is guarded while editing
+  let model = Update.update(model, Msg.SelectNote(alpha))
+  let editing = Update.update(model, Msg.StartEditingNote(alpha))
+  let guarded = Update.update(editing, Msg.DeleteSelectedNotes)
+  check(Model.noteCount(guarded) == 2, "delete guarded while editing")
+
+  // Delete works otherwise, and cleans backlinks
+  let model = Update.update(model, Msg.StopEditingNote)
+  let model = Update.update(model, Msg.LinkNotes(alpha, beta))
+  let model = Update.update(model, Msg.DeleteSelectedNotes)
+  check(Model.noteCount(model) == 1, "delete removes selected note")
+  check(Model.getBacklinks(model, beta) == [], "backlinks cleaned after delete")
+  check(model.selection == NoSelection, "selection cleared after delete")
+}
diff --git a/ui/tests/contract.test.js b/ui/tests/contract.test.js
new file mode 100644
index 0000000..9dcfe52
--- /dev/null
+++ b/ui/tests/contract.test.js
@@ -0,0 +1,95 @@
+// SPDX-License-Identifier: MPL-2.0
+// Golden-fixture contract test — the JS half of core/tests/golden.rs.
+// Both sides decode tests/fixtures/notebook.golden.json; a serde shape
+// change that breaks the UI contract fails here.
+
+import initWasm, { WasmNotebook } from "../../web/wasm/nexia_core.js";
+
+const wasmBytes = await Deno.readFile(
+  new URL("../../web/wasm/nexia_core_bg.wasm", import.meta.url),
+);
+await initWasm({ module_or_path: wasmBytes });
+
+const fixture = await Deno.readTextFile(
+  new URL("../../tests/fixtures/notebook.golden.json", import.meta.url),
+);
+
+const ALPHA = "11111111-1111-4111-8111-111111111111";
+const BETA = "22222222-2222-4222-8222-222222222222";
+
+function assert(cond, label) {
+  if (!cond) throw new Error(`contract violated: ${label}`);
+}
+
+Deno.test("golden fixture decodes to the UI shape", () => {
+  const nb = WasmNotebook.from_json(fixture);
+  const snap = nb.snapshot();
+
+  assert(snap.name === "Golden", "notebook name");
+  assert(Object.keys(snap.notes).length === 2, "two notes");
+
+  const alpha = snap.notes[ALPHA];
+  assert(alpha.title === "Alpha", "title");
+  assert(
+    alpha.createdAt.startsWith("2026-01-01T00:00:00"),
+    "camelCase createdAt",
+  );
+  assert(
+    alpha.modifiedAt.startsWith("2026-01-02T00:00:00"),
+    "camelCase modifiedAt",
+  );
+  assert(
+    Array.isArray(alpha.links) && alpha.links.includes(BETA),
+    "links present",
+  );
+  assert(
+    alpha.position && alpha.position.x === 10 && alpha.position.y === 20,
+    "position object",
+  );
+  assert(Array.isArray(alpha.size) && alpha.size[0] === 200, "size tuple");
+  assert(
+    alpha.attributes && alpha.attributes.status === "todo",
+    "attributes object",
+  );
+
+  const beta = snap.notes[BETA];
+  assert(
+    Array.isArray(beta.links) && beta.links.length === 0,
+    "empty links always present",
+  );
+  assert(
+    beta.position === undefined,
+    "absent position is undefined (ReScript None)",
+  );
+
+  // Backlinks rebuilt even though the fixture omits them.
+  assert(snap.backlinks[BETA].includes(ALPHA), "backlinks rebuilt on load");
+});
+
+Deno.test("mutations return granular views; disk format stays snake_case", () => {
+  const nb = WasmNotebook.from_json(fixture);
+
+  const created = nb.create_note("Gamma");
+  assert(
+    typeof created.id === "string" && created.id.length === 36,
+    "core-issued UUID",
+  );
+  assert(created.links.length === 0, "fresh note has no links");
+
+  const delta = nb.link(created.id, ALPHA);
+  assert(delta.changed[0].links.includes(ALPHA), "delta carries changed note");
+  assert(
+    delta.backlinks[ALPHA].includes(created.id),
+    "delta carries backlink entry",
+  );
+
+  const ids = nb.search("gamma");
+  assert(ids.length === 1 && ids[0] === created.id, "search finds new note");
+
+  const disk = JSON.parse(nb.to_json());
+  assert(
+    "created_at" in Object.values(disk.notes)[0],
+    "disk format is snake_case",
+  );
+  assert(disk.name === "Golden", "disk name preserved");
+});
diff --git a/ui/tests/update.test.js b/ui/tests/update.test.js
new file mode 100644
index 0000000..d595b83
--- /dev/null
+++ b/ui/tests/update.test.js
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: MPL-2.0
+// Deno test wrapper: initializes the wasm core, then runs the ReScript TEA
+// update tests (UpdateTests.res). Requires `deno task build:res` and
+// `deno task build:wasm` first.
+
+import initWasm from "../../web/wasm/nexia_core.js";
+import { runAll } from "./UpdateTests.res.js";
+
+const wasmBytes = await Deno.readFile(
+  new URL("../../web/wasm/nexia_core_bg.wasm", import.meta.url),
+);
+await initWasm({ module_or_path: wasmBytes });
+
+Deno.test("TEA update delegates to the wasm core", () => {
+  runAll();
+});
diff --git a/web/index.html b/web/index.html
index 342a6ec..fc3fbd0 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,14 +1,17 @@
 <!DOCTYPE html>
 <!-- SPDX-License-Identifier: MPL-2.0 -->
 <html lang="en">
-<head>
-  <meta charset="UTF-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1.0">
-  <title>Nexia</title>
-  <link rel="stylesheet" href="styles.css">
-</head>
-<body>
-  <div id="root"></div>
-  <script type="module" src="../ui/src/Main.res.js"></script>
-</body>
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta name="description"
+      content="Nexia — local-first spatial knowledge management">
+    <title>Nexia</title>
+    <link rel="stylesheet" href="./styles.css">
+  </head>
+  <body>
+    <noscript>Nexia is a local-first app that runs entirely in your browser and requires JavaScript.</noscript>
+    <div id="root" aria-live="polite"></div>
+    <script type="module" src="./app.js"></script>
+  </body>
 </html>
diff --git a/web/styles.css b/web/styles.css
index 6da5edb..2d09b0a 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -21,7 +21,8 @@
 }
 
 body {
-  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+    sans-serif;
   background: var(--bg-primary);
   color: var(--text-primary);
   line-height: 1.5;
@@ -43,7 +44,9 @@ body {
   border-bottom: 1px solid var(--border);
 }
 
-.toolbar-left, .toolbar-center, .toolbar-right {
+.toolbar-left,
+.toolbar-center,
+.toolbar-right {
   display: flex;
   gap: 0.5rem;
   align-items: center;
