is not rendered.
svg.append(circle, cone, dot);
// MapLibre owns this group's DOM once addTo(map) runs — it parents it into the
// map canvas wrapper. DO NOT document.body.appendChild(group): an un-parented
// body child has no positioning CSS and paints UNDER the `inset: 0` map, so the
// marker would be invisible even though it exists.
group.appendChild(svg);
const marker = new Marker({ element: group }).setLngLat([0, 0]);
return {
marker,
update(map, fix) {
marker.setLngLat([fix.lng, fix.lat]);
circle.setAttribute("r", String(accuracyRadiusPx(map, fix.lat, fix.accuracyM ?? 0)));
if (fix.headingDeg === null) cone.style.display = "none"; // hidden when unknown — never a half-open guess
else { cone.style.display = ""; cone.style.transform = `rotate(${fix.headingDeg}deg)`; }
},
setStale(v) { group.dataset.stale = String(v); },
};
}
```
**`web/src/map/follow.ts`**:
```typescript
import type { Map as MapLibreMap } from "maplibre-gl";
import type { Fix } from "../location/types.ts";
export const CAMERA_PADDING_PX = 96; // 12 x space-sm — marker never hides under strip or controls
export const RECENTER_DURATION_MS = 400;
export type FollowMode = "following" | "free";
export function createFollowController(map: MapLibreMap, onModeChange: (m: FollowMode) => void) {
let mode: FollowMode = "following";
let lock = false;
const set = (m: FollowMode): void => { if (m !== mode) { mode = m; onModeChange(m); } };
map.on("movestart", (e: { originalEvent?: unknown }) => {
if (lock) return;
if (e.originalEvent) set("free"); // a user gesture only, never a programmatic move
});
return {
get mode(): FollowMode { return mode; },
follow(fix: Fix): void {
lock = true;
map.flyTo({ center: [fix.lng, fix.lat], padding: CAMERA_PADDING_PX, duration: RECENTER_DURATION_MS });
set("following");
lock = false;
},
};
}
```
cd /home/C-Fu/araharah && npm run build -w web 2>&1 | tail -20
- `npm run build -w web` exits 0 (maplibre-gl v6 ESM resolves; the style JSON import resolves)
- `grep -q 'setWorkerUrl(workerUrl)' web/src/map/map.ts` exits 0
- `grep -q 'worker&url' web/src/map/map.ts` exits 0
- `grep -v '^ \*\|^/\*\|^$' web/src/shell.css | grep -c 'transition:' | head -1` returns 0 AND the same for `animation:` — no chrome motion
- `grep -v '^ \*\|^/\*\|^$' web/src/shell.css | grep -cE '#[0-9a-fA-F]{3,6}' | head -1` returns 0 — no literal hex, tokens only
- `grep -c 'border-radius: 0' web/src/shell.css | head -1` returns >= 1
- `grep -q 'CAMERA_PADDING_PX = 96' web/src/map/follow.ts` exits 0 AND `grep -q 'RECENTER_DURATION_MS = 400' web/src/map/follow.ts` exits 0
- `grep -q 'GPUInitializationError' web/src/map/map.ts` exits 0
- `grep -q 'DISPLAY_ACCURACY_CAP_M = 100' web/src/map/marker.ts` exits 0
- `grep -q 'headingDeg === null' web/src/map/marker.ts` exits 0
- `grep -q 'data-map="absent"' web/src/shell.css` exits 0 (the WebGL2-absent shell state is deliberate, not a crash)
- `ls web/dist/assets/ | grep -cE '\.(css|js)$' | head -1` returns >= 2
The Rams basemap boots under MapLibre v6 with a WebGL2 fallback, the marker geometry matches the UI-SPEC, and follow/recenter use the specified 96px padding and 400ms flyTo.
task 3: status strip, state cards, recenter control, and the boot choreography (`main.ts`)
web/src/ui/strip.ts, web/src/ui/cards.ts, web/src/ui/recenter.ts, web/src/main.ts, web/test/session.test.mjs
- `theme/src/components.css` lines 22-134 (`.btn-primary`, `.btn-secondary`, `.card`, `.card-header`, `.card-ref`, `.card-content`) — compose these, never restyle
- `web/src/location/types.ts` and `web/src/location/chain.ts` (the `LocationState` union you render)
- `web/src/session/client.ts`, `web/src/session/store.ts`, `web/src/telegram/*.ts` (the wiring you compose)
- `web/src/i18n/strings.ts` (your own task-1 output — every string comes from here)
- `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` sections "Boot Choreography", "Status Strip", "Micro-Behaviors", "BackButton Interaction Contract"
**`web/src/ui/strip.ts`** — renders the six states from the UI-SPEC table:
```typescript
import type { LocationState } from "../location/types.ts";
import { strings, type Locale } from "../i18n/strings.ts";
export function createStatusStrip(host: HTMLElement): {
setLocale(locale: Locale): void;
render(state: LocationState, fixAgeSec: number | null, accuracyM: number | null): void;
} { /* ... */ }
```
Render rules (UI-SPEC section "Status Strip", verbatim semantics):
- Lamp is 8x8px, `data-state` in `{validating, acquiring, live, lost, denied, awaiting}`; colors come from `.strip .lamp[data-state=...]` in `shell.css`.
- Left label from `strings[locale].strip*`. Right telemetry is HIDDEN until live; on live it renders `± M` and `FIX S AGO`; on lost it renders `LAST FIX S AGO`; on acquiring it renders elapsed seconds since the state was entered. **The live accuracy uses the U+00B1 `±` glyph, not an ASCII `+-`** — the UI-SPEC copy contract (Status Strip table and Micro-Behaviors) pins `±12 M` and requires "same format, units unchanged" in Malay; `+-` is a visible one-glyph deviation from the approved contract.
- The strip is Rams-constant matte-black under both themes — no theme selector touches it.
- The lamp element carries `aria-hidden="true"`; state is carried by the word label.
**`web/src/ui/cards.ts`** — three card builders, all strings imported:
```typescript
export function buildBootCard(locale: Locale, stage: 1 | 2): HTMLElement;
export function buildDeniedCard(locale: Locale, opts: { awaitingPin?: boolean }): HTMLElement;
export function buildSignalLostCard(locale: Locale): HTMLElement;
export function startGantry(el: HTMLElement): void;
export function stopGantry(el: HTMLElement): void;
```
- Boot card: `.card` containing `.card-header` (register from `strings.bootValidatingRegister` / `bootAcquiringRegister` inside a `.card-ref`), `.text-headline-md` headline, `.text-body-md` body, plus the 8px `.gantry` square carrying `data-fill`.
- The gantry blink is a JS `setInterval(..., 500)` toggling `data-fill` between `"on"` and `"off"` — a machine-blink duty cycle, NOT a CSS animation. `startGantry`/`stopGantry` let `main.ts` stop it when live.
- Denied card (D-06): `.card` + `.card-header` (register from `strings.deniedRegister` = `GEO // LOCATION`, English under both locales as instrument vocabulary) + `.text-headline-md` + `.text-body-md` + `.text-body-sm` hint + `.card-actions` holding a `.btn-secondary` TRY AGAIN and a `.btn-primary` ASK VIA CHAT. When `opts.awaitingPin` is true the body swaps to `strings.chatFallbackWaiting`.
**D-06 is load-bearing here:** the denied state is a deliberate Rams-styled surface that explains why location matters, offers retry, and points at Telegram's attach then Location path as the manual fallback. It is NEVER a silent banner, and there is **no client-specific variant** — Desktop Telegram (which has no geolocation at all, issue #77) gets byte-identical copy, because a Desktop user cannot fix a permission they were never asked for. The `ASK VIA CHAT` CTA is the real Desktop path, so it carries `.btn-primary` (TRY AGAIN carries `.btn-secondary`), per RESEARCH Pitfall 4 ("make sure it is NOT styled as secondary there") and `03-PATTERNS.md` §`web/src/ui/*`. **This inverts the class mapping in `03-UI-SPEC.md`'s "Consumed component classes" table — that table already carries the inversion as a `Planner amendment (RESEARCH Pitfall 4)` callout directly beneath it, so **verify it is present and leave it as-is; append only if missing** (see the amendment-verification block at the end of this task):**
`> Planner amendment (RESEARCH Pitfall 4): the class mapping is inverted for the denied card — \`ASK VIA CHAT\` is \`.btn-primary\` (it is the only working origin path on Desktop Telegram) and \`TRY AGAIN\` is \`.btn-secondary\`. The Desktop leg of the device matrix is what proves the CTA renders, so this must not read as a diminished secondary action.`
- Signal-lost card: register from `strings.lostRegister` (`SYS // POSITION`), `lostHeadline`, `lostBody`, `lostHint`.
- No inline copy; every text node comes from `strings[locale]` — INCLUDING the two card-register labels. `"GEO // LOCATION"` and `"SYS // POSITION"` appear as string literals in `web/src/i18n/strings.ts` and NOWHERE else in `web/src/`; COPY5 (task 1) fails if either appears in a shell module.
**`web/src/ui/recenter.ts`**:
```typescript
export function createRecenterControl(host: HTMLElement, locale: Locale, onRecenter: () => void): {
setMode(mode: "following" | "free"): void;
setFixState(state: "fresh" | "lost"): void;
setLocale(locale: Locale): void;
} { /* ... */ }
```
- A `
cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25 ; npm run typecheck -w web ; npm run build -w web 2>&1 | tail -5
- `npm test -w web` exits 0 with `[runner] N passed, 0 failed` (N >= 26: 17 from plan 03-02 plus 6 COPY plus 4 SESS, minus overlap)
- `npm run typecheck -w web` exits 0
- `npm run build -w web` exits 0
- `node -e "const s=require('fs').readFileSync('web/src/main.ts','utf8'); const m=s.indexOf('bootMap('), c=s.indexOf('acquireFix('); if(m===-1||c===-1) throw new Error('missing calls'); if(m>c) throw new Error('VIOLATION: chain starts before map mounts (D-09)'); console.log('boot order OK');"` exits 0
- `grep -v '^ \*\|^/\*\|^$' web/src/main.ts | grep -c '(__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__' | head -1` returns 1 — the mock install is a fallback, never an override of a profile-configured harness mock, and is reachable in the harness build (03-04) as well as `vite dev`
- `grep -v '^ \*\|^/\*\|^$' web/src/main.ts | grep -c 'localeFromInitData(wa.initData)' | head -1` returns 1 AND `grep -q 'pickLocale(wa?.initData ? undefined : undefined)' web/src/main.ts; test $? -ne 0` — must FAIL (no garbled no-op locale derivation)
- `grep -c 'setInterval' web/src/main.ts | head -1` returns >= 1 AND `grep -c 'isFixStale' web/src/main.ts | head -1` returns >= 1
- `grep -c 'transition\|animation' web/src/ui/cards.ts web/src/ui/strip.ts web/src/ui/recenter.ts | grep -v ':0' | wc -l` returns 0
- `grep -qE 'Validating session|Acquiring location|TRY AGAIN|ASK VIA CHAT|Location is switched off|Signal lost|GEO // LOCATION|SYS // POSITION' web/src/ui/cards.ts 2>/dev/null; test $? -ne 0` — must FAIL (no inline copy, register labels included)
- `grep -q 'onclick=' web/src/ui/cards.ts web/src/ui/strip.ts web/src/ui/recenter.ts 2>/dev/null; test $? -ne 0` — must FAIL (D-01: no inline handlers)
- `grep -q 'data-map="absent"' web/src/main.ts` exits 0
- **Null-map guard (WebGL2 absent, Open Question 1):** `node -e "const s=require('fs').readFileSync('web/src/main.ts','utf8'); const guards=(s.match(/if \(map !== null\)/g)||[]).length; if(guards<2) throw new Error('expected the follow-construction guard AND the live-path guard; found '+guards); if(!s.includes('follow?.follow')) throw new Error('follow.follow is unguarded'); if(!/follow = createFollowController\(map/.test(s)) throw new Error('follow controller creation not mapped'); console.log('null-map guards OK ('+guards+')')"` exits 0 — with `bootMap()` returning `null`, `createFollowController(map, …)`, `marker.update(map, …)`, and `follow.follow(…)` must all be skipped, or the boot choreography throws at step 8 and the WebGL2-absent device shows a blank page instead of the shell over `--color-surface-dim`
- `grep -v '^ \*\|^/\*\|^$' web/src/main.ts | grep -c 'ARAHARAH-LIVE' | head -1` returns >= 1 AND that emit is NOT nested inside the `if (map !== null)` block — the capture race must still resolve on a map-less device. Assert by reading the surrounding lines, not by a bare count.
- **Marker is wired to the map (the marker must actually be visible):** `grep -q '\.addTo(map)' web/src/main.ts` exits 0 AND `grep -q 'createPositionMarker()' web/src/main.ts` exits 0 — `createPositionMarker()` alone only builds detached DOM; without `.addTo(map)` the group is never parented into the map canvas and no marker renders.
- **Marker attachment is inside the null guard and precedes the first update:** `node -e "const s=require('fs').readFileSync('web/src/main.ts','utf8'); const g=s.indexOf('if (map !== null) {'); if(g===-1) throw new Error('no null-map guard'); const add=s.indexOf('.addTo(map)',g); if(add===-1) throw new Error('addTo(map) is not inside the guard'); const up=s.indexOf('marker.update(map, fix)',g); if(up===-1) throw new Error('no marker.update in the guarded block'); if(add>up) throw new Error('VIOLATION: addTo(map) must precede the first marker.update'); console.log('marker wiring OK');"` exits 0 — attaching after the first update, or outside the guard, means either an NPE on a WebGL2-absent device or an invisible marker.
- **Marker DOM structure (SVG namespace + explicit geometry + no stray body parent):** `node -e "const s=require('fs').readFileSync('web/src/map/marker.ts','utf8'); const svg=s.match(/<[a-zA-Z]*svg[\s\S]*?<\/svg>/)||s; if(!/svg\.append\(circle, cone, dot\)|svg\.appendChild\(circle\);[\s\S]*?svg\.appendChild\(cone\);[\s\S]*?svg\.appendChild\(dot\)/.test(s)) throw new Error('cone/dot are not children of the svg'); if(/group\.append\(cone|group\.appendChild\(cone|group\.append\(cone, dot/.test(s)) throw new Error('SVG nodes appended to the HTML div — they will not render'); if(/document\.body\.appendChild\(group\)/.test(s)) throw new Error('group is appended to document.body; MapLibre owns it once addTo(map) runs'); if(!/viewBox/.test(s)) throw new Error('svg has no viewBox'); if(!/width/.test(s)||!/height/.test(s)) throw new Error('svg has no explicit width/height'); const dots=(s.match(/setAttribute\(\"r\", \"10\"\)/g)||[]).length; if(dots<1) throw new Error('dot has no r'); if((s.match(/setAttribute\(\"cx\", \"0\"\)/g)||[]).length<2) throw new Error('dot/circle lack explicit cx/cy'); console.log('marker DOM OK');"` exits 0 — the dot and cone are SVG-namespace nodes under an `