feat(03-03): status strip, state cards, recenter control, boot choreography — map-first, D-09 staged states

- strip.ts: six-state lamp+word left cluster, ±M / FIX Ns AGO telemetry (U+00B1 glyph), elapsed-seconds for acquiring
- cards.ts: boot/denied/lost builders from theme classes, machine-blink gantry (500ms JS duty cycle), GEO/SYS registers from strings.ts, D-06 CTA inversion (chat-fallback=btn-primary, retry=btn-secondary)
- recenter.ts: 40x40 plate w/ >=44 hit slop, orthogonal inline-SVG crosshair, three data-attribute notch states, no inline handlers
- main.ts: boot order map->session->chain (bootMap before acquireFix), null-map guards on every map consumer, ARAHARAH-LIVE emitted outside the guard, data-map=absent fallback, localeFromInitData, ASK VIA CHAT w/ D-03 revalidation, 1s tick re-running the chain at 45s staleness
- map.ts: attribution control moved bottom-left (UI-SPEC flush chip, compact)
- shell.css: attrib chip #map-scoped override (wins cascade over maplibre css), strip left cluster, recenter z-index
- vite.config.ts: define __ARAHARAH_HARNESS__ keyed on --mode harness (bare ambient global would ReferenceError in plain dev; Rule 3)
- session.test.mjs: SESS1-4 memory-token gates
This commit is contained in:
araharah-executor
2026-10-23 00:08:34 +08:00
parent c437d312f6
commit d573acc583
9 changed files with 717 additions and 16 deletions
+307 -8
View File
@@ -1,10 +1,309 @@
/* /*
* main.ts — the Mini App entry module (D-01, D-02, D-11). * main.ts — boot choreography (D-08, D-09, D-10).
* -------------------------------------------------------- * ORDER IS LOAD-BEARING: the basemap mounts and starts rendering tiles
* The one-and-only DOM entry: web/index.html loads it as `type="module"` * FIRST; the location chain starts SECOND. The reverse order produces the
* into `<div id="app">`. This plan (03-01) scaffolds the workspace; the * blank map D-09 forbids. Every boot stage renders Rams furniture over the
* Telegram WebApp binding behind the narrow TelegramWebAppLike interface * live map — validating then acquiring then live; none blank.
* and the map shell mount here in plans 03-02 and 03-03. Kept *
* dependency-free until those plans introduce their modules. * Planner amendment (RESEARCH Open Question 1): when WebGL2 is absent,
* bootMap() returns null and the shell renders over --color-surface-dim
* with data-map="absent" — no blank screen, no crash. EVERY map-consuming
* call site is null-guarded; the ARAHARAH-LIVE signal is emitted outside
* the guard so the capture race still resolves on a map-less device.
*
* Theme flips re-slot CSS custom properties only (D-10 / PX-02); watchTheme
* is the entire theming surface and the map style is never re-set. The Fix
* lives in this module's closure and is passed by argument (D-07) — no
* storage API anywhere on the path.
*/ */
export {};
/* Ambient harness constant. Its canonical declaration home is
web/src/vite-env.d.ts (plan 03-04 task 2); until that file exists it is
declared here — single `declare const`, never re-typed later. Vite's
`define` (vite.config.ts) supplies true ONLY in --mode harness builds
and false everywhere else, so a plain dev run cannot hit a ReferenceError. */
declare const __ARAHARAH_HARNESS__: boolean;
import "./shell.css";
import { bootMap } from "./map/map.ts";
import { createPositionMarker } from "./map/marker.ts";
import { createFollowController } from "./map/follow.ts";
import { createStatusStrip } from "./ui/strip.ts";
import { buildBootCard, buildDeniedCard, buildSignalLostCard, startGantry, stopGantry } from "./ui/cards.ts";
import { createRecenterControl } from "./ui/recenter.ts";
import { getWebApp } from "./telegram/webapp.ts";
import { watchTheme } from "./telegram/theme.ts";
import { wireBackButton } from "./telegram/backbutton.ts";
import { resolveSafeInsets, applySafeArea } from "./telegram/safe-area.ts";
import { createSession } from "./session/client.ts";
import { getSession, isSessionStale } from "./session/store.ts";
import { acquireFix, isFixStale, fixAgeSeconds } from "./location/chain.ts";
import { requestChatFallback } from "./location/chat-pin.ts";
import type { Fix, LocationState } from "./location/types.ts";
import { localeFromInitData, type Locale } from "./i18n/strings.ts";
import { installMockWebApp } from "./mock/telegram-mock.ts";
declare global {
interface Window {
// set by plan 03-04's profile-configured runner page BEFORE this module
// runs — read-only here, never written, never a storage surface (D-07)
__ARAHARAH_MOCK__?: unknown;
}
}
void boot();
async function boot(): Promise<void> {
// 1 — safe-area vars are set before any layout reads them (PX-03 groundwork).
applySafeArea(resolveSafeInsets(getWebApp()));
// 3 (declared ahead of its first use inside the BackButton closure) — the
// single-shell predicate for the no-dead-ends wire.
const overlayPresent = (): boolean => false; // Phase 3: single shell state (UI-SPEC BackButton contract)
// 2 — Telegram bindings: theme slot switch + BackButton total wire.
const wa = getWebApp();
if (wa) {
watchTheme(wa);
wireBackButton(wa, overlayPresent);
}
// 3 — the map register mounts FIRST (D-08/D-09 anti-blank invariant).
const appRoot = document.getElementById("app") ?? document.body;
const mainEl = document.createElement("main");
const mapEl = document.createElement("div");
mapEl.id = "map";
mainEl.appendChild(mapEl);
appRoot.appendChild(mainEl);
const map = bootMap(document.getElementById("map")!);
if (!map) document.body.dataset.map = "absent"; // WebGL2 absent — the shell still renders
let follow: ReturnType<typeof createFollowController> | null = null;
const marker = createPositionMarker(); // created here, attached in the guarded block below
// 4 — chrome registers: strip, card stack, recenter control.
const stripHost = document.createElement("div");
mainEl.appendChild(stripHost);
const strip = createStatusStrip(stripHost);
const stack = document.createElement("div");
stack.className = "card-stack";
mainEl.appendChild(stack);
// 12 — locale comes from the RAW initData string (the session POST returns
// only {token} — the claim's language_code is not reachable client-side).
const locale: Locale = wa ? localeFromInitData(wa.initData) : "en";
strip.setLocale(locale);
const recenter = createRecenterControl(mainEl, locale, () => {
if (lastFix !== null && map !== null) follow?.follow(lastFix);
});
// Cards are built once at boot (boot-time locale) and reused across re-runs.
const bootCard1 = buildBootCard(locale, 1);
const bootCard2 = buildBootCard(locale, 2);
const deniedCard = buildDeniedCard(locale, { awaitingPin: false });
const awaitingCard = buildDeniedCard(locale, { awaitingPin: true });
const lostCard = buildSignalLostCard(locale);
const gantryOf = (card: HTMLElement): HTMLElement => {
const el = card.querySelector(".gantry");
return el instanceof HTMLElement ? el : card;
};
let lastFix: Fix | null = null; // in-memory only (D-07)
let staleHandled = false;
let tickStarted = false;
let chainInFlight = false;
const retryButton = deniedCard.querySelector(".btn-secondary");
if (retryButton instanceof HTMLButtonElement) {
retryButton.addEventListener("click", () => void runFromSession()); // D-06: re-runs from link 1
}
const askButton = deniedCard.querySelector(".btn-primary");
if (askButton instanceof HTMLButtonElement) {
askButton.addEventListener("click", () => void askViaChat()); // D-06: the Desktop Telegram path
}
function showCard(card: HTMLElement | null, register: "center" | "bottom" = "center"): void {
if (card !== null) {
stack.replaceChildren(card);
stack.style.display = "";
} else {
stack.style.display = "none";
}
if (register === "bottom") {
// signal-lost folds into the bottom register (UI-SPEC boot table);
// space-md clearance above the recenter control's safe-bottom offset
stack.style.top = "auto";
stack.style.bottom = "calc(var(--safe-bottom, 0px) + var(--spacing-space-xl))";
stack.style.transform = "none";
} else {
stack.style.top = "";
stack.style.bottom = "";
stack.style.transform = "";
}
}
function renderState(state: LocationState): void {
const ageSec = lastFix !== null ? fixAgeSeconds(lastFix, Date.now()) : null;
const accuracyM = lastFix !== null ? lastFix.accuracyM : null;
strip.render(state, ageSec, accuracyM);
switch (state.kind) {
case "validating":
showCard(bootCard1);
startGantry(gantryOf(bootCard1));
break;
case "acquiring":
showCard(bootCard2);
startGantry(gantryOf(bootCard2));
break;
case "live":
showCard(null);
break;
case "stale":
showCard(lostCard, "bottom");
break;
case "denied":
showCard(deniedCard);
break;
case "awaiting-pin":
showCard(awaitingCard);
break;
default:
showCard(null);
}
}
// 8 — every map-consuming call site is behind the null guard: with
// bootMap() returning null (no WebGL2), map.on/getCanvas/flyTo would
// throw and convert the graceful fallback into a blank screen (D-09).
if (map !== null) {
follow = createFollowController(map, (m) => recenter.setMode(m));
marker.marker.addTo(map); // FIRST attachment — the map is the marker's only parent
}
// 5 — mock install is a fallback, never an override: the capture runner
// page installs a profile-configured mock BEFORE this module runs, and
// an unconditional install would clobber it (Android/light fake over a
// configured Desktop/dark profile). In plain vite dev nothing set the
// global, so the default install still fires.
if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) installMockWebApp();
// 6 — session is validated SECOND (HMAC server-side, T-03-08); a failure
// renders the persistent denied card (D-06) and stops — never blank.
await runFromSession();
async function runFromSession(): Promise<void> {
renderState({ kind: "validating" });
const session = await createSession(wa?.initData ?? "");
if (!session.ok) {
staleHandled = false; // session-first failure is boot-level, not a stale-fix recovery
renderDenied();
return;
}
staleHandled = false;
await runChain();
}
function renderDenied(): void {
renderState({ kind: "denied", reason: "no-permission" });
}
function renderAwaitingPin(): void {
renderState({ kind: "awaiting-pin", since: Date.now() });
}
// 7 — stage 2 renders BEFORE the chain runs; never an empty card swap.
async function runChain(): Promise<void> {
if (chainInFlight) return;
chainInFlight = true;
try {
renderState({ kind: "acquiring", since: Date.now() });
const { fix, state } = await acquireFix({ wa, getToken: getSession }, lastFix);
if (fix !== null) {
handleLive(fix);
} else if (state.kind === "awaiting-pin") {
renderAwaitingPin();
} else {
renderDenied();
}
} finally {
chainInFlight = false;
}
}
// 9 — live: guarded map path + the ALWAYS-emitted capture signal.
function handleLive(fix: Fix): void {
lastFix = fix;
staleHandled = false;
stopGantry(gantryOf(bootCard1));
stopGantry(gantryOf(bootCard2));
if (map !== null) {
marker.update(map, fix);
follow?.follow(fix);
}
marker.setStale(false);
recenter.setFixState("fresh");
renderState({ kind: "live", fix });
if (!tickStarted) startTick();
// emitted UNCONDITIONALLY (outside the map guard above): a WebGL2-absent
// device must still resolve the capture race, or its boot reads as failed
console.log("ARAHARAH-LIVE fix=" + fix.source + " acc=" + String(fix.accuracyM));
}
// 10 — the 1s telemetry tick exists ONLY after live; staleness re-runs the
// chain (never a continuous poll — Phase 6 owns the cadence, D-05).
function startTick(): void {
tickStarted = true;
setInterval(tick, 1000);
}
function tick(): void {
if (lastFix === null) return;
if (!isFixStale(lastFix, Date.now())) {
renderState({ kind: "live", fix: lastFix }); // refresh ±M / FIX Ns AGO
return;
}
if (staleHandled) return;
staleHandled = true;
marker.setStale(true);
recenter.setFixState("lost");
renderState({ kind: "stale", fix: lastFix });
void runChain(); // re-acquire — the re-run refreshes, not the clock
}
// 11 — the chat-fallback trigger (D-06 and GEO-02): Requests the bot's
// keyboard so a Desktop user can send a pin (issue #77). A thrown
// request means the session expired (401, D-03): revalidate once,
// retry once; persistent failure returns to the denied card.
async function askViaChat(): Promise<void> {
let token = getSession();
if (token === null || isSessionStale()) {
renderState({ kind: "validating" });
const res = await createSession(wa?.initData ?? "");
if (!res.ok) {
renderDenied();
return;
}
token = res.token;
}
try {
renderAwaitingPin();
await requestChatFallback(token);
await runChain();
} catch {
const res = await createSession(wa?.initData ?? "");
if (res.ok) {
try {
renderAwaitingPin();
await requestChatFallback(res.token);
await runChain();
} catch {
renderAwaitingPin(); // the fallback request itself failed — AWAITING PIN is honest
}
} else {
renderDenied();
}
}
}
}
+3 -2
View File
@@ -14,7 +14,7 @@
* screenshot toolchain (theme/scripts/screenshot.mjs) and plan 03-04's * screenshot toolchain (theme/scripts/screenshot.mjs) and plan 03-04's
* capture race both wait on exactly that prefix. * capture race both wait on exactly that prefix.
*/ */
import { Map as MapLibreMap, setWorkerUrl, GPUInitializationError, type StyleSpecification } from "maplibre-gl"; import { Map as MapLibreMap, setWorkerUrl, GPUInitializationError, AttributionControl, type StyleSpecification } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url"; import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
import ramsStyle from "@araharah/theme/basemap/liberty-rams.json"; import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";
@@ -32,8 +32,9 @@ export function bootMap(container: HTMLElement): MapLibreMap | null {
style: ramsStyle as StyleSpecification, // inlined at build time (D-14) — NEVER re-set style: ramsStyle as StyleSpecification, // inlined at build time (D-14) — NEVER re-set
center: KL_CENTER, center: KL_CENTER,
zoom: 13, zoom: 13,
attributionControl: { compact: true }, attributionControl: false, // added below at bottom-left (UI-SPEC §Shell Layout: flush bottom-left chip)
}); });
map.addControl(new AttributionControl({ compact: true }), "bottom-left");
map.on("error", (e: { error?: { message?: string } }) => { map.on("error", (e: { error?: { message?: string } }) => {
const inner = e && e.error; const inner = e && e.error;
console.error("RAMS-BASEMAP-ERROR", inner && inner.message ? inner.message : inner); console.error("RAMS-BASEMAP-ERROR", inner && inner.message ? inner.message : inner);
+3 -3
View File
@@ -52,9 +52,9 @@ export function createPositionMarker(): {
// of the HTML <div> — an SVG element directly under a <div> is not rendered. // of the HTML <div> — an SVG element directly under a <div> is not rendered.
svg.append(circle, cone, dot); svg.append(circle, cone, dot);
// MapLibre owns this group's DOM once addTo(map) runs — it parents it into the // 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 // map canvas wrapper. DO NOT parent this group under the document body here: an
// body child has no positioning CSS and paints UNDER the `inset: 0` map, so the // un-parented body child has no positioning CSS and paints UNDER the `inset: 0`
// marker would be invisible even though it exists. // map, so the marker would be invisible even though it exists.
group.appendChild(svg); group.appendChild(svg);
const marker = new Marker({ element: group }).setLngLat([0, 0]); const marker = new Marker({ element: group }).setLngLat([0, 0]);
return { return {
+8 -1
View File
@@ -38,7 +38,12 @@ body[data-map="absent"] main { background: var(--color-surface-dim); }
/* map register: full-bleed, the dominant surface */ /* map register: full-bleed, the dominant surface */
main { position: fixed; inset: 0; } main { position: fixed; inset: 0; }
#map { position: absolute; inset: 0; } #map { position: absolute; inset: 0; }
.maplibregl-ctrl-attrib { border-radius: 0; background: var(--color-matte-black); color: var(--color-pure-white); font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; } /* MapLibre's stylesheet lands AFTER shell.css in the bundle (map.ts imports
it at module scope), so the Rams chip override needs the #map-scoped
specificity to win the cascade — flat matte-black chip, no translucency. */
#map .maplibregl-ctrl-attrib { border-radius: 0; background: var(--color-matte-black); color: var(--color-pure-white); font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; }
#map .maplibregl-ctrl-attrib a { color: var(--color-pure-white); }
#map .maplibregl-ctrl-attrib-button { background-color: var(--color-matte-black); }
/* top register: status strip */ /* top register: status strip */
.strip { .strip {
@@ -49,6 +54,7 @@ main { position: fixed; inset: 0; }
background: var(--color-matte-black); color: var(--color-inverse-on-surface); background: var(--color-matte-black); color: var(--color-inverse-on-surface);
font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase;
} }
.strip .left { display: flex; align-items: center; gap: var(--spacing-space-xs); }
.strip .lamp { width: 8px; height: 8px; border: 1px solid var(--color-pure-white); } .strip .lamp { width: 8px; height: 8px; border: 1px solid var(--color-pure-white); }
.strip .lamp[data-state="acquiring"], .strip .lamp[data-state="validating"], .strip .lamp[data-state="awaiting"] { background: var(--color-signal-orange); } .strip .lamp[data-state="acquiring"], .strip .lamp[data-state="validating"], .strip .lamp[data-state="awaiting"] { background: var(--color-signal-orange); }
.strip .lamp[data-state="live"] { background: var(--color-olive-green); } .strip .lamp[data-state="live"] { background: var(--color-olive-green); }
@@ -67,6 +73,7 @@ main { position: fixed; inset: 0; }
width: 40px; height: 40px; padding: 0; border: 0; border-radius: 0; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 0;
background: var(--color-matte-black); color: var(--color-pure-white); background: var(--color-matte-black); color: var(--color-pure-white);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
z-index: 2; /* above MapLibre's own chrome; the attrib chip lives bottom-left */
} }
.recenter::after { content: ""; position: absolute; inset: -2px; } .recenter::after { content: ""; position: absolute; inset: -2px; }
.recenter .notch { width: 8px; height: 8px; background: var(--color-signal-orange); } .recenter .notch { width: 8px; height: 8px; background: var(--color-signal-orange); }
+145
View File
@@ -0,0 +1,145 @@
/*
* cards.ts — the boot / denied / signal-lost card builders (D-06, D-09).
* ------------------------------------------------------------------------
* Composed from the theme's consumed component classes (.card, .card-header,
* .card-ref, .btn-primary, .btn-secondary, .text-headline-md, .text-body-md,
* .text-body-sm) — never restyled locally. Every text node comes from the
* strings module — zero inline copy in either shape (COPY5 gate), card
* register labels included: they are keys of i18n/strings.ts and exist
* nowhere else in the shell sources.
*
* D-06: the denied card is a deliberate Rams surface that explains why
* location matters, offers retry, and points at Telegram's attach then
* Location path. NO client-specific variant — Desktop Telegram (issue #77)
* gets byte-identical copy; the chat-fallback CTA (btn-primary) is its real
* origin path while the retry CTA (btn-secondary) is the mobile-permission
* retry — the RESEARCH Pitfall 4 class inversion recorded in the UI-SPEC.
*
* D-09: the boot card's gantry blinks via a JS-timed setInterval duty
* cycle (machine-blink, ~500ms period, two visible states) — a timer duty
* cycle, never a stylesheet-driven motion; instant hard swaps everywhere.
*/
import { strings, type Locale } from "../i18n/strings.ts";
/* The gantry blink timers, keyed by their host element so startGantry is
idempotent and stopGantry finds the running timer (main.ts stops the
blink the moment the chain reaches live). */
const gantryTimers = new WeakMap<HTMLElement, ReturnType<typeof setInterval>>();
export function startGantry(el: HTMLElement): void {
stopGantry(el); // idempotent: re-starting never stacks two timers
el.dataset.fill = "on";
const timer = setInterval(() => {
el.dataset.fill = el.dataset.fill === "on" ? "off" : "on";
}, 500);
gantryTimers.set(el, timer);
}
export function stopGantry(el: HTMLElement): void {
const timer = gantryTimers.get(el);
if (timer !== undefined) clearInterval(timer);
gantryTimers.delete(el);
}
type CardKey = keyof typeof strings.en; // a ShellStrings key — copy stays centralized (COPY5)
/** `.card` with a trailing body-sm hint line and — in the denied variant —
* the CTA action row. The CTA button markup carries named-string-module
* data slots ({strings.ctaTryAgain} / {strings.ctaAskViaChat}) that are
* overwritten with the real locale-rendered label before the card is
* ever mounted; the slots keep the class-to-CTA mapping element-specific
* and grep-able in source without inlining any copy. */
function mountHintedCard(
locale: Locale,
register: CardKey,
headline: CardKey,
body: CardKey,
hint: CardKey,
withActions: boolean,
): HTMLElement {
const card = document.createElement("div");
card.className = "card";
card.innerHTML = `
<div class="card-header"><span class="card-ref"></span></div>
<h2 class="text-headline-md"></h2>
<p class="text-body-md"></p>
<p class="text-body-sm"></p>
<div class="card-actions">
<button type="button" class="btn-secondary">{strings.ctaTryAgain}</button>
<button type="button" class="btn-primary">{strings.ctaAskViaChat}</button>
</div>`;
const headerRef = card.children[0]?.children[0];
if (headerRef instanceof HTMLElement) headerRef.textContent = strings[locale][register];
const headlineEl = card.children[1];
if (headlineEl instanceof HTMLElement) headlineEl.textContent = strings[locale][headline];
const bodyEl = card.children[2];
if (bodyEl instanceof HTMLElement) bodyEl.textContent = strings[locale][body];
const hintEl = card.children[3];
if (hintEl instanceof HTMLElement) hintEl.textContent = strings[locale][hint];
const actions = card.children[4];
if (!withActions) {
actions?.remove();
} else {
const [retry, ask] = (actions instanceof HTMLElement ? actions.children : []) as Array<HTMLElement | undefined>;
if (retry) retry.textContent = strings[locale].ctaTryAgain;
if (ask) ask.textContent = strings[locale].ctaAskViaChat;
}
return card;
}
/** `.card` skeleton for the two boot stages: register line, machine-blink
* gantry, headline, body — no CTA row, no hint (D-09). */
function mountBootCard(
locale: Locale,
register: CardKey,
headline: CardKey,
body: CardKey,
): HTMLElement {
const card = document.createElement("div");
card.className = "card";
card.innerHTML = `
<div class="card-header"><span class="card-ref"></span></div>
<div class="gantry" data-fill="off"></div>
<h2 class="text-headline-md"></h2>
<p class="text-body-md"></p>`;
const headerRef = card.children[0]?.children[0];
if (headerRef instanceof HTMLElement) headerRef.textContent = strings[locale][register];
const headlineEl = card.children[2];
if (headlineEl instanceof HTMLElement) headlineEl.textContent = strings[locale][headline];
const bodyEl = card.children[3];
if (bodyEl instanceof HTMLElement) bodyEl.textContent = strings[locale][body];
return card;
}
/** D-09 stage card: stage 1 = BOOT-01 // SESSION (instrument vocabulary),
* stage 2 = BOOT-02 // LOCATION. Returns the card; main.ts queries its
* `.gantry` and drives startGantry/stopGantry. */
export function buildBootCard(locale: Locale, stage: 1 | 2): HTMLElement {
const registerKey: CardKey = stage === 1 ? "bootValidatingRegister" : "bootAcquiringRegister";
const headlineKey: CardKey = stage === 1 ? "bootValidatingHeadline" : "bootAcquiringHeadline";
const bodyKey: CardKey = stage === 1 ? "bootValidatingBody" : "bootAcquiringBody";
return mountBootCard(locale, registerKey, headlineKey, bodyKey);
}
/** D-06: the denied card — persistent, deliberate, copy identical on every
* client. The chat-fallback CTA is .btn-primary (the Desktop origin path,
* issue #77) and the retry CTA is .btn-secondary (the RESEARCH Pitfall 4
* inversion, recorded as a callout in the UI-SPEC). With opts.awaitingPin
* the body swaps to the chat-fallback waiting copy. */
export function buildDeniedCard(locale: Locale, opts: { awaitingPin?: boolean } = {}): HTMLElement {
return mountHintedCard(
locale,
"deniedRegister",
"deniedHeadline",
opts.awaitingPin ? "chatFallbackWaiting" : "deniedBody",
"deniedHint",
true,
);
}
/** SYS-labeled position register — the 45s-staleness card (folds into the
* bottom register per UI-SPEC); reasserts the manual chat-pin path in the
* body-sm hint. No CTA row (reacquisition runs automatically). */
export function buildSignalLostCard(locale: Locale): HTMLElement {
return mountHintedCard(locale, "lostRegister", "lostHeadline", "lostBody", "lostHint", false);
}
+76
View File
@@ -0,0 +1,76 @@
/*
* recenter.ts — the follow/recenter control (UI-SPEC §Follow / Recenter Control).
* ---------------------------------------------------------------------------------
* One 40x40 floating square plate, bottom-right, machined microswitch:
* inline SVG crosshair (2px uniform stroke, square linecaps, orthogonal
* geometry — self-hosted inline vector, no Material Symbols CDN per D-14)
* plus the 8px center notch. Three instant notch states via data attributes
* (colors come from shell.css; swaps are instantaneous, zero easing):
* following + fresh → solid signal-orange notch (locked-on register)
* free → notch removed (user panned away)
* following + lost → soft-beige notch (unlocked, NOT red — red is
* reserved for the strip/degraded registers)
* Press feedback is the CSS :active invert — instant. The click handler is
* an addEventListener wire, never an inline onclick attribute (D-01).
*/
import { strings, type Locale } from "../i18n/strings.ts";
export function createRecenterControl(
host: HTMLElement,
locale: Locale,
onRecenter: () => void,
): {
setMode(mode: "following" | "free"): void;
setFixState(state: "fresh" | "lost"): void;
setLocale(locale: Locale): void;
} {
const button = document.createElement("button");
button.type = "button";
button.className = "recenter";
button.dataset.follow = "following";
button.dataset.fix = "fresh";
button.setAttribute("aria-label", strings[locale].ariaRecenter);
myLocationGlyph(button);
button.addEventListener("click", () => onRecenter());
host.appendChild(button);
return {
setMode(mode: "following" | "free"): void {
button.dataset.follow = mode;
},
setFixState(state: "fresh" | "lost"): void {
button.dataset.fix = state;
},
setLocale(next: Locale): void {
button.setAttribute("aria-label", strings[next].ariaRecenter);
},
};
}
/** my_location silhouette redrawn as an orthogonal glyph: outer square
* frame + four orthogonal ticks + a filled 4x4 center dot. stroke-linecap
* square, weight 2 — uniform with the marker geometry. currentColor keeps
* the plate/glyph pairing theme-driven via CSS alone. */
function myLocationGlyph(button: HTMLButtonElement): void {
const svgNs = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNs, "svg");
svg.setAttribute("width", "20");
svg.setAttribute("height", "20");
svg.setAttribute("viewBox", "-12 -12 24 24");
svg.setAttribute("aria-hidden", "true");
const attributes = (el: SVGElement, map: Record<string, string>): void => {
for (const [k, v] of Object.entries(map)) el.setAttribute(k, v);
};
const frame = document.createElementNS(svgNs, "rect");
attributes(frame, { x: "-8", y: "-8", width: "16", height: "16", fill: "none", stroke: "currentColor", "stroke-width": "2" });
const tick = (x1: number, y1: number, x2: number, y2: number): SVGElement => {
const line = document.createElementNS(svgNs, "line");
attributes(line, { x1: String(x1), y1: String(y1), x2: String(x2), y2: String(y2), stroke: "currentColor", "stroke-width": "2" });
return line;
};
const dot = document.createElementNS(svgNs, "rect");
attributes(dot, { x: "-2", y: "-2", width: "4", height: "4", fill: "currentColor" });
svg.append(frame, tick(0, -12, 0, -8), tick(0, 8, 0, 12), tick(-12, 0, -8, 0), tick(8, 0, 12, 0), dot);
const notch = document.createElement("span");
notch.className = "notch";
button.append(svg, notch);
}
+110
View File
@@ -0,0 +1,110 @@
/*
* strip.ts — the status strip (UI-SPEC §Status Strip: states table).
* --------------------------------------------------------------------
* A Rams-constant matte-black band that marks the exact location state
* machine: left cluster = 8px state lamp (decorative, aria-hidden) +
* label-sm state word; right cluster = label-sm telemetry, hidden until
* live. The strip never hides a degradation silently — lamp colors come
* from shell.css `[data-state]` selectors; the carrier here is the word.
* Every string comes from the strings module — no inline copy (COPY5).
*/
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;
} {
let locale: Locale = "en";
let enteredAt: number | null = null; // elapsed-seconds anchor for acquiring / awaiting
let lastKind: LocationState["kind"] | null = null;
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.setAttribute("aria-hidden", "true"); // state is carried by the word label
const label = document.createElement("span");
label.className = "word text-label-sm";
const cluster = document.createElement("span");
cluster.className = "left"; // lamp + word stay one left cluster (UI-SPEC §Status Strip)
cluster.appendChild(lamp);
cluster.appendChild(label);
const telemetry = document.createElement("span");
telemetry.className = "telemetry text-label-sm";
host.className = "strip";
host.appendChild(cluster);
host.appendChild(telemetry);
/** The live accuracy uses the U+00B1 `±` glyph, not an ASCII `+-` — the
* UI-SPEC copy contract (Status Strip table + Micro-Behaviors) pins
* `±12 M` and requires "same format, units unchanged" in Malay. */
function telemetryText(state: LocationState, fixAgeSec: number | null, accuracyM: number | null): string {
if (state.kind === "live") {
const acc = accuracyM === null ? "—" : `±${accuracyM} M`;
const age = fixAgeSec === null ? "" : ` · FIX ${fixAgeSec} S AGO`;
return `${acc}${age}`;
}
if (state.kind === "stale") {
return fixAgeSec === null ? "" : `LAST FIX ${fixAgeSec} S AGO`;
}
if (state.kind === "acquiring" && enteredAt !== null) {
return `${Math.floor((Date.now() - enteredAt) / 1000)} S`;
}
return ""; // hidden until live / elapsed contexts
}
function lampState(kind: LocationState["kind"]): string {
switch (kind) {
case "validating":
return "validating";
case "acquiring":
return "acquiring";
case "live":
return "live";
case "stale":
return "lost";
case "denied":
return "denied";
case "awaiting-pin":
return "awaiting";
default:
return "idle";
}
}
function labelText(state: LocationState): string {
const s = strings[locale];
switch (state.kind) {
case "validating":
return s.stripValidating;
case "acquiring":
return s.stripAcquiring;
case "live":
return s.stripLive;
case "stale":
return s.stripSignalLost;
case "denied":
return s.stripLocationOff;
case "awaiting-pin":
return s.stripAwaitingPin;
default:
return "—";
}
}
return {
setLocale(next: Locale): void {
locale = next;
},
render(state: LocationState, fixAgeSec: number | null, accuracyM: number | null): void {
if (state.kind !== lastKind) {
enteredAt = state.kind === "acquiring" || state.kind === "awaiting-pin" ? Date.now() : null;
lastKind = state.kind;
}
lamp.dataset.state = lampState(state.kind);
label.textContent = labelText(state);
const text = telemetryText(state, fixAgeSec, accuracyM);
telemetry.textContent = text;
telemetry.style.visibility = text === "" ? "hidden" : "visible";
},
};
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
/*
* session.test.mjs — SESS1..SESS4, pure-function gates over session/store.ts.
* ----------------------------------------------------------------------------
* The store is a module singleton: every case calls clearSession() first so
* case order never leaks token state. SESS4 is the D-03/D-07 source gate —
* store.ts must reference no persistence surface in CODE (comments stripped,
* GEO3 gate pattern from no-storage.test.mjs).
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "./harness.mjs";
const { setSession, getSession, isSessionStale, clearSession, SESSION_TTL_SECONDS } = await import("../src/session/store.ts");
test("SESS1 setSession/getSession round-trips and isSessionStale is false inside the 900s window", () => {
clearSession();
setSession("token-abc", 1_000);
assert.equal(getSession(), "token-abc");
assert.equal(isSessionStale(1_000 + SESSION_TTL_SECONDS - 1), false, "one second inside the window is fresh");
assert.equal(isSessionStale(1_000 + 1), false, "a fresh issuance is fresh");
});
test("SESS2 isSessionStale is true at or beyond 900s, and true before any token is set", () => {
clearSession();
assert.equal(isSessionStale(1_234), true, "no token — stale by definition");
setSession("token-abc", 1_000);
assert.equal(isSessionStale(1_000 + SESSION_TTL_SECONDS), true, "exactly 900s is already stale");
assert.equal(isSessionStale(1_000 + SESSION_TTL_SECONDS + 42), true, "beyond the window is stale");
});
test("SESS3 clearSession nulls the token", () => {
setSession("to-be-cleared", 42);
assert.equal(getSession(), "to-be-cleared");
clearSession();
assert.equal(getSession(), null);
assert.equal(isSessionStale(43), true, "a cleared store is a stale store");
});
test("SESS4 store.ts references no storage API (source-introspection, comments stripped)", () => {
const file = path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src", "session", "store.ts");
const code = readFileSync(file, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
for (const banned of [
"localStorage", "sessionStorage", "indexedDB", "caches", "document.cookie",
"setItem", "getItem", "removeItem",
]) {
assert.ok(!code.includes(banned), `store.ts code references ${banned} — the memory-only invariant is broken`);
}
assert.ok(code.includes("SESSION_TTL_SECONDS = 900"), "the 900s TTL mirror by name stays present (D-03)");
});
+11 -2
View File
@@ -1,7 +1,16 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig(({ mode }) => ({
base: "/", base: "/",
// 03-03: the harness constant (declared ambient in main.ts; canonical home
// is web/src/vite-env.d.ts under plan 03-04) is REAL only in --mode harness
// builds and statically false everywhere else — with a bare ambient global
// referenced in main.ts, this define keeps plain `vite dev` from throwing a
// ReferenceError while the fallback mock install there still fires via
// import.meta.env.DEV. 03-04's dual-build config keys on the same mode.
define: {
__ARAHARAH_HARNESS__: JSON.stringify(mode === "harness"),
},
build: { build: {
outDir: "dist", outDir: "dist",
emptyOutDir: true, emptyOutDir: true,
@@ -11,4 +20,4 @@ export default defineConfig({
port: 5173, port: 5173,
strictPort: true, strictPort: true,
}, },
}); }));