22 KiB
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.
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
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):
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.
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"]:::edit
TC["npm run bot:typecheck<br/>npm run web:typecheck<br/>tsc --noEmit"]:::check
T1["npm run bot:test<br/>node test/runner.mjs"]:::check
T2["npm run web:test<br/>node test/runner.mjs"]:::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 --> 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)
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
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 |
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 ["help", "privacy"] array in
menuFor() 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.
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:
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
.tsextensions on relative imports. e.g.import { strings } from "../src/i18n/strings.ts",from "./harness.mjs". .mjsfor scripts and tests,.tsfor source. Everything inbot/scripts/,bot/test/,web/test/,web/scripts/, andtheme/scripts/is.mjsand 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.tsandweb/src/i18n/strings.ts, keyed by locale, withenandmscarrying 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 orwindow.
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:
- Never hardcode a value that belongs to a token. Not a hex, not a px
padding, not a font stack.
npm run auditwill flag it in rendered CSS. - Never restate token values in documentation.
DESIGN.mdand other docs describe principles and point at the token package. - 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, andtheme/scripts/screenshot.mjsfalls back to their committedscreen.pngon capture failure. - Adding a token means editing
tokens.css, rebuilding (npm run build -w theme), and re-runningnpm run audit. If it is a cartographic color, also consider whethertheme/basemap/build.mjsshould derive it. theme/src/theme.cssis the entry stylesheet: it@imports tailwindcss,tokens.css, andcomponents.css, declares seven self-hosted@font-facerules fromtheme/fonts/(no Google Fonts CDN), and scopes class generation with explicit@sourcelines (the four mockups are the allowed utility sources;@source not "../scripts"and@source not "../basemap"exclude prose).theme/basemap/build.mjsforks OpenFreeMaplibertyand derives every cartographic color fromtokens.cssviadesaturate(...), with an accent guard that fails the build if#EB8008,#ED3F1C, or#736B1Eleak into any layer.building-3dis 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 auditif 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/srcpersistence, 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:captureproduces the current states. - Do not commit build output.
theme/dist,web/dist,web/dist-harness, and thescreenshots/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/.