diff --git a/web/src/guidance/glyphs.ts b/web/src/guidance/glyphs.ts new file mode 100644 index 0000000..5b50a7e --- /dev/null +++ b/web/src/guidance/glyphs.ts @@ -0,0 +1,71 @@ +/* + * glyphs.ts — the 8-glyph committed maneuver set (06-UI-SPEC "The 8-glyph set"). + * ------------------------------------------------------------------------------ + * The committed SVG files under ./glyphs/ are the SINGLE SOURCE: this module + * imports them verbatim with Vite's `?raw` and never re-states their geometry. + * The same files rasterize once to 256×256 PNGs for the chat turn-cards + * (maintainer `build-glyphs.mjs`, plan 07) — one geometry, two renderings. + * In-app the raw SVG renders inline: 32×32 on the banner's arrow plate, + * 24×24 in the maneuver list. Strokes ride `currentColor` (signal-orange on + * the banner plate, matte-black/outline per list row state) — the PNG build + * injects the token literals; nothing here hardcodes a color (DSGN-01). + * + * svgHost is the shell's ONE sanctioned innerHTML site: the template parses + * a STATIC import value (GLYPHS[kind], committed at build time), never a + * name-derived or runtime string — the surface gate whitelists exactly this + * line (guidance-surface structural case). + */ +import depart from "./glyphs/depart.svg?raw"; +import straight from "./glyphs/straight.svg?raw"; +import left from "./glyphs/left.svg?raw"; +import right from "./glyphs/right.svg?raw"; +import slightLeft from "./glyphs/slight-left.svg?raw"; +import slightRight from "./glyphs/slight-right.svg?raw"; +import uturn from "./glyphs/uturn.svg?raw"; +import arrive from "./glyphs/arrive.svg?raw"; + +/** The maneuver vocabulary the UI-SPEC commits (8 kinds, trip-order names — + * RouteStep.kind maps onto these in the controller). */ +export type GlyphKind = + | "depart" + | "straight" + | "left" + | "right" + | "slight-left" + | "slight-right" + | "uturn" + | "arrive"; + +/** The 8 kinds in committed order (depart → arrive). */ +export const GLYPH_KINDS: readonly GlyphKind[] = [ + "depart", + "straight", + "left", + "right", + "slight-left", + "slight-right", + "uturn", + "arrive", +]; + +/** kind → raw SVG string of the committed file (the single source). */ +export const GLYPHS: Record = { + depart, + straight, + left, + right, + "slight-left": slightLeft, + "slight-right": slightRight, + uturn, + arrive, +}; + +/** Parse a committed glyph into a live inline-SVG element. The caller sizes + * the host (banner 32×32, list 24×24); color rides currentColor. */ +export function svgHost(kind: GlyphKind): SVGSVGElement { + const template = document.createElement("template"); + // THE one sanctioned innerHTML site: a static import value — never a + // name-derived or runtime string (the surface gate's exact whitelist). + template.innerHTML = GLYPHS[kind]; + return template.content.firstElementChild as SVGSVGElement; +} diff --git a/web/src/guidance/glyphs/arrive.svg b/web/src/guidance/glyphs/arrive.svg new file mode 100644 index 0000000..6daf633 --- /dev/null +++ b/web/src/guidance/glyphs/arrive.svg @@ -0,0 +1 @@ + diff --git a/web/src/guidance/glyphs/depart.svg b/web/src/guidance/glyphs/depart.svg new file mode 100644 index 0000000..15b5e7d --- /dev/null +++ b/web/src/guidance/glyphs/depart.svg @@ -0,0 +1 @@ + diff --git a/web/src/guidance/glyphs/left.svg b/web/src/guidance/glyphs/left.svg new file mode 100644 index 0000000..f350d67 --- /dev/null +++ b/web/src/guidance/glyphs/left.svg @@ -0,0 +1 @@ + diff --git a/web/src/guidance/glyphs/right.svg b/web/src/guidance/glyphs/right.svg new file mode 100644 index 0000000..e68b55e --- /dev/null +++ b/web/src/guidance/glyphs/right.svg @@ -0,0 +1 @@ + diff --git a/web/src/guidance/glyphs/slight-left.svg b/web/src/guidance/glyphs/slight-left.svg new file mode 100644 index 0000000..d3487e8 --- /dev/null +++ b/web/src/guidance/glyphs/slight-left.svg @@ -0,0 +1 @@ + diff --git a/web/src/guidance/glyphs/slight-right.svg b/web/src/guidance/glyphs/slight-right.svg new file mode 100644 index 0000000..a19f876 --- /dev/null +++ b/web/src/guidance/glyphs/slight-right.svg @@ -0,0 +1 @@ + diff --git a/web/src/guidance/glyphs/straight.svg b/web/src/guidance/glyphs/straight.svg new file mode 100644 index 0000000..6117d28 --- /dev/null +++ b/web/src/guidance/glyphs/straight.svg @@ -0,0 +1 @@ + diff --git a/web/src/guidance/glyphs/uturn.svg b/web/src/guidance/glyphs/uturn.svg new file mode 100644 index 0000000..25781bf --- /dev/null +++ b/web/src/guidance/glyphs/uturn.svg @@ -0,0 +1 @@ + diff --git a/web/src/i18n/strings.ts b/web/src/i18n/strings.ts index 05c7705..b349f71 100644 --- a/web/src/i18n/strings.ts +++ b/web/src/i18n/strings.ts @@ -6,7 +6,8 @@ * module, and the copy gate (web/test/copy-gate.test.mjs COPY1..COPY7) keeps * it verbatim against the approved UI-SPEC table in en + ms. Register labels * (`BOOT-01 //`, `BOOT-02 //`, `GEO // LOCATION`, `SYS // POSITION`, `ROUTE`, - * `ROUTE // ROUTER`) are instrument vocabulary: identical in both locales by + * `ROUTE // ROUTER`, `GUIDE`) are instrument vocabulary: identical in both + * locales by * design. Tone is clinical + honest (D-01): plain statements of what is * stored and what is not. * @@ -58,6 +59,48 @@ export interface ShellStrings { ariaRouteAlternatives: string; /** Template — {n}/{min}/{km} placeholders are replaced in code. */ ariaRouteChip: string; + + /* — Phase 6 guidance chrome (06-UI-SPEC §Copywriting Contract; D-06/D-09/D-14). + Every guidance word lives here in en + ms — never inline (the copy gates). + Register labels (GUIDE, GUIDE // NEXT/REROUTING/…, GUIDE // T-nn) are + instrument vocabulary composed in code from guideRegister. Tone: clinical, + honest — the privacy promise stated once, at the exact moment it executes. */ + /** The banner/card register base word — composed with the state word in code. */ + guideRegister: string; + /** Fallback street line for an unnamed OSM segment (D-06 street slot). */ + guideStreetUnnamed: string; + /** REROUTING calm-state line — no blame, no apology (D-04). */ + guideReroutingLine: string; + /** RESUMING projected-state line until the first live fix (D-06/D-11). */ + guideResumingLine: string; + /** NO REROUTE banner line — router-pending degradation (D-03). */ + guideNoRerouteLine: string; + /** NO REROUTE card state line — the longer honest copy (D-03). */ + guideNoRerouteBody: string; + /** Banner toggle word, collapsed state (real-word affordance, no chevron). */ + ctaBannerList: string; + /** Banner toggle word, expanded state. */ + ctaBannerClose: string; + /** The guidance card's ONLY action — opens the abandon confirm card (D-09). */ + ctaEndTrip: string; + /** The abandon-route confirm card (D-09/PX-03) — the one destructive guard. */ + guideConfirmHeadline: string; + /** The privacy clause stated at the exact moment it executes (D-14/PRIV-01). */ + guideConfirmBody: string; + /** Confirm primary CTA — deliberately NOT red (deliberate, not an error). */ + ctaConfirmAbandon: string; + /** Confirm dismiss — guidance continues. */ + ctaConfirmContinue: string; + /** Trip-ended card (D-14) — ONE card for arrival and abandon. */ + guideEndHeadline: string; + /** Trip-end deletion copy — the TTL law's promise, visible. */ + guideEndBody: string; + /** The maneuver list region label (list = labelled region of real text rows). */ + ariaManeuverList: string; + /** The banner button label (one toggle: aria-expanded + aria-controls). */ + ariaBannerToggle: string; + /** The guidance card's progress bar label. */ + ariaTripProgress: string; } export type Locale = "en" | "ms"; @@ -106,6 +149,26 @@ export const strings: Record = { ctaSearchAgain: "SEARCH AGAIN", ariaRouteAlternatives: "Alternative routes", ariaRouteChip: "Route {n}: {min} minutes, {km} kilometres", + + guideRegister: "GUIDE", + guideStreetUnnamed: "Unnamed road", + guideReroutingLine: "Finding a new route from where you are.", + guideResumingLine: "Resuming from your last known progress.", + guideNoRerouteLine: "Rerouting unavailable on this server.", + guideNoRerouteBody: "No road graph here — follow the drawn route; recalculation is unavailable.", + ctaBannerList: "LIST", + ctaBannerClose: "CLOSE", + ctaEndTrip: "END TRIP", + guideConfirmHeadline: "Abandon route?", + guideConfirmBody: "Guidance stops and this trip's record is deleted. Nothing is stored.", + ctaConfirmAbandon: "YES, ABANDON", + ctaConfirmContinue: "CONTINUE", + guideEndHeadline: "Trip ended", + guideEndBody: + "Route guidance stopped and the trip record is deleted — nothing about this drive remains.", + ariaManeuverList: "Full maneuver list", + ariaBannerToggle: "Next maneuver", + ariaTripProgress: "Trip progress", }, ms: { bootValidatingRegister: "BOOT-01 // SESSION", @@ -150,6 +213,26 @@ export const strings: Record = { ctaSearchAgain: "CARI SEMULA", ariaRouteAlternatives: "Laluan alternatif", ariaRouteChip: "Laluan {n}: {min} minit, {km} kilometer", + + guideRegister: "GUIDE", + guideStreetUnnamed: "Jalan tanpa nama", + guideReroutingLine: "Mencari laluan baharu dari kedudukan anda.", + guideResumingLine: "Sambung semula dari kemajuan terakhir anda.", + guideNoRerouteLine: "Pengiraan semula tiada pada pelayan ini.", + guideNoRerouteBody: "Tiada graf jalan di sini — ikut laluan yang dilukis; pengiraan semula tidak tersedia.", + ctaBannerList: "SENARAI", + ctaBannerClose: "TUTUP", + ctaEndTrip: "TAMAT PERJALANAN", + guideConfirmHeadline: "Tinggalkan laluan?", + guideConfirmBody: "Panduan berhenti dan rekod perjalanan ini dipadam. Tiada apa-apa disimpan.", + ctaConfirmAbandon: "YA, TINGGALKAN", + ctaConfirmContinue: "TERUSKAN", + guideEndHeadline: "Perjalanan tamat", + guideEndBody: + "Panduan laluan dihentikan dan rekod perjalanan dipadam — tiada apa-apa tentang pemanduan ini kekal.", + ariaManeuverList: "Senarai penuh manuver", + ariaBannerToggle: "Manuver seterusnya", + ariaTripProgress: "Kemajuan perjalanan", }, }; diff --git a/web/test/copy-gate.test.mjs b/web/test/copy-gate.test.mjs index ef8fb3b..dd4fada 100644 --- a/web/test/copy-gate.test.mjs +++ b/web/test/copy-gate.test.mjs @@ -24,12 +24,15 @@ test("COPY1 en and ms carry identical key-sets (parity gate, both locales)", () const enKeys = Object.keys(strings.en).sort(); const msKeys = Object.keys(strings.ms).sort(); assert.deepEqual(msKeys, enKeys, "ms missing/duplicated keys vs en (D-05 locale parity)"); - assert.ok(enKeys.length >= 40, `expected the full ShellStrings surface (40 keys incl. the 10 route keys), found ${enKeys.length}`); + assert.ok(enKeys.length >= 58, `expected the full ShellStrings surface (58 keys incl. the 10 route + 18 guidance keys), found ${enKeys.length}`); assert.ok(enKeys.includes("deniedRegister"), "deniedRegister is a keys — cards.ts renders GEO //"); assert.ok(enKeys.includes("lostRegister"), "lostRegister is a key — cards.ts renders SYS //"); assert.ok(enKeys.includes("routeRegister"), "routeRegister is a key — route-card.ts renders ROUTE"); assert.ok(enKeys.includes("routePendingRegister"), "routePendingRegister is a key — the pending card renders ROUTE // ROUTER"); assert.ok(enKeys.includes("ctaSearchAgain"), "ctaSearchAgain is a key — the D-10 restart affordance"); + assert.ok(enKeys.includes("guideRegister"), "guideRegister is a key — the banner/card renders GUIDE"); + assert.ok(enKeys.includes("ctaEndTrip"), "ctaEndTrip is a key — the guidance card's only action"); + assert.ok(enKeys.includes("guideConfirmHeadline"), "guideConfirmHeadline is a key — the D-09 confirm guard"); }); /* ————— COPY2 — denied copy names location ————— */ @@ -131,3 +134,68 @@ test("COPY7 route copy verbatim (the 10 route keys, en + ms)", () => { "instrument vocabulary — identical in both locales", ); }); + +/* ————— COPY8 — guidance copy verbatim (the 18 guidance keys, en + ms) ————— */ + +test("COPY8 guidance copy verbatim (the 18 guidance keys, en + ms, 06-UI-SPEC table)", () => { + // register + street fallback + assert.equal(strings.en.guideRegister, "GUIDE"); + assert.equal(strings.ms.guideRegister, "GUIDE", "instrument vocabulary — identical in both locales"); + assert.equal(strings.en.guideStreetUnnamed, "Unnamed road"); + assert.equal(strings.ms.guideStreetUnnamed, "Jalan tanpa nama"); + // the calm banner states (D-04/D-06/D-03) + assert.equal(strings.en.guideReroutingLine, "Finding a new route from where you are."); + assert.equal(strings.ms.guideReroutingLine, "Mencari laluan baharu dari kedudukan anda."); + assert.equal(strings.en.guideResumingLine, "Resuming from your last known progress."); + assert.equal(strings.ms.guideResumingLine, "Sambung semula dari kemajuan terakhir anda."); + assert.equal(strings.en.guideNoRerouteLine, "Rerouting unavailable on this server."); + assert.equal(strings.ms.guideNoRerouteLine, "Pengiraan semula tiada pada pelayan ini."); + assert.equal( + strings.en.guideNoRerouteBody, + "No road graph here — follow the drawn route; recalculation is unavailable.", + ); + assert.equal( + strings.ms.guideNoRerouteBody, + "Tiada graf jalan di sini — ikut laluan yang dilukis; pengiraan semula tidak tersedia.", + ); + // banner toggle + the guidance card's single action + assert.equal(strings.en.ctaBannerList, "LIST"); + assert.equal(strings.ms.ctaBannerList, "SENARAI"); + assert.equal(strings.en.ctaBannerClose, "CLOSE"); + assert.equal(strings.ms.ctaBannerClose, "TUTUP"); + assert.equal(strings.en.ctaEndTrip, "END TRIP"); + assert.equal(strings.ms.ctaEndTrip, "TAMAT PERJALANAN"); + // the D-09 confirm guard — the exact privacy copy at the moment it executes + assert.equal(strings.en.guideConfirmHeadline, "Abandon route?"); + assert.equal(strings.ms.guideConfirmHeadline, "Tinggalkan laluan?"); + assert.equal( + strings.en.guideConfirmBody, + "Guidance stops and this trip's record is deleted. Nothing is stored.", + ); + assert.equal( + strings.ms.guideConfirmBody, + "Panduan berhenti dan rekod perjalanan ini dipadam. Tiada apa-apa disimpan.", + ); + assert.equal(strings.en.ctaConfirmAbandon, "YES, ABANDON"); + assert.equal(strings.ms.ctaConfirmAbandon, "YA, TINGGALKAN"); + assert.equal(strings.en.ctaConfirmContinue, "CONTINUE"); + assert.equal(strings.ms.ctaConfirmContinue, "TERUSKAN"); + // the D-14 trip-end card — ONE card for arrival and abandon + assert.equal(strings.en.guideEndHeadline, "Trip ended"); + assert.equal(strings.ms.guideEndHeadline, "Perjalanan tamat"); + assert.equal( + strings.en.guideEndBody, + "Route guidance stopped and the trip record is deleted — nothing about this drive remains.", + ); + assert.equal( + strings.ms.guideEndBody, + "Panduan laluan dihentikan dan rekod perjalanan dipadam — tiada apa-apa tentang pemanduan ini kekal.", + ); + // aria labels + assert.equal(strings.en.ariaManeuverList, "Full maneuver list"); + assert.equal(strings.ms.ariaManeuverList, "Senarai penuh manuver"); + assert.equal(strings.en.ariaBannerToggle, "Next maneuver"); + assert.equal(strings.ms.ariaBannerToggle, "Manuver seterusnya"); + assert.equal(strings.en.ariaTripProgress, "Trip progress"); + assert.equal(strings.ms.ariaTripProgress, "Kemajuan perjalanan"); +});