| type | architecture |
|---|---|
| description | Agent orientation for lector — purpose, the four shared cores, footguns, and build/release model. |
lector is a macOS Tauri v2 app: a console of grouped tabs over locally-rendered documentation
sites. One tab is one doc repo on disk. Selecting a tab starts a compositor serve loop on an
ephemeral loopback port and points a webview at it, so editing a .md file live-reloads the tab
with nothing deployed — no build step, no publish, nothing to keep in sync. It's the third
sibling to two existing apps: warden curates terminals, curator curates browser tabs,
lector curates local documentation.
The app is complete and functional end-to-end. crates/lector-config is complete
(parse/validate/format/identity, unit-tested standalone). src-tauri has the full app shell: the
manifest pinning all four cores, build.rs, tauri.conf.json, the
Servers supervisor (servers.rs — one compositor::serve_handle() per open tab, idempotent
start/stop/retain/shutdown_all, dead-thread detection via reap/is_alive), the chrome controller
(src/chrome.js + the commands it calls in commands.rs), content-webview management with link
escape (webviews.rs — is_own_origin keeps a doc's off-site links from stranding the tab, and
another tab's own loopback port is escaped too), native mouse side-button back/forward (shell-core's
shared mouse_nav::install NSEvent monitor, wired in the setup hook with lector's
focused-active-webview resolver — WKWebView never delivers the side buttons to the DOM, so it can't
be done in the page; see shell-core's CLAUDE.md), a thin determinate loading bar per content
webview (shell-core's progress_bar::install at the show_in build site, driven by WKWebView
estimatedProgress, tinted with the window accent via AppState::colour_for), and run()'s full
setup: config load, window
build, the shared launch/hot-reload reconciliation path (reload.rs), open_on_launch startup
selection, a config-file watcher (format-on-save, last-good-on-failure), and a clean-quit
RunEvent::Exit handler that shuts down every server. The validate/fmt CLI subcommands round
it out. The in-app updater is fully wired: it has its permission (see the capabilities footgun
below) and tauri.conf.json carries the real minisign pubkey, whose private half lives only on
the maintainer's machine and in their password manager — never in this repo.
The shape mirrors curator's: a Cargo workspace with a platform-neutral config crate
(crates/lector-config — parse/validate/format/identity, no Tauri deps, unit-tested standalone)
consumed by the Tauri app crate (src-tauri/, package lector) — windows, the Servers
supervisor managing one compositor::serve_handle() loop per open tab, the chrome controller,
commands, config hot-reload.
src-tauri/— the macOS Tauri app: the manifest,build.rs,tauri.conf.json,capabilities/default.json, plugin registration, theServerssupervisor (servers.rs), the chrome controller + commands (commands.rs), content-webview management + link escape (webviews.rs), the shared launch/hot-reload reconciliation path (reload.rs), the window lifecycle (windows.rs),run()'s full setup (lib.rs), and thevalidate/fmtCLI.crates/lector-config— the config parser: config-core's sharedWindowwith lector'sLectorWindowextras (a lector tab carries adir— a local doc repo path — rather than aurl),parse_and_validate/load_config,resolve_config_path, thehashhelper, theWindowTabstrait (tab_views/startup_label, labelled under the window's label byshell_core::child_label). Re-exports config-core's shared house formatter + colour parsing, the same way curator/warden do.
The [patch] overrides for all four shared cores live in the workspace-root Cargo.toml (a
[patch] must sit at the workspace root, not a member manifest) — see The [patch] rule and
land order below.
lector pins four git dependencies — one more than curator's and warden's three, because lector also consumes the render/serve engine:
-
compositor (
https://github.com/Lockyc/compositor) — the Markdown render/serve engine. lector'sSiteServercalls itsserve_handle()once per open tab: a non-blocking call that runs a doc repo's live-reloading site on a loopback port and returns once bound. Pinned by rev insrc-tauri/Cargo.toml. Keep this pin current — re-pin it forward (just compositor-pin) as compositor advances rather than letting it drift behind. lector is compositor's only consumer, so a stale pin silently splits the source lector develops against from the source it ships; and because lector's build is the drift detector for compositor's source under lector's channel (see Toolchain lockstep), a long-idle pin banks that risk instead of surfacing it — the divergence lands all at once, loudly, only when the pin finally moves. Keeping it current keeps the detector live and the split from opening. A lagging pin is resolvable (the pinned rev is pushed) so it never breaks another clone or CI — but "resolvable" is not "current," and current is the standard here. -
chrome-core (
https://github.com/Lockyc/chrome-core) — the sidebar chrome (grouped tab rows, resize-drag, density tokens), shared with warden and curator. A build-dependency:src-tauri/build.rsmaterializes its CSS/JS intosrc/chrome-core.{css,js}(git-ignored) before Tauri embedssrc/. Pinned by rev insrc-tauri/Cargo.toml. The sidebar chrome IS chrome-core — read its CLAUDE.md before assuming a sidebar feature is app-specific or missing. Its frontend source isassets/sidebar.{css,js}(notsrc/), and it already provides shared opt-in capabilities lector can enable via DTO fields rather than building — e.g. project-tree/root sections (tree/treePathrows → a collapsible folder tree +⟳rescan button firingonRescan) and the self-updater. lector'ssrc/chrome.jsis a thin controller mapping those callbacks/DTO fields to its own commands. The forwarding premise (referenced throughout below): a new DTO field is inert untilchrome.js's map copies it across — invisible to chrome-core otherwise. Each forwarded field is commented at its map site insrc/chrome.js. -
config-core (
https://github.com/Lockyc/config-core) — the TOML config engine (parse, validate, format, hot-reload diff) behind the shared house formatter. Pinned by rev incrates/lector-config/Cargo.toml, kept at the same rev as warden's and curator's own config-core pins. -
shell-core (
https://github.com/Lockyc/shell-core) — shared release tooling (the three release scripts, materialized git-ignored intoscripts/) plus a sliver of Tauri runtime setup (register_pluginsfor window geometry/updater/process). Pinned twice insrc-tauri/Cargo.toml, per its zero-dep/runtime feature split:[build-dependencies]:default-features = false—build.rsneeds onlybuild_stamp()and the embedded script consts, which are zero-dependency.[dependencies]:features = ["runtime"]—register_pluginsneeds the tauri tree.
The
default-features = falseon the build-dep is load-bearing: without it, every consumer's[build-dependencies]drags in the whole tauri tree just to runbuild.rs. Resolver 2 resolves the two feature sets independently. Bump both entries to the same rev in lockstep.Window geometry — shell-core's own
geometrymodule, replacingtauri-plugin-window-state(which lector never depended on directly). It persists each window's size/position in AppKit points, clamps every restore to the target monitor's work area, and never records geometry while a window is fullscreen or minimized (covers classic Split View; Sequoia's drag-to-edge tiling is not a fullscreen space, so a tiled window's ordinary bounds are still recorded, correctly). Only the home surface is excluded from save and restore structurally, inside the module —register_plugins'sskip_labelsis reserved for an app's own transient windows, and lector passes&[](it has none). A popped-out tab's window is persisted like any other, keyed by itsshell-detach:label;detach_window_tokenmakes that label the tab's own (dir-hash-derived, config-stable) label, so a re-popped tab reopens at the size and position it was last left at rather thanDETACHED_DEFAULT_*.
All four are git dependencies, git-ignored/materialized, or [patch]-overridable for local dev
— never vendor one in-tree.
lector adopted shell-core's menu::build_spine and home::{home_state, show_home, close_home},
and chrome-core's appName field, from scratch — it never had its own menu, error window, or
launcher to delete, so it's the cleanest case of the three apps consuming these.
- lector contributes no app-specific menu items. App, Edit, Config and Window come from
shell_core::menu::build_spineand the whole Tab submenu frombuild_tab_menuwith no app groups. curator's Tab submenu adds Reload Tab / Reset All Tabs / Open Developer Tools; warden's adds Split and its session items — none of those map onto lector: compositor's file watcher already live-reloads every open tab on save, so a manual "Reload Tab" item would be a no-op button; there is no per-tab "session" to reset (a compositorSiteServerhas no state beyond the served files); and a release build has no inspector to open (tauri'sdevtoolsfeature is off — a debug build's right-click Inspect Element is the route). Other tab-scoped actions (reveal-in-Finder, copy the served URL, open the repo dir) are open roadmap, not excluded — the submenu is just empty of them for now. - The whole menu is built by one function,
refresh_menu(lib.rs), from theWindowSetandAppState— at setup, on every clean load (sotab_digit_keys, below, flips live), and whenever a window opens, closes, is renamed or deleted (the Window submenu's ticks and "(closed)" labels). - Windows are shell-core's
WindowSet(managed state), withwindows::LectorHostbuilding and tearing down a window's content. It owns each window's label (the sanitized title), which windows are open, the home surface and the quitting flag; a user-closed window stays listed and stays closed across a reload. New Window… and the banner's right-click Edit / Delete Window… (window_menu) are shell-core's flow (WindowSet::handle_menu);LectorHost::writewrites the config through config-core'sedit, and a rename retitles in place,last_cfgincluded. A user close of the last window quits (quit_on_last_close). - The home surface (shell-core, shown by the
WindowSet) is what a fresh install shows. Its "Create a starter config" button iscommands::shell_home_create_config, callingconfig_core::write_default_configwith the trackedsrc/default-config.tomltemplate. appName: "lector"(src/chrome.js's mount config) names the app in chrome-core's#cc-titlebarstrip beside the traffic lights;src/chrome.css's#sidebarcarries nopadding-topof its own — chrome-core owns that inset now.
tab_digit_keys (Config, whole-app, no per-window cascade) picks what ⌘1/⌘2 do: default
jump — ⌘1–⌘9 jump straight to a tab position; cycle makes ⌘1 next tab / ⌘2 previous and
shifts the jump items to ⌘3–⌘9. An unrecognised token is a load error (config_core::TabDigitKeys's
own serde impl), not a silent fallback. Tab ▸ Cycle Tabs with ⌘1/⌘2 writes the flip
(commands::toggle_tab_digit_keys, flipping what the file says and seeding the starter config
first); the watcher's reload applies it.
- The menu handler is mode-blind.
shell_core::menu::routeresolves a fired id to aSpineActionand the window it acts on (from a popped-out window: its origin) — the cycle aliases arrive as plain Next/Prev — andon_spine_actionemitsnav-tab({dir, from}) orjump-tab(1-based position) to that window's chrome, likeclose-tab/pop-out-tab. - The chrome routes through the normal click path.
src/chrome.js'snav-tab/jump-tablisteners call chrome-core'sselectByOffset/selectByIndex, so a cold tab that a cycle or jump lands on still starts itscompositor serveloop on demand — nothing here bypassesselect_tab. Cycling spans every tab (liveOnly: false): lector has no cold-skip concept the way a throttled/hidden tab might mean elsewhere.
A ⤢ control on each sidebar row (chrome-core's onPopOut) and Pop Out Tab (⌘⇧O) (the shared
Tab submenu — see above) pop the origin window's active tab
into its own banner-only detached window (shell-core's shared detach shell,
shell_core::detach::open_detached). Closing that window returns the tab to its origin — reopening
the origin first if the user closed it while the tab was out.
- The defining property: recreate on the SAME running server, not stop-and-restart. A lector
tab's content is a
compositor serve_handle()loop bound to a loopback port; lector can move neither a server nor a webview between windows, sopop_out_tab(commands.rs) closes the origin's content webview andwebviews::show_onbuilds a fresh one on the detached window pointed at the exact same port. Returning (redock, inlib.rs) does the mirror-image recreation back on the origin, reusing the portLectorDetachedrecorded. The compositor serve loop and its file watcher run continuously across the whole hop, so the pop is near-lossless — live-reload-on-save keeps working throughout, and only in-page state a webview recreation can't carry (scroll position, in-page navigation) is lost. - The server must survive the whole hop — three guards keep it alive and are load-bearing:
pop_out_tabtears down only the webview (webviews::close), never the server;unload_tabno-ops on a detached tab (so a stray hover-✕ or ⌘W can't stop the server behind a still-open detached window); andreload::reconcile'sServers::retainunions in the detached labels so a hot-reload can't drop a popped-out tab's server mid-session. A genuinely removed tab's server-stop is deferred toredock's tab-removed branch — unless its whole window left the config, whenwindow_closed(lib.rs) closes the detached window with it. Seecommands.rs/reload.rs. - State:
AppState.detached: Mutex<HashMap<detached_label, LectorDetached>>, whereLectorDetached { origin_wid, origin_title, tab_label, port }, the origin found by title (a window renamed in place and closed reopens under another label;AppState::resolve_originrehomes the tab's label and server onto it) — kept separate from theWindowSetso the Window submenu never sees a detached window as a real one; its count is mirrored into the set (sync_detached) so the home surface stays away while one is on screen.detached_tab_labels()flattens the map to the set of tab labels, used bytab_dtosto set each row'sdetachedflag and by theunload_tab/retainguards above. - Chrome-facing:
chrome.js's DTO map forwardsdetached(the forwarding premise above); a detached row renders muted with its ⤢ control suppressed, and clicking it callsraise_popped_windowinstead ofselect_tab— no local webview to select, so "select" means "raise the popped-out window." - Neither
pop_out_tabnorredockever holds a lock across the window build or a webview call.pop_out_tabreadswindow_cfg()/starts the server (both release their locks before returning) and only then callsopen_detached/show_onwith no lock held;redockpeeksdetached(clone out, lock released), reopens the origin window if needed, then takes and removes the entry in a second short-lived lock before recreating the webview. WindowSet::is_quittingmakesredocka no-op during ⌘Q.RunEvent::ExitRequestedcallsWindowSet::mark_quittingbefore any window'sDestroyedfires, so a detached window's teardown during quit doesn't reopen its origin or recreate a webview while every server is about to be shut down wholesale anyway (RunEvent::Exit'sservers.shutdown_all()).
A [[window.root]] block points at a projects dir; every git repo under it (to depth, default
config_core::DEFAULT_ROOT_DEPTH) becomes a discovered doc tab, rendered as chrome-core's
collapsible folder-tree section with a ⟳ rescan button. The leaf is { name, dir, depth } only —
shell/cmd/probe/kill are warden's and are rejected by deny_unknown_fields.
- Discovery is shared, synthesis is per-app. config-core owns the leaf-free discovery machinery
(
scan_rootwalks the tree stopping at every.git;resolve_root_dirvalidates the{name,dir,depth};discover_projects(&[RootDir]) → Vec<DiscoveredProject>flattens roots into{path, tree_path, section}), shared with warden. lector owns only the synthesis:WindowTabs::tab_views_with_discoveredmaps eachDiscoveredProjectonto a docTabView. This is the constellation's "config-core owns discovery, the consuming app bridges" seam — never a leaf-generic in config-core (see its charter). - Synthesized tabs are ALWAYS lazy (
load_on_open: false) — a root can synthesize dozens of tabs, and eager-starting acompositor servefor each at launch is never wanted. A discovered tab's server starts on select, like any cold tab. - One choke point re-scans.
reload::reconcilescans every window'sresolved_roots()on each call, so launch, config hot-reload, and the rescan button all pick up roots through the same path; a root-less window scans nothing and behaves exactly as before. Identity is the canonicalized-dir label, so a curated tab shadows a same-dir discovered project (the discovered duplicate is dropped) and a repo reachable via two roots lands once — the opposite of the-nsuffixing two hand-authored same-dir tabs get. tree/tree_pathare forwarded throughchrome.js's DTO map (the forwarding premise above); chrome-core renders the folder tree +⟳fromtree: truerows and firesonRescan.- The
⟳button is chrome-core's, not a menu item — there is no ⌘R.onRescaninvokes therescan_rootcommand, which re-reads the config and re-runsreconcile(re-scanning disk) viaapply_config, then emitsconfig-reloadedto refresh each window; a config that now fails to parse keeps last-good and surfacesconfig-error, mirroring the file watcher.
Any hash driving a persistent identifier must use a fixed algorithm (fnv1a_64), never
std::hash::DefaultHasher — std doesn't guarantee stability across Rust releases, so a toolchain
bump silently changes the value and the app reads as "forgot my layout." This is a
constellation-wide rule, single-sourced in
shell-core's CLAUDE.md; the
config-crate copy's own rationale and test vectors live at crates/lector-config/src/hash.rs.
lector has two such hashes (a window's label is its sanitized title, from the WindowSet — no
hash):
- Tab label identity —
hash.rs::fnv1a_64hashes a tab's canonicalized dir into its stable webview label; this copy stays in the config crate (label identity is its domain). A tab's title is display-only, never an identity or address — duplicates are allowed, andopen_on_launchis a plainbool(unset/false= firstload_on_opentab,true= first tab even if cold). Don't reintroduce title-as-address — anopen_on_launch = "<title>"arm (or any title-keyed lookup) re-imposes title uniqueness and gives first-match on a duplicate. curator, lector, and warden share this "title is display-only" rule, so changing it is a family-wide decision. - Window geometry-store filename — owned by shell-core, not lector:
register_pluginsderives it from the resolved config path viashell_core::geometry_filename, which hashes the canonicalized path with shell-core's own copy offnv1a_64(see shell-core's CLAUDE.md) — a separate instance from this crate's, hashing a different domain (a config path, not a window title or a tab dir). lector just hands over the path.
lector's own #[tauri::command]s need no capabilities entry (with no app ACL manifest, IPC dispatch
lets local-sidebar commands through and rejects remote content webviews) — but that bypass does
not cover core plugin commands (core:event, core:window, updater, process), which
carry their own default-denied permissions regardless. So the sidebar's hot-reload events, drag
region, and updater each need an explicit grant in src-tauri/capabilities/default.json, and a
missing grant fails silently (the JS listen() promise rejects while Rust-side emit_to still
returns Ok). The full command-isolation model is single-sourced in
shell-core's CLAUDE.md
("command-isolation security model"); lector's verification of it against the pinned tauri source is
in commands.rs's header.
A plugin's permission can only be named if tauri-build's ACL discovery sees the plugin as a
direct dependency — so src-tauri/Cargo.toml carries tauri-plugin-updater/
tauri-plugin-process entries despite nothing here calling them. Do not delete them as dead
weight — the reason and the silent-failure modes are on those entries in src-tauri/Cargo.toml.
on_spine_action (and redock's emit_to, both in lib.rs) address a chrome
by its webview label — but Tauri's dispatch short-circuits any listener registered as
EventTarget::Any past the emit's target filter (match_any_or_filter, tauri
src/event/listener.rs), and a bare JS listen(event, handler) registers exactly that.
Footgun: it looks scoped and isn't — every window's chrome receives every per-window event.
src/chrome.js therefore binds its listeners to the chrome webview
(window.__TAURI__.webview.getCurrentWebview().listen), registering
EventTarget::Webview { label }, the one target the emit's AnyLabel filter narrows on. A new
per-window listener must use that local listen wrapper, never the raw
window.__TAURI__.event.listen.
rustup picks a toolchain by walking up from the directory cargo runs in — never from what's
being compiled. The shared cores are git dependencies, built out of
~/.cargo/git/checkouts/, which rustup never looks in — so a core's own rust-toolchain.toml
is inert when lector builds it; lector compiles the whole dependency graph with its own pin.
- This repo's
rust-toolchain.tomlmatches config-core's canonical pin — bump config-core first, then match it here (and in warden/curator, which share the same pin). The two files are the source of truth for the channel; don't restate it in prose, here or anywhere else. - compositor is the deliberate exception: it pins its own, newer channel for its own
CI/gate equivalence. lector compiles compositor's source with lector's pin, not
compositor's — lector's build is the drift detector for compositor's source; do not "fix"
the mismatch by matching compositor's channel here. (
cargo treeand the tworust-toolchain.tomlfiles answer "which channels?" truthfully; a number written here goes stale the next time either moves.)
An active [patch] path override builds green locally (against a sibling checkout, including
uncommitted edits) and is unresolvable for every other clone and CI. Two guards enforce
this: .githooks/pre-commit (activated per clone via git config core.hooksPath .githooks) and
just gate's own check. Never hand-edit the #PATCH:<core># lines or commit one active —
use the recipes:
just <core>-dev(chrome-dev/config-dev/shell-dev/compositor-dev) activates that core's patch against the sibling../<core>checkout.just <core>-pinre-pins to that sibling's pushed HEAD and deactivates the patch.just cores-pinre-pins all four in one go, inscripts/tooling.env'sCORESorder.
All of them wrap shell-core's generated scripts/core-patch.sh; CORES names each core's crate
and the manifest its rev lives in.
Land order for a change that spans a shared core and lector: push the core change first (its
new rev must exist on the remote before lector can pin it), then run just <core>-pin in
lector, build/test, and commit lector's re-pinned Cargo.toml/Cargo.lock. Run just gate (or
at minimum just cores-pin) before every commit that isn't itself a core re-pin, to catch a
patch left active by habit.
Two long-lived branches: dev is the integration trunk — all work lands here. main
carries the latest tagged release and stays a clean ancestor of dev; it advances only by
fast-forwarding to a release commit, or by a documentation-only commit immediately
forward-merged back into dev. Never commit code directly to main.
Version lives in src-tauri/Cargo.toml — the single source of truth, like
curator's and warden's. Releases are notarized .app bundles with an in-app minisign-signed
updater (tauri-plugin-updater), mirroring curator's and warden's release shape: bump the
version on dev, commit and push, then just release [notes=<file>] (just gate +
scripts/release.sh, generated from shell-core, parameterized by the tracked
scripts/tooling.env) builds and notarizes, then tags v<version>, fast-forwards main, pushes
both, and publishes the GitHub release with the notes (default: commit subjects since the previous
tag) and every asset — nothing goes public until the build succeeds. No CI: lector ships on macOS
only, so the local just gate is the check — run it per change.
What just release needs from the build environment (it is env-driven, and refuses to run
without them): TAURI_SIGNING_PRIVATE_KEY — the contents of the updater key file, conventionally
~/.tauri/lector-updater.key and mode-600, never a path — plus
TAURI_SIGNING_PRIVATE_KEY_PASSWORD, and the Apple notary creds (APPLE_SIGNING_IDENTITY pointing at
a Developer ID Application cert, plus APPLE_ID/APPLE_PASSWORD/APPLE_TEAM_ID, or the
APPLE_API_KEY* trio). How a given maintainer supplies those is theirs to decide — this repo only
states the interface.
Building without any of them is supported and expected. just build with the signing/notary vars
unset produces an ad-hoc, unsigned bundle, and just install strips the Gatekeeper quarantine xattr so
a local copy still runs — the from-source path a contributor gets. Signing is enabled by the
environment, never pinned in tauri.conf.json: createUpdaterArtifacts is switched on release-only
via release.sh's --config override, because baking it in would make every cargo tauri build
demand the signing key and break the keyless path.
The shared scripts (listed in .gitignore) are generated, git-ignored — materialized by src-tauri/build.rs from
the pinned shell-core rev. Edit them in shell-core, never here; a local edit is silently
overwritten on the next build. The tracked scripts/tooling.env is the only per-app input they read.
just installlaunches viascripts/launch-app.sh, never a bareopen.openforwards the caller's whole environment to the launched app (process parentage is clean — launchd — the environment is not), so installing from a terminal runs lector with that terminal'sTERM*,GHOSTTY_*,TMUX*,SHELLand any agent/tooling vars: an environment no Dock/Spotlight launch ever reproduces, which makes bugs appear or vanish by launch method.launch-app.shwrapsopeninenv -iso the app gets only the launchd GUI-session environment. Shared with warden + curator; the script's header carries the full footgun.
Conscious deferrals are tracked in docs/FOLLOWUPS.md. Consult it before "fixing" a gap you've just noticed — it may be a recorded deferral
with a reason.
lector is a public repo — every tracked file must be self-contained: no references to machine-local paths, scripts, or personal tooling. Clones, the build-from-source path, and any future CI only have what's in the tree.