# 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](../README.md). For *how the system is put together*, see [ARCHITECTURE.md](./ARCHITECTURE.md). For *what to configure* (secrets, `wrangler.jsonc`, env vars), see [CONFIGURATION.md](./CONFIGURATION.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. | | 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`, and KV access. | | 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, and the Play CDN in the mockup reference pages are all fetched over the network; the capture harnesses need it. | ### Clone and install ```bash git clone http://gitmirah:3000/c-fu/arah2.git cd arah2 npm install ``` `npm install` at the root installs all three workspaces (`theme`, `bot`, `web`) 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`): ```bash cp bot/.dev.vars.example bot/.dev.vars # then edit bot/.dev.vars and fill in BOT_TOKEN, WEBHOOK_SECRET, WEBHOOK_URL ``` Full details, including the `process.env` vs `.dev.vars` precedence rule, are in [CONFIGURATION.md](./CONFIGURATION.md#environment-variables). ### 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: ```bash npm run build # theme -> web (this is the deployable artifact) ``` --- ## The dev loop ```mermaid 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
bot/src, web/src, theme/src"]:::edit TC["npm run bot:typecheck
npm run web:typecheck
tsc --noEmit"]:::check T1["npm run bot:test
node test/runner.mjs"]:::check T2["npm run web:test
node test/runner.mjs"]:::check AU["npm run audit
off-token CSS audit"]:::check BUILD["npm run build
theme then web"]:::check DEV1["npm run bot:dev
wrangler dev :8787"]:::edit DEV2["npm run web:dev
vite :5173"]:::edit HARN["npm run web:build:harness
+ npm run web:capture"]:::check COMMIT["git commit
type(phase): subject"]:::gate EDIT --> TC EDIT --> T1 EDIT --> T2 EDIT --> AU TC --> BUILD T1 --> BUILD T2 --> BUILD AU --> BUILD BUILD --> DEV1 BUILD --> DEV2 BUILD --> HARN DEV1 --> COMMIT DEV2 --> COMMIT HARN --> COMMIT COMMIT --> EDIT ``` ### Bot workspace (`@araharah/bot`) ```bash npm run bot:dev # wrangler dev --port 8787 --local ``` Runs the Worker on . `--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: ```bash npm run build && npm run bot:dev ``` ### Web workspace (`@araharah/web`) ```bash 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 (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: ```bash 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 | ### Theme workspace (`@araharah/theme`) ```bash 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: ```bash 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 `["help", "privacy"]` array in `menuFor()` **and** to the corresponding `helpMenu` lines — the script throws `helpMenu for "" is missing its / 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`. --- ## 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 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 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` | `node test/runner.mjs` — zero-dependency, auto-discovers `*.test.mjs`. | | `npm run typecheck` | `bot`, `web` | `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 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`. | ### Before you open a change Run all four. They are fast and they are the project's actual quality gates: ```bash npm run bot:typecheck npm run web:typecheck npm run bot:test npm run web: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 `bot` and `web`. --- ## 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 four `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 grep gates described below. ### TypeScript Both `bot/tsconfig.json` and `web/tsconfig.json` are strict beyond the default. The settings that actually change how you write code: | Setting | `bot` | `web` | Why it matters | | --- | --- | --- | --- | | `strict` | yes | yes | No implicit `any`, strict null checks. | | `noUncheckedIndexedAccess` | 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 | Type-only imports must use `import type`. | | `allowImportingTsExtensions` | yes | yes | Import with explicit `.ts` extensions is allowed and is the house style. | | `noEmit` | yes | yes | `typecheck` is `tsc --noEmit`; bundling is wrangler's or Vite's job. | | `target` / `lib` | ES2023 / ES2023 | ES2023 / ES2023 + DOM | No downleveling. | | `types` | `@cloudflare/workers-types` | `vite/client` | Determines which globals exist. `bot` has no DOM; `web` does. | | `resolveJsonModule` | no | yes | `web` imports the basemap JSON style. | `moduleResolution` is `"bundler"` in both — deliberately not `node16`, because both runtimes are bundler-hosted (wrangler / Vite), not raw Node. ### House conventions observable in the source - **Explicit `.ts` extensions on relative imports.** e.g. `import { strings } from "../src/i18n/strings.ts"`, `from "./harness.mjs"`. - **`.mjs` for scripts and tests, `.ts` for source.** Everything in `bot/scripts/`, `bot/test/`, `web/test/`, `web/scripts/`, and `theme/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`. 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, …)` — dependencies are passed in so tests can supply fakes without touching Workers types or `window`. --- ## Project conventions (the grep 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`, 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. | | `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`. | | `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. | | `CS6` | `bot/test/commands.test.mjs` | The command stack registers exactly `/start`, `/mula`, `/help`, `/privacy`, `/privasi`. | | `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. | | audit exit codes | `theme/scripts/audit.mjs` | `0` clean, `1` off-token violations, `2` token-sheet drift. No silent passes. | 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 two design invariants worth internalizing **Access control.** The `chat_id` for `/api/location` and `/api/location-fallback` is *always* resolved from the session claim, never from client input; locale comes from `claim.language_code`; the fallback request body is never read. 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"`. Copy along this pattern: if you cannot do the thing, say so in a state, don't fake it. ### 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 `@import`s tailwindcss, `tokens.css`, and `components.css`, declares seven self-hosted `@font-face` rules from `theme/fonts/` (no Google Fonts CDN), and scopes class generation with explicit `@source` lines (the four mockups are the allowed utility sources; `@source not "../scripts"` and `@source not "../basemap"` exclude prose). 6. **`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 branch is `main` (there is a stray `master` but it is not the active branch), 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: both typechecks, both test suites, and `npm run audit` if any rendered CSS or theme changed. - **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, or the i18n strings, say which gates you re-verified (`PG1`, `GEO3`, `CS6`, `COPY1`…). - **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`, 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. `.planning/` is also gitignored (local-only planning docs), as is `bot/.dev.vars` and `bot/.wrangler/`.