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,
},
-});
+}));