Skip to content

Architecture

Michael Dohmen edited this page Aug 23, 2026 · 3 revisions

Architecture

Project layout

src/domain.js          the only file most tools need to change
src/app.jsx             shell, list, form, save logic
src/settings.jsx        settings page
src/chat.jsx            AI assistant dock
src/brand.jsx           wordmark and uploaded logo
src/i18n.js             interface language dictionary (English, German)
src/tokens.css          colour and type primitives
src/styles.css          semantic roles and components
src/lib/payload.js      read and write the embedded data block
src/lib/crypto.js       PBKDF2 + AES-GCM
src/lib/ai.js           endpoint client, dialect negotiation, context building
src/lib/actions.js      validation and application of AI-proposed changes
src/lib/search.js       global search and per-field filters over the lists
src/lib/sort.js         typed column sorting for the entity lists
src/lib/svg.js          logo sanitiser
src/lib/color.js        palette derivation and contrast check
test/smoke.mjs          end-to-end test against a real headless browser

Stack: Preact for the UI, Vite with vite-plugin-singlefile for the build. No router, no state management library, no CSS framework — the whole thing is small enough that none of those earn their weight in the shipped file.

How persistence works

index.html ships with an empty data block:

<script id="sb-payload" type="application/json">null</script>

The file is, quite literally, the database:

  1. On startup, the app snapshots the untouched document source (document.documentElement.outerHTML) before it changes the DOM at all — this pristine copy is what gets rewritten on save.
  2. On save, only that one <script> block is replaced (via a regex match on its id, not a full DOM re-render) with a new JSON payload — settings, records, and an encryption envelope if the file is sealed — and the whole document is written out as a new file.
  3. On next open, the app reads that block back out and picks up exactly where it left off: theme, language, colours, records, everything.

Write paths, in descending order of comfort

Path Where it's used Behaviour
File System Access API, existing handle Chromium, after the first save Writes straight back, no dialog
File System Access API, new handle Chromium, first save One native "Save As" dialog, then remembered for the session
Anchor-download fallback Firefox, Safari Every save goes to the Downloads folder

Why no localStorage or IndexedDB

Deliberately absent. Both are unreliable under file://: Chrome refuses IndexedDB when third-party cookies are blocked, and localStorage is shared across all local files opened from the same origin in some browsers — which for file:// can mean every HTML file on the whole machine sharing one storage bucket. The embedded payload sidesteps both problems and works identically everywhere.

Encoding a payload safely into a <script> tag

Naively embedding JSON.stringify(payload) into a <script> block is not safe — a stray </script substring inside a text field would terminate the script early and corrupt the file. Every < character in the serialized JSON is escaped to < before embedding (a safe superset: it escapes </script> regardless of case or whitespace variations, without needing to specifically pattern-match the string "script"), and Unicode line/paragraph separators (U+2028/U+2029) are escaped too, since older JS engines treat them as line terminators inside string literals.

Theme, density and colour, before Preact even mounts

Two things need to be correct on the very first paint, before any component runs — otherwise a file saved in dark mode would flash the light interface for a frame:

  • A small inline <script> in index.html's <head> reads theme straight out of the payload and sets document.documentElement.dataset.theme before the body renders.
  • Once Preact mounts, Workbench (in src/app.jsx) applies chosen colours as CSS custom properties on the root element (paletteVariables() in src/lib/color.js), so dark mode, fixed overlays (drawer, dialogs, watermark) and the rest of the UI all inherit from one source instead of each needing their own light/dark logic.

Build configuration, and the two footguns it exists to avoid

vite.config.js is short, but every line is there because something broke without it:

export default defineConfig({
  base: './',
  plugins: [preact(), viteSingleFile()],
  build: {
    target: 'es2020',
    cssCodeSplit: false,
    assetsInlineLimit: 100_000_000,
    chunkSizeWarningLimit: 4000,
    rollupOptions: { output: { inlineDynamicImports: true } },
  },
})
  • base: './' — without a relative base, asset paths point at the server root, which resolves to nothing under file://.
  • Do not set removeViteModuleLoader: true. In combination with the rest of this config, that option empties the inlined script block entirely, producing a file that's a few KB and renders nothing. It's tempting because it sounds like more aggressive inlining; it's actually the opposite.
  • type="module" on the script tag is fine to keep even though file:// normally can't load modules over CORS — after inlining, nothing loads externally anymore, so the restriction never triggers. It also conveniently brings deferred-script semantics for free, so #app reliably exists in the DOM by the time the script runs.

Where the AI plumbing lives

src/lib/ai.js is the endpoint client — see AI Assistant for the dialect negotiation and context-building details. src/lib/actions.js validates and applies whatever the model proposes; nothing from a model response is trusted without going through it first.

Where branding lives

src/brand.jsx renders the wordmark (text or uploaded SVG) at four places — header, lock screen, settings footer, watermark — from one brand object in settings. src/lib/svg.js sanitises any uploaded logo before it's stored. See Branding.

Where the interface language lives

src/i18n.js is a flat dictionary, deliberately decoupled from everything above it — see Interface Languages.

Clone this wiki locally