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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user