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 @@ + +# 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 @@ + + + + +# 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 @@ + # 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::` 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 @@ - + # 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 ¬e.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 ¬e.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(value: &T) -> Result { + // 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::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, + #[serde(skip_serializing_if = "Option::is_none")] + size: Option<(f64, f64)>, + created_at: String, + modified_at: String, + links: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + prototype: Option, + attributes: HashMap, +} + +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, + removed: Vec, + backlinks: HashMap>, +} + +/// Full notebook snapshot, used on init/new/load. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct NotebookView { + notes: HashMap, + backlinks: HashMap>, + 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 { + 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 { + serde_json::to_string_pretty(&self.inner).map_err(err) + } + + pub fn snapshot(&self) -> Result { + 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 { + 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 { + 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 { + 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 { + self.with_note(id, |note| { + note.title = title.to_string(); + note.touch(); + }) + } + + pub fn update_content(&mut self, id: &str, content: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + prop_oneof![ + 2 => Just(Op::Create), + 3 => (any::(), any::()).prop_map(|(a, b)| Op::Link(a, b)), + 1 => (any::(), any::()).prop_map(|(a, b)| Op::Unlink(a, b)), + 1 => any::().prop_map(Op::Remove), + ] +} + +fn pick(ids: &[NoteId], index: usize) -> Option { + 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 = 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 = + nb.all_notes().filter(|n| n.links_to(id)).map(|n| n.id).collect(); + let actual: HashSet = 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 ¬e.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 = 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(¬e.id).unwrap(); + prop_assert_eq!(¬e.links, &restored.links); + prop_assert_eq!(¬e.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 @@ + +# 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 @@ + +# 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 @@ + +# 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:` 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 ). + +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 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)) }) } } -// Mount the app -switch ReactDOM.querySelector("#root") { -| Some(root) => ReactDOM.Client.createRoot(root)->ReactDOM.Client.Root.render() -| 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() + | 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( +
+ {React.string("Nexia failed to start. Check the browser console for details.")} +
, + ) + | 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 => { - 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): 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 = { ? {React.string("Unsaved")} : React.null} + 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 = "idbSet" +@module("./idb.js") external idbGet: string => promise> = "idbGet" +@module("./fileio.js") external downloadText: (string, string) => unit = "downloadText" +@module("./fileio.js") +external openTextFile: unit => promise> = "openTextFile" + +let autosaveKey = "nexia.autosave" +let autosaveDelayMs = 800 + +let pending: ref> = 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 => { + 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 => { + 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, + removed: array, + backlinks: Js.Dict.t>, +} + +@module("../../../web/wasm/nexia_core.js") +external initWasm: string => promise = "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 = "search" +@send external snapshotRaw: t => notebook = "snapshot" +@send external toJsonRaw: t => string = "to_json" + +let current: ref> = 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 => + 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 => + try Ok(toJsonRaw(instance())) catch { + | e => Error(errorMessage(e, "Could not serialize notebook")) + } + +let tryNote = (operation: unit => note): result => + try Ok(operation()) catch { + | e => Error(errorMessage(e, "Note operation failed")) + } + +let tryDelta = (operation: unit => delta): result => + 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 => 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 @@ - - - - Nexia - - - -
- - + + + + + Nexia + + + + +
+ + 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;