2
Development
araharah-executor edited this page 2026-10-25 12:36:12 +08:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

arah² — Development Guide

Contributor-facing reference for working on arah² day to day: local setup, the per-workspace dev loop, the build commands, the code style the project actually enforces, branch and commit conventions, and the project-specific gates you must not break.

For using the app, see the README. For how the system is put together, see ARCHITECTURE.md. For what to configure (secrets, wrangler.jsonc, env vars), see CONFIGURATION.md. For the road-graph fork contract, see ROAD-GRAPH.md. For the free-tier posture, see BUDGETS.md.


Local setup

Prerequisites

Requirement Notes
Node.js + npm No engines field and no .nvmrc/.node-version exist in the repo. The README says to use a current Node.js LTS. The @araharah/search and @araharah/routing sources are consumed by plain Node (the corpus runners rely on Node's type-stripping for relative .ts imports — the source headers call out Node 24).
npm with workspace support npm 7+ required for workspaces. The repo uses npm exclusively — package-lock.json is committed and there is no pnpm-lock.yaml or yarn.lock.
A Cloudflare account wrangler authenticates on first use via an interactive browser login. Needed for bot:dev, bot:deploy, KV access, and the R2 bucket (road graph).
A Telegram bot token From @BotFather. Required by bot:dev and by every script in bot/scripts/.
Python 3 Only for theme's gallery / basemap:preview, which serve on port 8123 via python3 -m http.server 8123.
Network access Fonts, tiles, the Play CDN in the mockup reference pages, and the Overpass fetch inside build-road-graph.mjs are all fetched over the network; the capture harnesses need it.

Clone and install

git clone http://gitmirah:3000/c-fu/arah2.git
cd arah2
npm install

npm install at the root installs all five workspaces (theme, bot, web, search, routing) at once — npm hoists shared deps into the root node_modules/. There is no per-workspace npm install step.

Configure local secrets

There is no committed .env. The Worker reads secrets from bot/.dev.vars (gitignored via bot/.gitignore):

cp bot/.dev.vars.example bot/.dev.vars
# then edit bot/.dev.vars and fill in BOT_TOKEN, WEBHOOK_SECRET, WEBHOOK_URL, MINI_APP_URL

MINI_APP_URL is the public Mini App origin (not a secret) the /cari venue card's OPEN deep link is built from; it must match the URL registered in @BotFather. Full details, including the process.env vs .dev.vars precedence rule, are in CONFIGURATION.md.

Fork provisioning (before first run against real infra)

A fresh fork needs its own road graph and its own bindings before bot:dev / bot:deploy route for real:

  1. Create the R2 bucket (road-graph host — public configuration, never a secret): npx wrangler r2 bucket create arah²-graphs (or your own name — it must agree with bot/wrangler.jsonc's ROAD_GRAPH bucket_name).
  2. Build the graph: node routing/scripts/build-road-graph.mjs — fetches one Overpass extract and encodes routing/data/kl-roadgraph.v1.bin (build artifact, gitignored). --check <file> validates it offline. Do not commit the binary.
  3. Upload it: node routing/scripts/upload-road-graph.mjs (1 MB floor / 80 MB ceiling pre-flight).
  4. Point the webhook: npm run bot:deploy runs wrangler deploy + scripts/set-webhook.mjs automatically; or set it standalone.

Steps 1–3 are spelled out fully in ROAD-GRAPH.md — that doc is the fork contract. The search gazetteer needs no provisioning: its dataset (search/src/data/kl-gazetteer.json) is committed.

Pre-build before first run

Both npm run dev and npm run deploy run the build first, but building by hand first gives you faster feedback:

npm run build      # theme -> web (this is the deployable artifact)

The dev loop

graph TD
    classDef edit fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
    classDef check fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
    classDef gate fill:#ED3F1C,stroke:#1A1A1A,stroke-width:2px,color:#FFFFFF

    EDIT["Edit source<br/>bot/src, web/src, theme/src,<br/>search/src, routing/src"]:::edit
    TC["npm run bot:typecheck<br/>npm run web:typecheck<br/>npm run search:typecheck<br/>npm run routing:typecheck"]:::check
    T1["npm run bot:test"]:::check
    T2["npm run web:test"]:::check
    T3["npm run search:test"]:::check
    T4["npm run routing:test"]:::check
    AU["npm run audit<br/>off-token CSS audit"]:::check
    BUILD["npm run build<br/>theme then web"]:::check
    DEV1["npm run bot:dev<br/>wrangler dev :8787"]:::edit
    DEV2["npm run web:dev<br/>vite :5173"]:::edit
    HARN["npm run web:build:harness<br/>+ npm run web:capture"]:::check
    COMMIT["git commit<br/>type(phase): subject"]:::gate

    EDIT --> TC
    EDIT --> T1
    EDIT --> T2
    EDIT --> T3
    EDIT --> T4
    EDIT --> AU
    TC --> BUILD
    T1 --> BUILD
    T2 --> BUILD
    T3 --> BUILD
    T4 --> BUILD
    AU --> BUILD
    BUILD --> DEV1
    BUILD --> DEV2
    BUILD --> HARN
    DEV1 --> COMMIT
    DEV2 --> COMMIT
    HARN --> COMMIT
    COMMIT --> EDIT

Bot workspace (@araharah/bot)

npm run bot:dev        # wrangler dev --port 8787 --local

Runs the Worker on http://localhost:8787. --local keeps everything on Miniflare: KV is emulated under bot/.wrangler/ (gitignored) and nothing touches the production namespace. Secrets come from bot/.dev.vars.

The Worker serves both the API and the Mini App static assets, so after changing anything under web/ you must rebuild for wrangler dev to serve the new bundle:

npm run build && npm run bot:dev

The cron trigger is exercisable locally under wrangler dev:

curl "http://localhost:8787/cdn-cgi/local/scheduled"

Web workspace (@araharah/web)

npm run web:dev        # vite dev server on http://localhost:5173

Vite is configured with port: 5173 and strictPort: true, so it will fail rather than silently pick 5174.

Important: there is no Worker behind the Vite dev server. main.ts calls relative paths (/api/session, /api/location), which 404 against plain Vite. For interactive work against a real API, either run bot:dev and open http://localhost:8787 (the Worker serves the built bundle), or use the harness below. In import.meta.env.DEV, main.ts falls back to the mock Telegram WebApp so the shell still renders.

Harness (Mock-Telegram) workflow

The Mini App's UI can be driven without Telegram at all. The harness build swaps in web/src/mock/telegram-mock.ts plus /api/* fetch stubs:

npm run web:build:harness    # vite build --mode harness -> web/dist-harness/
npm run web:capture          # Playwright screenshots of every boot/live/denied state

npm run web:capture serves web/dist-harness (never web/dist — the harness block is dead-code-eliminated from the production build) and writes web/screenshots/*.png (gitignored). It waits on the RAMS-BASEMAP-READY and ARAHARAH-LIVE console signals and runs the GEO-03 runtime storage probe in-page before declaring success.

Harness URL parameters, documented in web/index.html:

Parameter Values Effect
?mock= android, ios, tdesktop, macos, web Which Telegram client surface to simulate
?scheme= light, dark Telegram color scheme
?pin= 1 Drive the chat-pin relay link (?pin=1 stubs /api/location)
?locale= en, ms Force a locale
?sessiondelay= milliseconds Delay the session handshake
?fixdelay= milliseconds Delay the location fix

Search workspace (@araharah/search)

npm run search:typecheck   # tsc --noEmit
npm run search:test        # node test/runner.mjs — unit suites + corpus assertions
npm run search:corpus      # the golden-corpus bake-off (search/scripts/gazetteer-corpus.mjs)

search:corpus measures the pinned golden corpus against the SAME shared search module the bot and the Mini App import — exit 0 only when top-3 hit rate clears the corpus threshold AND every negative is refused (zero leaks). corpus.test.mjs asserts the same measurements in-process, so a corpus regression fails the suite too. Two further maintainer scripts regenerate the dataset when needed: search/scripts/overpass-topup.mjs (fetch more POIs) and search/scripts/json-to-sqlite.mjs — the committed dataset has an ODbL notice inside its envelope.

Routing workspace (@araharah/routing)

npm run routing:typecheck  # tsc --noEmit
npm run routing:test       # node test/runner.mjs — codec, snap, A*, seams
npm run routing:corpus     # the offline tolerance gate (routing/scripts/route-corpus.mjs)
npm run routing:fixtures   # regenerate fixtures from the OSRM demo server (maintainer tool)
npm run glyphs -w routing  # render the turn-card PNGs into web/public/glyphs/

routing:corpus is fully offline (zero network in the measurement path): it measures the committed fixture dataset against the same module the webview imports. routing:fixtures is the only step that touches the OSRM demo server — the committed fixtures were generated there, and the committed dataset is what ships. The graph binary track lives under routing/data/ (see Fork provisioning above and ROAD-GRAPH.md); build-road-graph.mjs and upload-road-graph.mjs are maintainer tools, and scripts/measure-route-cpu.mjs feeds the budget audit's route-CPU gate.

Theme workspace (@araharah/theme)

npm run build -w theme     # src/theme.css -> dist/rams.css (minified)
npm run sheet              # build, then open theme/src/sheet.html
npm run gallery            # build + capture, then serve gallery.html + basemap on :8123
npm run audit              # off-token audit of the rendered CSS

npm run audit and npm run capture are the two pieces of design-system evidence tooling. capture renders sheet.html and the four read-only mockups via Playwright and writes theme/screenshots/ (gitignored). gallery blocks on python3 -m http.server 8123 serving the theme workspace, so stop it with Ctrl-C when done.

Note that npm run gallery ends with a blocking basemap:preview server — it will appear to hang. That is expected.

Registering bot commands

The Telegram / menu is persistent server state, not something the Worker pushes. Register it explicitly after adding or renaming a command:

npm run bot:commands      # node scripts/register-commands.mjs

bot/scripts/register-commands.mjs reads BOT_TOKEN (from process.env, else from bot/.dev.vars), derives each description from the helpMenu lines in bot/src/i18n/strings.ts, and makes exactly two setMyCommands calls — one for the default scope (English) and one for language_code: "ms" (Malay). Because the descriptions are derived from the same strings /help replies with, the menu and the reply cannot drift.

If you add a command, you must add it to the menuFor() command array and to the corresponding helpMenu lines — the script throws helpMenu for "<locale>" is missing its /<command> line (D-03 copy drift) if the two disagree. The gate CS5 D-03: register-commands derives both scopes from helpMenu in bot/test/commands.test.mjs enforces this.

Only re-run this when the command list or its descriptions change. Repointing the webhook is a separate script (set-webhook.mjs), run automatically by npm run deploy. scripts/set-menu-button.mjs sets the BotFather menu-button deep link (separate server state, rerun after origin changes).


Build and check commands

Root scripts (package.json)

Command Description
npm run build Build theme, then web. Produces the deployable artifact.
npm run dev build, then bot:dev — the one-command local app loop.
npm run deploy build, then bot:deploy (wrangler deploy + set-webhook.mjs).
npm run privacy-audit The consolidated privacy audit — five static gates + --live probes (node scripts/privacy-audit.mjs).
npm run budget-audit The consolidated free-tier budget audit — six measurement gates + the comparison table (node scripts/budget-audit.mjs).
npm run bot:test npm test -w bot — the bot's hand-rolled suite.
npm run bot:typecheck tsc --noEmit in bot.
npm run bot:dev npm run dev -w bot — wrangler dev --port 8787 --local.
npm run bot:deploy wrangler deploy && node scripts/set-webhook.mjs.
npm run bot:commands Register the Telegram command menu (EN + MS).
npm run web:test npm test -w web — the Mini App suite.
npm run web:typecheck tsc --noEmit in web.
npm run web:dev npm run dev -w web — Vite dev server on 5173.
npm run web:build Alias of build (theme then web).
npm run web:build:harness Harness-mode build into web/dist-harness/.
npm run web:capture Playwright screenshots of the Mini App states.
npm run search:test npm test -w search — the gazetteer suite (incl. corpus assertions).
npm run search:typecheck tsc --noEmit in search.
npm run search:corpus The golden-corpus search bake-off.
npm run routing:test npm test -w routing — the routing suite (codec, snap, A*, seams).
npm run routing:typecheck tsc --noEmit in routing.
npm run routing:corpus The offline route-tolerance gate.
npm run routing:fixtures Regenerate committed fixtures from the OSRM demo server (maintainer tool).
npm run sheet Passthrough to the theme workspace.
npm run gallery Passthrough to the theme workspace.
npm run audit Passthrough to the theme workspace — off-token CSS audit.
npm run capture Passthrough to the theme workspace — token-sheet screenshots.

Workspace-local scripts

Command Where Description
npm test bot, web, search, routing node test/runner.mjs — zero-dependency, auto-discovers *.test.mjs.
npm run typecheck bot, web, search, routing tsc --noEmit, strict + noUncheckedIndexedAccess.
npm run dev bot wrangler dev --port 8787 --local.
npm run deploy bot wrangler deploy && node scripts/set-webhook.mjs.
npm run commands bot node scripts/register-commands.mjs.
npm run build web vite build → web/dist.
npm run build:harness web vite build --mode harness → web/dist-harness.
npm run capture web node scripts/capture.mjs.
npm run corpus search node scripts/gazetteer-corpus.mjs.
npm run corpus routing node scripts/route-corpus.mjs.
npm run fixtures routing node scripts/fetch-route-fixtures.mjs (network — OSRM demo; maintainer tool).
npm run glyphs routing node scripts/build-glyphs.mjs — render web/public/glyphs/*.png.
npm run build theme tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify.
npm run send theme Copy self-hosted woff2 fonts into dist/fonts/.
npm run basemap theme node basemap/build.mjs — derive basemap/liberty-rams.json from tokens.
npm run basemap:preview theme python3 -m http.server 8123 (blocking).
npm run audit theme node ./scripts/audit.mjs ./dist/rams.css.
npm run capture theme node ./scripts/screenshot.mjs.
npm run test theme node ./scripts/audit.test.mjs.

Maintainer scripts without an npm alias (run with node <path>): the road-graph track routing/scripts/build-road-graph.mjs and routing/scripts/upload-road-graph.mjs, the dataset tools search/scripts/overpass-topup.mjs and search/scripts/json-to-sqlite.mjs, and the CPU ladder routing/scripts/measure-route-cpu.mjs (also invoked by budget-audit's route-CPU gate).

Before you open a change

Run the four typechecks and the four test suites. They are fast and they are the project's actual quality gates:

npm run bot:typecheck
npm run web:typecheck
npm run search:typecheck
npm run routing:typecheck
npm run bot:test
npm run web:test
npm run search:test
npm run routing:test
npm run audit        # only if you touched theme/ or any rendered CSS

theme has its own suite (npm test -w theme → node ./scripts/audit.test.mjs). Note it has no root passthrough script — you must use the -w theme form to run it, unlike the other four workspaces.

As a release gate, run the two consolidated audits (they print evidence lines per gate; exit 2 means NOT RUN and is never a pass):

npm run privacy-audit           # add --live when credentials + a build exist
npm run budget-audit

Code style

There is no linter and no formatter configured in this repository. No ESLint, no Prettier, no Biome, no .editorconfig, no lint or format script in any of the five package.json files, and no commit hooks (no Husky, no .git/hooks wiring). If you add one, it becomes the project's standard retroactively — but today style is a review-time convention, not an automated check. What is mechanically enforced is TypeScript strictness and the gate suites described below.

TypeScript

All four TS workspaces (bot, web, search, routing) are strict beyond the default. The settings that actually change how you write code:

Setting bot web search / routing Why it matters
strict yes yes yes No implicit any, strict null checks.
noUncheckedIndexedAccess yes yes yes Indexing an array/record yields T | undefined — you must narrow. This is the single most common source of compile friction in this repo.
verbatimModuleSyntax yes yes yes Type-only imports must use import type.
allowImportingTsExtensions yes yes yes Import with explicit .ts extensions is allowed and is the house style.
noEmit yes yes yes typecheck is tsc --noEmit; bundling is wrangler's or Vite's job.
target / lib ES2023 / ES2023 ES2023 / ES2023 + DOM ES2023 / ES2023 No downleveling. The data planes are DOM-free.
types @cloudflare/workers-types vite/client none Determines which globals exist. bot has no DOM; web does; the data planes are V8-native.
resolveJsonModule no yes yes web imports the basemap JSON style; the data planes load their committed datasets via static JSON imports.

moduleResolution is "bundler" in all four — deliberately not node16, because the runtimes are bundler-hosted (wrangler / Vite) or Node type-stripping (the corpus runners), not raw Node + CommonJS.

House conventions observable in the source

  • Explicit .ts extensions on relative imports. e.g. import { strings } from "../src/i18n/strings.ts", from "./harness.mjs". Data-plane consumers import the subpath with extension too (@araharah/search/src/index.ts) — a bare package specifier resolves to nothing (no exports/main field).
  • .mjs for scripts and tests, .ts for source. Everything in bot/scripts/, bot/test/, web/test/, web/scripts/, search/scripts/, search/test/, routing/scripts/, routing/test/, theme/scripts/, and root scripts/ is .mjs and runs under plain Node.
  • node: prefixed builtins — import { readFileSync } from "node:fs", never bare "fs".
  • Heavy header comments. Every script and several source modules open with a block comment stating what it is, which decision IDs it satisfies, and its known constraints. Match that when adding a file.
  • Named constants in SCREAMING_SNAKE for tunables — SESSION_TTL_SECONDS = 900, PIN_TTL_SECONDS = 300, GUIDANCE_SESSION_TTL_SECONDS = 7200, CARD_CADENCE_S = 240. The privacy gate greps for this exact shape (see below).
  • Zero inline user-facing copy. All strings live in bot/src/i18n/strings.ts and web/src/i18n/strings.ts, keyed by locale, with en and ms carrying identical key-sets. Gate: COPY5 no shell module inlines copy.
  • Structural interfaces for injection. KvLike, TelegramWebAppLike, acquireFix(deps, …), createGuidanceWatch(geo, …), HttpRoutingSourceOptions — dependencies are passed in so tests can supply fakes without touching Workers types or window.
  • No runtime deps in the data planes. @araharah/search and @araharah/routing are V8-native and dependency-free; routing/scripts/ may use dev tools (sharp for glyph rendering) but the library code never does.

Project conventions (the gates)

These are the rules the codebase enforces on itself. Each is a real test that reads source or build artifacts — treat them as non-negotiable, because a change that breaks one is a regression even if the behavior looks fine.

Gate Suite Rule
PG1 bot/test/privacy.test.mjs Every .put( line in kv/sessions.ts, kv/location-pin.ts, kv/guidance-sessions.ts, and webhook.ts must carry expirationTtl on the same line, and the value must be a named SCREAMING_SNAKE constant — never an inline literal. No TTL-free write path can ship. The same law is asserted repo-wide by the kv-ttl gate in the consolidated privacy audit.
PG2 bot/test/privacy.test.mjs The KV modules export nothing persistence-shaped: no saveProfile, storeProfile, saveLocation, storeLocation, saveHistory, storeHistory, putHistory, setLocation.
GEO3 web/test/no-storage.test.mjs After stripping comments, no web/src file may reference localStorage, sessionStorage, indexedDB, caches, CacheStorage, document.cookie, WebApp.DeviceStorage, WebApp.SecureStorage, or WebApp.CloudStorage. Also no serviceWorker.register, navigator.serviceWorker, or workbox. The storage-api gate generalizes this repo-wide.
WK5 bot/test/webhook.test.mjs An empty WEBHOOK_SECRET must fail the webhook closed with a constant 500, without running the bot or touching KV.
CS4 bot/test/commands.test.mjs All replies go out with link previews disabled and nothing else attached; the command tree has no reply-menu or inline-button surface — except the two documented exceptions (the location reply keyboard and the /cari venue card).
CS6 bot/test/commands.test.mjs The command stack registers exactly /start, /mula, /help, /privacy, /privasi, /cari, /search.
COPY1 web/test/copy-gate.test.mjs en and ms carry identical key-sets (locale parity).
COPY5 web/test/copy-gate.test.mjs No module under web/src/ui/*.ts or web/src/main.ts inlines copy strings.
BA1–BA6 web/test/build-artifact.test.mjs The production bundle references no third-party CDN, self-hosts its woff2 fonts, contains a hashed MapLibre worker chunk, carries the inlined Rams style JSON, and contains no Mock-Telegram installer or runner-page fetch stub in any emitted file.
R12 web/test/guidance-replay.test.mjs The guidance state machine is pure and replay-verifiable: every behavior is decided in guidance.ts, and its structural case gates that the module contains no console, no document, no maplibre, no localStorage.
route-privacy bot/test/route-privacy.test.mjs The /api/route handler persists nothing and logs nothing beyond categories — the origin coordinate never touches storage or a log line.
audit exit codes theme/scripts/audit.mjs 0 clean, 1 off-token violations, 2 token-sheet drift. No silent passes.

The consolidated audits (release gates)

Two root tools carry the same laws beyond the suites — both are maintainer-time repo tools (nothing in bot/src, web/src, routing/src, or search/src imports them), both follow the same contract: one evidence line per gate, exit 0 clean / 1 violations / 2 NOT RUN (environment drift — a skipped probe is NEVER mistaken for a pass), category-only output (never a coordinate, id, token, or payload). Both honor a PRIVACY_AUDIT_ROOT override and are cwd-independent, and both double as the fork onboarding check.

npm run privacy-audit (scripts/privacy-audit.mjs, gates in scripts/lib/source-gates.mjs + scripts/lib/bundle-gates.mjs):

Gate Rule
storage-api The memory-only law, repo-wide.
kv-ttl The PG1 same-line TTL law, repo-wide.
log-vocabulary The D-14 category-only log law.
runtime-hosts The POSITIVE fetch-host allowlist — no fetch outside it.
bundle-telemetry The production web/dist scan — no analytics/beacon/telemetry.
kv-expiry / asset-headers (--live) Live KV expiry metadata + cache headers, read verbatim.

npm run budget-audit (scripts/budget-audit.mjs, gates in scripts/lib/budget-gates.mjs):

Gate Rule
route-cpu Re-runs the A* CPU ladder (child process) — inside its budget or a documented, decided escalation.
bundle-budget Route-payload gzip + whole-app bundle gzip ceilings.
kv-writes Write classes + the cron's ZERO-writes law.
subrequests The per-request-class subrequest inventory.
golden-corpus Re-runs routing:corpus + search:corpus.
cache-headers (--live) Observed cache headers, verbatim.

The human record for both lives in docs/BUDGETS.md and docs/PRIVACY-AUDIT.md.

The same privacy promise is asserted from both sides: the bot must never write without a TTL, and the web client must have no storage surface at all. If you add a persistence mechanism, these suites fail — which is the point.

The three invariants worth internalizing

Access control. The chat_id for /api/location, /api/location-fallback, and /api/guidance is always resolved from the session claim, never from client input; locale comes from claim.language_code; the fallback request body is never read and a guidance body's chat_id is structurally dropped. /api/route uses the claim only as an identity gate and persists nothing about the caller. If you add a route, derive identity the same way.

Honest degradation. Every failure is a labeled state, never a silent one and never a fabricated value. A null heading hides the marker cone instead of guessing; getPin range-validates so a corrupt record cannot fly the camera to null island; the chat-pin poll is bounded at 60 s so AWAITING PIN cannot persist forever; every source timeout resolves null so the chain always advances; bootMap() returning null renders the shell with data-map="absent"; every /api/route miss class answers 200 null (the honest router-pending card); a null road graph degrades a cron card to the street-less bare verb but still sends. Copy along this pattern: if you cannot do the thing, say so in a state, don't fake it.

The backend ladders are import-site code. Search and routing backends are selected by constructing them at the import site — no env read, no config, no registry. If you add a backend rung (e.g. engine B), it slots beneath the existing seam and the ladder documentation updates in ARCHITECTURE.md/docs/SEARCH-QUALITY.md — never a runtime switch.

Design-token workflow

theme/src/tokens.css is the single source of truth for every color, spacing, typography, and radius value in the project. It declares them in a Tailwind v4 @theme static block (55 --color-* tokens plus spacing and typography); static forces every token into the compiled CSS so the package is consumable as plain custom properties by anything, including the MapLibre basemap derivation.

Rules:

  1. Never hardcode a value that belongs to a token. Not a hex, not a px padding, not a font stack. npm run audit will flag it in rendered CSS.
  2. Never restate token values in documentation. DESIGN.md and other docs describe principles and point at the token package.
  3. Never edit stitch_minimalist_route_navigator/. Those four mockups are read-only reference; theme/src/theme.css @source-scans them to compile their utility vocabulary, and theme/scripts/screenshot.mjs falls back to their committed screen.png on capture failure.
  4. Adding a token means editing tokens.css, rebuilding (npm run build -w theme), and re-running npm run audit. If it is a cartographic color, also consider whether theme/basemap/build.mjs should derive it.
  5. theme/src/theme.css is the entry stylesheet: it @imports tailwindcss, tokens.css, and components.css, declares seven self-hosted @font-face rules from theme/fonts/ (no Google Fonts CDN), scopes class generation with explicit @source lines (the four mockups are the allowed utility sources; @source not "../scripts" and @source not "../basemap" exclude prose), and defines the --shell-bg / --shell-on-surface theme slots.
  6. The theme-slot machine. Light/dark is a slot switch, not a color import: web/src/telegram/theme.ts reads Telegram's signal (or the header_bg_color luminance fallback) and its applyTheme's ONLY statement is the data-tg-theme attribute set — CSS custom properties re-slot between the two Rams token pairs and the MapLibre style is never re-set. web/test/theme.test.mjs greps that invariant; don't fight it.
  7. theme/basemap/build.mjs forks OpenFreeMap liberty and derives every cartographic color from tokens.css via desaturate(...), with an accent guard that fails the build if #EB8008, #ED3F1C, or #736B1E leak into any layer. building-3d is stripped (no faux 3D).

Branch and commit conventions

Branches

The repo's only active branch is main (there is a stray master but it is not the active branch; the remote is origin/main), and there is no remote branch convention documented — no CONTRIBUTING.md, no PR template, no CI config (no .github/, no .gitea/). In practice, work lands on main via atomic commits rather than long-lived feature branches. If you prefer branches, feat/, fix/, and docs/ prefixes match the commit vocabulary below.

Commit messages

The convention is conventional-commit style with a phase-scoped type: type(phase): subject. The type is one of feat, fix, docs, chore, test. The scope in parentheses is a phase identifier, sometimes with a sub-task (03-04). Examples straight from git log:

feat(03-03): status strip, state cards, recenter control, boot choreography — map-first, D-09 staged states
fix(03-04): load telegram-web-app.js in head — production WebApp was never defined
docs(03-04): log debug round 5 — diagnostic probe armed
test(02-01): hand-rolled harness + RED initData vector suite (D-12, TDD RED)
chore(03-01): lockfile resolution for the web workspace
fix(02-02): ... (when the scope names the phase area rather than a number)

The subject is lowercase after the colon, imperative, and — the house habit — carries an em-dash clause explaining the why or the observable failure it fixes ("production WebApp was never defined (no initData…)"). Reference decision IDs (D-09, WR-03, PRIV-02) are appended in parentheses when relevant.

Keep commits atomic: the log shows one concern per commit, frequently a fix(...) immediately after the docs(...) log entry that motivated it.


Pull requests

There is no PR process configured — no .github/PULL_REQUEST_TEMPLATE.md, no CONTRIBUTING file, no CI, no required reviewers. Given the private single-branch setup, changes are typically committed straight to main. If you do open a PR, match what the project actually relies on:

  • Run the gates and paste the output: all four typechecks, all four test suites, npm run audit if any rendered CSS or theme changed, and — for anything privacy- or budget-relevant — npm run privacy-audit / npm run budget-audit.
  • Explain the observable before/after, following the commit-subject habit (what was broken, what it does now).
  • Call out gate-relevant surface: if you touched a KV write, web/src persistence, the command surface, the i18n strings, or the guidance envelope, say which gates you re-verified (PG1, GEO3, CS6, COPY1, R12…).
  • Include screenshots for any Mini App change — npm run web:build:harness && npm run web:capture produces the current states.
  • Do not commit build output. theme/dist, web/dist, web/dist-harness, routing/data/*.bin, and the screenshots/ directories are gitignored by design; they are regenerated at build time.

Committing generated artifacts

dist/ and the screenshot directories are never committed. This is a deliberate rule, called out in .gitignore: the web build consumes theme/dist/rams.css at build time without vendoring, so a committed theme/dist would go stale. Always rebuild (npm run build) rather than expecting a checked-in artifact.

routing/data/kl-roadgraph.v1.bin is likewise a build artifact (routing/data/.gitignore ignores *.bin — it is ODbL-derived and per-fork), and web/dist-harness/ exists only for capture runs.

.planning/ is also gitignored (local-only planning docs), as is bot/.dev.vars and bot/.wrangler/.