Conventions and how-to recipes for working on Pica.
Doc scope: coding practices, page conventions, how to add common things (page, translation, test, route), how to run things locally. For structural background see architecture.md. For security rationale see security.md.
$ node server.js
Passphrase: ********
Pica listening on http://localhost:8080That's it. No npm install, no build step, no watcher.
For automated runs (testing, CI), set the passphrase via env var:
$ PICA_PASSPHRASE=devpassphrase node server.jsThe passphrase must be at least 8 characters. The first run also
needs setup — POST /api/setup with { "username": "admin", "password": "..." }
to create the first employer.
All 41 suites can be run with:
$ for f in tests/test-*.mjs; do printf '%s: ' "$f"; node "$f" 2>&1 | tail -1; doneOr run any single suite directly:
$ node tests/test-dek.mjs
$ node tests/test-keyring.mjs
$ node tests/test-rotate.mjs
$ node tests/test-masterkey-envelope.mjs
$ node tests/test-security-routes.mjs
$ node tests/test-config-mail.mjs # 0.25.0 — config mail block
$ node tests/test-mail-smtp.mjs # 0.25.0 — SMTP submission client
$ node tests/test-mail-templates.mjs # 0.25.0 — message templates
$ node tests/test-mail-mailer.mjs # 0.25.0 — gating + best-effort
$ node tests/test-reminder-scheduler.mjs # 0.25.0 — 24h leave reminder
$ node tests/test-mail-routes.mjs # 0.25.0 — POST /api/mail/test
$ node tests/test-mail-config-store.mjs # 0.26.0 — encrypted SMTP config storeTwo suites carry a pre-existing flake unrelated to any recent
feature (both fail identically on the pre-feature baseline):
test-reports.mjs overnight shift attributes hours to each day separately (host-timezone sensitive) and test-auth.mjs (~1/64
probabilistic — a base64url last-character signature-tamper artifact
in the test itself, not the auth code; re-run it alone 2–3× to
confirm intermittence before treating a red as a regression).
Each suite is independent; it creates its own temp directories and
cleans up after itself. The suites never touch ./data,
./backups, or ./config.json — those are live install state and
must never be deleted by tooling.
Smokes use throwaway temp directories only. Never delete ./data,
./backups, or ./config.json — these hold live install state
and are irreplaceable. Use a dedicated temp path for each smoke run:
$ SMOKE_DIR=$(mktemp -d)
$ PICA_DATA_DIR="$SMOKE_DIR/data" PICA_BACKUP_DIR="$SMOKE_DIR/backups" \
PICA_CONFIG="$SMOKE_DIR/config.json" PICA_PASSPHRASE=smokepass \
node server.js > /tmp/pica.out 2>&1 &
$ sleep 2
$ curl -s -X POST -H "Content-Type: application/json" -c /tmp/cj \
-d '{"username":"admin","password":"adminpass123"}' \
http://127.0.0.1:8080/api/setup
$ # ... your curls here, with -b /tmp/cj for the cookie ...
$ kill %1 && rm -rf "$SMOKE_DIR"If you need to run against the real config layout (e.g. to test
PICA_RESET=1), use a separate project clone — never the live one.
The passphrase must be at least 8 characters. The setup password must too.
Every authenticated page in Pica follows the same shape. Skipping any of these is a real bug, not a style preference.
A page is three files in public/:
foo.html
foo.css (optional — only if foo needs page-specific styles)
foo.js
The HTML uses a <main class="container"> (or container--wide for
dashboards/2-column layouts) wrapper. Headers, sidebars, and footer
are mounted by topbar.js — pages don't write any of that markup.
Every page's <head> has this script BEFORE any stylesheet:
<script>
// Read color mode preference and apply it before paint to avoid FOUC.
try {
const mode = localStorage.getItem('pica-color-mode') || 'system';
if (mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.dataset.theme = 'dark';
}
} catch {}
</script>This prevents the white flash on dark-mode pages. Don't use a
stylesheet-based dark mode (@media (prefers-color-scheme: dark))
because it would override the user's manual choice from Preferences.
The .js file's first lines are always:
import { /* helpers */ } from '/app.js';
import { t, /* maybe tn, translateError, applyTranslations, fmt* */ } from '/i18n.js';
import { mountTopBar, mountFooter } from '/topbar.js';
mountTopBar();
mountFooter();
applyTranslations();Login and setup pages are the exception — they call mountFooter()
only (no top-bar before sign-in).
Always destructure named imports. Always use absolute paths:
import x from '/topbar.js', never './topbar.js'. The browser
serves public/ at /, and absolute paths make the imports work
identically from any URL depth (e.g. /leaves/calendar and /punch
both resolve /topbar.js the same way).
- Don't shadow imports. A common bug: writing
const t = document.createElement('table')inside a function that also callst('some.key')— JS scoping turns the secondtinto the table element. Usetblor any other name. Thefrontend-importstest only catches missing imports, not shadowed ones. - Don't forget
applyTranslations(). If you adddata-i18nattributes to an HTML page, the JS must callapplyTranslations()at module load. - Don't add inline styles in
public/. Use the page's.cssfile or a token fromapp.css. The CSPstyle-srchas nounsafe-inline, so astyle="…"attribute — whether hand-written in HTML or injected viainnerHTML— is blocked and floods the console with violations. To set a value dynamically (e.g. a per-row--hueor--pct), carry it as adata-*attribute and apply it through the CSSOM after the node is in the DOM (el.style.setProperty('--hue', el.dataset.hue)). The CSSOM is not subject tostyle-src. Seetopbar.js/reports.jsfor the pattern.
Suppose you want a new page /foobar.
- HTML —
public/foobar.html. Copy any existing page (e.g.leaves.html) and adjust:<title data-i18n="title.foobar">Pica — Foobar</title>- The
<main class="container">body - Reference
/foobar.jsand/foobar.css(if used) at the bottom
- CSS —
public/foobar.cssif needed. Use design tokens (var(--gap-4),var(--text), etc.) — don't hardcode colors or spacings. - JS —
public/foobar.js. Module bootstrap as above. - Translations — add
title.foobarand any new visible strings to BOTHpublic/locales/en-US.jsandpublic/locales/pt-PT.js. The i18n test will fail until parity is restored. - Server route — register a GET handler in
src/routes/pages.js:router.get('/foobar', requireAuth((req, res) => { return sendHtml(res, 'foobar.html', req); }));
sendHtmldoes locale meta-injection. Don't bypass it with raw file streaming. - Top-bar nav link — if the page should appear in the
sidebar, edit
topbar.js'sNAV_ITEMSarray. Use a translation key (labelKey: 'nav.foobar') and add it to the dictionaries. - Service Worker — if the page is one of the pre-cached shell
pages (rare; we currently only pre-cache the literal shell
files), add it to
PRECACHE_URLSinpublic/sw.js. Most pages should NOT be pre-cached, see security.md → Service Worker caching. - Smoke — boot the server, hit
/foobar, look for 200 + the right text in the response body.
The dictionary is two files:
public/locales/en-US.js
public/locales/pt-PT.js
Each is a default-exported object: { 'foo.bar': 'Some text', ... }.
- Key naming: lowerCamelCase with dot namespacing.
nav.employees,punch.statusIn,leaves.type.vacation. errors.*keys use snake_case to match backend error codes (errors.invalid_credentials).- Plural keys are objects with
one/other:Used via'punch.queueWaiting': { one: '{count} punch waiting', other: '{count} punches waiting', }
tn('punch.queueWaiting', count). - Placeholders use
{name}syntax. Both locales must declare the same placeholders for the same key — the test enforces this.
- Pick a key (or reuse an existing one if it fits).
- Add it to both dictionaries with translated values. The i18n test fails on missing parity.
- Use
t('your.key')in JS, ordata-i18n="your.key"on the HTML element (and make sure the page callsapplyTranslations()). - For attribute-based translations (e.g. placeholders):
data-i18n-attr="placeholder:your.key". - Run
node tests/test-i18n.mjsto confirm parity, plural shape, placeholder match.
Use the helpers from i18n.js:
import { fmtDate, fmtTime, fmtDateTime, getLocale } from '/i18n.js';
fmtTime('2026-05-02T09:14:00Z') // '09:14'
fmtDate('2026-05-02') // '2 May 2026' / '2 mai 2026'
fmtDateTime('2026-05-02T09:14:00Z') // '2 May 2026, 09:14'These wrap Intl.DateTimeFormat with the current locale. For
absolute control over format options, call
new Intl.DateTimeFormat(getLocale(), { ... }).format(date).
The frontend has translateError(code, fallback) that looks up
errors.<code> in the dictionary, falling back to the fallback
string (which is usually the error field from the API response).
Today the backend mostly returns English error strings without
errorCode — the frontend silently falls back to the English. M12
will add errorCode emission to every error response site.
Pattern at the frontend call site:
const result = await postJson('/api/leaves', body);
if (!result.ok) {
showMessage(messageEl,
translateError(result.data.errorCode, result.data.error || 'Generic message'),
'error');
}Routes live under src/routes/<resource>.js. Each module exports a
registerXxxRoutes(router, deps) function that the entry-point
calls during startup.
// src/routes/foobar.js
//
// Routes:
// GET /api/foobar — list
// GET /api/foobar/:id — read one
// POST /api/foobar — create
// PUT /api/foobar/:id — update
// DELETE /api/foobar/:id — delete
export function registerFoobarRoutes(router, { foobarStore, requireAuth, requireRole }) {
router.get('/api/foobar', requireAuth(async (req, res) => {
const items = foobarStore.list();
res.json({ foobars: items });
}));
router.post('/api/foobar', requireRole('employer')(async (req, res) => {
const body = await req.json();
// validate body...
const created = foobarStore.create(body);
res.json({ foobar: created });
}));
// ...
}The router is first-match-wins. A more specific path must be registered before a less specific one:
router.get('/api/leaves/approved', ...); // first — exact match
router.get('/api/leaves/:id', ...); // second — :id matches anythingIf you register :id first, GET /api/leaves/approved would treat
approved as an ID and 404 (or worse, return some random leave
whose ID happens to be approved).
res.badRequest('Reason here', { errorCode: 'invalid_value' });
res.notFound('Foo not found', { errorCode: 'not_found' });
res.forbidden();
res.unauthorized();The helpers live in src/http/responses.js. Always pass an
errorCode for known business errors so the frontend can localize.
The top-of-file comment block lists every route the file registers, with a one-line description. This is the source of truth for the API surface — no separate API doc to maintain.
Tests are node:test-style suites in tests/. Each suite is a
single .mjs file you run directly:
$ node tests/test-foobar.mjsimport assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createFoobarStore } from '../src/storage/foobar.js';
let passed = 0;
let failed = 0;
async function test(name, fn) {
try {
await fn();
console.log(` ✓ ${name}`);
passed++;
} catch (err) {
console.error(` ✗ ${name}`);
console.error(` ${err.message}`);
failed++;
}
}
const masterKey = Buffer.alloc(32, 1);
console.log('foobar storage');
await test('creates and reads back', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pica-foo-'));
try {
const s = createFoobarStore(dir, masterKey);
const created = s.create({ name: 'Alice' });
const got = s.findById(created.id);
assert.equal(got.name, 'Alice');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
console.log('');
console.log(`${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);- Each test gets its own temp dir. No shared state.
- Always clean up.
try/finallyis the simplest pattern. - Use a fixed master key for tests (
Buffer.alloc(32, 1)). The encryption is identical, just deterministic for repro. - No mocking framework. If a test needs to control time or randomness, swap the dependency in via the store factory.
Every release does ALL of these in the same commit:
package.json— bumpversionand updatereleaseDate. The footer reads both. Format:"version": "0.16.1","releaseDate": "2026-05-02".public/sw.js— bumpCACHE_VERSIONif any pre-cached or runtime-cached asset changed (CSS, JS, i18n, locales, icons, manifest). When in doubt, bump.RELEASES.md— add an entry under the new version with what shipped, why, files touched, honest disclosures.docs/*.md— update the relevant doc file(s) and bump the "Last touched in vX.Y.Z" footer. Architecture changes → architecture.md. New deployment expectations → security.md. New conventions or how-tos → development.md (this file). Milestone status → roadmap.md.README.md— only if the entry-point info changed (rare).
- Patch (0.X.Y → 0.X.Y+1): bug fix, doc-only change, small UX tweak that doesn't change semantics.
- Minor (0.X.0 → 0.X+1.0): new feature, milestone close, reorganization. Pica is pre-1.0, so we don't promise backward compat across minors — but we don't break things gratuitously.
- Major (0.x → 1.0): not yet planned. Probably ties to a combination of M11+M12 closing and a real production deployment.
These are the rules I (Claude) commit to following on every change. They're listed here because they're easy to forget and losing them is how documentation rots.
- Every release bumps both
versionANDreleaseDateinpackage.json. No exceptions. The footer reads them. - Every release adds a
RELEASES.mdentry. Detailed: what shipped, why, files touched, honest disclosures. - Any change to a pre-cached SW asset bumps
CACHE_VERSION. - Every code change touches the relevant
docs/*.mdfile in the same turn, with the_Last touched in vX.Y.Z_footer updated. - Token budget conservation: grep before view; narrow line
ranges; batch tool calls; prefer
str_replaceover rewriting whole files; use scripted batch edits via Python for repetitive work. - Drop pattern: substantial features split into a backend-only drop then a frontend-only drop. Each drop ships its own zip.
Last touched in 0.53.5.