- The application UI must be in English.
- Source code, code comments, and repository documentation must be in English.
See also conventions.md.
- Node.js 20+ with
npm - A Google Cloud project
- A Google account for testing
npm installIf node or npm is installed but not available in the current PowerShell session, reopen the terminal or prepend Node.js to PATH temporarily:
$env:Path='C:\Program Files\nodejs;' + $env:PathCreate .env from .env.example:
cp .env.example .envOn Windows PowerShell:
Copy-Item .env.example .envThen edit .env:
VITE_GOOGLE_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
VITE_SUTRAPAD_FILE_NAME=sutrapad-data.jsonnpm run devThe dev server listens on all local interfaces, so you can open it from other devices in your LAN:
- local machine: http://localhost:5173
- another device:
http://YOUR-LAN-IP:5173
For full PWA behavior on another device, use a trusted local certificate because service workers require a secure context outside localhost.
Add these optional variables to .env:
VITE_DEV_HTTPS_KEY_PATH=.cert/dev-key.pem
VITE_DEV_HTTPS_CERT_PATH=.cert/dev-cert.pemThen start the app with the usual command:
npm run devIf both files exist, Vite serves the app over HTTPS and you can open:
- local machine:
https://localhost:5173 - another device:
https://YOUR-LAN-IP:5173
Recommended workflow:
- create a local certificate with
mkcertforlocalhostand your LAN IP - trust the generated root CA on the devices you want to test with
If the certificate files are configured but missing, the dev server exits with a clear error.
- Install
mkcert.
If you use winget:
winget install FiloSottile.mkcert- Install and trust the local root CA on your development machine:
mkcert -install- Find your LAN IP address.
Example:
ipconfigLook for your active adapter and copy the IPv4 address, for example 192.168.1.25.
- Generate a certificate for
localhost, loopback, and your LAN IP:
New-Item -ItemType Directory -Force .cert
mkcert -key-file .cert/dev-key.pem -cert-file .cert/dev-cert.pem localhost 127.0.0.1 ::1 192.168.1.25You can also use the helper script, which tries to detect your LAN IP automatically:
npm run cert:devIf auto-detection picks the wrong address or cannot find one, pass the IP manually:
npm run cert:dev -- 192.168.1.25The script creates .cert/dev-key.pem and .cert/dev-cert.pem for the selected LAN IP.
- Add the generated file paths to
.env:
VITE_DEV_HTTPS_KEY_PATH=.cert/dev-key.pem
VITE_DEV_HTTPS_CERT_PATH=.cert/dev-cert.pem- Start the dev server:
npm run dev- Open the app:
- on your computer:
https://localhost:5173 - on another device in the same network:
https://192.168.1.25:5173
To avoid browser security warnings on phones or tablets, the device must trust the same mkcert root CA.
- iPhone/iPad:
- export the
mkcertroot CA from your computer - install it as a profile on the device
- enable full trust for that certificate in
Settings > General > About > Certificate Trust Settings
- export the
- Android:
- copy the root CA certificate to the device
- install it from security certificate settings
- note that some browsers or work profiles may still apply extra certificate restrictions
If you only need quick UI checks, plain HTTP over LAN is simpler. Use HTTPS when you need realistic PWA behavior such as service worker registration and installability.
npm run checkThis runs:
npm run lintnpm testnpm run build
npm run test:mutationStrykerJS needs TypeScript 6.x. Both its tsconfig preprocessor and the
typescript-checker plugin call ts.parseConfigFileTextToJson, which the
TypeScript 7 rewrite removed — on TypeScript 7 the run dies with
TypeError: ts.parseConfigFileTextToJson is not a function right after
instrumentation, while npm run check stays green because tsc itself is
happy. Stryker 10 makes the same call, so upgrading Stryker is not a way out.
The typescript dependency is therefore held at ^6.0.3, with matching holds
in renovate.json and .github/dependabot.yml; lift all three together once
Stryker supports TypeScript 7.
This runs StrykerJS mutation testing. stryker.config.mjs is the single
source of truth for the mutate scope — it carries a comment per entry
explaining why the file is in or out. This page used to duplicate the list and
went stale the moment the scope was widened (6 view modules documented vs. 21
actually configured), so it describes the shape instead:
- Globbed wholesale, so a new module in one of these is mutated
automatically:
src/lib/**,src/app/logic/**,src/app/storage/**,src/app/session/**,src/app/capture/**. - Listed one by one, because their directories also hold modules without
dedicated tests: the Drive services,
src/app/lifecycle/*, and thesrc/app/view/**modules that have a// @vitest-environment happy-domsuite of their own. - Explicitly excluded with a
!pattern:*.d.ts, and pure-data modules likesrc/app/logic/lexicon/stoplist.ts(a frozen Czech-stopword Set — Stryker fires a StringLiteral mutant per word and no real test can pin them),src/app/logic/lexicon/types.ts, and the message catalogssrc/lib/i18n/en.ts/src/lib/i18n/cs.ts(several hundred sentences; the shipped wording is asserted where it renders, not through these modules). Note the exclusion is the data, never the logic beside it:i18n/locales,i18n/plural,i18n/indexandapp/logic/localeall stay in scope.
Adding a new source file:
- If it lands in one of the globbed directories, the include is automatic.
- Otherwise, add an explicit path to
mutate:in the same change as the file. If it's pure data or types, add a matching!exclusion so the score reflects logic only. If it genuinely has to wait for a test, add it toDEFERRED_FROM_MUTATIONintests/mutate-scope.test.tswith a reason. tests/mutate-scope.test.tsenforces all of the above — it fails when asrc/**/*.tsis none of mutated,!-excluded, or deferred-with-a-reason, so a module can no longer go silently unmeasured. It also fails on a mutated module whose only testvi.mocks it or imports just its type: that shape reports every mutant as NoCoverage, which is howsrc/app/view/palette.tssat at 0.00 % with 166 mutants.- A new file that pulls overall below
thresholds.breakis a signal to either write more tests before merging or to lowerbreak:temporarily with a comment explaining the deferred work — never silently merge a file that pulls CI red.
The HTML report is written to:
reports/mutation/mutation.html
A machine-readable JSON report (standard mutationtestingelementsschema.json shape) is written alongside it at reports/mutation/mutation.json.
Important:
- Do not run
npm run test:coverageandnpm run test:mutationin parallel. - Both tools use coverage artifacts during their runs, and running them at the same time can break Stryker with missing
coverage/.tmpfiles. - If you want both reports locally, run them sequentially.
GitHub Actions also runs mutation testing in a separate Daily Mutation Testing workflow once per day and on manual dispatch.
In Google Cloud Console:
- Create or open a project.
- Enable
Google Drive API. - Configure the OAuth consent screen.
- Create an OAuth client with type
Web application.
Set the OAuth client to include:
Authorized JavaScript originshttp://localhost:5173https://localhost:5173http://YOUR-LAN-IP:5173when testing over LAN without HTTPShttps://YOUR-LAN-IP:5173when testing over LAN with HTTPShttps://filda.github.io
Authorized redirect URIs- none is required for the popup token flow used by this app
Notes:
- The OAuth client type must be
Web application. - The origin must match exactly, including protocol, host, and port.
- If you switch from
localhostto a LAN IP such as192.168.88.40, that LAN origin must be added explicitly. redirect_uri_mismatchin this app usually means the current page origin is missing fromAuthorized JavaScript origins, or the wrong OAuth client ID is being used.
Scopes used by the app:
openidprofileemailhttps://www.googleapis.com/auth/drive.file
Official references:
- Enable the Google Drive API
- Configure the OAuth consent screen
- Choose Google Drive API scopes
- Google Identity Services token model for web apps
- Create and manage files in Google Drive
Production site:
GitHub Pages is configured through GitHub Actions and uses the repository subpath /sutrapad/.
- Push the desired commit to GitHub.
- Open the repository
Actionstab. - Run the
Validate and Deployworkflow manually. - Wait for the workflow to finish and then open the production URL above.
- Add repository variable
VITE_GOOGLE_CLIENT_IDinSettings > Secrets and variables > Actions > Variables. - The
Validate and Deployworkflow reads this variable during the production build. - A separate
.envfile is not created in CI; the value is injected directly into the build environment.
- local development:
- production:
Optional query parameters:
url- the captured page URLtitle- optional page title passed by a bookmarklet
The app can enrich note titles with:
- time-of-day labels such as
early morningorhigh noon - reverse geocoded place labels via Nominatim
- cached location labels in
localStorage
When the PWA is installed on Android, the app can appear in the system share sheet.
- shared links are routed to the existing
?url=and?title=capture flow - shared plain text is routed to the existing
?note=capture flow - the manifest
share_targetusesGET, so no extra service worker POST handling is needed for text and link shares - Android testing note: the app needs the browser's real
Installflow, not onlyAdd to Home screen - a home screen shortcut alone may not register SutraPad as a share target
- verified path so far: install the PWA through Chrome on Android, then share into SutraPad from the system share sheet
The app exposes a bookmarklet link in the UI.
Compatibility notes:
- Desktop Chrome, Brave, and Opera generally work well with drag-to-bookmarks-bar bookmarklets.
- Desktop Safari supports bookmarklets too, but adding them is often easier by creating a normal bookmark first and then replacing its URL with the copied bookmarklet code.
- On iPhone and iPad, the Shortcut is the recommended capture flow.
- Client-only application with no backend
- PWA foundation powered by
vite-plugin-pwa - Sign-in with Google Identity Services
- Multiple notes stored in Google Drive
- One note per JSON file plus a notebook index file
- Each note is stored as its own JSON file in Google Drive
- A separate JSON index file keeps the note list and active note selection
- Production PWA assets and the service worker are generated by
vite-plugin-pwa - Location labels are powered by OpenStreetMap and Nominatim
src/app.tsacts as the main controller and orchestration layersrc/app/logic/**contains pure helpers that should be preferred for business rulessrc/app/storage/**contains local persistence and backward-compatibility normalizationsrc/app/session/**contains sign-in and sync orchestrationsrc/app/capture/**contains capture-specific use casessrc/app/view/**contains DOM-building UI helpers
- When a piece of logic becomes awkward to test through DOM setup in
src/app.ts, extract it intosrc/app/logic/**,src/app/storage/**, or another pure helper module first. - Coverage and mutation testing both improve faster when new behavior lands in pure modules instead of controller or DOM wiring code.
src/app/view/render-app.tsis intentionally still mostly untested at the unit level; prefer moving decision-heavy logic out of it before adding large DOM-heavy tests.- Stryker mutates the pure-helper areas (
src/lib,src/app/{logic,storage,session,capture}), the auth + Drive services, thesrc/app/lifecyclemodules with focused tests, and the subset ofsrc/app/view/**that has dedicated happy-dom tests. The full list lives instryker.config.mjs;tests/mutate-scope.test.tsfails if a new module isn't classified, so the two can't drift apart silently. - View files without a dedicated test, the composition root in
src/app.ts, and glue modules undersrc/app/{render-callbacks,render-helpers,silent-capture-runner,state-store,sync-helpers}.tsare intentionally not mutated yet — their only coverage is the smoke test, which is too coarse to discriminate mutants. Add a focused test before widening the mutate scope to one of these; each one is listed with its reason inDEFERRED_FROM_MUTATION(tests/mutate-scope.test.ts), so that list doubles as the backlog.
tests/nfr/** asserts properties of the system rather than answers from functions: request counts, concurrency caps, round-trip idempotence of load → save → load, index drift recovery, and the resident-model shape — all against a workspace generated in the shape of the real one (tests/nfr/workspace-fixture.ts, ~6 470 notes) and an in-memory Drive (tests/nfr/fake-drive.ts, which evaluates the stores' real query strings through tests/nfr/drive-query.ts and counts every call). The numbers they assert come from src/lib/budgets.ts, the same module the app's runtime guards in src/services/drive/save-policy.ts read, so a budget is changed in one place and both sides move together. Counts and invariants only; no timing assertions — see docs/nfr-testing-plan.md for the rationale and the layers still to come.
The whole tests/nfr/ tree runs in npm test (a few seconds; npm run test:nfr runs it alone, and CI runs it as its own "Non-functional budgets" step so a red there reads as "something scales with the note count" rather than "a unit test broke") and is excluded under Stryker (vitest.config.ts): a broad property test kills no mutant a targeted unit test shouldn't already kill, and would mask the missing assertion the mutation score exists to expose. Logic the NFR layer exercises still needs its own unit test under mutation pressure.
At runtime the same budgets are observed, not just tested: GoogleDriveStore reports soft-budget overruns through onBudgetOverrun, every Drive operation is metered (src/services/drive/drive-meter.ts, one meter per load / save / refresh / rebuild via createWorkspaceIO's measured()), and src/app/session/main-thread-observers.ts listens for long tasks, slow interactions and a heap sample. All of it lands in the diagnostics$ snapshot (src/app/logic/diagnostics.ts) and is shown on Settings → Diagnostics; overruns, failed operations and interactions over 200 ms also reach the console as [budget], [drive] and [main-thread] lines.
Two guard tests keep codebase-level invariants from drifting the way tests/mutate-scope.test.ts does for the mutate scope: tests/body-reader-scope.test.ts fails when a src/ module reads a note's body without being classified as placeholder-aware (guarded) or placeholder-safe (harmless) — the class of bug behind the 2026-09-07 incident.
src/services/google-auth.ts- browser OAuth token flowsrc/services/drive-store.ts- notebook index and per-note file storage in Drivesrc/app.ts- app controller and orchestrationsrc/app/logic/- pure business logic helperssrc/app/storage/- local workspace persistence and normalizationsrc/app/session/- session restore and sync flowssrc/app/capture/- note capture flowssrc/app/view/- DOM rendering helpers