Astrolabe Documentation العربية GitHub

Development

Running Astrolabe from source, the gate scripts, the screenshot harnesses, and how to contribute a change.


Dev mode

npm run dev

This runs two things side by side: the API server, and Vite, the tool that serves the client and reloads it in the browser the moment you save a file.

PortWhatWhen
6801Hono server (API + built client)npm start / always
5801Vite dev server (proxies /api → 6801)npm run dev only

In dev mode you open port 5801; requests to /api are passed through to the server on 6801. PORT overrides the server port.

The scripts

ScriptWhat it does
npm run devAPI server (Node's own --watch) and Vite side by side
npm run buildBuild the client into dist/. The server needs no build — Node runs the TypeScript directly
npm startBuild, then serve
npm run serveServe without rebuilding
node scripts/rebrand.mjs --name … --icon …Stamp your own product name and icon on the desktop build before npm --prefix desktop run dist (the desktop app)
npm run hash-passwordPrompt (no echo, or piped stdin) and print an argon2id hash for ADMIN_PASSWORD_HASH
npm run typechecktsc --noEmit — the strict TypeScript gate
npm testThe unit suite, node --test tests/*.test.ts: the pure logic under shared/, server/ and client/, no browser. It is the release gate, and it runs the same code as several of the gates below (check-keymap, check-docs) from a second door
npm run build-docsBuild this manual, both languages, into docs/site/
npm run check-docsEvery link, anchor, image and settings path in this manual resolves, in both languages (below)
npm run gen-iconsRedraw the folder-mark glyph set from its catalog; npm run check-icons fails when the drawing is stale
npm run check-desktopThe desktop wrapper's own checks, then its tsc
npm run check-windows-layoutThe desktop shell at five window widths × three device pixel ratios × its pointer postures, and which shell each width gets (below)

Where the code lives

The client is client/ (React; the desktop shell is App.tsx, the phone shell client/phone/), the server server/ (Node and Hono), the logic both use shared/, the Android shell mobile/, the desktop wrapper electron/ and desktop/, the gates scripts/, the suite tests/. The modules that grew largest are cut into parts along their own seams, and each kept its name and every export, so an import of it reads what it always did (tests/splits.test.ts holds each family to that):

ModuleIts parts
server/indexer.ts — the index's store, building, the event queueserver/indexer/: language, resolve, folders, publish, posts, queries
server/api.ts — middleware, the auth guard, notes and folders, search, the mountsserver/*Routes.ts, one router per group (trash, tags, replace, files, comments, Orbits, settings, sync, versions, the event stream, rename), mounted where its routes stood; server/requestBody.ts
client/state.ts — the storeclient/state/: the types, the document writes, local storage, the helpers, the theme's mirror, and five slices (fields, session, workspace, preferences, notes)
client/components/Sidebar.tsxclient/components/tree/ (expansion, icons, the row, the keyboard cursor) and TagShelf.tsx
client/components/CommandPalette.tsxclient/components/palette/commands.ts — the command table both shells run
client/components/GraphView.tsxclient/graph/sim.ts — the simulation and the canvas
client/books/BookReader.tsxReaderPanels.tsx (the overlays) and pdfHighlight.ts (the search hit)
client/styles/app.cssreset.css, tree.css, editor.css and publish.css linked before it, grips.css after — in client/index.html, where the order is the cascade
client/i18n.ts — t() and the loaderclient/i18n/en.ts (the key list) and client/i18n/ar.ts, one chunk per language

The gates

A gate is a script that checks one specific promise the product makes, and exits with an error when the promise is broken. Each one below exits non-zero on failure. The gates that need no browser need no setup at all. The browser gates need a running instance, plus npm i -D playwright and either npx playwright install chromium or a system browser via CHROMIUM=/usr/bin/chromium. Those that sign in take ASTROLABE_PASSWORD (open local mode needs no password).

npm run check-i18n — the dictionary

Every string a user can see comes from one dictionary, in two files: client/i18n/en.ts, which is the key list, and client/i18n/ar.ts, held to it by type, so a key in one and not the other does not compile. A page fetches only the language it speaks. This gate fails if any t() key is missing, untranslated or dead (used nowhere), or if the English and Arabic sides of an entry disagree about their {placeholders}. It also fails on hardcoded English text in JSX and in code that builds DOM elements by hand. "Dead" is counted from the call sites only: the dictionary file is excluded from the usage scan, because a key whose English value happens to be its own name (read: { en: "read" }) would otherwise match inside its own definition and report itself as used.

It reads the TypeScript syntax tree, not lines (scripts/i18nScan.mjs). The line scan it replaced could not see a single word, an attribute expression (title={open ? "Hide" : "Show"}), a template literal, JSX text split across lines, aria-description or aria-valuetext, or a string child in braces; tests/i18nScan.test.ts runs the retired scan beside the new one on each of those shapes and shows it missing them. A literal that is not copy — a honeypot field only bots see, a folder's literal default name — says "not copy" on or just above its line, and says why. The gate also reads the Android shell, mobile/src, which keeps its own two-language table because its screens speak before the client loads: every value present in both languages, the Arabic in Arabic, the same parameters interpolated, every key used, and no English in its builders or in the error bodies its service worker answers with.

npm run check-names — the names gate

Two features were renamed in 3.15 and again in 3.16: the daily routine is Sigils, and Orbits is spaced repetition. A rename that leaves one toast, one hint or one heading saying the old word is worse than none, so this gate greps every surface a reader sees — every English and Arabic value in client/i18n/en.ts, client/i18n/ar.ts and client/orbits/copy.ts, every page of this manual in both languages, the README, the seed vault, the what's-new deck and the headings of CONTRACTS.md and every contracts/*.md — for the words that may not appear there any more (the old word for a sigil, the study page's working name, and "flash card", which is "card" now; and their Arabic), and client/ and server/ for the old page addresses, which may exist only as redirect sources. A line that has to tell the history (which older fences still work, what a release was called at the time) carries the word lineage in a comment on that line — <!-- lineage --> in Markdown, // lineage in the deck — and is skipped; a dictionary value never gets that exception. Each hit is printed as file:line, and the script exits non-zero.

npm run check-contrast — the accessibility gate

Holds every one of the forty-six themes in client/styles/tokens.css to the WCAG contrast rules on the five text tokens: body text, headings and secondary text against all three backgrounds (--bg, the raised surfaces, and the hover background the tag pills sit on), the accent colour against the page, and --text-faint at the 3:1 non-text floor on the two backgrounds it is allowed on. The accent pair is read as text twice over (wikilinks and tag pills in the prose, and the lit mode pill, which is the same two colours swapped). Run it after touching theme tokens.

The formulas and floors live in shared/contrast.ts, which the custom theme builder imports as well — one implementation, so the builder can never bless a theme the gate rejects.

It also checks the text-colour palettes (shared/textColors.ts, client/styles/textcolor.css). These exist in two tiers for an arithmetic reason: against void's #050508 a colour needs relative luminance ≥ 0.186, and against solar's #ffffff it needs ≤ 0.183, so no single colour clears AA on every theme. The theme-aware tier (var(--vc-*), the default) therefore carries one value per theme group and is held to 4.5:1 against every background in its group. The fixed-ink tier carries one hex for all themes and is held to 3:1, WCAG 1.4.11's non-text floor, which is the most a fixed colour can promise. The gate prints both, and checks the stylesheet's values against the module's.

npm run check-sections — the section-surgery gate

No browser, no server. Dragging a heading in the outline rewrites the note: a block of lines leaves one place and arrives in another, with the moved subtree's headings re-levelled. That is the most destructive operation in the product that is not called "delete". It runs on a gesture with no key, it is one 4px slip away by accident, and the reader is looking at a forty-row outline rather than at the 1,200 lines being rearranged — so a single dropped paragraph would be invisible until the day it was needed.

The gate generates thousands of documents out of the shapes that break naive implementations — YAML frontmatter, code fences whose bodies contain ### lines, headings that skip levels, empty sections, a section at end of file, CRLF, no trailing newline — and asserts the reorder is a permutation: it may change the order of a note's lines and the depth of the moved subtree's own headings, and it may add a blank line at a seam; it may never lose a line and never duplicate one. It also asserts that a section cannot be dropped inside itself, that a zero-distance move changes nothing, and that extraction's two halves cover the original exactly. SEED=… replays a failure, ROUNDS=… sets the sample size.

npm run check-caret — the click-to-caret gate

Live preview replaces Markdown source with rendered boxes of a different width and a different length — eighteen characters of $7.7\ \text{km/s}$ standing under seven glyphs of KaTeX — so any pointer-to-document mapping that reasons about geometry instead of about the DOM drifts by exactly that difference.

The gate writes its own note (inline math, inline code, wikilinks, tags, highlights and an image, in English and Arabic, on lines long enough to wrap several times), drives a real mouse over it — single, double and triple click, drag, shift-click, select-all — runs the whole matrix once in each shell direction, and reads back what the reader would actually copy (window.getSelection()), requiring every clicked glyph to take the caret within one character. Before the fix it reported misses of up to 82 characters; after it, zero.

It exists because that one question has broken four separate ways here (click position, hover previews, mod-click navigation, text selection), and the common cause is always a pixel the editor's height map cannot see: nothing inside .cm-content may carry a vertical CSS margin. CodeMirror measures every line and block widget by its border box, so padding and borders are counted and margins are not — put the air on a wrapper's padding, or in a transparent border with background-clip: padding-box, never in a margin. The gate restores the instance language and deletes its fixture however the run ends.

npm run check-french — the auto-correction gate

tests/french.test.ts proves the table (no source is a real French word, no duplicates, every target differs from its source by marks alone) and the line detector. What only a browser can prove is the editor's half: the correction is a second transaction, dispatched off a microtask after the one that typed the space, and it has to land as its own undo step with the space left standing. So the gate writes a note, types into it with a real keyboard, and checks each promise the docs make: tres → très , coeur → cœur , Enter and a stepped-over ) as boundaries, a line corrected whole when it becomes French, an English line with one French word left alone (and an all-caps UN, and a Spanish line), a code fence never touched, one Ctrl Z giving tres back and the same word not corrected again, the device switch off and on, lang="fr" on the French line and nothing on the English one, the narrow no-break space before ?, ... → … on a French line only, and a correction in vim's insert mode. Needs CHROMIUM and, against a password-protected instance, ASTROLABE_PASSWORD; deletes its fixture however the run ends.

npm run check-layouts — the keyboard-layout gate

KeyboardEvent.key is the character the keyboard layout produced. The shell used to compare it to Latin letters, so on an Arabic keyboard — where the key marked P reports ح — every global shortcut in the product was dead, in an app that ships a complete Arabic translation and mirrors its whole interface for it. No test caught it, because every test typed Latin letters.

So this one does not. It drives the real app through the DevTools Protocol (Input.dispatchKeyEvent, the only way to set key, code and keyCode independently — Playwright's own keyboard always sends the US key for a code) with the keydowns that Arabic 101, ЙЦУКЕН, Greek, Hebrew, AZERTY, Dvorak and US QWERTY actually send, and asserts that the palette, the graph, the shortcut sheet, zen, reading view, search, the pane toggles, bold and strikethrough all still fire — including Ctrl/Cmd K in the signed-out blog shell, which is an anonymous reader's only binding. 72 checks; 46 of them passed before the fix.

Its second half keeps the fix from over-correcting: on Dvorak the key that types b is physical KeyN, so Ctrl Alt on physical KeyB — which types x there — must do nothing. Layout first, physical position only as the fallback. tests/shortcuts.test.ts runs the same matrix over the resolver with no browser at all, and holds the cases a browser cannot deliver (Chromium flattens Arabic's two-code-point lam-alef to an empty key). A binding added to the shortcut sheet and not to that file is a binding untested on every non-Latin keyboard on earth.

node scripts/shoot-layouts.mjs is the companion picture: the Ctrl/Cmd / sheet with the layout map stubbed to Arabic and to Russian, which is how the annotated keycaps are reviewed.

npm run check-windows-layout — the window gate

Every other browser gate here looks at the app at one or two comfortable widths. The app is not used at comfortable widths. A Windows laptop panel is 1366 physical pixels, which is 1093 CSS px at 125% scaling and 904 at 150%; half of it under Win+← is 683. Four release rounds of "resizing of panels and windows is clunky and weird on Windows" were four separate defects that no gate could see, because each of them is a function of the viewport and the pointer together — and because the one machine none of us has is the one they all happened on.

So this gate is a ladder rather than a screenshot. It drives the built app at 1366, 1280, 1024, 900 and 720 CSS px — the desktop shell's own widths — each at device pixel ratio 1, 1.25 and 1.5, in both directions, with the pane widths both empty and seeded to a pair the reader could actually drag them to ({560, 560}), under the three pointer postures Chromium really reports on Windows:

posturewhat it iswhat it must give
mousea desktop tower or a plain laptopdocked panes and grips at every width above 700
slatea hardware slate — touch, a rotation sensor, the ACPI slate bit. An attached mouse does not change Chromium's answerthe phone shell, at every width
touchlaptopa fine pointer and a fingerdocked panes, grips, and 44px rows

The postures are Blink settings on the browser process (--blink-settings=availablePointerTypes=…), which is what pointer_device_win.cc itself hands the renderer — not DevTools media emulation, which setViewportSize silently drops halfway down a ladder.

Which shell. A last rung, whichShell, walks each posture down the widths (700 and 600 too) and asserts the phone shell below 700 and on a slate at every width, and the desktop — unmoved — everywhere else. (Until 3.27.0 the ladder ran in the Classic phone layout, to measure its drawer cells; the drawer went with it, so every cell the desktop is mounted in is docked and says so.)

In each cell it asserts that the sidebar is a real grid column (not auto-placed, not an overlay) wherever a pointer can hit a strip, that each docked pane has a grip whose 12px hit area is centred on the pane's own 1px divider — elementFromPoint at the seam must return the grip — that the note column never falls below its 320px floor whatever is stored, that neither pane and neither pane's close button runs off the window, and that the document never scrolls sideways.

Three rungs after the ladder watch the frames the ladder cannot see, because it waits 300ms after each resize and a 0.18s transition is over by then. They ask what the reader actually sees while a window is being dragged and while a pane is being folded: the note column still has its 320px one frame after a resize (at two ratios and in both directions), and the reader's own Ctrl/Cmd Alt B fold is still mid-flight 60ms in, at two widths. Each failed at some point on the same flag — the class that says "this width is not the hand's doing" — being up when it should have been down, or down when it should have been up.

Needs a running instance, CHROMIUM, and the instance's password as the second argument.

npm run check-windows-layout -- http://127.0.0.1:8177 <password>

scratchpad/win/run.sh is the other half of this and is not a gate: it launches the packaged Windows executable under Wine with --remote-debugging-port=9333, which is the only way to see the real Windows renderer's scrollbars, pointer events and media queries without a Windows machine. Run the same assertions over CDP there before a release that touches the shell.

npm run check-keymap — the binding ledger

A colliding shortcut is the quietest bug this product can have. One handler answers the key, the other never sees the event, and neither knows the other exists — so it surfaces weeks later as "Ctrl+B does nothing", on one platform, from one reader, with nothing to grep for, because nothing is wrong with either binding. What is wrong is that there are two.

So a binding exists in ONE place: the GROUPS table in client/components/ShortcutsHelp.tsx, the same table Ctrl/Cmd / prints. This gate parses it out of the source text (never imports it — the rows carry React and store closures, and a gate that needs a browser is a gate nobody runs), turns every row's keys into one standard spelling, and fails when two rows resolve to the same key, modifiers and scope. Scope is the shell (app / blog) and the runtime (browser / desktop), and deliberately not admin: an admin session sees the visitor's rows plus its own, so admin never keeps two bindings apart — it names the reader a collision reaches first.

One overlap is real, argued and declared: Ctrl/Cmd Shift Z is zen AND CodeMirror's only macOS redo binding, and client/App.tsx breaks the tie by where the caret is. Declaring one costs a paragraph in RESOLVED (client/keymap.ts) saying where the tie is broken, and a declaration that stops colliding fails the build too — a dead exception is a claim the next reader believes.

The second half is docs/keymap.md, which is a RENDERING of the ledger rather than a second copy of it: the gate diffs the chords in the tables between <!-- keymap:begin --> and <!-- keymap:end --> against GROUPS, in both directions. Surfaces that carry no keystroke — a click, the slash menu, an outline drag — live below the end marker, where the gate leaves them alone. tests/keymap.test.ts runs the same code with no files to write.

The third half is the one a consistent ledger cannot prove: that the key does something. Every row with keys must be vouched for by a comment // keymap: <label> on the code that answers it — the branch in client/globalKeys.ts (the window listener both shells mount), the CodeMirror keymap entry, or a component's own listener; a library keymap (history, search, fold) is marked where the editor installs it. A row with no mark fails (NO HANDLER), and so does a mark naming a label that is no longer a row. Ctrl/Cmd Alt L (turn a note over to its twin) sat on the sheet, the palette row and this manual with no handler at all until 3.26.1, because nothing tied a row to its code.

npm run check-excerpt — the tag-in-prose gate

DESIGN.md's hard rule is that a snippet shown outside the editor either STRIPS Markdown or RENDERS it. Removing a # and leaving the bare word standing in the sentence is neither, and it shipped: a post ending "…it buys the reader a breath. #design #typography" printed on the front page as "…it buys the reader a breath. design typography". The three surfaces that flow through one stripper (stripInlineMd) are all walked from one fixture whose body ends in a tag line: the post excerpt (/api/posts — blog cards, RSS, og:description), the search snippet (/api/search), and the backlink context line (/api/backlinks). It also checks the other direction — that the stripped sentence survives, and that the tags still appear where tags belong (post.tags, and the search index still matches them) — so a stripper that passes by deleting everything fails too. No browser needed; it deletes its fixtures however the run ends.

npm run check-design — the error boundary

The gate for the design engine's one promise that cannot be reviewed by reading the code: that a section which breaks does not take the whole page down with it. It breaks a designed site three ways on purpose (a corrupt designs.json, a section pointing at a note that is not there, and a section renderer patched to throw and rebuilt) and, for each, measures what a VISITOR gets (the built-in blog, a page with real text on it, nothing escaping the boundary) against what the OWNER gets (the designed page, the failing section named, the revert control present). It also round-trips stock ⇄ designed and asserts the design comes back byte-identical. Everything it touched is restored on the way out, including on failure: PORT=6801 ASTROLABE_PASSWORD=… npm run check-design.

npm run check-board — the designer's section board

Three ways to move a row (the ↑/↓ buttons, a pointer drag, and a Space/arrows/Space keyboard lift), the drop preview before commit, Esc belonging to the innermost layer, the save-bar count, and a Ctrl/Cmd S round trip through the store.

npm run check-preview — the designer's live preview

That it is a real iframe under frame-src 'none', that styles and theme reach it (including a live theme switch), and that it lays out at 390 / tablet / 1440 device widths.

npm run check-print — the printed page

PORT=6801 npm run check-print. The only surface nobody looks at while they work: @media print rules are invisible to every screenshot harness above, because a browser applies them only when a human opens the print dialog. So this drives the app under emulateMedia("print") and asserts that the print host is the only thing on the paper (and is display: none on screen, so it can never flash), that the paper palette wins over a dark theme, that a folded callout prints its body, that headings stay real h1–h6 with ids and internal links keep their #fragment hrefs — the two things Chrome builds a PDF's bookmark outline and its link annotations from — and that an Arabic note prints as a right-to-left page from an English instance. It writes two fixture notes through the API and deletes them on the way out. See Printing & PDF.

npm run check-deck — every what's-new slide, measured

CHROMIUM=/usr/bin/chromium npm run check-deck -- http://127.0.0.1:6801 <admin password>. The what's-new deck is drawings and live demos, and a drawing can look right in the language it was drawn in and run off its frame in the other. This opens every release's deck in English and then Arabic, freezes the loop at the point where every part has arrived, and fails if any text leaves the frame, sits outside the chip it belongs to, overlaps another, is Arabic set right-to-left inside a drawing, or is an Arabic sentence inside a drawing (prose belongs in a DOM demo). Run it before every release that adds a slide.

npm run check-presets — the preset catalog

Unique slug ids, a bilingual name and blurb with real Arabic, a known family, at least one preset per family, and no preset naming a note in somebody's vault. It runs the shared assertCatalog rather than reimplementing it.

npm run check-docs — the manual

This page and every other one, in both languages. The gate walks every link in README.md, docs/*.md and docs/ar/*.md with a Markdown lexer (so link syntax quoted inside backticks is left alone) and resolves each against the tree: a relative link must land on a file, an #anchor must name a heading of the page it points at, and an image must be on disk. Anchors resolve through the one slug rule in scripts/build-docs.mjs, which is GitHub's — punctuation dropped rather than hyphenated, Arabic letters kept — so a link that passes here lands on the site and on GitHub both. Every Settings → Tab → Row path in the prose is read against the panel's own source (the tab table, the group headings and the row index, with labels resolved through client/i18n.ts in the page's language), and every page in the site's table must exist in Arabic with the same heading structure as its English twin. tests/docs.test.ts runs the same function under npm test.

npm run check-settings — the settings index

client/components/settings/settingsIndex.ts, which the panel's search reads, is generated from the panel's source by node scripts/gen-settings-index.mjs; this gate fails when the checked-in file and the source disagree, which is the only way they drift. A search that silently stops finding a row is worse than no search.

npm run check-whatsnew — the deck

A minor version (x.Y.0) must be listed in client/whatsnew/versions.ts and have a deck with at least one slide in releaseNotes.ts, and every slide's title and body must carry both languages. A patch inherits its minor's deck. A version bump without a deck fails here, which is the reminder.

npm run check-a11y — static accessibility

Holds the line an audit drew, from the source alone: no outline: none without a replacement focus ring in the same rule, an accessible name on every icon-only control, and the rest of the list at the top of the script. Like check-i18n, it exists for the class of regression that is invisible in review and invisible in a screenshot.

npm run check-cascade — no phone rule is undone by a later one

No browser, no server. The phone and the touch shell are @media blocks written over the desktop's rules, and a block only wins when it comes after what it adjusts. Five times a phone rule shipped dead because a rule for the same selector and property sat later in the cascade with no condition at all: What's-new's position count (shown for a phone, then hidden for everyone eighteen lines further down), the library-roots editor's wrapping, three settings declarations (stated in app.css, restated for the desktop by settings.css, which loads later), the tag shelf's sort button and the unlinked mentions' link buttons. Each file reads correctly on its own; the loser is simply never applied, so neither a diff nor a screenshot review can see it.

The gate reads every client/styles/*.css in the browser's order — the sheets client/index.html links, in link order, then every sheet a module imports (those always follow the linked ones, in an order the script cannot know, so two imported sheets are never compared). It fails on a declaration inside a phone or touch block (an @media asking (pointer: coarse), (hover: none) or a max-width of 1000px or less) whose property is set again, for the same selector, by a later rule with no @media around it. !important counts the way the cascade counts it; a shorthand undoes its longhands and a logical property its physical twins (padding undoes padding-inline, min-height undoes min-block-size). A later rule under a reader's preference — prefers-reduced-motion, forced-colors — is narrower than the phone block and overrides it on purpose, so it is not a failure. Different selectors that reach the same element are check-phone's to measure. --list prints every finding without failing.

Its companion is tests/breakpoints.test.ts, which holds every stylesheet under client/ and mobile/src to ONE phone width: a max-width between 601 and 799px is the shell's 700 (PHONE_SHELL_QUERY, client/shellQuery.ts), unless the three lines above it say why not with the words "not the shell's 700" — the public blog's 640 does, for its own grids — and no source file spells a phone query out instead of importing the constant.

npm run check-phone — the phone shell, driven

A browser gate: CHROMIUM=/usr/bin/chromium ASTROLABE_PASSWORD=<pw> npm run check-phone -- <url> [outdir], against a scratch server. It drives the phone shell (client/phone/) the way a reader's thumb does, in English and Arabic, on six shapes, and photographs every screen and sheet it passes.

What it does, as assertions. A tap on a folder pushes the folder; a tap on a note changes the URL and the title (the old gate was green the day a tree tap on a phone opened nothing); the note screen has no tab bar; the note sheet takes a history entry and the browser's back closes it before it pops the note; Publish asks, and a cancelled publish publishes nothing; the mode icon flips the mode it names; back pops the note to its folder; a long press raises the row's action sheet and back closes it; Search is focused on arrival; a calendar day opens as a sheet; a deep link opens its note and back from it comes home to Today; no page errors. And, since 3.27.0, each of Round 2's screens is asked what a thumb asks of it: Orbits lists its decks, a deck opens, and Study starts the session full screen (on a tablet it takes both columns) and back lands on the deck; Sigils lists its sigils, a sigil opens and a tick answers at once and the server has it; Media lists its shelves and a tracker opens its card; a book wears one bar of its own (no desktop bar, no phone top bar) and its scrubber moves the page; the reader's ⋯ is an action sheet and back closes it and keeps the book; the theme picker (a layer on <body>) takes a history entry and back closes it; Settings is a list, a section is a screen, an edit raises the save bar, back with an edit asks and Cancel keeps the edit, and Save writes it; the tag picker opens over the note sheet and writes the tag into the note; and a folder comes back scrolled where it was left. Since 3.34, the report of a reader on a Galaxy Z Fold, asked first: two folders down, a reload keeps the folder and its ‹ lands on the parent, and so it does after a trip to Today and back, with the OS back going up from there rather than home; the folder's top bar is its path as crumbs that end in its own name and go up (through "…" where they fold); the Notes tab starts on the Tree on two columns and on Folders on one; the Tree opens a folder in place, remembers it across a reload, folds a folder's files into one row that opens in place, says an empty folder is empty, and a long press on its row is the menu; and on two columns the list keeps its scroll and its lit row while the note changes, the grip widens the list and the width is remembered, and the note's sheet slides over the note's column, not the list. CHECK_PHONE_SHAPES=phone,fold-open runs a few shapes while working; the gate is all six.

What it measures, on every screen and sheet. Nothing overflows sideways (a strip that scrolls on purpose is exempt); every shell target is ≥44px (height always, width too when the control carries no text; prose, a checkbox inside a ≥44px label and a data picture's cells are not shell targets); every text field is ≥16px, below which iOS Safari zooms into the field; and nothing covers a target (elementFromPoint at its centre answers the target). Under an open sheet only the sheet is asked, because the page under it is inert on purpose. The note sheet's Outline and Backlinks panes are measured WITH ROWS IN THEM: when the note has headings and backlinks (asked of the server), the gate first asserts that the pane lists them, so a 44px check never passes over an empty pane.

The six shapes. phone is a Pixel 7 at 412×915 with a finger. fold-cover and fold-open are a Galaxy Z Fold's two screens as Chrome reports them at its DPR of 2.625 — the cover screen at 344×882 and the inner screen opened at 690×829, both one column (two columns start at 1000px), the inner one with the note's tabs. stylus is 720×820 at DPR 1.5 with a pen — availablePointerTypes=6, primaryPointerType=2, availableHoverTypes=3, primaryHoverType=1, a blink setting on its own browser, because hasTouch makes Chromium report a coarse-only device whatever the pointer flags say; it is the posture that was once served the desktop shell, and it draws one column, the open Fold's width. tablet and tablet-land are a touch tablet at 820×1180 (one column, with tabs) and 1180×820, where the shell draws its rail, its list column and the note, and the note sheet slides over from the side. After the matrix, a note takes the page: at 344, 690, 829×690, 768, 820, 1024 and 1180, in both languages, an open note is at least 90% of the viewport under 1000px; above it the list is at most 360px and Hide list makes the note at least 90%, remembered across a reload; a 690 window widened to 1180 and back keeps its note open; and from 600px a second note opened from the list is a second tab, the first one tap away, × showing its neighbour and a reload keeping both.

npm run check-shell-seam — two shells, no shared chrome

Static. The phone shell and the desktop shell share the store, the API layer, the dictionary and every content surface, and nothing of each other's chrome: client/phone/ may not import app.css or the desktop's tab strip, pane grid, grips, status bar or sidebar; client/components/ may not import from client/phone/; and client/phone/phone.css asks no width question, because being mounted is the condition. tests/phoneShell.test.ts runs the same rules.

npm run check-bundle — what each audience downloads

After npm run build. The client ships one entry chunk plus a chunk per surface, and the split only means something if it holds: one careless import at the top of a file the entry already loads brings the whole app shell back into an anonymous reader's first request. The gate measures every audience's download against a budget; a budget moves only by the actual overage, with the cause written beside it. The dictionary is two chunks, one per language, and neither is in the entry: each audience is measured with one language in it, the larger, since a page downloads the one it speaks.

npm run check-perf — the performance gate

After npm run build. Every other gate here holds a promise about what the product does; this one holds the promise about how it feels, and that promise is the one that decays invisibly — no screenshot shows a keystroke arriving a frame later, and nothing at all shows on the seed vault, because the vault where it shows is the one with two thousand notes in it.

So the gate brings its own. scripts/perf-fixture.mjs generates a vault from a fixed seed — 2,000 notes across 40 folders with frontmatter, wikilinks and tags, a 3,000-line note, a note with fifty embeds, a year of daily notes and twelve sigils with most of a year of log under each — and check-perf starts its own server over it, on its own port, in open local mode. It never takes a vault path, and the fixture reads nobody's disk, so neither can be pointed at yours. Set ASTROLABE_SEED_VAULT=<vault> to fold a real book and real Sigils and Orbits notes in as well; leave it unset and the gate measures the generated vault, which is the same vault on every machine.

Nine budgets over seven surfaces, each the best of several rounds with the CPU throttled to a quarter speed (Lighthouse's mid-tier multiplier — an unthrottled loopback has no headroom left in which a regression could show). Best-of, not average: other work on the machine can only ever make a round slower, so the fastest round is the one closest to the cost of the work itself, and a gate built on the average is a gate that fails because somebody started a build.

BudgetWhat it catches
first paint of the admin appa static import that drags a lazy surface back into the shell's first request
keypress → paint in the 3,000-line note, median and p95a per-keystroke pass that has quietly become O(document)
long-task time over a 40-keystroke burstthe same failure, measured as work rather than as which side of a frame boundary it landed on — the sharpest of them, and the one the purge moved most
reading render of that notethe same, for the one operation whose cost is the whole document at once
the Sigils page, door → twelve cards drawn (budget 1,650 ms; 1,241 when set)a card whose streak, heatmap or week went back to walking the whole log per render
the Calendar page, door → the month drawn with its lines and daily-note dots (250 ms; 157 when set)a day cell that reads the vault instead of the agenda the page computed once
the phone's Tree, a tap → a folder of fifty open (100 ms; 70 when set), and the longest main-thread task over sixteen taps (none)a tree that walks the vault instead of the rows that show, or measures its window after the rows are in the document

Typing latency is the browser's own Event Timing — hardware keydown to the paint that shows the letter — not a frame counter, and the caret is put at line ~1,500 first, because typing at line 1 of a long note measures a short note. Budgets move like check-bundle's: by the actual overage, with the cause written beside them, or down when a round earns it. PERF_ROUNDS=1 is the quick form; PERF_KEEP=1 leaves the generated vault behind to look at.

npm run check-media — the folder chip

In a browser, against a running instance (node scripts/check-media.mjs http://localhost:6801, ASTROLABE_PASSWORD when the instance has one). Writes two trackers through the API, one over a plain folder of two notes and one over a folder that has its own note, opens the Media page and presses each folder chip: the folder's row must be in the tree, unfolded, scrolled on screen and painted — without the tree holding keyboard focus, which a click on the Media page never gives it — and the folder with its own note must open that note as well. The fixtures are deleted, permanently, however the run ends.

npm run check-books — the reader

After npm run build. Ten properties of the book reader — both surfaces, the PDF one and the EPUB one — that are invisible in review and expensive to discover in production, the first being that the pdf.js worker is a real same-origin asset rather than a blob: URL — which works under the dev server's absent CSP and dies under the real one.

npm run check-icons — the folder marks

shared/folderIconNames.ts and shared/folderIconPaths.ts are drawn from the catalog by npm run gen-icons; this is the --check form, failing when the drawing is stale.

npm run check-signatures — every signature house

A browser gate that renders every signature house through the real public renderer against an isolated fixture API, touching no live vault, settings, saved design or account. SIGNATURES=a,b narrows it, THEME=<id> renders every house in one theme, SHOTS=1 writes screenshots.

npm run check-hovercache — the hover-card cache

A browser gate proving the LRU bound on hover previews holds in a real session: the cache is keyed by note path, so without CACHE_MAX an evening of skimming links would retain every note skimmed. A bound that is only asserted by a constant is a bound that silently stops being true.

npm run check-designer-nav — the designer's navigation and alignment

A browser gate born of a bug that shipped past every other check, in the owner's own language: a preview scaled with a physical transform-origin inside a logical layout sits away from its box in [dir="rtl"]. It measures every designer surface in both directions, and walks the designer's navigation.

scripts/check-pdfsearch.mjs — search inside a book

A bare script with no package.json entry: CHROMIUM=/usr/bin/chromium node scripts/check-pdfsearch.mjs <url> <password>. Against a server whose vault holds a PDF with a word that appears in no note, it proves the whole loop — the API answers with a kind: "book" row naming the page, the sidebar draws it, and clicking it opens the reader on that page with the word found.

scripts/check-desktop-boot.sh and check-desktop-relaunch.sh — the desktop gates

Both take an AppImage and boot it under Xvfb (a virtual screen) with an isolated config directory, from an empty temp directory (never from the checkout: an app started beside a .env links itself to that deployment), over an empty temp vault named by ASTROLABE_VAULT. The boot gate fails on an uncaught exception or a syntax error in the first 25 seconds — the 3.1.0–3.3.4 builds crashed at load and nothing said so. The relaunch gate sets ASTROLABE_SELFTEST=relaunch, which makes the app restart itself four seconds after boot exactly the way an applied update does, and passes only when the first process is gone and a second one started from the same file is running — app.relaunch() looked like it worked and did not, because Electron's relauncher runs from the mounted image after it is unmounted. Every AppImage release runs both before upload.

The parity tests — one rule, every side

Some promises are not a script's but the suite's, because they hold between two pieces of code that must agree. contracts/gates.md lists every gate; these are the tests that hold one rule across the places that used to keep a copy each:

TestWhat it holds
tests/pocketParity.test.tsThe phone's pocket index and the server's give the same answers on one fixture vault: link resolution, aliases, tags, banners, backlinks, search and the tree's order.
tests/headings.test.tsOne heading rule (shared/headings.ts): the editor's [[Note# offers, the outline, the anchor table and the reading view's ids are the same list, and a YAML # comment is never a heading.
tests/byteRange.test.tsOne Range: parser (shared/byteRange.ts) behind both /api/file routes, and the same five requests answered the same way by each.
tests/fileTypes.test.tsOne served-type table, one kind table and one image test (shared/attachments.ts), one tree order (shared/tree.ts), one local day (shared/dates.ts) — and no stray copy of either of the last two in the code.
tests/breakpoints.test.tsOne phone width in every stylesheet (above).
tests/i18nScan.test.tsThe copy scan sees what the line scan could not (above).
tests/rtlGlyphs.test.tsA Bidi_Mirrored glyph (‹ › « ») is mirrored once: whether the browser mirrors it depends on the font, so a glyph flipped by hand under RTL must be pinned left-to-right first.
tests/sourceText.test.tsNo source file carries a literal control character, so grep never calls it binary.

Performance

What was measured, on what, what it cost before and after — and what is still slow and why.

The fixture

Performance here is measured on a vault nobody has, because the two vaults that exist are both useless for it: the seed vault is a dozen notes, where everything is instant, and the owner's is private and is never served. scripts/perf-fixture.mjs generates the third one from a fixed seed, so that a number taken today is comparable with one taken next month:

  • 2,000 notes across 40 folders, each with six frontmatter properties, five [[wikilinks]] and three #tags — 110 distinct tags in a tree, and 2,000 nodes for the graph;
  • a 3,000-line note (291,000 characters, 54,000 words) — the editor's worst honest case;
  • a note with fifty ![[embeds]];
  • a year of daily notes, so the Calendar page has a month with something in every cell;
  • and, only when ASTROLABE_SEED_VAULT names a vault to take them from, a real 665-page PDF and the real Sigils and Orbits notes — a reader and two shelves with something of their own to draw. It is opt-in and it says so in its own output, because a fixture that reached into a vault nobody named would be two bad things at once: an unauthenticated scratch server over somebody's private notes, and a budget only one machine could meet. It measurably would have been the second — the generated vault carries 110 tags everywhere and 121 on the laptop the purge was measured on.

The sweep below was run with that variable set, so its vault was 2,376 notes rather than the 2,367 the generator alone writes. The nine notes are noted, not hidden; nothing in the table turns on them.

Every number below was taken through the DevTools protocol against a scratch server over that vault, with the CPU throttled to a quarter speed — Lighthouse's mid-tier multiplier. The throttle is not pessimism: unthrottled on a loopback socket, every surface here lands inside one animation frame and there is no headroom left in which a regression could ever show. Typing is the browser's own Event Timing (hardware keydown → the paint that shows the letter); "before" is 3.18.0 and "after" is the same tree with the purge applied, measured back to back on one machine.

One caveat about every millisecond on this page: the machine this was measured on routinely carries a load average of 13–17, and throttled numbers move with it. Everything below was taken back to back on the same machine in the same state, which is what makes a before and an after comparable — but the absolute values are a busy laptop's, not a benchmark rig's. Where load matters to a budget, scripts/check-perf.mjs says so beside that budget.

The numbers

BeforeAfter
Typing, 3,000-line note — keypress → paint, median32 ms24 ms
… p9548 ms32 ms
… main-thread long-task time over a 40-key burst571 ms184 ms
Typing, 50-embed note — input handler, median18.0 ms6.5 ms
Reading view, 3,000-line note, render1,673 ms1,081 ms
Admin app — first paint1,008 ms936 ms
… JavaScript in the first request (check-bundle)1,551.7 kB1,048.7 kB
… time to interactive (end of the last long task)1,638 ms1,516 ms
Public site — first paint608 ms548 ms
GET /api/props (the properties shelf)28.3 ms0.9 ms
GET /api/tags1.1 ms0.9 ms
Sigils page, open2,861 ms2,628 ms
Calendar page, open2,265 ms2,182 ms

Unchanged, and measured so: the tree opening forty folders at once (974 → 963 ms), the tag shelf's tab flip (39 → 43 ms), search's per-keystroke paint (16 → 16 ms), a single note's save and re-index on the server (7.0 → 7.7 ms), a 300-file watcher burst reaching the index (353 → 347 ms), and the indexer's cold start over 2,376 notes (1,276 → 1,264 ms). Heap after four hundred navigations over ten minutes stayed flat both times (−6.2 MB and +1.2 MB after a forced collection — a walk of the whole product retains nothing).

What the purge actually moved, in the order it was worth moving:

  1. The whole vault was walked once per wikilink. resolveLink called collectNotes, which flattens the tree and sorts it with localeCompare — so rendering the 3,000-line note walked 2,376 notes and re-sorted 2,000 of them 176 times, once per link. It is memoized on the tree object now (the store replaces it and never mutates it, so identity is an exact stamp), with a name table and a path table built once beside it, and the two loops in resolveLink became two map lookups. 6.8% of the reading render and 2.8% of every keystroke, gone.
  2. Every note's annotations were placed 120 ms after every keystroke — including the notes with no annotations. The painter reduced the whole document to prose with a per-character offset map, folded it, and placed nothing: an empty list is truthy, so the guard above it never fired. The single largest cost of a keystroke, spent on nothing.
  3. The status bar re-counted the note on every autosave. The bar draws the live count the buffer publishes; the fetched copy behind it was re-fetched and re-counted every 600 ms of typing to fill a field it never draws. It now counts only when there is no live count to draw, and the buffer's own count is memoized on the document.
  4. The outline pane was in the admin's first request. It reaches the section surgery → the editor's sectioning extension → the live-preview decoration engine → the reading renderer → KaTeX. One lazySurface boundary took 503 kB and fifteen files out of the first paint.
  5. Intl.DateTimeFormat was constructed per date. Building a formatter loads ICU data; formatting with one is a lookup. The Sigils page draws one date per card plus one per heatmap cell, twice over where both calendars are shown — 5.9% of opening the page. Formatters are cached by their own arguments now, which is what shared/calendar.ts already did for Hijri.
  6. The tag and property shelves were recomputed on every request. props() walks every note and splits, trims and case-folds every frontmatter value; nothing memoized it. It is now validated against a shelfRevision() the index moves — exactly the bargain server/graphCache.ts already strikes — which is the 28.3 ms → 0.9 ms above.

Where the time still goes

Measured, and left alone, because the honest answer is that the cost is real:

  • The tree holds 2,151 rows in the DOM with forty folders open, and opening all forty at once costs ~960 ms of React reconciliation and DOM creation. Scrolling it is a clean 16.7 ms frame, and nobody opens forty folders in one gesture — one folder is a fortieth of that number. The list is not virtualised: the rows carry drag-and-drop, a roving tab index, aria-posinset over the filtered set and a keyboard walk, and a virtualiser under all four would trade correctness for a number no reader produces. The per-folder cap ("Show N more", 300 rows) is the existing answer and it still holds.
  • The graph's layout is 53% of its own time, in a hand-written Barnes-Hut force simulation over typed arrays, with another 17% in drawImage. That is a simulation doing its work, not a bug; 2,000 nodes settle in about eight seconds and the canvas is up in two.
  • Search is 91% idle. The ~950 ms from the first keystroke to the first row is the 200 ms debounce plus the typing itself plus a 6 ms server round trip; each keystroke paints in 16 ms. The debounce is the feature.
  • The reading render's remaining 1,081 ms is mostly layout — six thousand nodes of prose being measured by the browser. The JavaScript above it is now a small fraction.
  • Opening a surface for the first time costs its chunk. The Sigils, Orbits and Calendar pages are ~2.2–2.6 s on a cold visit, of which two thirds is fetching and parsing the lazy chunk those pages exist inside. That is the split working, not failing.

Offline, measured properly

The service worker installs and takes control in about 250 ms, and a short read leaves ~88 entries in its cache. With the server stopped — not with the browser's offline emulation, which intercepts a navigation above the worker and never lets it answer — a navigation to a note renders the cached shell in about 5 s with the "Reading this device's copy" strip on it. A harness that uses the emulation instead reports net::ERR_FAILED and has proved nothing; see Offline.

The done bar

The sequence a change runs before it is called finished, in this order: npm run typecheck · node scripts/check-i18n.mjs · npm test · npm run build and then npm run check-bundle · npm run check-perf (on a quiet machine) · npm run check-a11y · npm run check-contrast · npm run check-settings (with node scripts/gen-settings-index.mjs first when a row changed) · npm run check-keymap when a key changed · npm run check-names · npm run check-shell-seam · npm run check-docs · npm run build-docs · npm run check-desktop when electron/ or desktop/ changed · npm run check-windows-layout when the shell's layout, the panes or their breakpoints changed · npm run check-cascade whenever a stylesheet changed. Then the browser gates the change touches — npm run check-phone whenever a stylesheet or a piece of the shell moved — with CHROMIUM and ASTROLABE_PASSWORD set, against a scratch server over a scratch vault — never the owner's.

Screenshot harnesses

These are not wired into package.json; run them by hand, for visual review. All take CHROMIUM, and most take THEME=parchment and LANGSET=ar to check a theme or the right-to-left mirror.

HarnessCaptures
node scripts/shoot.mjs <url> <outdir>The editor, graph and palette in both themes
node scripts/shoot-settings.mjs <url> <password> <outdir>Every settings section through the rail, printing the panel's scroll geometry and the specimen font sizes
node scripts/shoot-sync.mjs <url> <password> <outdir>The Backup & sync section plus the status badge's detail panel
node scripts/shoot-themes.mjsEvery theme (ONLY= narrows it)
node scripts/shoot-rtl.mjsThe Arabic mirror — and asserts glyph order in tag chips
node scripts/shoot-tex.mjs.tex notes, with six assertions
node scripts/shoot-templates.mjsThe template picker, with assertions
node scripts/shoot-hover.mjsHover previews, with assertions
node scripts/shoot-controls.mjsThe hand-drawn control set (W/H set the viewport)
node scripts/shoot-fontupload.mjsThe font uploader

Contributing a change

  1. Read DESIGN.md and the contract for your area first. DESIGN.md carries the rules a change is judged against; the contracts carry the promises the code has already made — CONTRACTS.md is the map, and the words are in contracts/, one file per area (core, the vault and server, the shells, themes, the editor, reading, the public site, features, sync and the apps, i18n, the gates, the release history). A change is written into the section it changes — edit the area, append nothing — and a release adds one line to contracts/releases.md. Most review comments here are one of those documents quoted back.
  2. Run npm run typecheck. The build is strict, and the server has no compile step to catch things later.
  3. Run the gates your change touches. Theme tokens mean check-contrast; any user-visible string means check-i18n; the outline or note-rewriting code means check-sections; the editor means check-caret (and check-french for anything that touches typing); the designer means check-board / check-preview / check-design; and anything that reads a keystroke means check-keymap and check-layouts, plus tests/shortcuts.test.ts — is the binding unique, and can a non-Latin keyboard reach it?
  4. Both languages, both directions. Every string comes from client/i18n.ts, and every layout is built on CSS logical properties. A change that only reads correctly left-to-right is not finished — LANGSET=ar on any shoot harness is the cheapest way to see it.
  5. Say why in the code. This codebase's comments explain the decision, not the mechanism. A patch that changes a rule should move the paragraph that stated it.

Edit this page on GitHub

Astrolabe is free software. These pages are built from the Markdown in the repo's docs folder.