-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
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.
index.html ships with an empty data block:
<script id="sb-payload" type="application/json">null</script>The file is, quite literally, the database:
-
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. -
On save, only that one
<script>block is replaced (via a regex match on itsid, 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. - On next open, the app reads that block back out and picks up exactly where it left off: theme, language, colours, records, everything.
| 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 |
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.
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.
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>inindex.html's<head>readsthemestraight out of the payload and setsdocument.documentElement.dataset.themebefore the body renders. - Once Preact mounts,
Workbench(insrc/app.jsx) applies chosen colours as CSS custom properties on the root element (paletteVariables()insrc/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.
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 underfile://. -
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 thoughfile://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#appreliably exists in the DOM by the time the script runs.
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.
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.
src/i18n.js is a flat dictionary, deliberately decoupled from everything above it — see
Interface Languages.