Imported from pitermarx/Virgulas (
AGENTS.md). Install upstream withnpx skills add pitermarx/Virgulas. Copyright stays with the author.
Agents
This is the contributor and agent handbook for Virgulas. It contains the binding rules, the local setup, the architecture, and the test/CI workflow.
- Using Virgulas? Read
README.mdand the in-app tour (source/intro.vmd). - Changing Virgulas? Read this file completely before starting.
Companion documents:
| Document | Role |
|---|---|
docs/SPEC.vmd |
Normative product behaviour (source of truth) |
docs/VMD.md |
The VMD plain-text outline format |
docs/design.md |
Visual and interaction design system |
docs/SECURITY.md |
Open security work |
source/.well-known/security.txt |
Deployed security policy: how to report, and accepted risks |
docs/review/README.md |
Review findings and the ordered roadmap (not tickets yet) |
docs/storage-interface.md |
Storage seam design: DocStore/RawStore contracts, codecs, rollout plan |
The public site follows The Website Specification. The
compliance notes (edge security headers, crawl and discovery files, the privacy and 404 pages,
and the accessibility baseline) live in docs/SPEC.vmd → SITE AND DISCOVERABILITY.
Rules
Rule 1 — Respect SPEC.vmd
SPEC.vmd is the source of truth. Do not change it unless specifically instructed.
- Features absent from
SPEC.vmdmust be removed - Features present in
SPEC.vmdmust exist in the app - If the SPEC is ambiguous or contradictory, stop and ask — do not assume
- If a task requires a SPEC change to implement correctly, stop and ask
SPEC changes are pre-approved on a branch with a pull request. A change that redefines
behaviour must land on a branch and go through review like any other code change — never as an
unexplained edit, and never pushed straight to main. State in the PR description which SPEC lines
moved and why. docs/SPEC.vmd stays the source of truth either way: update it in the same commit as
the code it describes, so the shipped app never disagrees with it.
Rule 2 — Keep README.md in sync
README.md is the user-facing description of the app (what it does and why it is useful).
Whenever you add, remove, or change a user-visible feature, update README.md in the same commit.
Contributor workflow changes belong in this file instead, not in README.md.
Rule 3 — Every feature must have tests
Whenever you add or change a feature or behaviour, add or update the corresponding test(s).
- Pure logic (document model, parsers, crypto, sync merge, markdown) → unit suite in
tests/unit/suites/*.ts, run withbun test. Register the suite intests/unit/unit.test.ts. - UI behaviour and integration flows → Playwright spec in
tests/*.spec.ts. - E2E specs import app singletons from the built bundle (
/js/app.js) so they observe the same module instances as the running app. - A feature is not considered implemented until its tests pass
- Tests must cover the happy path and all edge cases described in
SPEC.vmd - Do not mark a task complete if any test is failing
Rule 4 — Update schema files for any database schema change
Whenever you add, modify, or remove a table, column, index, policy, or function,
update the corresponding file in supabase/schemas/ in the same commit.
- Schema files live in
supabase/schemas/(e.g.outlines.sql) - Migrations are auto-generated by Supabase from the schema diff — do not write them manually
- To apply and generate a migration after editing a schema file:
bunx supabase migration new <migration-name> - Checklist for schema changes:
- The relevant
supabase/schemas/*.sqlfile is updated
- The relevant
Rule 5 — File structure
Do not create files outside these locations without explicit instruction:
/
├── .github/workflows/ — ci.yml (test, db publish, release, deploy), daily.yml
├── docs/ — SPEC.vmd, VMD.md, design.md, SECURITY.md, demo.png, review/ (findings and roadmap), storage-interface.md (storage seam design)
├── scripts/ — build, cache-bump, hooks, release, serve, CSP + edge-header helpers
├── source/
│ ├── index.html — HTML entry point (loads js/app.js + js/app.css)
│ ├── intro.vmd — in-app tour loaded in Memory mode
│ ├── site.webmanifest — PWA manifest (Quick capture shortcut, share target)
│ ├── version.json — version/sha/GitHub timestamp; stamped by the build
│ ├── sw.js — service worker
│ ├── privacy.html — privacy policy page
│ ├── 404.html — branded not-found page (served with a real 404 status)
│ ├── robots.txt — crawl policy, Content-Signal, sitemap reference
│ ├── sitemap.xml — canonical URL list
│ ├── llms.txt — curated index for AI agents
│ ├── favicon.ico/.svg — root favicon fallbacks
│ ├── .well-known/ — security.txt, gpc.json
│ ├── css/ — modular stylesheets bundled into app.css
│ ├── js/ — TypeScript application modules (entry: app.ts)
│ ├── fonts/ — self-hosted Inter webfonts
│ └── media/ — icons and static assets (PWA icons, Quick capture icon)
├── supabase/
│ ├── schemas/ — SQL schema files, one per table
│ ├── migrations/ — generated migrations
│ ├── config.toml — local Supabase configuration
│ └── seed.sql — optional local seed script (currently no-op)
├── tests/
│ ├── *.spec.ts — Playwright E2E specs
│ ├── test.ts — shared Playwright fixtures/helpers
│ ├── setup/dom.ts — happy-dom preload for unit tests
│ └── unit/
│ ├── unit.test.ts — bun test entry (replays the suites)
│ ├── testing.ts — section harness (asserts + runner)
│ └── suites/*.ts — unit suites
├── AGENTS.md — this file
├── README.md — user-facing description, keep in sync
├── LICENSE — Unlicense (public domain)
├── package.json / bun.lock — dependencies and scripts
├── bunfig.toml — bun test preload (happy-dom)
├── tsconfig.json — strict app config
├── tsconfig.test.json — test config (inherits strict)
└── playwright.config.ts — E2E config (webServer: `bun run dev`)
Generated artifacts (dist/, node_modules/, playwright-report/, test-results/) are gitignored.
Rule 6 — Tech stack
Do not introduce dependencies or frameworks beyond what is listed here.
- TypeScript throughout (
source/js/*.ts), strict mode. No plain.jsapp modules. - Bun is the runtime, bundler (
bun build), and unit test runner (bun test). - Preact + Preact Signals + htm for UI, installed from npm and bundled — no import map, no CDN
- Supabase JS client from npm (imported in
sync.ts) - marked + dompurify for markdown; happy-dom for the unit-test DOM
- All crypto via the native Web Crypto API — no third-party crypto libraries
- All styling via plain CSS and CSS variables — no frameworks or preprocessors
- If a task seems to require a new dependency, stop and ask
Rule 7 — Code conventions
- Two-space indentation throughout
- CSS variables for all colours, sizes, and font definitions — no hardcoded values
- No
var— onlyconstandlet - Async/await throughout — no raw Promise chains
- All encryption and decryption must go through
crypto2.ts. Do not inline crypto logic elsewhere. Exception:biometrics.tsperforms its own AES-GCM wrapping for the device-local passphrase seal. That key is never used for document encryption. - All Supabase calls must go through
sync.ts(remoteSync). Do not call Supabase from UI code. - All
localStoragereads and writes must go through thestoreslots inutils.ts. Do not accesslocalStoragedirectly from UI code. Only the encrypted document payload (vmd_data_enc) is ciphertext; mode/theme/username, sync timestamps, the Supabase config, and the inbox queue are non-secret metadata stored in plaintext.
Rule 8 — Definition of done
A task is complete only when all of the following are true:
- The feature behaves exactly as described in
SPEC.vmd -
bun run typecheckpasses (0 errors, app and tests) - All existing unit tests pass (
bun run test:unit) - All existing Playwright tests pass (
bun run test:e2e) - New/updated unit and/or Playwright tests cover the changed behaviour
-
README.mdanddocs/SPEC.vmdare updated to reflect the change - If the schema changed, the relevant
supabase/schemas/*.sqlfile is updated and the migration generated - No new dependencies have been introduced
- No files exist outside the locations defined in Rule 5
Rule 9 — Task size
Do not implement more than one atomic feature per session. If a task spans multiple items on the list below, split it.
- App shell and splash screen
- localStorage encrypted read/write via
storagemodule - Passphrase setup and unlock screen
- PBKDF2 key derivation via
cryptomodule - Node rendering and editing
- Node focus and keyboard navigation
- Node description field
- Node collapse and expand
- Node indent and unindent
- Node move (up/down)
- Node delete
- Multi-select
- Zoom and breadcrumb
- Markdown rendering
- Search
- Keyboard shortcuts modal
- Options panel (theme, purge, repository link)
- Supabase Auth (sign up, sign in, sign out)
- Cloud sync (upload and download encrypted document)
- Status toolbar
Rule 10 — When in doubt, stop
If any of the following are true, stop and ask rather than proceeding:
- The SPEC does not clearly describe the expected behaviour
- Implementing the task requires changing
SPEC.vmd - Implementing the task requires a new dependency
- Implementing the task requires files outside the defined structure
- A test is failing and the fix is not obvious
- Two rules in this document appear to conflict
Rule 11 — Conventional Commits are required
All commits (human and agent-authored) must use a Conventional Commits header:
type(scope)!: subject
- Allowed types:
feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert - Scope is optional but recommended for non-trivial changes
!is required for breaking changes; also includeBREAKING CHANGE:in the body when applicable- Auto-generated merge commit messages are allowed
Examples:
feat(sync): add remote retry backofffix(ui)!: rename storage mode labelchore: bump sw cache versions
Enforcement:
- Local
commit-msghook: runbun run sw:hooksonce per clone - CI validation: all commits in push/PR range must pass Conventional Commits checks
Known drift (documented)
These are known gaps between the spec and the current implementation. Do not assume the feature exists; update this list when a gap is found or closed.
- None at the moment.
Environment setup
Prerequisites
- Bun 1.4 or later (runs TypeScript directly, the bundler, and the unit test runner)
- Node.js 20 or later (only needed to launch Playwright)
- A Supabase project (free tier is sufficient)
Environment variables
For normal local development, .env is optional.
If you want to point tests or the app to a specific external environment, create a .env file in the project root (gitignored, never commit it):
SUPABASE_URL=https://<your-project-ref>.supabase.co
SUPABASE_ANON_KEY=<your-anon-key>
The app reads Supabase settings from localStorage.supabaseconfig.
On first run, it seeds this key with hosted defaults:
{
"url": "https://gcpdascpdrakecpknrtt.supabase.co",
"key": "sb_publishable_9Uxo-0GD-21K6mUPQ2FSuw_mDO06TJc"
}
To use local Supabase in the browser, set localStorage.supabaseconfig with your local URL/key.
Running locally
The browser only ever loads the built bundle; it cannot run source/js/*.ts directly.
Build and serve locally (builds dist/ then serves it):
bun run dev
To run the Playwright-shaped build (test hooks enabled, PBKDF2 scaled down) manually:
bun run dev:test
Both scripts watch nothing; re-run them after source changes.
Database setup
Initialize local Supabase files (first time only):
bunx supabase init
Start local Supabase:
bun run db:start
Playwright local tests assume bun run db:start has been run. They read credentials from
.env when present, otherwise from bunx supabase status -o env.
Get local URL and anon key for .env:
bunx supabase status
Generate a migration after editing supabase/schemas/*.sql:
bunx supabase migration new <migration-name>
Apply migrations locally (no seed):
bunx supabase db reset
Tests that require a signed-in state should first attempt sign-in and create the user if it does not exist.
Stop local Supabase:
bun run db:stop
Production migrations
Link the hosted project and push migrations:
bunx supabase login
bunx supabase link --project-ref <your-project-ref>
bunx supabase db push --linked
Preview migration application without applying:
bunx supabase db push --linked --dry-run
Architecture
Runtime shape
Virgulas is a static site. There is no application server: index.html loads the bundled
js/app.js and js/app.css from the same origin, and every document operation happens in the
browser. The only network access is Supabase in Remote mode, plus the SRI-pinned Umami tracker.
source/js/app.ts is the entry point. It renders the Preact tree, owns the lock screen and modal
orchestration, and delegates everything else to the focused modules listed under
Frontend module map.
Storage modes and boot
| Mode | Where the document lives | Encryption |
|---|---|---|
| Memory | JS memory only; lost on reload | none |
| Local | localStorage.vmd_data_enc |
passphrase → PBKDF2 → AES-GCM |
| Remote | Supabase outlines row (plus the local cache) |
passphrase → PBKDF2 → AES-GCM |
| File | a user-chosen .vmd file (File System Access API) |
none |
persistence.getAuthBootstrap picks the mode from the remembered mode and existing data signals
before any document work happens.
- Startup-path rule: the File-mode handle lookup is gated on
filesystemand the Supabase session probe onremote. Do not make either unconditional — both cost time-to-reveal. - Memory mode is the first-visit default and loads
source/intro.vmd(served as/intro.vmd). If the fetch fails the app falls back to a single empty node. - A quick-capture visit never boots the app until the user confirms: no lock screen, no KDF, no
decryption, no network, and nothing written to
vmd_inbox_queue. A capture URL is untrusted input (sec-01), so it only pre-fills the confirmation prompt; the queue write and the normal boot both happen on the "Add" click. Keep it that way.
Encryption
crypto2.tsis the only module allowed to encrypt or decrypt document data (Rule 7).- The stored value is
localStorage.vmd_data_enc=<salt>|<envelope>; the salt is generated once and reused. - The envelope is
v2:<iterations>:<base64(iv || ciphertext)>; legacy payloads are raw base64 and decrypt at 310,000 iterations. The current default is 600,000 PBKDF2-HMAC-SHA256 iterations with AES-GCM-256, and a normal save re-encrypts an older envelope at the current parameters. - The derived key is cached for the unlocked session, so autosave does not re-run the KDF. The cache is multi-entry (bounded LRU) keyed by (passphrase, salt, iterations) so alternating salts cost one derivation each.
biometrics.tsmay wrap the passphrase behind WebAuthn for device unlock. That wrapping key never encrypts the document (Rule 7).
Sync
All Supabase access goes through sync.ts (remoteSync) — Rule 7.
- Uploads are scheduled after typing pauses; a 60-second poll checks for remote updates while the app is open in Remote mode, and defers to active local edits.
- Pull-before-push: the remote
outlines.updated_atis compared withvmd_sync_ts; a newer remote is fetched and merged before the local write is uploaded. - Merge is per node using
lastModifiedtimestamps. Same-node different-field edits auto-merge; same-field conflicts open a blocking modal. @supabase/supabase-jsis imported dynamically and emitted as a separate chunk so Local, Memory and File users never download it. Do not turn that into a static import.
Analytics tracker and SRI
The Umami tracker (https://um.vps.pitermarx.com/script.js) is the only remote script. It is
pinned in source/index.html with a sha384 integrity attribute and crossorigin="anonymous",
and the CSP script-src allow-lists only 'self' and that host. The digest is committed on
purpose, so a changed tracker build cannot ship silently: the browser refuses to run bytes that do
not match it.
When the tracker is upgraded:
- Recompute the digest of the new script:
bun -e "const b = await (await fetch('https://um.vps.pitermarx.com/script.js')).arrayBuffer(); console.log('sha384-' + new Bun.CryptoHasher('sha384').update(b).digest('base64'))" - Update the
integrityattribute of the<script>tag insource/index.htmlwith that value. - If the host changes, update the
script-srcandconnect-srcentries of the CSP in the same file (andscripts/csp.tsif its tests reference the host), plus thepreconnectlink. - Run
bun run test:unitand the Playwright suite. The suite blocks the tracker, so also load the built site once in a browser and confirm the console shows no integrity or CSP errors.
SRI covers the script bytes only. The data-* attributes on the tag (website id and any tracker
options) are not protected by the hash and are changed with a normal review.
Frontend module map (source/js)
Use this as the default responsibility split. Keep files focused and avoid mixing concerns.
app.ts: App bootstrap, lock screen/auth flow, top-level render tree, modal orchestration.ui.ts: Preact UI components for the outliner surface and toolbars (Outline, node rendering, search results UI, tasks panel, debug panels).search.ts: Search UI state and pure search helpers shared by UI and keyboard handling (searchQuery,searchResultIndex,currentSearchMatchId, match flattening helpers).shortcuts.ts: Keyboard interaction and focus/navigation behaviour (including search key handling).outline.ts: Core document model and tree operations (CRUD, move/indent/outdent, serialization, VMD parser/writer, search tree generation).docCodec.ts: Document serialization boundary (storage seam, seedocs/storage-interface.md): theOutlineDocvalue type,jsonCodec(Local/Remote/Memory at-rest format) andvmdCodec(File/export format) — pure codecs overoutline.tssemantics.merge.ts: Pure three-way merge over the outline model (local vs remote vs numeric baseline): node-levelmergeDocumentsand document-levelmerge. No store I/O.storage.ts: Storage seam contracts (seedocs/storage-interface.md):DocStore,StringStore,RawStore,AuthProvider,StoreCapabilities, and the two-channelStoreErrortaxonomy. Contracts only — adapters import this module, never the reverse.encryptedStore.ts: The crypto decorator:encrypted(raw, session)wraps a RawStore into a StringStore, andKeySessionis the single in-memory retention point for the session passphrase (rotation, lock). All crypto goes throughcrypto2.ts(Rule 7).memoryStore.ts,fileStore.ts,localStore.ts: Built-in backend adapters: RAM string store, File System Access.vmdhandle store, and thestore-slot RawStore behindvmd_data_enc(salt|envelope, quota failures surface astoo-large).supabaseBackend.ts: Supabase RawStore + AuthProvider over@supabase/supabase-js(dynamic import preserved,__TEST_HOOKS__seam): row CRUD,updated_athead token, cached-session uid on the write path, PGRST116/401/transport error mapping (provider messages preserved), and the auth contract (deleteUser absent by design).cachedStore.ts: The offline-cache decorator (§4.7): Remote =codecStore(jsonCodec, cached(encrypted(supabaseRaw), encrypted(slotRaw)))— cache-first writes, network-only read fallback, exists-union, primary-only head and unlock.persistence.ts: Persistence orchestration for Local/Remote/File/Memory modes, unlock/sign-in flows, autosave wiring, export/import. All four modes' data paths run through the storage seam — Remote iscached(encrypted(supabaseRaw), encrypted(slotRaw))over one shared KeySession;localSaveFailedsurfaces failed local/file saves (data-06); a rotated remote locks the session and prompts (sync-01) viaremoteRekeyedNotice.sync.ts: Remote sync scheduling: the background poll (head-token change detection againstvmd_sync_head), typing-gate/epoch bookkeeping, backoff, status/conflict signals, and the thinremoteSyncauth facade oversupabaseBackend. No client code, no row payloads, no secrets — pulls and pushes go through the injected remote DocStore; the merge algorithm lives inmerge.ts(mergeDocumentsis re-exported for the e2e seam).crypto2.ts: Cryptographic primitives and key derivation (Web Crypto only).markdown.ts: Inline markdown rendering and sanitisation.meta.ts:due:/rec:metadata parsing and formatting.tasks.ts: Grouped task selectors (pending/scheduled/done) and breadcrumbs.inbox.ts: Quick-capture queue and Inbox node reconciliation.biometrics.ts: WebAuthn passkey enrolment and passphrase sealing.utils.ts: Tiny shared utilities,appVersion, and thelocalStoragestore slots.globals.d.ts,css.d.ts: Ambient declarations for browser globals and CSS imports.
Unit test suites live in tests/unit/suites/ (see Testing below).
Naming and splitting rules
- Name files by responsibility
- If a module exceeds ~350-450 lines and mixes unrelated concerns, split it.
Preferred split order:
- shared state/helpers into a focused module
- feature-specific logic into that feature module
- keep orchestration in the original module
- Avoid circular dependencies.
If two modules need shared state, extract that state into a third module (as done with
search.ts).
Building and bundling
bun run build (scripts/build-bun.mjs) copies source/ to dist/, bundles
source/js/app.ts and the source/css/*.css modules, prunes the TypeScript sources and
dist/css/, then stamps the version into dist/index.html and dist/version.json.
Build flags (bun scripts/build-bun.mjs source dist [flags]):
--version <semver>overrides the version stamped into the app (defaults topackage.json)--localkeeps the developmentconnect-src(localhost +*.supabase.co); production builds tighten it to the concrete Supabase origin fromsync.ts(--supabase-url/SUPABASE_PROJECT/SUPABASE_URLcan override)--test-hooksenables the__TEST_HOOKS__seam (window.supabase) and scales PBKDF2 down for Playwright. Production must leave this off--sourcemap[=none|linked|inline|external],--no-sourcemap;--minify/--no-minify
Notes:
- Runtime dependencies (
preact,@preact/signals,htm,marked,dompurify,@supabase/supabase-js) are resolved fromnode_modulesat build time. - There is no import map and no CDN dependency at runtime.
scripts/serve-bun.mjsis a Bun static server used for local dev and Playwright.- Do not add a runtime CDN import; add dependencies to
package.jsonand import them. - Code splitting:
@supabase/supabase-js(~220 KB minified) is imported dynamically insync.tsand emitted as a separatedist/js/chunk-*.js. It is fetched only when Remote is the persisted mode (persistence.getAuthBootstrapgates the session probe on it), so local, memory and file users never download or parse it. Do not turn that import back into a static one — a top-levelimportputs it back on the critical path for everyone. The chunk name is content-hashed, so it is intentionally not listed in the service workerAPP_SHELL; the SW caches it cache-first on first use (see the service worker section below). - Source maps:
bun run devemits a linkeddist/js/app.js.map(withsourcesContent, so the pruned.tsfiles still resolve in DevTools).bun run buildpasses--no-sourcemap, and CI fails the deploy ifdist/js/app.js.mapexists. Bun's bundler emits JS maps only — there is no CSS source map. Source maps are intentionally not in the service workerAPP_SHELL.
Service worker caches (source/sw.js)
The service worker uses two versioned caches and caches only what the app needs to boot
offline. A same-origin request it does not recognise passes straight through — never
intercepted, never cached — and activate prunes any non-allowlisted entry an earlier build
left behind.
| Cache constant | Cache name pattern | Covers | Strategy |
|---|---|---|---|
FONTS_CACHE |
virgulas-fonts-v<N> |
source/fonts/, source/media/ assets, root /favicon.ico |
Cache-first |
APP_CACHE |
virgulas-app-v<N> |
app shell, version.json, intro.vmd, Supabase js/chunk-*.js |
Per request type (below) |
APP_CACHE routing, by request type:
- Shell navigations (
/,/index.html) → network-first, stored under the single canonical./index.htmlkey — never the requested URL, so a capture URL or#nodeIdcannot reach Cache Storage. Non-shell navigations (e.g./privacy.html) are served but not cached. - Shell assets (
js/app.js,js/app.css,site.webmanifest) → stale-while-revalidate. version.jsonandintro.vmd→ network-first (fresh when online, cache fallback offline).- Supabase
js/chunk-*.js→ cache-first (content-hashed, so not precached; Remote mode gets it offline). - Any other same-origin request → passthrough (not intercepted, not cached).
Version bumps are automated. scripts/bump-sw-caches.mjs hashes each file group and increments the matching version constant in sw.js only when the files have changed. Hashes are stored in scripts/.sw-cache-hashes.json (committed).
bun installruns the bump script automatically (viapostinstall).- For changes to fonts, media, or app files, run
bun run sw:bumpbefore committing. - To automate this for every push and enforce commit format, install Git hooks once per clone:
Installed hooks:bun run sw:hookspre-pushrunssw:bumpand aborts the push ifsw.jswas modified, prompting you to commit the version bump first.commit-msgvalidates Conventional Commits headers.
Adding a new file to a pre-cached shell: add the path to the appropriate *_SHELL array in source/sw.js, add the same path (or its parent directory) to the matching group in scripts/bump-sw-caches.mjs, then run bun run sw:bump.
Testing
bun run test # unit + e2e
bun run test:e2e # e2e specs only
bun run test:e2e -- tests/sync.spec.ts
bun run test:e2e -- --headed # visible browser
bun run test:unit # bun test (tests/unit)
bun run typecheck # tsc app config + test config
bun run test always runs both suites and returns non-zero if either suite fails.
Unit tests
- Run in-process under
bun testwith happy-dom, preloaded throughbunfig.toml→tests/setup/dom.ts. - Suites live in
tests/unit/suites/*.tsand must be registered intests/unit/unit.test.ts. tests/unit/testing.tsprovides the shared assert/runner harness.tests/unit/suites/cspTests.tsandstaticPathTests.tscover build/server helpers rather than browser modules; keep that pattern when adding tooling tests.
Playwright tests
playwright.config.tsstarts the app automatically viabun run dev(build + static server) — no separate server step.- Local runs require Supabase credentials:
.env, or a running local Supabase reachable viabunx supabase status -o env. The config fails fast when neither is available. - The fixture (
tests/test.ts) overrideslocalStorage.supabaseconfigfrom.envbefore every page load. - Locally Playwright runs Chromium only. In CI (
CI=true) it runs Chromium, Firefox, and WebKit. - CI shards the suite across three jobs with
--shard=<i>/3. - Specs import app singletons from the built bundle (
/js/app.js) so they observe the same module instances as the running app. The singletons are published through a single gatede2ebinding (sec-05): specs read(await import('/js/app.js')).e2e. In production the binding is empty, so no same-origin module can reach the singletons or the old passphrase accessor;tests/e2e-seam.spec.tsasserts both builds behave. tests/browserTestsInNode.jsis a legacy Node runner; the supported entry points are the bun unit suite and Playwright.
Test seams
__TEST_HOOKS__(build define) gates thewindow.supabaseseam used by admin/auth/sync specs, and the/js/app.jse2eintrospection binding used by every spec. It isfalsein production, so shipped code never reads that global and never publishes the singletons.__TEST_KDF_SCALE__divides the PBKDF2 work factor in test builds so deriving keys hundreds of times does not dominate the suite. The envelope still records the nominal iteration count, sogetEnvelopeIterations/needsKdfUpgradeand the on-screenPBKDF2 600kvalue are unchanged. Production defines the scale as1.setupDocseeds the ciphertext from the Node runner, so it must derive at the same work factor the target app will use to decrypt: the local Playwright scale (60) when the suite runs against the built test bundle, and1wheneverBASE_URLtargets an external/production build. Seeding at the wrong scale makes every unlock fail with a wrong-key error.
CI/CD
Workflows live in .github/workflows/.
ci.yml — CI and Deploy
| Job | When | What |
|---|---|---|
commit-policy |
every push/PR | validates Conventional Commit headers in the range |
test |
every push/PR | runs bun run test:unit and E2E sharded across 3 jobs; uploads Playwright results artifacts |
publish-db |
main only |
bunx supabase link --project-ref "$SUPABASE_PROJECT" then bunx supabase db push --linked --include-all |
release |
main only |
plans the semver bump with scripts/release-from-commits.mjs, creates the GitHub Release, commits the version bump |
deploy |
main only |
builds dist/ with the resolved version, uploads the Pages artifact, applies the Cloudflare edge headers (cf:headers, fails the deploy if it cannot), purges Cloudflare |
publish-dbmust run beforedeploy.- The deploy build passes
--no-sourcemapand refuses to deploy ifdist/js/app.js.mapappears. - The deploy job zips
dist/contents (no wrapper directory, no maps) intovirgulas-<version>.zipand attaches it to thev<version>release when that tag exists. - Pull-request workflows do not publish Pages artifacts.
daily.yml — production E2E
Runs the suite against https://virgulas.com, sharded the same way. Specs that rely on the
__TEST_HOOKS__ seam (admin, auth, sync, sync-polling-merge, synctrigger) are excluded, because
production builds compile that seam out.
Repository secrets and variables
SUPABASE_PROJECT(repository variable: project ref; used to pin the production CSP origin and bypublish-db)SUPABASE_ACCESS_TOKEN(secret: CI migration publish)CLOUDFLARE_ZONE_ID,CLOUDFLARE_API_TOKEN(optional secrets: cache purge after deploy, andbun run cf:headers— the token needs Zone → Zone Settings → Edit (Always Use HTTPS) and Zone → Transform Rules → Edit (the response-header ruleset; the API calls this permissionZone Transform Rules Write). Without them the deploy fails.)
Releases and versioning
scripts/release-from-commits.mjsreads the commits since the lastvX.Y.Ztag and derives the bump: breaking → major,feat→ minor,fix/perf/refactor/revert→ patch. It writes grouped release notes and emitsGITHUB_OUTPUTvalues for the workflow.- On
main, the release job creates the tag and Release, bumpspackage.jsonandsource/version.json, and pusheschore: bump version to <version> [skip ci]. - The deploy build stamps
<version>into theapp-versionmeta tag and intodist/version.json(version,sha,generatedAt). The in-app version label reads it. - Dry-run locally:
bun scripts/release-from-commits.mjs
Adding a feature
- Check
docs/SPEC.vmd(Rule 1). If the behaviour is not there, stop and ask. - Pick the module from the module map; extract shared state instead of importing across features.
- Implement, keeping Rule 7 conventions (crypto through
crypto2.ts, Supabase throughsync.ts,localStoragethroughstoreslots). - Add or update tests (Rule 3): pure logic → unit suite; UI/integration → Playwright spec.
- Update
docs/SPEC.vmdif it was missing the behaviour, andREADME.md(Rule 2). - If you touched
source/fonts/,source/media/, the app shell, or the manifest, runbun run sw:bumpand commit the version bump. - Run
bun run typecheck,bun run test:unit,bun run test:e2e(Rule 8). - Commit with a Conventional Commit header (Rule 11).
