From d573acc583a275691e0c5a1d5bc45cf6437a1d79 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Fri, 23 Oct 2026 00:08:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(03-03):=20status=20strip,=20state=20cards,?= =?UTF-8?q?=20recenter=20control,=20boot=20choreography=20=E2=80=94=20map-?= =?UTF-8?q?first,=20D-09=20staged=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/main.ts | 315 +++++++++++++++++++++++++++++++++++++- web/src/map/map.ts | 5 +- web/src/map/marker.ts | 6 +- web/src/shell.css | 9 +- web/src/ui/cards.ts | 145 ++++++++++++++++++ web/src/ui/recenter.ts | 76 +++++++++ web/src/ui/strip.ts | 110 +++++++++++++ web/test/session.test.mjs | 54 +++++++ web/vite.config.ts | 13 +- 9 files changed, 717 insertions(+), 16 deletions(-) create mode 100644 web/src/ui/cards.ts create mode 100644 web/src/ui/recenter.ts create mode 100644 web/src/ui/strip.ts create mode 100644 web/test/session.test.mjs diff --git a/web/src/main.ts b/web/src/main.ts index 686efa1..1facfd2 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,10 +1,309 @@ /* - * main.ts — the Mini App entry module (D-01, D-02, D-11). - * -------------------------------------------------------- - * The one-and-only DOM entry: web/index.html loads it as `type="module"` - * into `
`. This plan (03-01) scaffolds the workspace; the - * Telegram WebApp binding behind the narrow TelegramWebAppLike interface - * and the map shell mount here in plans 03-02 and 03-03. Kept - * dependency-free until those plans introduce their modules. + * main.ts — boot choreography (D-08, D-09, D-10). + * ORDER IS LOAD-BEARING: the basemap mounts and starts rendering tiles + * FIRST; the location chain starts SECOND. The reverse order produces the + * blank map D-09 forbids. Every boot stage renders Rams furniture over the + * live map — validating then acquiring then live; none blank. + * + * 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 { + // 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 | 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 { + 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 { + 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 { + 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(); + } + } + } +} diff --git a/web/src/map/map.ts b/web/src/map/map.ts index 07b6741..f9232fc 100644 --- a/web/src/map/map.ts +++ b/web/src/map/map.ts @@ -14,7 +14,7 @@ * screenshot toolchain (theme/scripts/screenshot.mjs) and plan 03-04's * 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 workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url"; 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 center: KL_CENTER, 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 } }) => { const inner = e && e.error; console.error("RAMS-BASEMAP-ERROR", inner && inner.message ? inner.message : inner); diff --git a/web/src/map/marker.ts b/web/src/map/marker.ts index 059ea4c..c225096 100644 --- a/web/src/map/marker.ts +++ b/web/src/map/marker.ts @@ -52,9 +52,9 @@ export function createPositionMarker(): { // of the HTML
— an SVG element directly under a
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. + // map canvas wrapper. DO NOT parent this group under the document body here: 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 { diff --git a/web/src/shell.css b/web/src/shell.css index 4f3ccfd..e3bf24b 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -38,7 +38,12 @@ body[data-map="absent"] main { background: var(--color-surface-dim); } /* map register: full-bleed, the dominant surface */ main { position: fixed; 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 */ .strip { @@ -49,6 +54,7 @@ main { position: fixed; inset: 0; } 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; } +.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[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); } @@ -67,6 +73,7 @@ main { position: fixed; inset: 0; } width: 40px; height: 40px; padding: 0; border: 0; border-radius: 0; background: var(--color-matte-black); color: var(--color-pure-white); 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 .notch { width: 8px; height: 8px; background: var(--color-signal-orange); } diff --git a/web/src/ui/cards.ts b/web/src/ui/cards.ts new file mode 100644 index 0000000..2b70f97 --- /dev/null +++ b/web/src/ui/cards.ts @@ -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>(); + +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 = ` +
+

+

+

+
+ + +
`; + 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; + 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 = ` +
+
+

+

`; + 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); +} diff --git a/web/src/ui/recenter.ts b/web/src/ui/recenter.ts new file mode 100644 index 0000000..c85165d --- /dev/null +++ b/web/src/ui/recenter.ts @@ -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): 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); +} diff --git a/web/src/ui/strip.ts b/web/src/ui/strip.ts new file mode 100644 index 0000000..121f62a --- /dev/null +++ b/web/src/ui/strip.ts @@ -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"; + }, + }; +} diff --git a/web/test/session.test.mjs b/web/test/session.test.mjs new file mode 100644 index 0000000..db1b6ea --- /dev/null +++ b/web/test/session.test.mjs @@ -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)"); +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index 65f294e..cee87ea 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,7 +1,16 @@ import { defineConfig } from "vite"; -export default defineConfig({ +export default defineConfig(({ mode }) => ({ 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: { outDir: "dist", emptyOutDir: true, @@ -11,4 +20,4 @@ export default defineConfig({ port: 5173, strictPort: true, }, -}); +}));