No description
  • Rust 71.9%
  • JavaScript 25.9%
  • CSS 1.8%
  • Nix 0.3%
  • Python 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-20 21:52:45 +02:00
.claude Set up OpenSpec for roadmap items that need a design 2026-10-07 21:01:16 +02:00
.moira ci: add moira 2026-09-13 19:16:38 +02:00
assets Draw flashcards as tiles and find them in any note 2026-10-20 20:33:51 +02:00
docs Merge branch 'main' into JMARyA/add-card-tiles 2026-10-20 20:52:39 +02:00
icon Add Windows cross-compilation and installer outputs 2026-09-14 08:33:15 +02:00
js Default translucent chrome to 50% opacity, not 35 2026-10-20 21:44:23 +02:00
nix Ship the built-in skills to the packaged build; remount the filter boxes 2026-10-19 19:29:00 +02:00
openspec Propose mind maps 2026-10-20 21:52:45 +02:00
sample-workspace Merge branch 'main' into JMARyA/add-card-tiles 2026-10-20 20:52:39 +02:00
scripts Read PDFs in a tab of their own, with Obsidian/PDF++ links into and out of them 2026-10-18 01:46:54 +02:00
src Merge branch 'main' into JMARyA/add-window-vibrancy 2026-10-20 21:44:40 +02:00
tests Run cargo fmt 2026-10-20 21:28:20 +02:00
.envrc NOITA: a Dioxus + CodeMirror 6 workspace prototype 2026-09-12 14:36:38 +02:00
.gitignore Lay DOT out with Graphviz in a worker 2026-10-18 07:20:47 +02:00
build.rs Merge branch 'JMARyA/add-dot-graphs' into main 2026-10-18 10:50:32 +02:00
Cargo.lock Blur behind translucent chrome on macOS, with a blur switch and an opacity slider 2026-10-20 20:50:32 +02:00
Cargo.toml Blur behind translucent chrome on macOS, with a blur switch and an opacity slider 2026-10-20 20:50:32 +02:00
CONTRIBUTING.md Refresh project documentation and fix published table links 2026-10-15 22:06:03 +02:00
Dioxus.toml Record audio notes from the ribbon and transcribe them into the note 2026-10-14 04:26:36 +02:00
flake.lock NOITA: a Dioxus + CodeMirror 6 workspace prototype 2026-09-12 14:36:38 +02:00
flake.nix Play audio and video embeds in notes 2026-10-19 01:54:24 +02:00
package-lock.json Merge branch 'JMARyA/improve-terminal-quality' into main 2026-10-18 10:51:02 +02:00
package.json Merge branch 'JMARyA/improve-terminal-quality' into main 2026-10-18 10:51:02 +02:00
README.md Default translucent chrome to 50% opacity, not 35 2026-10-20 21:44:23 +02:00
ROADMAP.md Merge branch 'main' into JMARyA/add-window-vibrancy 2026-10-20 20:55:47 +02:00

noita

A local-first GUI workspace you can shape around your files and tools.
The vision is a vault—an ordinary project folder—that carries its own programmable
working environment in .noita/: declarative layouts, custom views, styles, and
scriptable actions. Views observe local material; actions change it; relevant
changes cause views to refresh.

See What is Noita? for the product direction, the
roadmap for feature and use-case checklists, what we take from the
neighbours
for where each idea comes from, and
programmable views for the proposed view
and capability contract. This is the ambition, not a claim that the prototype
already implements it.

Current prototype

A file-based workspace built with Dioxus 0.7, CodeMirror 6, and a Rust-owned
filesystem and application model.
It has grown beyond the original editor
proof: Markdown and structured-data editing, shared tables and read-only
spreadsheets, queries and charts, graphs, maps, calendars, tasks, boards and
Obsidian Kanban notes, canvas boards,
slides, Git, terminals, browser tabs, audio notes, a CLI, and static publishing.

This remains a prototype, not a production-readiness claim. Programmable custom
views and their capability model are still design work. Platform support and
manual acceptance vary by feature; see the documentation index
for current feature guides, specifications, and known limits.

Find your way

┌──────────────────────────────────────────────────────────┐
│ noita                         [live preview]  README.md ●│
├──────────────┬───────────────────────────────────────────┤
│ FILES        │                                           │
│ architecture │   Obsidian-style live preview: Markdown   │
│ ideas.md     │   syntax is decorated away and reappears  │
│ notes.md     │   when the cursor reaches it.             │
│ README.md    │                                           │
├──────────────┴───────────────────────────────────────────┤
│ ● status: saved README.md      ⌘-click a [[wiki-link]]   │
└──────────────────────────────────────────────────────────┘

Run it

npm ci                # CodeMirror 6, xterm.js, and esbuild
npm run build         # bundles editor + terminal JS/CSS locally (no runtime CDN)
dx serve --desktop

Open a different workspace by passing a directory:

dx serve --desktop --args ~/notes
# or, after `dx build --desktop`:
./target/dx/noita/debug/macos/Noita.app/Contents/MacOS/Noita ~/notes

On macOS the desktop build has a native application menu. On Windows and Linux,
where the top bar is the window's title bar, the same menus open from the ☰
button at its start. Either way the common commands work regardless of whether
focus is in the editor, terminal, or app chrome:

Command macOS Windows/Linux
New note / new tab ⌘N / ⌘T Ctrl+N / Ctrl+T
Open a note by name (quick switcher) ⌘O Ctrl+O
Open vault ⇧⌘O Ctrl+Shift+O
Save now / close tab ⌘S / ⌘W Ctrl+S / Ctrl+W
Back / forward in a tab ⌥⌘← / ⌥⌘→ Ctrl+Alt+← / Ctrl+Alt+→
Open in a new tab ⌘-click or middle-click Ctrl+click or middle-click
Search ⌘F Ctrl+F
Toggle sidebar / reading / source ⌘B / ⌘E / ⇧⌘E Ctrl+B / Ctrl+E / Ctrl+Shift+E
Links to this note ⇧⌘L Ctrl+Shift+L
Follow a link or a tag ⌘-click Ctrl+click
New terminal ⌘` Ctrl+`
Command palette / today's daily note ⇧⌘P / ⇧⌘D Ctrl+Shift+P / Ctrl+Shift+D

These are defaults. Open Hotkeys (in the palette, the Help menu, or
Settings) lists every command with its keys: press + and then the key you
want, × to remove one, restore to go back to the default; keys two
commands share are shown in red, and the keyboard button searches by key. What
it saves is a [hotkeys] table in your own config.toml, holding only what
differs from the defaults:

[hotkeys]
"graph.open" = ["Mod+G", "Mod+Alt+G"]   # Mod is ⌘ on macOS, Ctrl elsewhere
"tab.new" = []                          # no key at all

Each template has two commands of its own, "template.new:<name>" and
"template.insert:<name>", which bind the same way
(templates).

A vault's config.toml may bind keys too and wins per command. A hotkey needs
Ctrl, ⌘ or Alt (or is a function key); copy, cut, paste, select all, undo and
redo cannot be taken. One that is also an editing key of the note editor (⌘/
toggles a comment) is marked on the page and runs the command instead.

Closing a dirty file tab saves it first and leaves the tab open if that write
fails. Closing a terminal tab only hides its still-running session, matching the
existing tab lifecycle rather than killing work by surprise.

With Nix:

nix develop             # rustc, dioxus-cli, node, platform WebView deps
npm ci && npm run build && dx serve --desktop

nix develop .#android   # + Android SDK 34, NDK 27, rust android targets
dx serve --android

nix develop .#windows   # + mingw-w64, NSIS, the rust windows-gnu target
cargo build --release --target x86_64-pc-windows-gnu

The Android shell is separate because the SDK and NDK are several gigabytes and
no desktop build needs them. The Windows shell is separate for a smaller reason:
the linker and resource-compiler variables it exports are wrong for anything but
a cross build.

Android uses the same assets/icon.png as desktop, with an adaptive launcher
variant. Dioxus 0.7 generates stock icons despite bundle.icon, so
icon/android.gradle replaces them during Gradle configuration. Both the Android
Nix shell and APK derivation install this hook automatically. Re-enter the shell
after updating it; rebuild and reinstall the APK to see the new icon. The shell
keeps its Gradle cache and hook in target/gradle-android.

Getting an APK

On x86_64 Linux, build the ARM64 APK as a Nix derivation:

nix build .#apk
find -L result -name '*.apk' # install with adb install -r <path>

This builds the renderer, cross-compiles Rust, and runs Gradle offline with a
hash-pinned Maven repository and Gradle distribution. The APK is debug-signed
(for testing, not store distribution); the Rust code uses the release profile.
Allow ~30 GB of free space for the toolchains and build.

For an interactive build instead, dx cross-compiles Rust and scaffolds a
Gradle project around it:

git clone <this repo> && cd noita
nix develop .#android        # sets ANDROID_NDK_HOME, which dx requires
npm ci && npm run build      # required: the bundle is generated, not committed
dx bundle --android --package-types apk --release --out-dir dist
# or: dx serve --android, with a device on adb

The file is app-debug.apk even with --release: the flag picks the Rust
profile, and Gradle still signs with the debug key. Signing it for distribution
is a keystore this repo does not carry.

npm run build is not optional on a fresh clone. assets/editor/editor.js is a
build artifact, and asset!() resolves at compile time, so without it the build
fails — build.rs says so up front rather than letting the macro report it.

dx prints the output path (under target/dx/noita/); the Gradle wrapper
downloads its own Gradle — 9.1.0, ~140 MB — plus ~190 MB of Maven dependencies
into GRADLE_USER_HOME on first run, so that step needs network.

Getting a Windows build

Two shapes, both built on Linux as Nix derivations — no Windows machine anywhere
in the loop:

nix build .#windows            # Noita_0.1.0_x64-setup.exe   — NSIS installer
nix build .#windows-portable   # Noita_0.1.0_x64-portable.zip — unzip and run
find -L result -type f

The portable zip is the same app without the install step. It unpacks to a
Noita/ folder holding noita.exe and its assets/ — which is all Windows
needs, because asset lookup is current_exe()/assets. Put it on a stick, run
it, delete the folder: no registry keys, no Program Files, no admin prompt,
because nothing ever installed anything. (The app still creates
%USERPROFILE%\Noita on first launch when you do not pass it a directory —
that is its workspace, not install state.)

There is no single-file .exe. dx has no such mode, the assets are resolved
next to the executable at runtime, and a self-extracting one-file build would be
an installer again in everything but name.

The toolchain is mingw-w64, so the target is x86_64-pc-windows-gnu rather
than -msvc: MSVC needs a Windows host to run. Nothing about the app changes —
wry reaches WebView2 through the same COM interfaces on either, and
windows-gnu is a tier-1 Rust target.

The package type is NSIS, not MSI, for the same reason. dx builds an MSI
by running WiX's candle.exe and light.exe directly — no wine — so that path
needs Windows. Its NSIS path shells out to a plain makensis, and nixpkgs
builds one of those natively. dx would otherwise download Tauri's NSIS
archive, which carries makensis.exe and would be no use here, so the
derivation seeds $DX_HOME with the native binary and dx never reaches for
it. The dev shell does the same thing into target/dx-windows.

The installer does not bundle the WebView2 runtime: Windows 11 ships it, and
Edge has been installing it on Windows 10 for years. webview_install_mode in
Dioxus.toml is what says so — the default would have dx download Microsoft's
~150 MB offline installer at bundle time, which is neither reproducible nor
possible inside the sandbox. Nothing is code-signed, so SmartScreen will warn on
first run; signing is a certificate this repo does not carry.

assets/icon.ico is the Windows icon, built by icon/windows.py from the same
wizard art as the others, with each size rendered at its own scale. dx compiles
it and the version info into the .exe through windres.

Terminals work on Windows, through ConPTY, and the new-view menu offers every
shell the machine has — PowerShell, Windows PowerShell, Command Prompt, Git
Bash, and one entry per installed WSL distribution. See
Terminal sessions for how that differs from the Unix
implementation.

Two things in dx need working around for a cross build, both handled by the
flake:

  • It defaults the target triple to the host's, so --target has to be named
    explicitly or --platform windows quietly builds a Linux binary.
  • It appends MSVC's /SUBSYSTEM: and /ENTRY: link arguments to every Windows
    bundle without checking which toolchain is in use, and GNU ld reads a
    leading / as a file name. The flake links through a wrapper that drops them;
    #![windows_subsystem = "windows"] in src/main.rs is what actually keeps
    release builds from opening a console behind the window, in the spelling
    rustc translates for whichever linker it has. Debug builds keep the console,
    which is where panics go.

For an interactive build instead:

nix develop .#windows
npm ci && npm run build
dx bundle --platform windows --target x86_64-pc-windows-gnu \
  --package-types nsis --release --out-dir dist
# or, for the portable layout — dx build has no --out-dir, it writes to
# target/dx/noita/release/windows/app/
dx build --platform windows --target x86_64-pc-windows-gnu --release

Note that dx and the dioxus crate must agree on version — a mismatch is
reported as dx and dioxus versions are incompatible. The Nix shell pins
dioxus-cli 0.7.10 against the 0.7 crate, which is why it is the recommended
way in.

Run git init before using the Nix shells. A path flake in a directory
that is not a git repository copies the entire directory into the Nix store
— target/ and node_modules/ included — on every evaluation, which is about
4 GB a time. With a git repository, Nix copies only tracked files and honours
.gitignore.

cargo test covers domain and application policy, filesystem/session safety,
CLI workflows, and native PTY lifecycle tests on macOS/Linux. npm test covers
the renderer, editor modes, tables, transforms, terminal protocol, published
site, and documentation links. See Contributing for the full
validation commands and platform prerequisites. build.rs rebuilds the renderer
assets when their source or package-lock inputs change and refuses to ship stale
assets after a failed build.

From the shell

noita <command> reads and changes a vault without opening a window — no
display, no running Noita — through the same link resolution, query engine and
checks the app uses:

noita tasks --open --format ndjson     # every open task, with path and line
noita backlinks ideas                   # what links here
noita query 'LIST FROM #work' --json    # DQL, as a query view answers it
noita daily "standup: shipped it"       # append to today's daily note
noita mv ideas.md archive/ideas         # move, and readdress every link to it
noita check --strict                    # the Problems panel as a CI gate
noita check --fix --dry-run             # the diff its fixes would make

Exit codes say what happened (0 ok, 1 findings, 2 usage, 3 not found, 4
conflict, 5 I/O), errors go to stderr, every write takes --dry-run, and
noita completions <shell> prints completions. noita alone or with a path
still opens the app. See the command line.

Publish

noita export <out> writes the vault as a static site: one page per note, as
the reading view draws it, readable with JavaScript off; diagrams, charts, maps,
canvases and the graph come alive with it on. No hosting, no node, no display:

noita export ~/site --vault ~/notes       # then serve ~/site, or open it from disk
noita export ~/site --dry-run             # every page it would write, and from what

See publishing a vault.

CI and artefacts

CI is moira, and it is split the way moira
splits things: gates run as steps, artefacts are derivations.

The gates — .moira/ci.yaml

Runs on every push to main and every pull request against it:

fmt → deps → assets → clippy → test

Every step runs in devShells.ci — the same toolchain the artefacts are built
with, so a gate cannot pass against a different compiler than the one that
ships. deps (cargo fetch --locked) is the only step with network; everything
after it runs --offline, so a build reaching for an undeclared dependency
fails there as a lockfile problem instead of quietly resolving one. assets
copies in packages.renderer rather than running npm, because that derivation
has already built the bundles and run the JS protocol tests.

Run the whole thing before pushing — no server, no agent:

moira check .            # lint the thread definitions
moira run . ci --local   # run them, streaming to your terminal
moira logs <run-id>      # what a step actually printed

Thread definitions are read from the default branch, so a pull request that
edits .moira/ci.yaml runs main's copy of it. moira check is how you
validate the edit before it merges.

The thread pins itself to system: x86_64-linux. Any agent could run these
steps, but only a Linux one has a working bwrap, so only there does a passing
test step also mean the sandbox held — and a darwin agent is worth reserving
for the artefact only it can produce. Runs are not public, because the
repository is not.

The artefacts — moiraFlake

Every push builds packages.* as derivations, each with its own status on the
commit and each pushed to the org's binary cache, so an untouched one is
substituted instead of rebuilt:

output what it is built on
packages.renderer the CodeMirror + xterm bundles, npm test run on the way any system
packages.linux noita_0.1.0_amd64.deb Linux
packages.windows NSIS .exe installer, cross-compiled with mingw-w64 Linux
packages.windows-portable the same app as a .zip — no installer Linux
packages.apk ARM64 Android APK, debug-signed with release Rust x86_64 Linux
packages.macos Noita.app macOS
packages.dmg the .app wrapped for distribution macOS
nix build .#macos        # the release .app, offline, from vendored crates
nix build .#renderer     # just the bundles

On Apple Silicon macOS, .#macos produces an ad-hoc-signed .app (not
Developer ID signed or notarized). Desktop launches without a workspace argument
reopen the last vault when available, then try sample-workspace in the current
directory, otherwise ~/Noita, created with a welcome note on first launch. No runtime path points into the Nix
build sandbox.

nix/app.nix is the whole of it: dx is the only thing that can produce a
bundle — manganis rewrites every asset! at compile time and dx collects the
results into the platform's layout — so the derivation's job is to hand dx a
tree it can build offline, and keep what it emits.

Android packaging lives in nix/apk.nix. nix/android-maven.json pins Maven
artifacts by URL and SHA-256; nix/android-init.gradle replaces remote repositories
with that local repository and forces offline resolution. To update dependencies,
run the interactive APK build with a fresh GRADLE_USER_HOME, then run
python nix/update-android-maven.py "$GRADLE_USER_HOME" and rebuild .#apk.
If Dioxus changes its Gradle wrapper version, update the distribution hash and
wrapper cache directory in nix/apk.nix too.

One remaining infrastructure limitation:

  • packages.macos needs a darwin agent. Discovery is per-agent-system, so
    the Linux builder never sees the macOS outputs and never queues them; a darwin
    agent evaluating the same commit is what puts them on the board. Until there
    is one, the .app is a nix build you run on your own Mac.

Terminal sessions

Use the tab bar's + menu to create a New Markdown file, New terminal,
or Empty tab. A terminal starts a real interactive shell in the workspace
directory; you can run installed tools and AI agent CLIs there yourself.
Terminal sessions in the ribbon or topbar still provides process management
and an alternative New terminal in vault action.

  • Closing a terminal tab hides it; the Noita-owned process keeps running.
  • Show reopens the same session with its retained screen and scrollback.
  • Stop terminates its ordinary foreground/background jobs; Dismiss releases
    an exited session's screen. There is no fixed session limit: Sessions shows
    how many are retained, and a session the OS refuses (descriptor or process
    limits) appears as failed with the OS reason.
  • Ctrl/⌘-F in a terminal finds in its scrollback (match case, regex, count).
    Scrolling up while output arrives keeps your place; ↓ Follow jumps back.
  • Ctrl/⌘-click a URL or a path:line:col location: web addresses go
    through the same rule as links in notes, files in the vault open at the line,
    and anything else is refused with a notice.
  • With shell_integration = true in your own config.toml, bash, zsh and fish
    report where each command starts and ends: a green or red mark per command,
    Terminal: Previous/Next/Last Failed Command, and Copy Command Output /
    Copy Command as Shell Block (also on right-click), which pastes into a note
    as a runnable block. Your startup files still run first; none is edited.
  • Agents in the ribbon lists terminals running an agent CLI — marked by you,
    launched from a profile with agent = "…", or reporting through noita agent
    from the agent's own hooks — with what each last reported, apart from whether
    its process runs. Agents that asked a question, want approval or failed sort
    first and are counted on the ribbon and status bar; opt-in notifications show
    the terminal. Link a vault task and tick it after a done report, or review
    the Git diff since the agent started. Nothing there answers or types for you.
    Agents in one window message each other and you, ask and wait for an answer,
    and take advisory locks (noita agent send|ask|lock), shown in its Messages
    and Locks tabs. See terminal agents.
  • Processes live for this Noita run, not across application restarts. Normal exit
    initiates cleanup. Deliberately daemonized processes are outside this containment.

Nothing starts automatically. These shells have your user permissions, not a
vault sandbox
. Files they change are reconciled with open editor buffers like
any other outside edit (see Edits made outside Noita).
Actual AI CLI and packaged GUI acceptance remain manual checks.
See terminal design, limits, and acceptance.

Running blocks

A shell block in a note can be run where it is written. In live preview and
reading view, a fence whose language is sh, shell, console, bash or
zsh (on Windows: powershell, pwsh or cmd) has a Run control next to
Copy. The output appears under the block as a small live terminal:

```sh name=build cwd=..
cargo build
```
  • Nothing runs unless you click Run. Opening a note or a vault, editing a
    block, and restarting Noita never start anything. The whole script is always
    visible: what you see is what a click runs.
  • The block's text goes to its shell as one script (sh -c), so a cd or a
    variable carries to later lines. In a console block, only the lines with a
    $ or # prompt run; the rest is recorded output.
  • It runs in the note's folder. After the language, cwd= picks another folder
    relative to the note (inside the vault only), name= names the block, and
    tab always runs it in a terminal tab, for servers and watchers. Other
    editors read only the first word, so these options cost the note nothing.
  • The process gets NOITA_VAULT, NOITA_NOTE and NOITA_BLOCK.
  • Click into the output to answer a prompt. The bar above it shows Running or
    the exit code, with Stop, Rerun, Expand, Open in tab (the same
    session, not restarted) and Dismiss.
  • Edit a block after it ran, and its output dims as having run on earlier text
    until you run it again.
  • Running never changes the file. Output is not saved: it is kept while the note
    is open, through tab switches, and goes when its tab closes. A run is an
    ordinary terminal session: it is listed in Sessions under
    its note and block, and counts toward the eight.

The shell runs non-interactively and reads no profile, so a run behaves the
same for everyone. That means your aliases and rc-file functions are not
available. It inherits Noita's environment, PATH included.

Which shells you get

The + menu lists one entry per shell this machine offers. Detection differs
by platform, and configuration has the last word on all of it:

platform detected
macOS / Linux $SHELL as the login shell, then zsh, bash, fish, sh where present
Windows PowerShell 7, Windows PowerShell, Command Prompt, Git Bash, and one entry per WSL distribution

WSL distributions come from one wsl.exe --list --quiet probe, cached for the
run of the app — so a distribution installed while Noita is open shows up after
a restart. The probe is Microsoft's own tool at a fixed system path, asked only
for names; detection never runs anything a vault supplies.

Rename, reorder, hide or add to the list in .noita/config.toml:

[terminal]
default_profile = "pwsh"
hidden = ["cmd"]
scrollback = 10000      # lines per screen, 1 to 100000
screen_reader = false   # also: Terminal: Toggle Screen Reader Mode

# A new id appends. Reusing a detected id replaces that profile in place, so a
# renamed shell stays where you expect it in the menu.
[[terminal.profiles]]
id = "deploy"
name = "Deploy shell"
program = "/bin/bash"
args = ["-lc", "cd deploy && exec bash -i"]

Two workers, one owner

domain/terminal/manager.rs is the same on every platform: the session table, the credited input/output protocol, and a shutdown that stays bounded
even when the OS will not cooperate. What a platform supplies is one run
function, and the two differ for a reason worth stating:

macOS / Linux Windows
PTY POSIX pseudo terminal ConPTY
Loop one thread — the master descriptor is set O_NONBLOCK, so a single loop services input, output, resize and Stop without ever parking three threads — ConPTY hands out blocking pipes, so reading and writing each get their own and the worker stays free to reap and resize
Cleanup SIGHUP then SIGKILL to every process in the PTY's POSIX session, guarded by getsid kill the shell, taskkill /T /F over its process tree, then close the pseudoconsole, which ends the console session its processes share

Both give the same guarantee with the same hole in it: everything the shell
started is Noita's to clean up, and a process that deliberately detaches itself
is not. The Windows tree walk runs before the shell is reaped, so Windows still
has its process id reserved and cannot have handed it to something else.

There is no unsafe in either. The Unix worker reaches POSIX through nix;
the Windows one stays in std. A Job Object would be the stronger primitive —
it cannot be escaped by re-parenting — but it needs raw handle calls, and that
is a trade worth making deliberately rather than in passing.

Browser tabs

+ → Browser opens a web page in a tab, and Ctrl/Cmd-click on a web link in
a note opens it in one. Pages run in a web view and browsing profile of their
own, apart from the vault and from Noita's interface: no file: addresses, no
way to call into Noita, no pop-up windows (they become tabs), no downloads. Tabs
come back at their address when the vault reopens.
See browser tabs.

Vaults

A vault is an ordinary folder you opened — the same thing an IDE calls a
project. Nothing is copied into it, nothing is registered on its behalf, and it
carries no lock-in: everything noita writes lives in .noita/, and deleting
that leaves a folder of Markdown.

The vault name at the foot of the sidebar opens the switcher. It lists the
folders this machine has opened, most recent first, and Open folder as vault…
asks the platform's own chooser for a new one. Removing an entry from the list
leaves the folder alone; a vault on a disconnected drive stays listed and greyed
out, because an unplugged disk comes back.

Switching happens in place. The window is not rebuilt and CodeMirror is not
torn down — every store is reset and repointed, which is what makes it feel like
changing projects rather than restarting. The order matters and is deliberate:

  • The outgoing vault's machine-local state is written before anything is
    cleared, because afterwards nothing is left to describe which tabs were open.
  • A folder that cannot be opened fails before any of that, so a bad path costs
    an error message rather than the session.
  • The incoming vault reopens exactly as a fresh launch into it would — its tabs,
    its collapsed folders, its panels.
  • Terminal sessions keep running. They are app-owned and already outlive their
    tabs; ending someone's shell because they looked at another folder would be a
    surprising way to lose work. Their views close, and they stay in Sessions.

Launching with no argument reopens the vault you were last in. An explicit path
still wins:

noita ~/notes     # this folder
noita             # whatever you had open last

The list of opened vaults is machine state, so it lives beside your own
configuration at ~/.config/noita/vaults.toml — never inside a vault. A vault
shared with someone else has no business carrying a list of the folders its
author had open.

Properties

Frontmatter is not merely displayed badly without help — it is displayed as
something else entirely. The Markdown grammar has no idea it exists, and ---
is also how you underline a heading, so this:

---
status: open
tags: [work, noita]
---

parses as a horizontal rule, then a setext heading containing the metadata,
with [work, noita] read as a link. A note's metadata renders as a headline.

So frontmatter gets a panel instead: one row per property, with the control its
value deserves — text, number, checkbox, date, or chips for a list. Keys can be
renamed in place, properties added and removed. Anything the panel cannot
honestly represent — a list of maps, say — is shown read-only rather than
flattened into a guess.

Every edit goes back into the buffer as an ordinary change, so it reaches Rust
and is autosaved like any keystroke, and undo covers it. Controls commit on
change rather than on every keypress: writing per letter would rebuild the
panel under the cursor and take the field away mid-word.

Your formatting survives. yaml's document API edits in place, so comments,
key order and quoting style are left alone, and an inline tags: [work, noita]
stays inline instead of being reflowed into three lines the first time you tick
a checkbox. There are tests for each of those, because a properties panel that
quietly reformats a vault would be worse than no properties panel.

Why it is a "syntax" transform

It is registered with the same pipeline as queries and diagrams, but declares
role: "syntax", and both halves of that matter:

  • The cursor never dissolves it. Every other block gives its source back
    when you click in, because the source is what you were editing. Here the panel
    is the editor — and frontmatter starts at position zero, which is where the
    cursor already is when a note opens, so the usual bargain would mean a note
    never showed its properties at all.
  • Source mode shows the YAML, because source mode means the file as it is.
    That is also the escape hatch for anything the panel will not edit.

Parsing happens in the renderer rather than in Rust, which is a deliberate
split: queries read files from disk through jsaw, while this reads the buffer
you are typing into, and it has to work on platforms where the query engine is
deliberately absent. The two are already looking at different things whenever
there are unsaved edits.

Blocks that draw themselves

Some fenced blocks are not code to look at but something to render: a query
that becomes a table, a diagram that becomes a picture. There is one mechanism
for all of them, because the alternative — each feature reaching into the
editor its own way — is how a renderer becomes unmaintainable.

js/pipeline.js owns everything such a block needs and a transform owns only
what makes it different. The pipeline finds claimed regions and snaps them to
whole lines; keeps one answer store keyed by transform and source text; serves
the decorations from a StateField (a decoration replacing a line break may
only come from a field — a plugin throws, and that is what once made every note
with a query in it unopenable); gives the source back when the cursor goes in;
reveals nothing in reading view; asks for each answer exactly once and shows a
placeholder meanwhile; and draws the error frame — including for a transform
that throws while rendering, which costs its own block and nothing else.

A transform supplies the rest:

name the cache key, the wire name, and the CSS class of its frame
languages the fence info strings it claims
request(sources, view) "these have no answer yet" — fire and forget
render(value, ctx) draw one answer
placeholder, extension the waiting text, and a theme

Omitting request means the source is the value and the block draws
immediately. Answers arrive one way only — the setBlockResult effect —
whoever computed them, a promise inside the bundle or the engine in Rust.
Registering one is a line in the list in js/editor.js; the Rust side of that
registry is a single match on the transform name in app/mod.rs.

Diagrams

A block tagged mermaid is replaced by the drawing:

```mermaid
graph TD
    A[Note] --> B[Pipeline]
    B --> C[Diagram]
```

sample-workspace/diagrams.md has a few, including one that is deliberately
malformed so you can see an error stay inside its own block. A test parses every
mermaid block in the vault with mermaid itself — the same gate the dashboard
queries get, and for the same reason: the first version of that file shipped
with a diagram whose edge labels contained backticks, which mermaid cannot lex,
and nothing caught it until it was on screen. The broken one is marked
%% noita:expect-error, and the test insists it still fails.

Mermaid is not in the editor bundle. It is about five megabytes bundled —
four times the whole editor — and most notes contain no diagram, so it is built
as its own asset (npm run build:mermaid) and fetched the first time a diagram
actually appears. Rust mints the hashed path and sends it over the bridge at
startup as set_assets; the transform injects the script tag on demand and
caches the promise, so two diagrams in one note share one download and a
failed fetch is retried by the next note rather than remembered for ever.

Diagram text is the note's own, so mermaid runs at its strict security level,
which strips scripts and event handlers from labels. The SVG it returns is the
only markup this renderer ever inserts as markup.

Queries

Notes are one kind of local material; the data in them is another. A fenced
block tagged dataview is one of the transforms above, and is replaced by the
table it describes:

```dataview
TABLE status, due
FROM "projects"
WHERE status != "done"
SORT due ASC
LIMIT 20
```

Put the cursor inside and the query comes back as editable text; move away and
it renders again. That is the same bargain live preview makes with every other
piece of Markdown syntax — the buffer always contains the fence, and the
decoration only hides it.

sample-workspace/Dashboard.md demonstrates the lot, and a test runs every
query in it so the demo cannot quietly rot.

The language

Dataview's, as far as it goes:

TABLE a, b AS "Header" columns, optionally renamed
TABLE WITHOUT ID … drop the implicit file-link column
LIST / LIST field one line per result
FROM "folder" / FROM #tag combine with and / or
WHERE … = != < <= > >=, and or not, contains(a, b)
SORT key [ASC|DESC], … several keys, in order
GROUP BY key · LIMIT n
TABLE length(rows), sum(rows.x) … GROUP BY key one row per group: length/count, sum, average, min, max, distinct over rows or rows.field, and rows.field for every value; LIMIT then counts groups
… GROUP BY key SORT length(rows) DESC a SORT after GROUP BY orders the groups: by key, a column or its AS header, or length(rows) and the like, shown or not; a plain field there still orders the rows

Records come from files: Markdown frontmatter, and JSON, TOML, YAML, CSV and TSV
beside it (see tables).
A file holding an array becomes one record per element. Fields are addressed by
dot-path (author.name), alongside the implicit ones — file.name, file.path,
file.folder, file.mtime, and md.tasks_open and friends for Markdown.
file.gday and file.gcday are the days of the last and first commit that
touched the file. file.date is the date the file's published page carries,
from the sources [publish] dates lists (frontmatter updated/date, the
dates file, the last commit, the modification time), so a "recently updated"
list means the same in CI, where every file's mtime is the checkout's.

Where the language stops, it stops loudly. An unsupported clause is an error
naming the clause, never a quietly ignored line that returns confidently wrong
rows. And it is not a wall — WHERE jq: hands the predicate straight to the
engine:

```dataview
TABLE WITHOUT ID file.link, md.tasks_open AS "Open"
WHERE jq: (.md.tasks_open // 0) > 0
```

A query view — + → New query — is a tab for working on one, with the same
language and the same engine. ⌘/Ctrl-Enter runs it.

How it is put together

The engine is JSAW, consumed as published.
jsaw-core walks and parses files and evaluates filters; its FileRecord
already exposes the implicit fields Dataview does, and its mtime-keyed cache
re-parses a file only when it actually changed.

The DQL translation is noita's, and deliberately not jsaw's. Dataview's
language is a compatibility surface with a successor already announced in
Obsidian's Bases; making a general structured-data engine responsible for one
editor's syntax — and, in time, its replacement — would put that cost in the
wrong place. So:

domain/query/mod.rs      Query — a neutral question. Names no query language.
domain/query/dql.rs      one front-end: Dataview's language → Query
domain/query/engine.rs   the back-end: Query → jq filters and jsaw records

A Bases-style front-end is a new module beside dql that produces the same
Query, and neither the engine nor jsaw has to know it happened.

Queries run on the engine's own thread, with its own multi-threaded runtime.
jsaw's query layer is async and uses block_in_place, and neither may run on
the thread painting the window: a slow vault must not stall a keystroke. Both
surfaces share one engine, and therefore one parse cache per vault, so a note
with six query blocks reads each file once. Each vault's cache is kept in its
own .noita/local/query-cache.bin between runs (see
search).

Values reach the renderer as typed cells rather than as HTML. A link stays a
link — clickable, and it opens the file — and nothing a query returns is ever
interpreted as markup. Query text is escaped into jq as string literals, so a
value containing a quote is data, not program.

Where queries are not available

Android/iOS only, and by choice: jsaw pulls reqwest and a TLS stack
transitively, and a mobile build has no reason to grow that tree to draw a
table. domain::query::engine is cfg-ed out there, and the UI says queries
are unavailable rather than offering a button that does nothing.

Every desktop target has them, mingw included. That took a change in jsaw:
reqwest's rustls feature selects the aws-lc-rs provider, whose vendored C
sources #include <sched.h>, which mingw does not have — so anything depending
on jsaw-core failed outright for x86_64-pc-windows-gnu. jsaw now asks for
rustls-no-provider and installs ring itself, which drops aws-lc-sys from the
graph. The rev pinned in Cargo.toml is the one carrying that fix.

Because jsaw is a git dependency, nix/app.nix has to name a hash for it —
importCargoLock has no registry checksum to fall back on, and the same is true
of jsonfilter, which jsaw pulls from git in turn. Those hashes cover the
fetched source tree at the pinned revision, so bumping the rev means
updating the hash
: set it to lib.fakeHash, build, and take the hash the
error reports.

Reading and editing

Three modes, Obsidian's three, and Ctrl-E moves between the two halves:

Reading The document, rendered. Click, select, follow links, scroll — nothing you press changes it, and no syntax appears under the cursor.
Live preview Editing. Markdown syntax is decorated away until the cursor reaches it, then reappears so it can be edited.
Source Editing. All of it, exactly as it is on disk, with line numbers.

Ctrl-E toggles reading against whichever editing mode you were last in — someone
who works in source is not dropped into live preview every time they read
something. The header's <> button switches between the two editing modes and
is only offered while editing: in reading view there is no syntax to show.

Reading is deliberately not "live preview that happens to be read-only". Live
preview reveals the syntax under the cursor so you can edit it, which is exactly
what someone reading does not want — the text would shift as they clicked
through it. So reading reveals nothing, and the caret goes away rather than
sitting in a document that would refuse it.

The mode is remembered per vault, in .noita/local/, and restored when you
reopen it.

A block of HTML in a note (a <table>, a <div>, <details>) is drawn as
what it says in reading view and live preview, and gives its source back when
the cursor goes in, like a Markdown table. Only the elements and attributes
prose uses are drawn: no script, style, frame, form, handler or javascript:
link, whatever the note holds. The Markdown between its tags is read (escapes,
code, emphasis, links, bullets), and a block that blank lines split into
pieces, the way a converter writes a table with a paragraph per cell, is one
block from its opening tag to the one that closes it. Inline <kbd>, <mark>,
<sup> and the like draw as what they say.

What makes live preview feel smooth

Hidden syntax is atomic: the ** around a word, the # on a heading, the
[[ of a wiki-link are one unit as far as the cursor is concerned. Without
that, moving through a decorated line means pressing an arrow key several times
to cross marks you cannot see — the cursor lands inside a zero-width range and
appears to stick. With it, the caret glides over them in one press, and
selection does the same.

Reveal is per line for block syntax (headings, quotes, bullets) and per element
for inline marks, so putting the cursor in a bold word shows only that word's
asterisks rather than the whole paragraph's. A wiki-link reveals per line, and
reveals whole: the Markdown grammar reads the inside of [[notes]] as a
shortcut link and would hide its own pair of brackets, which would leave
[notes] — a link half opened up, neither readable nor editable.

An aliased link reads as its alias. [[design/views|the views]] shows "the
views" until the cursor reaches the line, at which point the address it stands
for comes back so it can be corrected. [[a|]] and [[|b]] are left alone:
hiding either half would leave a link with nothing to read, or one that goes
nowhere looking exactly like one that goes somewhere.

Inside a code fence or a code span, a link is left alone entirely — brackets and
all. An example of a link that renders as the link is no longer an example of
anything, and the editor does not treat that one as followable either, which is
the same answer the links panel gives about mentions.

Working with files

Right-click anything in the file tree — a note, a folder, or the empty space
below the last row, which means the vault itself:

note folder empty space
New note / New folder beside it inside it at the vault root
Refresh ✓ ✓ ✓
Rename ✓ ✓
Move to… ✓ with everything in it
Delete ✓ with everything in it

Renaming happens in the row itself rather than in a dialog: the surrounding
names are the context you need to pick a good one, and there is nothing to
dismiss afterwards. Enter accepts, Escape cancels, clicking away accepts. A new
note opens already in that state, so creating and naming are one gesture.

A rename carries everything that pointed at the old path — the tab keeps its
identity and its unsaved changes, and an autosave already in flight writes to
the new name rather than recreating the old one. Renaming a folder does the same
for every note open inside it.

Moving is a drag in the tree: a row let go over a folder goes into it, over a
file into that file's folder, and over the empty space or the header to the
top of the vault (or of the burrow). Move to… picks a folder by typing, and
a path typed while renaming (archive/old ideas, /inbox/x, ../x) moves
too. A move carries tabs, unsaved changes and links as a rename does; see
moving files.

Deleting asks first by default and names what will be removed. [trash] chooses
the destination: deleted_files = "system" (the default), "vault" for the
vault's .trash/, or "permanent". System trash is unavailable on mobile;
Noita reports that rather than silently deleting permanently. confirm = false
disables the ordinary confirmation; orphan-attachment questions can still ask.
A note with unsaved changes is not written out on the way past — closing a tab
normally saves it, but a file being deleted should not be.

The Trash tab (the vault's menu in the tree, or Open Trash in the
palette) lists what this vault deleted, from .trash/ and, on Linux and
Windows, the system trash: restore an item to where it was, with the
attachments deleted alongside it, or delete it for good. Restore last
deleted
undoes the last delete. The vault trash records where each item came
from in .trash/.trash.toml; see the trash.

Names are checked against the most restrictive of the platforms a vault might
travel to, not against the one you are on. re:view is a legal filename on
Linux and an unusable one on Windows, so it is refused everywhere; the point of
a vault is that it is an ordinary folder someone else can check out.

Every right-click menu — the file tree's, a tag's, a table's, a canvas card's,
a PDF page's — opens fully inside the window. It opens at the pointer when it
fits there, upward from it near the bottom edge and leftward near the right
one. A menu taller than the window scrolls. ↑/↓ move between its items
(wrapping), Home/End jump to the ends, Enter or Space chooses, and Escape
closes it. Resizing the window closes an open menu.

Edits made outside Noita

A vault is an ordinary folder, so other programs change it: another editor,
git pull, a formatter, a CLI in the built-in terminal. Noita watches the vault
and follows them. A note created, deleted or renamed elsewhere appears in the
tree, and links and tags follow, without a manual refresh.

What happens to an open note depends on whether it has unsaved edits:

  • No unsaved edits: it reloads in place. The cursor, scroll position and
    undo history stay where they were.
  • Unsaved edits: the tab is marked ! and a banner offers Keep my
    version
    (write yours over disk) or Use disk version (discard your
    edits). Until you choose, nothing is written: autosave pauses, and ⌘/Ctrl-S
    does not pick a side for you. Nothing is merged automatically.
  • Deleted on disk: the tab stays open with its text, its title struck
    through. Autosave does not bring the file back; Save to restore does.

The watcher is only a hint to look again. Whether a file changed is decided by
comparing its content with what Noita last read or wrote, and every save checks
that first. So a lost notification costs only time: the change is still found
when the window regains focus, on Refresh, or by the next save, which refuses
rather than overwrites.

Git

If the vault is in a Git repository, Noita shows where it stands. It does not
need one: a vault that is not a repository, a machine without git, and a vault
with [git] enabled = false look exactly as they did before. Noita runs your own
git (2.31 or later) and reads what it says. It keeps no Git data of its own, so
hooks, signing, credential helpers, pull.rebase, LFS and .gitignore behave as
they do in a terminal.

  • The tree marks each changed note (M modified, A added, D deleted, R
    renamed, U untracked, ! conflicted) and dims ignored ones. A folder with a
    change anywhere inside it gets a dot, collapsed or not.
  • Tabs carry the same letter beside the unsaved-edit dot.
  • The status bar shows the branch (or the commit, when HEAD is detached),
    ↑/↓ against its upstream, and a merge or rebase in progress. Click it for
    the Git panel.
  • The editor margin marks lines added, changed or deleted since the last
    commit, as you type. Click a mark for the diff. [git] gutter = false turns
    the marks off.
  • Open changes (from the tree, a tab, or the panel) opens a diff tab,
    side by side or unified. It compares the last commit, the staged version or
    the working version — which includes unsaved edits, and says so. Binary,
    very large and LFS files say what they are instead.
  • Compare with… (from a file's menu or the palette) opens the same diff
    tab on any two files, Git or not: pick the other in the quick switcher, or
    Select for compare one file and Compare with it from another.
  • The Git panel (ribbon) lists conflicts, staged, unstaged and untracked
    files. It stages, unstages, discards (after asking), commits, and fetches,
    pulls and pushes. A folder that is not a repository is offered
    Initialize repository.
  • Show history (on a note, a folder, the empty space below the tree, a tab,
    or the panel's clock) lists the commits behind it, newest first, following a
    note across renames. Each commit shows who made it and how. Agents and bots
    are marked as such, and a commit no person committed is flagged. Filter by
    agent involvement, by kind of commit, or by one actor. Open a commit to see
    its message, trailers and changed files, or compare an old version of a note
    with the current one.

Status follows on its own: after saves, after outside changes, after anything
that moves Git's index or refs — a commit in the terminal included — and when the
window regains focus. It runs off the UI thread, and a burst of changes costs one
status, not one per file.

Opening a vault does not run anything the repository configured. A vault can
arrive as a folder from someone else, .git/config included, and git status
would otherwise run its core.fsmonitor hook or a clean filter. The status and
diff reads Noita starts itself switch those off, and take no locks that could
get in the way of your own git. Hooks and the network are only reached when
you click Commit, Fetch, Pull or Push. None of those can prompt for a password
on a terminal you cannot see: without a credential helper or an agent, they
fail with Git's message instead of hanging.

People, agents and bots

History reads identities straight from Git: author, committer, and any
Co-authored-by: trailers, with your .mailmap applied, so one person under
two addresses shows up once. Whether an identity is a person, a coding agent or
a bot comes from rules. A few are built in (Claude, Copilot, GitHub [bot]
accounts), and the vault or your own configuration can add more. The vault's
rules are checked first:

[[git.actor]]
email = "agent@example.com"   # or email-glob = "*@agents.example.com", or name = "…"
kind = "agent"                # human, agent, or bot
display = "Our agent"         # optional

A commit's kind follows from its actors: committed by agent when no person
committed it, agent-authored when an agent wrote it and a person committed
it, agent-assisted when an agent is a co-author, and otherwise human. These
are labels, not proof: Git lets anyone write any email. An agent that commits
under your own identity, with no trailer, cannot be told apart from you.

Actors show their picture when one can be found: the GitHub avatar for a GitHub
noreply address, the avatar on the repository's own forge (Gitea, Forgejo — taken
from the origin remote), or Gravatar. Each of those learns a hash of the
author's email. Initials stay underneath, for anyone with no picture and when
offline. [git] avatars = false sends nothing.

Search and replace

Ctrl/⌘-F opens search in the sidebar instead of laying a temporary panel over
the document. It can search the current note or every Markdown file in the
vault, shows the file, line, column and line preview for each match, and selects
the exact match when opened. Current-note search reads Rust's live buffer, so it
also sees text that is still waiting for autosave.

Replacement is deliberately limited to Replace all in note for now. It is a
single CodeMirror transaction, so it is undoable and follows the normal autosave
path. Workspace-wide replacement stays disabled until Noita can present a
review and recover partial write failures; silently rewriting a vault from a
search box would be a poor trade for completeness.

Selecting ordinary text no longer highlights every equal word in the document.
That was CodeMirror's selection-match feature, not an active search, and made a
closed search panel appear to remain active.

A link is written as ordinary text — [[architecture#Decisions|why we chose it]]
is three parts in one pair of brackets: the note, the heading inside it, and
the words a reader sees. Ctrl/⌘-click follows one; on a phone, where there is no
modifier key, a long press does.

How a target is resolved depends on whether it spells out a folder:

written means
[[views]] a note called views, anywhere in the vault
[[design/views]] that one file, vault-relative
[[views#Open Questions]] a heading inside it
[[views#^a1b2]] the block that id is written beside
[[#Open Questions]] a heading in the note you are writing in
[[design/views|the views]] reads as "the views"

A bare name can be answered by more than one note, and that is not an error to
refuse: the nearest one opens — a neighbour in the same folder first, then the
shallowest — and the status bar says how many others answer to it. A name
nothing answers says so too. Resolution is a lookup against the enumerated file
list rather than a path join, so [[../../etc/passwd]] matches nothing: nothing
outside the vault was ever enumerated.

Markdown's own links are links too: [ripgrep](../cli/ripgrep.md) and
![diagram](pics/flow.png) are read as a path from the note they are written
in, so a vault written for Quartz, GitHub or any Markdown editor has backlinks,
a graph and broken-link warnings without being rewritten into wiki-links. Links
to the web are not vault links. Renaming a note rewrites its wiki-links; its
Markdown links are not rewritten yet.

Headings are matched the way a reader would match them — ignoring case and how
many spaces are between words — and following one selects the heading line, so
the note arrives scrolled to it rather than at the top. ATX headings
(## Decisions) only: the live preview decorates ATX, and addressing a form
the editor does not draw would be a promise it cannot keep. An address that does not
land opens the note anyway and says the heading is not there, which beats
leaving you wondering why nothing moved.

Ctrl/⌘-Shift-L, or the chain in the ribbon, opens the links panel for the
note you are in. It answers two questions at once, because both are about the
same note and read in the same glance:

  • Linked mentions — every link in the vault that lands here, grouped by the
    note it was written in, each row opening that note with the link selected.
    Unsaved buffers are read along with the files, so a mention you just typed
    counts. Links inside code fences and inline code do not: a [[link]] in an
    example is text about a link, the editor does not decorate it, and clicking
    it does nothing — a row for it would point at something the reader cannot
    find.
  • Unresolved — this note's own links that no file answers, each offering to
    Create the note it names. A link to a note that does not exist is not a
    mistake; it is the usual way of deciding that one should. The new note lands
    beside the one that linked to it, unless the target spelled out a folder of
    its own.

There is no link index, and so nothing that can be stale. The mentions are a
plain scan, recomputed when the active note changes or the tree is refreshed,
and capped at 200 rows for the same reason search is — a panel nobody can read
is not worth the walk. The unresolved half is recomputed as you type: it
describes the buffer in front of you, which is one note to read.

Addressing a block

A heading is a coarse address: everything under it moves when the prose does,
and the heading itself cannot be renamed without breaking every link into it. A
block — a paragraph, a list item, a table, a fenced block — is addressed by an
id written into the file beside it:

The decision was to keep the id in the file. ^a1b2

Ctrl/⌘-Shift-B, or Copy Block Address in the Edit menu, puts
[[note#^a1b2]] on the clipboard for the block the cursor is in — writing the
id in first if that block has none. That is the whole of "on demand": an id
reaches a file because someone asked for the address of a block, and never
otherwise. A vault is not stamped with ids nobody links to, because an id in a
sentence nobody addresses is noise in prose someone wrote.

The id is derived from the block's own text rather than drawn at random, so the
same block asked twice gives the same answer and nothing here needs a source of
randomness. Four base-36 digits, which is 1.6 million per file; a collision
inside one file is walked past rather than written, because two blocks answering
to one address is the single outcome an id must not have.

Where the id goes depends on what the block can hold. A paragraph or a list item
takes it at the end of its last line. A table row cannot — a trailing ^a1b2
would become another cell — and neither can a heading, where it would become
part of the heading's own text, nor a fenced block, whose last line is its
closing fence. Those take the id on the line under them, which is also read
back. Both forms are read wherever they are found, because a vault is edited by
other hands too.

Following [[note#^a1b2]] selects the block, not the id: the paragraph, the
whole list item, or the fence from its opening line to its closing one. An
address that does not land says the note has no such block, the same way a
heading address that misses says so. Live preview draws an id the way it draws a
link's brackets — hidden on a line nobody is editing, revealed on the live one —
because an id is an address rather than something to read. An ^a1b2 inside a
fence or a code span is an example of an id, and not one, on the same terms as a
[[link]] written there.

The insertion goes back through the editor rather than being written to disk, so
giving a block an id is one step of undo in the note in front of you, the cursor
stays where it was, and the change arrives back in Rust through the same path as
anything typed.

Rename ideas to plans and every link that led to it is written to say so,
across the vault and in the notes you have open but not saved. Only the target
changes: [[ideas#Open|the early sketches]] becomes
[[plans#Open|the early sketches]], because a heading lives inside the file and
an alias is your own words — a rename has no business editing a sentence you
wrote. Links nobody asked to move are copied through character for character, so
a rename never reflows a note it happened to visit.

Each link is put back in the form it was written in. A bare [[ideas]] stays a
bare name, [[notes/ideas]] stays a path, and [[notes/ideas.md]] keeps its
suffix. Then the new address is checked: it is resolved back from the note it
is written in, and only written if it lands on the renamed file. A name that is
correct about the tree is not always the name that wins from a particular note —
rename one/ideas while a plans already sits beside the note doing the
linking, and [[plans]] would reach the wrong one. The address widens until one
resolves, ending at the path, which can only mean one file.

If nothing resolves, the link is left exactly as it was and the status bar says
how many were left. Renaming ideas.md to plans.txt does that: a .txt is not
enumerated as a note, so no address reaches it. A link still naming the old file
is a broken link you can see and fix. One quietly pointing at a different note
is not, and that is the trade this chooses every time.

Renaming a folder is the same operation over everything inside it. Paths through
it move — [[notes/ideas]] becomes [[archive/ideas]] — and bare names do not
need to, because renaming a folder does not rename the notes in it. A note inside
the folder has its own links readdressed along with the rest.

One case is deliberately left alone. If two notes answer a bare name and the
nearer one is renamed away, the name now reaches the other note. Nothing that
pointed at the renamed file changed, so nothing is rewritten — the link's own
text is still exactly what its writer typed, and it still resolves. It simply
resolves somewhere else, as it would have if the two notes had always been
named that way.

Tags

A tag is written one of two ways, and noita reads both:

---
tags: [work, project/noita]      # or a bare `tags: work`
---

Filed inline, in the prose itself: #active

The hash is how a tag is written in a body, not part of its name, so #work in
a paragraph and work in frontmatter are one tag. Nothing else about the text
is interpreted — tags: work home is one tag whose name has a space in it,
because that is what a query matching it would find.

The tag button in the ribbon lists every tag in the vault with how many
files carry it, most-used first, with a filter box for the long tail. Clicking
one opens it as a query:

TABLE file.mtime AS "Modified" FROM #work
SORT file.mtime DESC

That is the whole design of the panel. Browsing a tag and querying it are the
same machinery seen from two ends: the list is read by the jsaw engine that
answers dataview blocks and query tabs, and a row hands its tag back as a
query you can then sort, narrow, or paste into a note as a live block. A
bespoke file list in the sidebar would have been a second result view that could
do none of that, with a second definition of what carrying a tag means — and
two definitions of that is how a count comes to disagree with the thing it
counts. Tags a bare word cannot spell are quoted in the query — FROM #"two words" — so every tag the panel lists is a tag a query can name.

The count is files rather than mentions: a CSV of a thousand rows sitting beside
a tagged note is one tagged file. The list is re-read whenever the panel opens
and on Refresh; jsaw caches parses by modification time, so re-reading an
unchanged vault is cheap, and a list that cannot be stale beats one that has to
be invalidated.

A tag in prose is also the way into its own query. The editor marks one, and
Ctrl/⌘-click follows it — or a long press, on a phone — exactly as it does a
wiki-link, which lands you in the same query tab the panel's row would open. The
hash stays visible: it is how a tag is written rather than part of its name, and
hiding it would leave a word reading as prose until the pointer happened to
cross it.

What the editor marks is narrower than what a query would find. A # glued to a
preceding word is a URL fragment, not a tag (page#section); a name of nothing
but digits is a number (issue #42); the # of [[note#heading]] addresses a
heading and belongs to the link it is written in; a tag inside a code fence or a
code span is an example of a tag; and a # in frontmatter is neither — tags:
there is a list of names, so tags: [#work] is the tag #work, hash included,
which FROM #work does not match. A name also ends where a word does, so an
inline #project/noita is the tag project; nesting is written in frontmatter,
where the whole string is the name.

Every one of those narrowings goes the same way: a tag the editor marks is a tag
the panel counts, never the reverse. That is the direction to be wrong in. A
marked tag whose query came back empty would be a promise the vault does not
keep, whereas a tag the panel counts and the prose does not mark is a count that
is honestly too high — which the panel already is, for a reason it inherits:

jsaw reads a note's inline tags by the same rules the editor marks them with
(no tag after a word character, none that is all digits, none inside code), so
the panel's count, the query a row opens (FROM #work) and the editor agree: a
FROM #work inside a dataview block, a page.html#section or an issue #42
is not a tag anywhere.

Settings and workspace state

Three files, none of them required, in increasing order of specificity:

~/.config/noita/config.toml        your defaults, for every vault
<vault>/.noita/config.toml         the vault's own — meant to be committed
<vault>/.noita/local/state.toml    this machine's state — never committed

The split is the point, and it is the roadmap's "separate shareable project
configuration from machine-specific and private state" made concrete. The first
two are settings: preferences someone chose, which a project may reasonably
want to travel with. The third is state: which tabs were open, which folders
were collapsed, whether the sidebar was showing — true of one checkout on one
machine and nobody else's business. .noita/local/ gets a .gitignore written
into it the first time anything is saved there, so sharing a vault cannot
accidentally share a window layout.

Later files override earlier ones field by field, so a vault overrides only what
it names. Lists are the exception — hidden and terminal.profiles accumulate
— so a vault adds to your defaults rather than replacing them. A file that does
not exist is not an error; one that does not parse is reported in the status bar
and in Settings, and defaults are used until it is fixed.

Open Settings from the ribbon to see where each file lives, what everything
resolved to, which profiles were detected, and whether Git integration is on
(and if not, why). Rendered prose keeps the existing
700px book-like width by default; set editor.content_width_px = 0 for a fluid
page, or another pixel width for a wider/narrower reading column. Source mode
always uses the available width, compact spacing, gutters, and monospace text —
it behaves as a code editor rather than inheriting the reading-page layout.
[view] decides what surrounds a note: inline_title = false stops drawing
the file name above it, sidebar opens the vault with the sidebar shown or
hidden (unset, it stays as this machine left it), and [view.tools] turns
ribbon buttons off one by one — map = false, calendar = false — while their
shortcuts keep working. tool_order = ["tasks", "calendar"] puts those
buttons first, in that order; the ones it leaves out follow in their usual
order, so a tool added later still appears. On the settings page the order is
a row of chips to drag, or to move with Alt and an arrow key. The vault's
settings page applies each change as it saves it.

[soundtrack] gives a vault its own sound, by folder or for all of it — rain
for the journal, something to focus to for a project:

[soundtrack]
volume = 0.5

[[soundtrack.track]]
sound = "audio/lofi.mp3"       # no files: every note no other track claims

[[soundtrack.track]]
files = ["journal"]            # globs; a folder matches everything in it
sound = "audio/rain.ogg"
volume = 0.6                   # times the soundtrack's volume

The open note's track plays, looped; notes that share it do not restart it, and
it keeps playing while a terminal or graph is in front. The status bar names
the track — click it to pause. Tracks come from the vault's own file only, and
enabled = false in your own defaults silences every vault, whatever it says.

The Settings overview deliberately does not edit settings directly (the
structured config.toml editor does): ROADMAP.md lists "how much customization can happen through the UI
before scripting is needed" as an open product decision, and a half-designed
form writing TOML back would decide it by accident. What it does offer is to
create the vault's config.toml from a template with every option written out
as a comment — the part that is genuinely hard to guess — and open it for
editing. Reload rereads the files, so configuring Noita inside Noita does
not need a restart.

Appearance

[appearance] sets how the window looks. Saving a config.toml applies it
at once; no Reload needed.

[appearance]
scheme = "light"          # "dark" (default), "light", or "system" to follow the desktop
accent = "#22aa88"        # links, tags, the active tab, selections
font = "Fira Sans"        # notes and interface; a comma list is tried in order
font_monospace = "JetBrains Mono"
font_size = 17            # notes' text, in px (headings scale with it)
ui_font_size = 13         # file tree, tabs, menus, in px
transparency = true       # desktop shows through ribbon, sidebar and top bar
opacity = 50              # how much of the chrome's colour is kept, in percent
blur = true               # ask the desktop to blur what shows through
blur_material = "sidebar" # macOS: sidebar, window, under-window, hud, popover, menu

The light scheme is the one published sites use: the chrome reads --base-*
variables, and the editor's injected styles are rewritten as they arrive
(js/theme.js) to read the variables domain/colors.rs names, so one palette
recolours both. Terminals follow the scheme too, with ANSI colours that
stay readable on a light background.

With transparency on, the notes stay opaque and everything around them lets
the desktop through, blurred where the desktop can blur it:

  • macOS: the chrome is drawn over the system's own material, the frosted
    backing of Finder's and Mail's sidebars, light or dark with Noita's scheme.
    blur_material picks which one: sidebar (the default), window,
    under-window, hud, popover or menu. Any other value is reported and
    the sidebar material is used. Other platforms ignore the key.
  • KDE Plasma: KWin blurs it. Noita asks through the Plasma blur protocol on
    Wayland and _KDE_NET_WM_BLUR_BEHIND_REGION on X11.
  • Hyprland and picom blur translucent windows by their own rules, whatever
    Noita asks.
  • GNOME has no way for a window to ask, so it is translucent without blur.

blur = false stops Noita asking: no material on macOS, no request to KWin,
and the chrome stays translucent and sharp. opacity is how much of the
chrome's own colour is kept over the desktop, 10 to 100, 50 by default. On the
config.toml page it is a slider that redraws the chrome as you drag it and
saves when you let go; it is disabled while the window is not translucent.
blur, blur_material and opacity apply at once. A window can only be made
transparent as it opens, so turning transparency on takes a restart the
first time; turning it off is immediate. It belongs in your own
~/.config/noita/config.toml more than in a shared vault's.

Themes, snippets and tokens

A theme is a file named in [appearance] theme. Noita looks for
<name>.toml, then <name>.css, in the current burrow's .noita/themes/,
each enclosing burrow's, the vault's .noita/themes/, then themes/ beside
your own config.toml, and last among its own (nord, solarized).

# .noita/themes/paper.toml: tokens only, for both schemes or one.
[tokens]
radius-m = "10px"
line-height = 1.6
[dark]
base-00 = "#1b1d23"
accent = "#e0a050"
[light]
base-00 = "#fbf8f1"
accent = "#a6681a"

A .css theme is any CSS; <html> carries theme-dark or theme-light for
the scheme in effect, so .theme-light .cm-content { … } styles one scheme.
The public tokens a theme may set, and the only ones that stay stable, are
--base-00, --base-10, --base-20, --base-25, --base-30, --base-40,
--base-50, --base-70, --base-100, --heading, --accent,
--accent-hover, --danger, --font-text, --font-mono,
--font-text-size, --font-ui, --line-height, --radius-s and
--radius-m. While a theme is on, notes' backgrounds, borders and greys
follow the base ramp, so ten tokens recolour the notes as well as the chrome.

Above the ramp sit semantic tokens, public and stable too: --surface,
--surface-raised, --fg, --fg-muted, --fg-faint, --line,
--on-accent, --success, --warning, --info, --focus-ring,
--selection, --font-ui-small, --radius-l, --shadow-s and
--shadow-m. The colours among them are worked out from the ramp and the
accent unless set, so a theme that sets only those still recolours buttons,
dialogs, menus and focus rings; set one to change only that.

Snippets written for Obsidian work where they read Obsidian's variables:
--background-primary, --background-secondary,
--background-modifier-border, --background-modifier-hover,
--text-normal, --text-muted, --text-faint, --text-accent,
--text-error, --text-on-accent, --interactive-accent,
--interactive-accent-hover, --font-interface and --font-monospace are
Noita's tokens under those names. The window's class names are not part of
this contract and may change between releases; style through the tokens
where you can.

The same tokens can be set without a file, and CSS snippets turned on by name:

[appearance.tokens]          # both schemes; [appearance.tokens.dark] / .light for one
radius-m = "12px"
[appearance.snippets]        # .noita/snippets/<name>.css, or snippets/ beside your config.toml
wide-tables = true

Styles apply in this order, later winning: Noita's own, the scheme, the
theme, accent and the fonts, [appearance.tokens], then the enabled
snippets in name order. Each layers per key like every other setting (user,
vault, burrows): a burrow can pick another theme or turn a snippet off.
The settings page of a config.toml offers the themes found as choices and
every snippet as a switch. Saving a theme or snippet in Noita, or one changing
in the vault, applies at once; your own folders apply at the next Reload
Settings.

A shared vault's CSS can restyle or hide anything in the window, including
what asks for your trust, so a vault's (or a burrow's) .css theme and
snippets wait until you allow them: Settings names what is held back and
offers Allow, remembered on this machine only in .noita/local/, as Mermaid
diagrams are. TOML themes, Noita's own themes and [appearance.tokens] apply
without asking, as do your own themes and snippets. Even allowed, a vault's
CSS may not load anything: @import rules and declarations with a url()
other than data: are removed, and Settings lists them under Styles as
blocked.

A configured font_size or ui_font_size wins over a theme's sizes; left at
the default, it does not.

noita export publishes the vault's own look: its theme, accent, fonts,
tokens and snippets, from the vault's config.toml and .noita/ only, in
both of the site's schemes. A site is the author's own, so it uses the
vault's CSS without asking.

Reopening a vault restores its editor tabs, collapsed folders and panel state.
Terminal tabs are deliberately not restored: a session belongs to the Noita run
that started it, so a restored terminal tab would be a tab with no process
behind it.

Icon

The app icon is the system wizard emoji on a violet plate — noita is Finnish
for witch, and the premise is that a plain folder of files can be made to do
surprising things.

python3 icon/build.py          # regenerate assets/icon.icns from the art
python3 icon/windows.py        # and assets/icon.ico, for the .exe
dx bundle --desktop            # a bundle with the icon installed

The glyph is rendered through CoreText by icon/emoji.swift, because 🧙🏼‍♂️ is a
ZWJ sequence with a skin-tone modifier and Pillow has no text shaping here.
Sizes at or below 32px are rendered at their own scale rather than downscaled:
below that the emoji's detail is gone and only its silhouette survives, so the
figure is drawn larger and the glow is dropped. dx build writes the plist
reference but not the file, so icon/build.py also drops the .icns into an
existing dev bundle.

icon/windows.py writes the .ico the same way — one render per size rather
than one image resized seven times. build.py needs macOS for iconutil and
the CoreText render; windows.py needs only Pillow, because the glyph it starts
from is the committed icon/wizard-raw.png.

Layout

src/
  main.rs        window + workspace root resolution
  platform.rs    desktop/mobile seams, workspace root resolution (+ tests)

  domain/        what noita is, with no idea how it is shown — no Dioxus
    session.rs   open views and tabs — no filesystem, no editor (+ tests)
    workspace.rs filesystem authority — no editor, no Dioxus (+ tests)
    terminal.rs  terminal resource identity and protocol types
    terminal/
      manager.rs    app-owned sessions, credited protocol, bounded shutdown
      profile.rs    which shells this machine offers (+ tests)
      unix.rs       the macOS/Linux PTY worker (+ tests)
      windows.rs    the ConPTY worker

  app/           what it does, and what it remembers
    state.rs     the stores, and the policy that crosses them (+ tests)
    action.rs    every intent, and the one place each is resolved
    settings.rs  configuration, and shared vs machine-local state (+ tests)

  ui/            everything that renders
    shell.rs topbar.rs ribbon.rs sidebar.rs tabs.rs main_view.rs
    settings_panel.rs
    editor.rs    the editor boundary: EditorCommand / EditorEvent
    terminal_bridge.rs  credited terminal transport and Sessions UI
    icons.rs     inlined stroke icons
js/
  editor.js      the only file that knows CodeMirror exists
  live-preview.js  Markdown syntax hiding, as decorations
  pipeline.js    blocks that draw themselves: one registry, one mechanism
  transform-dataview.js  a query fence, replaced by its table
  transform-mermaid.js   a mermaid fence, replaced by the drawing
  transform-properties.js  frontmatter, as a panel of real properties
  frontmatter.js   parsing and editing YAML frontmatter, as pure functions
  mermaid-bundle.js  its own entry point; see "Blocks that draw themselves"
  terminal.js    retained xterm screens
  terminal-protocol.js  bounded input and parser acknowledgments
assets/
  app.css        application chrome
  editor/editor.js  built bundle (generated by build.rs / npm run build)
  mermaid/mermaid.js  fetched on demand, never at startup
  terminal/        generated terminal JS and CSS
sample-workspace/  ordinary Markdown files

The dependency direction is strictly one-way:

domain/  ←──  app/  ←──  ui/  →  editor.js  →  CodeMirror
              ▲           │
              └── Action ─┘

domain/ imports no Dioxus at all — that is what makes the session, workspace,
profile and PTY tests real tests rather than component pokes. app/ owns state
and policy and does not know CodeMirror exists. ui/ renders and sends
Actions; it never calls a state method directly and never touches the
filesystem or a process. editor.js does not know files exist.

Why Action exists

docs/vision.md treats actions as a first-class piece of the model: buttons,
shortcuts and scripts should be able to invoke an operation without being the
operation. app/action.rs is that list. A component that wants something to
happen names it:

let mut send = use_dispatch();
// ...
onclick: move |_| send(Action::NewTerminal { profile: Some(id.clone()) }),

Which buys three things beyond tidiness. A command palette and a keybinding map
become tables of Action, not a second copy of every handler. A vault-declared
button — the thing phase 3 is actually about — has a vocabulary to be declared
in. And there is exactly one place that knows which changes are worth writing
to disk, so persistence cannot be forgotten in a new handler.

The view model

A tab is not an open file. It is an open view of a resource, and the
two are separate axes. A file can be shown as an editor, image, spreadsheet or
slide deck; resources also include terminals, queries, graphs, browser sessions
and more. Resource, ViewKind and OpenView define
the current types; Current architecture maps their use.

Files and terminals illustrate this distinction: closing a file view saves it,
while closing a terminal view leaves the separately owned session running. Blank
tabs have no resource yet.

Four consequences fall out of that shape rather than being special-cased:

  • Opening a file that is already open focuses its tab instead of duplicating it.
  • Opening another note — from the file tree, search, the quick switcher, a
    link — shows it in the focused pane's clean note tab, in every mode, so
    clicking through notes does not accumulate tabs; that tab's Back and Forward
    return through what it showed. A dirty tab is never repointed (the note gets
    a new tab), and nor is a graph, canvas, query or other surface.
    Ctrl/⌘-click, middle-click and "Open in new tab" always add a tab, and
    [files] open_in_new_tab = true makes every open do so.
  • Two views of the same resource are distinguishable, because the kind is part
    of a tab's identity and not merely its label.
  • Dirtiness belongs to the resource, not the tab, so every view of a file agrees
    about whether it has unsaved content.

Session owns the tab list, focus, two tab groups, and navigation history.
Workspace layouts save named arrangements; arbitrary
nested pane trees remain future work. Session data has no I/O, which is why its
behaviour is unit-tested rather than click-tested.

Rust holds the buffers. Noita::buffers maps each open resource to its
authoritative text, updated on every change event. The editor is a projection of
that map. This is what makes switching tabs lossless: a tab switch does not
round-trip through disk, and an edit made 50 ms before switching is not lost to
the autosave debounce. Each file has an independent save ticket, so editing another
file cannot cancel its save. Closing a file tab flushes before dropping its buffer
(unless another view still shows it); a failed write preserves the tab and edits.
This does not yet provide a full bridge-ordering or quit-time recovery guarantee.

One editor, many tabs. All Editor views share the single CodeMirror
instance; switching tabs is a set_document message, not a remount. That is the
current limit of the design: two Editor views cannot be visible at once.
The current split layout therefore has one live pane and one resting pane;
two simultaneously editable panes still need a bridge per pane or a pane-aware
protocol. See workspace layouts.


Architecture report

Historical design notes from the original editor proof, not a current API
reference.
The examples and assessments below describe that smaller prototype;
the protocol and feature set have since expanded. In particular, Rust now owns
the buffers, named layouts and split groups exist, and the bridge has many more
commands and events. Use Current architecture, the
feature guides, and source types for present-day behavior.

How CodeMirror is mounted into Dioxus

Dioxus renders a single, permanent host node:

main { class: "editor-pane",
    div { id: "noita-editor-host", class: "editor-host" }
}

It is rendered unconditionally and never sits inside a conditional branch, so
Dioxus's diffing has no reason to recreate it. CodeMirror owns everything below
that node; Dioxus owns everything above it. Neither reaches across.

The bundle (assets/editor/editor.js, an esbuild IIFE) is injected with
document::Script, and it registers exactly one global:

window.__NOITA_EDITOR__ = new NoitaEditor();
// mount / setDocument / focus / setReadOnly / setPreview / setSink

Script injection and component mounting are not ordered relative to each other,
so the Rust-side bridge polls (25 ms × 400) for both the global and the host
node before wiring anything up. That removes a whole class of startup races.

How Rust → JS messages work

EditorHandle::spawn() runs document::eval once for the lifetime of the
app with a ~40-line driver script. The driver is the entire JS-in-Rust surface
of the project; there are no other JS string literals anywhere in src/.

The driver ends in a pump:

while (true) {
  const command = await dioxus.recv();
  if (!command || typeof command.cmd !== "string") break;   // channel closed
  switch (command.cmd) { case "set_document": … }
}

On the Rust side, commands are a serde enum with an internal tag, so the wire
format is defined in one place and typo-proof:

#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum EditorCommand {
    SetDocument { doc: DocId, text: String },
    Focus,
    SetReadOnly { value: bool },
    SetPreview { enabled: bool },
}

Eval is Copy, so EditorHandle is a Copy value that drops into event
handlers and async tasks without ceremony. Eval::send is synchronous, and the
channel queues messages that arrive before JS reaches its first recv(), so
callers never need to wait for readiness.

Application code only ever sees:

editor.set_document(doc, text);
editor.focus();
editor.set_read_only(true);
editor.set_preview(false);

set_preview is worth noting as a boundary example: the live-preview/source
toggle is a button in Dioxus and a signal in Rust; the editor merely applies
it. Mode is application state, not editor state.

How JS → Rust messages work

The driver installs a sink into the bundle:

editor.setSink((message) => dioxus.send(message));

Every message the editor emits funnels through one emit() method, so the wire
shape stays auditable from a single place. Messages emitted before the sink
exists — notably anything that fails during mount — are buffered and drained
when it arrives.

Rust reads the other end in one long-lived use_future task:

while let Some(event) = editor.next_event().await {
    match event {
        EditorEvent::Ready                   => open the first file,
        EditorEvent::Change { doc, text }    => update state + schedule autosave,
        EditorEvent::OpenWikiLink { target } => resolve through Workspace,
        EditorEvent::Error { message }       => log + show in the status bar,
    }
}

One producer, one consumer, one task. No polling, no re-entrancy. Events are
received as raw JSON and decoded in next_event, so a single malformed payload
is logged and skipped rather than tearing down the only channel the editor has.

The bundle also forwards window.onerror, unhandledrejection and
console.error through that same channel. CodeMirror catches exceptions thrown
inside view plugins and reports them with console.error, which in a packaged
desktop build goes nowhere visible; routing them to Rust puts them in the status
bar and the log instead of a console nobody opens.

How the feedback loop is avoided

Two mechanisms, one structural and one for ordering.

Structural. Whole-document replacement uses EditorView.setState() rather
than a transaction. Reading the CodeMirror source confirms setState installs a
fresh EditorState without constructing a ViewUpdate — update listeners are
not called at all. A Rust-driven document swap therefore cannot produce a change
event. It is impossible by construction rather than suppressed by a flag. As a
bonus, each file gets its own undo history, so undo can never reach back across
a file switch into another file's content.

A FromRust transaction annotation is still defined and applied to the
Rust-originated reconfigure commands. It is the hook any future incremental
Rust→editor patching must use; today it costs two lines and documents the
invariant.

Ordering. Every set_document carries a monotonic DocId that the editor
echoes back on every change. Rust keeps the last 8 generations mapped to paths.
If you type and immediately click another file, the in-flight change still
resolves to the file it came from instead of overwriting the newly opened one —
the failure mode that silently eats data in naive implementations. A change for
a generation that has aged out is dropped and logged rather than guessed at.

How editor lifetime is handled

One editor, one bridge, one eval, for the whole run. Switching files is a
message, not a remount — CodeMirror is never reconstructed. mount() is
idempotent and re-parents an existing view rather than rebuilding it, so a
hot-reload that restarts the bridge preserves the document and its history.

Where filesystem authority lives

Entirely in workspace.rs, which imports neither Dioxus nor anything editor-
shaped. The editor cannot read or write a file; it can only ask, and it asks
in domain terms ({"type":"open_wiki_link","target":"architecture"}) rather
than in path terms. Rust decides what a target resolves to.

That string is untrusted input from a text buffer, so resolution is a name
lookup against the enumerated file list rather than a path join, and read /
write additionally reject any path that resolves outside the workspace root.
Paths are canonicalised before that comparison, so neither .. segments nor
symlinks can step outside — and a path that merely spells the root differently
(/tmp vs /private/tmp on macOS) is still correctly recognised as inside it.
Those four properties are what cargo test covers.

Autosave debouncing lives in Rust too (400 ms). The editor reports what
changed; Rust decides when that reaches disk. That is the whole boundary in
one sentence.

The noita-specific experiments

Wiki-links. [[architecture]] is decorated by a MatchDecorator /
ViewPlugin pair; holding ⌘/Ctrl underlines it and switches the cursor to a
pointer, and ⌘/Ctrl-click sends open_wiki_link to Rust, which resolves it
through Workspace and opens the file through the exact same path a sidebar
click takes.

Live preview. Markdown syntax is decorated away rather than rewritten:
#, **, backticks, >, list markers and the [[ ]] brackets are replaced
with nothing until the cursor reaches that line or inline element, at which
point the raw text reappears and stays editable. Bullets become •, ---
becomes a rule, and - [ ] becomes a real checkbox whose click dispatches an
ordinary document edit — so it autosaves through the same change path as typing.

In both cases the file on disk stays ordinary Markdown. See [[architecture]] for details. is literally those bytes; the decoration exists only in the view.
Nothing is rewritten into proprietary markup, and any other editor sees plain
text.

This is the argument for CodeMirror in one sentence: the same buffer is
simultaneously rendered prose, a navigable graph, and plain text — and the
second and third properties cost about 300 lines between them.

Limitations encountered

  • Eval is not Send. Fine for the desktop's single-threaded renderer, but
    the bridge cannot be driven from a worker thread. Filesystem work that must
    happen off-thread has to hop back before touching the handle.
  • A closed eval channel resolves dioxus.recv() to undefined rather than
    rejecting, which makes while (true) a busy-loop on teardown. The driver
    checks for it explicitly.
  • No ordering guarantee between document::Script injection and component
    mount
    , hence the polling handshake.
  • Webview errors are invisible by default in a packaged build — there is no
    console. Forwarding them over the bridge was not optional; it was the only way
    to know whether the view plugins were throwing.
  • A vertical margin on a .cm-line silently breaks clicking. CodeMirror
    measures line heights with getBoundingClientRect(), which excludes margins
    and collapses adjacent ones, so its height map drifts from the real layout and
    clicks land on the following line. Heading spacing must be padding. The bundle
    now asserts the coordsAtPos → posAtCoords round-trip over the viewport on
    every document load and reports failures over the bridge, because this is a
    one-line theme change away at all times and invisible in a screenshot.
  • Assets require dx. asset!() paths are rewritten by the Dioxus CLI, so
    a bare cargo run will not resolve them.
  • A synchronous flush-before-switch is not expressible. The editor holds the
    authoritative text of the live buffer, and asking for it is a round-trip. The
    DocId scheme makes this safe rather than fast; a real implementation would
    keep Rust's buffer authoritative and treat the editor as a projection.
  • tokio::time::sleep for the debounce is desktop-specific and would need a
    platform shim for web.

Is this viable beyond desktop?

Desktop: yes. The boundary is narrow (4 commands, 4 events), the editor is
long-lived, and nothing about the design fights the renderer.

Web: mostly. document::eval and the dioxus.send/recv channel are the
same API on web, and the bundle is already a plain browser asset, so the bridge
ports unchanged. What does not port is workspace.rs — there is no filesystem.
That is the architecture working as intended: the part that must be replaced is
exactly the part that was isolated. A web target swaps the Workspace
implementation (File System Access API, or a server behind a Dioxus fullstack
server function) and the timer shim, and touches nothing else.

Mobile: plausible, with caveats. Dioxus mobile uses the same WebView-plus-
native-Rust shape, so the bridge holds. The editor, not the architecture, is the
risk: CodeMirror's mobile IME, selection handles, and virtual-keyboard behaviour
are the real work, and a 1 MB bundle matters more there.

The one structural thing worth stating: the design assumes one editor per
bridge
, not one bridge per application. Multiple panes want a pane id in the
protocol or one eval per pane, and that decision is cheaper to make now than
later.

Next steps (assessed, not built)

Direction Assessment
Multiple editor panes The natural next step and the one that validates the protocol. One eval per pane is simplest and keeps failure domains separate; a pane id in the message envelope scales better. Decide before anything else is built on top.
Live preview, continued The foundation is in. Tables and images on a line of their own are drawn (role: "markup" transforms, js/transform-table.js and js/transform-image.js; images are served from the vault by Rust's vault-file route). What remains is images inside a sentence, footnotes, and callouts — plus the cursor-affinity edge cases that make hidden syntax feel right rather than jumpy. Stays inside the bundle; the bridge does not change.
Custom user HTML views The interesting one and the dangerous one. Each view needs an isolated frame plus a capability handle — a narrow, named set of Rust operations — rather than raw filesystem reach. open_wiki_link is already a miniature of exactly this: the view asks in domain terms, Rust decides. Design the capability protocol before the loader — programmable views is that design, on paper and unbuilt.
Workspace/layout persistence Cheap, and should be a plain file in the workspace (.noita/layout.toml), not a database. Keeps the "transparent data" promise honest and makes layouts diffable and shareable.
Mobile Architecture holds; the editor is the risk. Budget for CodeMirror mobile input handling, not for the bridge.
Web sandbox Workspace is the only thing to replace, which is the point of having isolated it. Note the XSS exposure that eval carries on web is far more serious than on desktop.
Native ↔ custom view capability APIs The real architectural frontier. Deliberately postponed in code, because getting the capability model wrong is the failure mode that cannot be refactored away later — so it is postponed as a build, not as a design: the operation set, permission grammar, portal rule, and trust flow are written down in programmable views first.