From e83946c207018d0c630f74060f2f2ee8d3a2d461 Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Tue, 29 Sep 2026 21:33:33 -0500 Subject: [PATCH] Checkpoint: current working state --- .shots/light-check.html | 998 ++++++++++++++-------------- .shots/light-test.html | 80 +-- .shots/scheme-test.html | 16 +- .shots/styles-light.css | 2 - CHECKPOINT-encryption.md | 10 + index.html | 6 +- lumen-share.zip | Bin 0 -> 100009 bytes share/README.md | 5 + share/assets/index-CaoEjGwW.js | 2 + share/assets/index-CaoEjGwW.js.map | 1 + share/assets/style-D9rb7TfV.css | 1 + share/fallback.html | 33 + share/index.html | 25 + share/manifest.webmanifest | 30 + share/precache-manifest.json | 16 + share/sol_lunar_icon.svg | 4 + share/start.sh | 3 + share/sw.js | 140 ++++ src/app/clock.ts | 55 ++ src/app/emergency.ts | 59 ++ src/app/layout.ts | 30 +- src/app/main.ts | 278 +++++++- src/app/map.ts | 135 ++++ src/app/readiness.ts | 593 +++++++++++++++++ src/app/schedule.ts | 89 +++ src/app/styles.css | 308 ++++++++- src/data/slot/store.ts | 20 + src/domain/clock/logic.ts | 55 ++ src/domain/clock/service.ts | 65 ++ src/domain/emergency/logic.ts | 141 +++- src/domain/emergency/types.ts | 5 + src/domain/favorites/README.md | 7 +- src/domain/favorites/logic.ts | 43 ++ src/domain/favorites/types.ts | 15 + src/domain/map/logic.ts | 156 +++++ src/domain/map/types.ts | 52 ++ src/domain/readiness/README.md | 5 +- src/domain/readiness/config.ts | 16 + src/domain/readiness/logic.ts | 177 +++++ src/domain/readiness/types.ts | 145 +++- src/domain/schedule/README.md | 7 +- src/domain/schedule/logic.ts | 111 ++++ src/domain/schedule/types.ts | 24 + src/platform/cache/shell-cache.ts | 57 ++ src/platform/sw/register.ts | 13 + src/sync/activation.ts | 8 +- src/ui/router/router.ts | 8 +- src/ui/views/emergency/emergency.ts | 182 +++-- src/ui/views/map/map.ts | 449 +++++++++++-- src/ui/views/schedule/schedule.ts | 429 +++++++++++- src/ui/views/status/README.md | 7 +- src/ui/views/status/status.ts | 191 ++++++ tests/unit/data-package.test.ts | 45 +- tests/unit/emergency.test.ts | 350 ++++++++++ tests/unit/readiness.test.ts | 627 +++++++++++++++++ tests/unit/schedule.test.ts | 528 +++++++++++++++ tests/unit/status.test.ts | 177 +++++ 57 files changed, 6258 insertions(+), 776 deletions(-) create mode 100644 CHECKPOINT-encryption.md create mode 100644 lumen-share.zip create mode 100644 share/README.md create mode 100644 share/assets/index-CaoEjGwW.js create mode 100644 share/assets/index-CaoEjGwW.js.map create mode 100644 share/assets/style-D9rb7TfV.css create mode 100644 share/fallback.html create mode 100644 share/index.html create mode 100644 share/manifest.webmanifest create mode 100644 share/precache-manifest.json create mode 100644 share/sol_lunar_icon.svg create mode 100755 share/start.sh create mode 100644 share/sw.js create mode 100644 src/app/clock.ts create mode 100644 src/app/emergency.ts create mode 100644 src/app/map.ts create mode 100644 src/app/readiness.ts create mode 100644 src/app/schedule.ts create mode 100644 src/domain/clock/service.ts create mode 100644 src/domain/favorites/logic.ts create mode 100644 src/domain/favorites/types.ts create mode 100644 src/domain/map/logic.ts create mode 100644 src/domain/map/types.ts create mode 100644 src/domain/readiness/config.ts create mode 100644 src/domain/readiness/logic.ts create mode 100644 src/domain/schedule/logic.ts create mode 100644 src/domain/schedule/types.ts create mode 100644 src/platform/cache/shell-cache.ts create mode 100644 src/ui/views/status/status.ts create mode 100644 tests/unit/emergency.test.ts create mode 100644 tests/unit/readiness.test.ts create mode 100644 tests/unit/schedule.test.ts create mode 100644 tests/unit/status.test.ts diff --git a/.shots/light-check.html b/.shots/light-check.html index 6cae8fb..d2aba81 100644 --- a/.shots/light-check.html +++ b/.shots/light-check.html @@ -1,6 +1,10 @@ - - - -
- - -
-
-

Home

-

Offline-first festival companion — shell is live.

-
-
- -
- \ No newline at end of file + /* Status chips — tinted pills */ + .chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + border-radius: 999px; + padding: 0.25rem 0.75rem; + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: 0.04em; + } + .chip--pink { + background: var(--chip-pink-bg); + color: var(--chip-pink-fg); + } + .chip--green { + background: var(--chip-green-bg); + color: var(--chip-green-fg); + } + .chip--blue { + background: var(--chip-green-bg); + color: var(--chip-green-fg); + } + .chip--indigo { + background: var(--chip-indigo-bg); + color: var(--chip-indigo-fg); + } + .chip--amber { + background: var(--chip-amber-bg); + color: var(--chip-amber-fg); + } + + + +
+
+ + +
+
+

Home

+

Offline-first festival companion — shell is live.

+
+
+ +
+
+ + diff --git a/.shots/light-test.html b/.shots/light-test.html index 8696bc5..543412b 100644 --- a/.shots/light-test.html +++ b/.shots/light-test.html @@ -1,42 +1,42 @@ - - - - - - -
- - -
- Festival companion -

Lumen

-

Offline-first guide to the festival grounds

- -
- Status -

Day 1 — Friday

-

Gates open at noon. First set on the main stage at 2:00.

- Synced - 12 updates -
-
- -
- - \ No newline at end of file + + + + + + +
+ + +
+ Festival companion +

Lumen

+

Offline-first guide to the festival grounds

+ +
+ Status +

Day 1 — Friday

+

Gates open at noon. First set on the main stage at 2:00.

+ Synced + 12 updates +
+
+ +
+ + diff --git a/.shots/scheme-test.html b/.shots/scheme-test.html index c1143c9..ecfdb89 100644 --- a/.shots/scheme-test.html +++ b/.shots/scheme-test.html @@ -1,4 +1,12 @@ -
- + + + +
+ + + diff --git a/.shots/styles-light.css b/.shots/styles-light.css index b5377d4..09fa45d 100644 --- a/.shots/styles-light.css +++ b/.shots/styles-light.css @@ -61,8 +61,6 @@ --content-max: 48rem; } - - * { box-sizing: border-box; } diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md new file mode 100644 index 0000000..332227c --- /dev/null +++ b/CHECKPOINT-encryption.md @@ -0,0 +1,10 @@ +# CHECKPOINT — Lumen (2025-09-28) + +- **Project:** /home/avi/Projects/Lumen/ +- **Latest commit:** Work in session — added ClockService interface to src/domain/clock/logic.ts; fixed TypeScript errors; build clean; 273 tests pass. +- **Working tree:** Clean build (dist/ rebuilt); uncommitted feature edit (ClockService interface). +- **Completed:** ClockService pure logic interface added (offline-first, no network, no persistence per SPIKE-08 / contract §3). Type errors resolved (main.ts createMapView call + map.ts narrowing). Production build verified. +- **New commits in session:** Feature addition — ClockService interface; verification (build + typecheck + test all pass). +- **Verification commands:** cargo/test analogs: `npm run typecheck` (green), `npm run build` (green, 330ms), `npm test` (273 passed). No Rust frontend to check (PWA only). +- **How to resume / reproduce:** `npm run dev` (localhost:5173 default) or `bash share/start.sh` + open on phone via same-network IP. +- **Outstanding / next step:** Implement ClockService logic (correctedNow, hasDrift, shouldWarnOutsideWindow, renderInZone, classifyNowNext) + readiness service (C1-C8) + feature views (emergency/schedule/map/festival) per IMPLEMENTATION-CONTRACT.md Stage 1. diff --git a/index.html b/index.html index 0d82847..bdb5da0 100644 --- a/index.html +++ b/index.html @@ -6,17 +6,17 @@ - + - Lumen + Solarpunk
Loading…
diff --git a/lumen-share.zip b/lumen-share.zip new file mode 100644 index 0000000000000000000000000000000000000000..c7293ee0fea560262d96a88b978adad05b78d1dc GIT binary patch literal 100009 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∨|At#1$;yk?R_3gfW{+uP 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%0DHg;0y6P25-m}sRuYc3OdM791Hc= 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 YgUd8lHct in e?st(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var he=(e,t,n)=>it(e,typeof t!="symbol"?t+"":t,n);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))a(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const r of i.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&a(r)}).observe(document,{childList:!0,subtree:!0});function n(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function a(s){if(s.ep)return;s.ep=!0;const i=n(s);fetch(s.href,i)}})();async function rt(){if(typeof caches>"u")return{ok:!1,cacheName:null,entry:null};let e;try{e=await caches.keys()}catch{return{ok:!1,cacheName:null,entry:null}}const t=e.filter(a=>a.startsWith("lumen-shell-")).sort().at(-1)??null;if(!t)return{ok:!1,cacheName:null,entry:null};let n;try{n=await caches.open(t)}catch{return{ok:!1,cacheName:t,entry:null}}for(const a of["/index.html","/"])try{if(await n.match(a))return{ok:!0,cacheName:t,entry:a}}catch{}return{ok:!1,cacheName:t,entry:null}}async function ot(){if(typeof navigator>"u"||!("serviceWorker"in navigator))return{status:"unsupported"};try{return{status:"registered",registration:await navigator.serviceWorker.register("/sw.js",{scope:"/"})}}catch(e){return{status:"failed",error:e}}}async function ct(){try{const e=navigator.storage;return e!=null&&e.estimate?await e.estimate()??null:null}catch{return null}}function lt(e){return e instanceof DOMException?e.name==="QuotaExceededError":typeof e=="object"&&e!==null&&(e.name==="QuotaExceededError"||e.code===22)?!0:String((e==null?void 0:e.message)??e).includes("QuotaExceededError")}function ge(e){return new Promise((t,n)=>{e.onsuccess=()=>{t(e.result)},e.onerror=()=>{n(e.error??new Error("IDB request failed"))}})}function ut(e){return new Promise((t,n)=>{e.oncomplete=()=>{t()},e.onerror=()=>{n(e.error??new Error("IDB transaction error"))},e.onabort=()=>{n(e.error??new Error("IDB transaction abort"))}})}function Re(e,t,n){return new Promise((a,s)=>{const i=indexedDB.open(e,t);i.onupgradeneeded=()=>{const r=i.result,o=i.transaction;o?n(r,i.result.version??dt(i),o):n(r,0,null)},i.onsuccess=()=>{a(i.result)},i.onerror=()=>{s(i.error??new Error("IDB open failed"))},i.onblocked=()=>{}})}function dt(e){return e.oldVersion??0}async function se(e,t,n,a){const s=Array.isArray(t)?t:[t],i=e.transaction(s,n);let r;try{r=await a(i)}catch(o){try{i.abort()}catch{}throw o}try{await ut(i)}catch(o){throw lt(o),o}return r}async function j(e,t,n){return se(e,t,"readonly",async a=>{const i=a.objectStore(t).get(n);return ge(i)})}async function mt(e,t){return se(e,t,"readonly",async n=>{const s=n.objectStore(t).getAll();return ge(s)})}async function Ze(e,t,n,a){await se(e,t,"readwrite",async s=>{const i=s.objectStore(t),r=a!==void 0?i.put(n,a):i.put(n);await ge(r)})}async function ft(e,t,n){await se(e,t,"readwrite",async a=>{const i=a.objectStore(t).delete(n);await ge(i)})}const de={SYSTEM:"lumen-system",SLOT_A:"lumen-slot-a",SLOT_B:"lumen-slot-b",USER:"lumen-user"};function pt(e){return e==="A"?de.SLOT_A:de.SLOT_B}const ee="meta",He="meta",te="files",Ae="assets",Se="staging",ne="favorites",me="prefs",Ie="diag",gt={activeSlot:null,activeEdition:null,activePackageVersion:null,verification:null,appVersionAtActivation:null,readbackPending:!1,clockSkew:null,bootCount:0},ht=1;function yt(e){e.objectStoreNames.contains(ee)||e.createObjectStore(ee)}function ie(){return Re(de.SYSTEM,ht,e=>{yt(e)})}async function Y(e){const t=await j(e,ee,He);if(!t)return{...gt};const n={...t};return delete n.staging,n}async function xe(e,t){await se(e,ee,"readwrite",async n=>{const s=n.objectStore(ee).put(t,He);await new Promise((i,r)=>{s.onsuccess=()=>{i()},s.onerror=()=>{r(s.error??new Error("IDB error"))}})})}async function Qe(e){const t=await Y(e);t.readbackPending&&await xe(e,{...t,readbackPending:!1})}const vt=2;function bt(e){e.objectStoreNames.contains(te)||e.createObjectStore(te),e.objectStoreNames.contains(Ae)||e.createObjectStore(Ae),e.objectStoreNames.contains(Se)||e.createObjectStore(Se)}function q(e){return Re(pt(e),vt,t=>{bt(t)})}const wt="journal";async function Z(e){return j(e,Se,wt)}async function fe(e,t){const n=await j(e,te,t);return n==null?void 0:n.json}async function ke(e,t){const n=await j(e,te,t);return n?{bytes:n.bytes,sha256:n.sha256}:void 0}const Et="manifest";async function Te(e){return await j(e,te,Et)??void 0}async function Xe(e,t){return j(e,Ae,t)}const At="lumen.package/1";function St(e,t,n){if(!t.includes(e.schemaVersion))return!1;const a=(s,i)=>{const r=s.split(".").map(c=>Number.parseInt(c,10)),o=i.split(".").map(c=>Number.parseInt(c,10));for(let c=0;c<3;c++){const l=r[c]??0,y=o[c]??0;if(l!==y)return l-y}return 0};return!(a(n,e.appCompatibility.minAppVersion)<0||e.appCompatibility.maxAppVersion!==null&&a(n,e.appCompatibility.maxAppVersion)>0)}const Ne={floor:!0,floorVersion:"1.0.0+3",emergencySchemaVersion:1,generatedAt:"2026-08-27T09:00:00Z",sourceContentVersion:3,services:{emergencyNumber:"911",security:{phone:"+1-555-0142",location:"Main Gate Kiosk"},firstAid:{location:"Behind Stage B",hours:"10:00–02:00"}},address:{lines:["123 Festival Way","Austin, TX 78701"],coordinates:{lat:30.2672,lon:-97.7431}},musterPoints:[{name:"North Field"}],exits:[{name:"East Gate"}],aedSummary:"AEDs: 1 locations",procedures:[{id:"weather",title:"Severe Weather",steps:["Seek shelter","Follow staff"]},{id:"medical",title:"Medical Emergency",steps:["Call 911","Locate first aid"]}]},kt=JSON.stringify(Ne),Me=new TextEncoder().encode(kt).length,Nt=16*1024;function I(e){return typeof e=="object"&&e!==null?e:null}function L(e){return typeof e=="string"&&e.trim().length>0}function Ct(e){const t=I(e),n=t&&I(t.services),a=n&&I(n.security),s=n&&I(n.firstAid),i=t&&I(t.address),r=i&&I(i.coordinates);return!!((t==null?void 0:t.floor)===!0&&L(t.floorVersion)&&Number.isInteger(t.emergencySchemaVersion)&&t.emergencySchemaVersion>=1&&L(t.generatedAt)&&!Number.isNaN(Date.parse(t.generatedAt))&&Number.isInteger(t.sourceContentVersion)&&t.sourceContentVersion>=1&&n&&L(n.emergencyNumber)&&a&&L(a.phone)&&s&&L(s.location)&&i&&Array.isArray(i.lines)&&i.lines.every(L)&&r&&typeof r.lat=="number"&&Number.isFinite(r.lat)&&typeof r.lon=="number"&&Number.isFinite(r.lon)&&Array.isArray(t.musterPoints)&&t.musterPoints.every(o=>{var c;return L((c=I(o))==null?void 0:c.name)})&&Array.isArray(t.exits)&&t.exits.every(o=>{var c;return L((c=I(o))==null?void 0:c.name)})&&L(t.aedSummary)&&Array.isArray(t.procedures)&&t.procedures.every(o=>{const c=I(o);return!!(c&&L(c.id)&&L(c.title)&&Array.isArray(c.steps)&&c.steps.every(L))}))}function Le(){return Me>Nt?{ok:!1,reason:"compiled emergency floor exceeds 16KB"}:Ct(Ne)?{ok:!0,floor:Ne,bytes:Me}:{ok:!1,reason:"compiled emergency floor is malformed"}}const Ot=10080*60*1e3,Ue=["emergency","schedule","map","info","assets"];async function Ce(e,t){if(!(t!=null&&t.complete)||!Ue.every(a=>t.stagedFiles.includes(a)))return!1;const n=await q(e);for(const a of Ue)if(!await ke(n,a))return n.close(),!1;for(const a of t.stagedAssets)if(!await Xe(n,a))return n.close(),!1;return n.close(),!0}async function Pt(){var r,o;const e=await ie(),t=await Y(e);if(!t.activeSlot)return e.close(),!1;const n=t.activeSlot==="A"?"B":"A",a=await q(n),s=await Z(a);if(a.close(),!await Ce(n,s)||!s)return e.close(),!1;const i={...t,activeSlot:n,activeEdition:s.edition,activePackageVersion:s.packageVersion,verification:{packageVersion:s.packageVersion,edition:s.edition,manifestSha256:s.manifestSha256,publicKeyFingerprint:s.publicKeyFingerprint??((r=t.verification)==null?void 0:r.publicKeyFingerprint)??"",verifiedAt:s.verifiedAt??((o=t.verification)==null?void 0:o.verifiedAt)??Date.now(),appVersionAtActivation:t.appVersionAtActivation??""},readbackPending:!0};return await xe(e,i),await Qe(e),e.close(),!0}async function Vt(){var o,c;const e=await ie(),t=await Y(e);if(!t.readbackPending||!t.activeSlot)return e.close(),!0;const n=await q(t.activeSlot),a=await Z(n);if(n.close(),await Ce(t.activeSlot,a))return await Qe(e),e.close(),!0;const s=t.activeSlot==="A"?"B":"A",i=await q(s),r=await Z(i);return i.close(),!await Ce(s,r)||!r?(e.close(),!1):(await xe(e,{...t,activeSlot:s,activeEdition:r.edition,activePackageVersion:r.packageVersion,verification:{packageVersion:r.packageVersion,edition:r.edition,manifestSha256:r.manifestSha256,publicKeyFingerprint:r.publicKeyFingerprint??((o=t.verification)==null?void 0:o.publicKeyFingerprint)??"",verifiedAt:r.verifiedAt??((c=t.verification)==null?void 0:c.verifiedAt)??Date.now(),appVersionAtActivation:t.appVersionAtActivation??""},readbackPending:!1}),e.close(),!0)}const Dt="0.1.0",Rt=[1,2],ae=["emergency","schedule","info","map","assets"],xt=["emergency","schedule"];function Tt(e){return ae.every(t=>e.includes(t))?"L2":xt.every(t=>e.includes(t))?"L1":"L0"}function W(e){const t=Tt(e.presentSections);return e.failure?{state:"FAILED",reason:null,missing:[...e.missing],failedOp:e.failure.op,level:t}:e.emergencyFloorOk?e.shellValid?e.datasetPresent?e.integrity==="files-missing"?{state:"RECOVERY",reason:"missing",missing:[...e.missing],failedOp:null,level:t}:e.integrity==="corrupt"?{state:"RECOVERY",reason:"corrupt",missing:[...e.missing],failedOp:null,level:t}:e.authenticity==="mismatch"?{state:"RECOVERY",reason:"corrupt",missing:[...e.missing],failedOp:null,level:t}:e.manifestOk&&(!e.schemaCompatible||e.incompatible)?{state:"RECOVERY",reason:"incompatible",missing:[...e.missing],failedOp:null,level:t}:e.authenticity==="record-missing"?{state:"PARTIAL",reason:null,missing:["verification",...e.missing],failedOp:null,level:t}:e.manifestOk?!e.requiredSectionsOk||!e.requiredAssetsOk?{state:"PARTIAL",reason:null,missing:[...e.missing],failedOp:null,level:t}:{state:"READY",reason:null,missing:[],failedOp:null,level:t}:{state:"PARTIAL",reason:null,missing:e.missing.includes("manifest")?[...e.missing]:["manifest",...e.missing],failedOp:null,level:t}:e.hadVerifiedDataset?{state:"RECOVERY",reason:"missing",missing:["dataset"],failedOp:null,level:t}:e.stagedPending?{state:"PARTIAL",reason:null,missing:[...e.stagedPending.missing],failedOp:null,level:t}:{state:"NOT_READY",reason:null,missing:[...e.missing],failedOp:null,level:t}:e.datasetPresent?{state:"PARTIAL",reason:null,missing:["shell-cache",...e.missing],failedOp:null,level:t}:{state:"NOT_READY",reason:null,missing:[...e.missing],failedOp:null,level:t}:{state:"FAILED",reason:null,missing:[...e.missing],failedOp:"emergency-floor",level:t}}function Lt(e,t){switch(e){case"READY":return{text:"OFFLINE READY ✓",tone:"ready",label:"Offline status: ready. All festival data is verified on this device."};case"PARTIAL":return{text:"PARTIAL",tone:"partial",label:`Offline status: partial. Missing: ${t??"some required data"}.`};case"NOT_READY":return{text:"Get festival data",tone:"neutral",label:"Offline status: not ready. Open with internet to get festival data."};case"RECOVERY":return{text:`RECOVERY — ${t??"data missing"}`,tone:"recovery",label:`Offline status: recovery needed (${t??"data missing"}). Emergency info still available.`};case"BASELINE_ONLY":return{text:"SAVING UNAVAILABLE",tone:"baseline",label:"Offline status: this browser cannot save offline data. Emergency info still available."};case"FAILED":return{text:`FAILED — ${t??"error"}`,tone:"failed",label:`Offline status: operation failed (${t??"error"}). Emergency info still available.`}}}const _t={openSystem:ie,openSlot:q,checkShell:rt,loadFloor:()=>{const e=Le();return e.ok?{ok:!0,version:e.floor.floorVersion,bytes:e.bytes}:{ok:!1,version:null,bytes:0}},estimate:ct,appVersion:Dt,supportedSchemaRange:Rt,now:()=>Date.now()};function pe(e,t,n){return{...W({shellValid:!1,datasetPresent:!1,authenticity:"record-missing",integrity:"files-missing",schemaCompatible:!1,requiredSectionsOk:!1,requiredAssetsOk:!1,emergencyFloorOk:t.ok,missing:[],hadVerifiedDataset:!1,manifestOk:!1,incompatible:!1,failure:{op:e},presentSections:[],stagedPending:null}),edition:null,packageVersion:null,generatedAt:null,fetchedAt:null,sections:[],assets:{total:0,present:0,missing:[]},floorVersion:t.version,storage:n,shellCache:null,canRestore:!1,activationPending:!1}}function $t(e,t){return{state:"BASELINE_ONLY",reason:null,missing:[],failedOp:null,level:"L0",edition:null,packageVersion:null,generatedAt:null,fetchedAt:null,sections:[],assets:{total:0,present:0,missing:[]},floorVersion:e.version,storage:t,shellCache:null,canRestore:!1,activationPending:!1}}function Ft(e){if(typeof e!="object"||e===null)return[];const t=e.assets;return Array.isArray(t)?t.map(n=>{const a=n;return typeof a.id=="string"&&typeof a.bytes=="number"?{id:a.id,bytes:a.bytes}:null}).filter(n=>n!==null):[]}function It(e,t){return(e==null?void 0:e.format)===At&&e.edition===t.edition&&e.packageVersion===t.packageVersion}async function Be(e,t){try{const n=await e(t);try{const a=await Z(n);return!!(a!=null&&a.complete&&ae.every(s=>a.stagedFiles.includes(s)))}finally{n.close()}}catch{return!1}}async function Mt(e,t){for(const n of["A","B"])try{const a=await e(n);try{const s=await Z(a);if(s&&t-s.lastProgressAt<=Ot)return{slot:n,staged:s.stagedFiles,complete:s.complete}}finally{a.close()}}catch{}return null}async function Ut(e=_t){const t=e.loadFloor();let n=null;try{n=await e.estimate()}catch{n=null}try{return await Bt(e,t,n)}catch{return pe("boot-check",t,n)}}async function Bt(e,t,n){const a=await e.checkShell().catch(()=>({ok:!1,cacheName:null}));let s;try{s=await e.openSystem()}catch{return $t(t,n)}try{let i=await Y(s);if(i.readbackPending)try{await Vt();const c=await e.openSystem();s.close(),s=c,i=await Y(s)}catch{return{...pe("boot-check",t,n),shellCache:a.cacheName}}if(!i.activeSlot)return await Kt(e,t,n,a,i.verification!==null);const r=i.activeSlot;let o;try{o=await e.openSlot(r)}catch{return{...pe("boot-check",t,n),shellCache:a.cacheName}}try{return await Yt(e,t,n,a,i,o,r)}finally{o.close()}}finally{s.close()}}async function Kt(e,t,n,a,s){if(s)return{...W(oe(t,a,{datasetPresent:!1,hadVerifiedDataset:!0})),edition:null,packageVersion:null,generatedAt:null,fetchedAt:null,sections:[],assets:{total:0,present:0,missing:[]},floorVersion:t.version,storage:n,shellCache:a.cacheName,canRestore:!1,activationPending:!1};const i=await Mt(e.openSlot,e.now());if(i){const o=ae.filter(l=>!i.staged.includes(l));return{...W(oe(t,a,{datasetPresent:!1,hadVerifiedDataset:!1,requiredSectionsOk:i.complete&&o.length===0,requiredAssetsOk:i.complete&&o.length===0,missing:o,presentSections:[...i.staged],stagedPending:{missing:o}})),edition:null,packageVersion:null,generatedAt:null,fetchedAt:null,sections:[],assets:{total:0,present:0,missing:[]},floorVersion:t.version,storage:n,shellCache:a.cacheName,canRestore:!1,activationPending:i.complete&&o.length===0}}return{...W(oe(t,a,{datasetPresent:!1,hadVerifiedDataset:!1})),edition:null,packageVersion:null,generatedAt:null,fetchedAt:null,sections:[],assets:{total:0,present:0,missing:[]},floorVersion:t.version,storage:n,shellCache:a.cacheName,canRestore:!1,activationPending:!1}}function oe(e,t,n){return{shellValid:t.ok,datasetPresent:!1,authenticity:"record-missing",integrity:"files-missing",schemaCompatible:!1,requiredSectionsOk:!1,requiredAssetsOk:!1,emergencyFloorOk:e.ok,missing:[],hadVerifiedDataset:!1,manifestOk:!0,incompatible:!1,failure:null,presentSections:[],stagedPending:null,...n}}async function Yt(e,t,n,a,s,i,r){const o=m=>({...pe(m,t,n),shellCache:a.cacheName});let c,l;try{c=await Z(i),l=await Te(i)}catch{return o("boot-check")}const y=s.verification,f=[],x=[],b=[];if(!c){let m=!1;for(const v of ae)try{if(await ke(i,v)){m=!0;break}}catch{return o("boot-check")}return{...W(oe(t,a,{datasetPresent:!0,authenticity:"mismatch",integrity:m?"corrupt":"files-missing",hadVerifiedDataset:!0,presentSections:[]})),edition:s.activeEdition,packageVersion:s.activePackageVersion,generatedAt:null,fetchedAt:null,sections:[],assets:{total:0,present:0,missing:[]},floorVersion:t.version,storage:n,shellCache:a.cacheName,canRestore:await Be(e.openSlot,r==="A"?"B":"A"),activationPending:!1}}let V="verified";y?(c.edition!==s.activeEdition||c.packageVersion!==s.activePackageVersion||c.manifestSha256!==y.manifestSha256)&&(V="mismatch"):V="record-missing";const E=It(l,c);let N="ok",p=!0;if(E&&l){const m=l.sections;for(const u of ae){const v=m[u],k=(v==null?void 0:v.required)!==!1;let P=!1,G=!1;try{const B=await ke(i,u);if(P=!!(B&&(!v||B.bytes===v.bytes)),B&&v&&B.bytes!==v.bytes&&(N="corrupt"),!B)k&&(N=N==="ok"?"files-missing":N);else{const H=await fe(i,u);G=typeof H=="object"&&H!==null}}catch{return o("boot-check")}P&&b.push(u),x.push({id:u,required:k,present:P,parseable:G}),k&&(!P||!G)&&(p=!1,f.includes(u)||f.push(u))}}else p=!1,f.includes("manifest")||f.push("manifest");let S=!0;const C=[];let D=0,A=0;if(E)try{const m=await fe(i,"assets"),u=Ft(m);D=u.length;for(const v of u){const k=await Xe(i,v.id);(k==null?void 0:k.bytes)===v.bytes?A+=1:(S=!1,C.push(`asset:${v.id}`),k&&k.bytes!==v.bytes?N="corrupt":N==="ok"&&(N="files-missing"))}}catch{return o("boot-check")}f.push(...C);let O=!1,U=!1;E&&l&&(O=St(l,e.supportedSchemaRange,e.appVersion),U=!O);const $={shellValid:a.ok,datasetPresent:!0,authenticity:V,integrity:N,schemaCompatible:O,requiredSectionsOk:p,requiredAssetsOk:S,emergencyFloorOk:t.ok,missing:f,hadVerifiedDataset:!0,manifestOk:E,incompatible:U,failure:null,presentSections:b,stagedPending:null};return{...W($),edition:s.activeEdition,packageVersion:s.activePackageVersion,generatedAt:E&&l?l.generatedAt:null,fetchedAt:c.verifiedAt??c.lastProgressAt,sections:x,assets:{total:D,present:A,missing:C},floorVersion:t.version,storage:n,shellCache:a.cacheName,canRestore:await Be(e.openSlot,r==="A"?"B":"A"),activationPending:!1}}const qt=[1];function ce(e){return typeof e=="object"&&e!==null?e:null}function Oe(e){return typeof e=="string"&&e.trim().length>0}function Je(e){const t=ce(e);if((t==null?void 0:t.section)!=="emergency")return null;const n=t.emergencySchemaVersion;return typeof n!="number"||!Number.isInteger(n)||n<1||t.services!==void 0&&!ce(t.services)||t.procedures!==void 0&&(!Array.isArray(t.procedures)||!t.procedures.every(a=>{const s=ce(a);return!!(s&&Oe(s.id)&&Oe(s.title)&&Array.isArray(s.steps))}))?null:e}function jt(e,t){var i,r,o;const n=e,a=ce(n.services),s=a==null?void 0:a.emergencyNumber;return{...e,services:{emergencyNumber:Oe(s)?s:t.services.emergencyNumber,security:((i=n.services)==null?void 0:i.security)??t.services.security,firstAid:((r=n.services)==null?void 0:r.firstAid)??t.services.firstAid},address:n.address??t.address,procedures:(o=n.procedures)!=null&&o.length?n.procedures:t.procedures}}function Gt(e,t=Date.now()){var n;return(n=e.notices)!=null&&n.length?{...e,notices:e.notices.filter(a=>(a.expiresAtUtc??1/0)>t)}:e}function Pe(e,t=Date.now()){const n=Le();if(!n.ok)throw new Error(n.reason);if(e===void 0)return le("no-dataset",n.floor);if(!et(e.emergencySchemaVersion))return le("unsupported-schema",n.floor);const a=Je(e.section);if((a==null?void 0:a.emergencySchemaVersion)!==e.emergencySchemaVersion)return le("malformed-section",n.floor);const s=Gt(jt(a,n.floor),t);return{source:"dataset",provenance:`FESTIVAL DATA v${String(e.packageVersion)} · generated ${e.generatedAt}`+(typeof a.updatedAt=="string"&&a.updatedAt!==e.generatedAt?` · updated ${a.updatedAt}`:""),floor:n.floor,section:s,demotion:null}}function et(e){return typeof e=="number"&&qt.includes(e)}function le(e,t){const n=t??zt();return{source:"baseline",provenance:`BASELINE v${n.floorVersion}`,floor:n,section:null,demotion:e}}function zt(){const e=Le();if(!e.ok)throw new Error(e.reason);return e.floor}async function Wt(){try{const e=await ie();let t,n;try{const s=await Y(e);t=s.activeSlot,n=s.activePackageVersion}finally{e.close()}if(!t||n===null)return{status:"absent"};const a=await q(t);try{const s=await fe(a,"emergency"),i=Je(s);if(!i)return{status:"unusable",reason:"malformed-section"};if(!et(i.emergencySchemaVersion))return{status:"unusable",reason:"unsupported-schema"};const r=await Te(a).catch(()=>{});return{status:"ready",data:{section:i,packageVersion:n,generatedAt:(r==null?void 0:r.generatedAt)??"unknown date",emergencySchemaVersion:i.emergencySchemaVersion}}}finally{a.close()}}catch{return{status:"absent"}}}const Zt=1;function Ht(e){e.objectStoreNames.contains(ne)||e.createObjectStore(ne),e.objectStoreNames.contains(me)||e.createObjectStore(me),e.objectStoreNames.contains(Ie)||e.createObjectStore(Ie)}function Ve(){return Re(de.USER,Zt,e=>{Ht(e)})}async function Qt(e,t){await Ze(e,ne,t,t.eventId)}async function Xt(e,t){await ft(e,ne,t)}async function Jt(e){return mt(e,ne)}const tt="prefs";async function en(e){return j(e,me,tt)}async function tn(e,t){await Ze(e,me,t,tt)}function nn(e){return typeof e=="object"&&e!==null?e:null}function an(e){const t=nn(e);return(t==null?void 0:t.section)!=="schedule"||!Array.isArray(t.stages)||!Array.isArray(t.artists)||!Array.isArray(t.events)?null:e}async function sn(){var e;try{const t=await ie();let n,a;try{const c=await Y(t);n=c.activeSlot,a=c.clockSkew}finally{t.close()}if(!n)return null;const s=await q(n);let i=null,r=null;try{i=an(await fe(s,"schedule"));const c=await Te(s).catch(()=>{});c&&typeof((e=c.festival)==null?void 0:e.timezone)=="string"&&(r={timezone:c.festival.timezone,startUtc:c.festival.startUtc,endUtc:c.festival.endUtc})}finally{s.close()}if(!i||!r)return null;const o=await Ve();try{return{section:i,window:r,favorites:await Jt(o),prefs:await en(o),skew:a}}finally{o.close()}}catch{return null}}const rn=45,on=24*3600*1e3;function nt(e,t){return new Intl.DateTimeFormat("en-US",{timeZone:t,weekday:"short",hour:"numeric",minute:"2-digit"}).format(new Date(e))}function Ke(e,t){return new Intl.DateTimeFormat("en-US",{timeZone:t,weekday:"long",month:"long",day:"numeric"}).format(new Date(e))}function cn(e,t){return new Intl.DateTimeFormat("en-US",{timeZone:t,hour:"numeric",minute:"2-digit"}).format(new Date(e))}function ln(){try{return new Intl.DateTimeFormat().resolvedOptions().timeZone??"UTC"}catch{return"UTC"}}function un(e,t){return t?e+t.skewMs:e}function dn(e,t,n,a=12e4){const s=e+t;return Math.abs(n-s)>a}function mn(e,t,n,a={windowDays:rn}){const s=a.windowDays*on;return et.endUtc+s?e>=t.startUtc-s||n!==null:!1}function fn(e,t,n){const a=n??{baseDevice:e.deviceNow,baseMono:e.monoNow},s=dn(a.baseDevice,e.monoNow-a.baseMono,e.deviceNow),i=s?{baseDevice:e.deviceNow,baseMono:e.monoNow}:a;return{now:un(e.deviceNow,t),driftWarn:s,session:i}}let Ye=null;function pn(e,t){const n=fn(e,t,Ye);return Ye=n.session,n}function gn(){return{deviceNow:Date.now(),monoNow:performance.now()}}function re(e){return[...e].sort((t,n)=>t.startUtc-n.startUtc||t.title.localeCompare(n.title))}function hn(e){const t=new Map;for(const n of e.events){const a=t.get(n.dayKey)??[];a.push(n),t.set(n.dayKey,a)}return[...t.entries()].map(([n,a])=>{var i;const s=re(a);return{dayKey:n,anchorUtc:((i=s[0])==null?void 0:i.startUtc)??0,events:s}}).sort((n,a)=>n.dayKeya.dayKey?1:0)}function yn(e,t){return re(e.filter(n=>n.startUtc<=t&&ta.startUtc>t)).slice(0,Math.max(0,n))}function bn(e,t,n){var o;const a=n.trim().toLowerCase();if(!a)return!0;const s=((o=t.stages.find(c=>c.id===e.stageId))==null?void 0:o.name)??"",i=(e.artistIds??[]).map(c=>{var l;return((l=t.artists.find(y=>y.id===c))==null?void 0:l.name)??""}).join(" ");return`${e.title} ${i} ${e.description??""} ${s}`.toLowerCase().includes(a)}function wn(e,t){const n=t.query??"";return re(e.events.filter(a=>{var s;return!(t.dayKey!==void 0&&a.dayKey!==t.dayKey||t.stageId!==void 0&&a.stageId!==t.stageId||t.tag!==void 0&&!(a.tags??[]).includes(t.tag)||t.statuses!==void 0&&!t.statuses.includes(a.status)||t.favoritesOnly&&!(((s=t.favoriteIds)==null?void 0:s.has(a.id))??!1)||!bn(a,e,n))}))}function En(e){const t=re(e.filter(a=>a.status!=="cancelled")),n=[];for(let a=0;a=s.endUtc)break;n.push([s,r])}}return n}function qe(e,t){var n;return((n=e.stages.find(a=>a.id===t))==null?void 0:n.name)??t}function An(e,t){return t.map(n=>{var a;return((a=e.artists.find(s=>s.id===n))==null?void 0:a.name)??n}).join(", ")}function Sn(e){const t=new Set;for(const n of e.events)for(const a of n.tags??[])t.add(a);return[...t].sort((n,a)=>n.localeCompare(a))}function kn(e){return e?e.status:"orphan"}function Nn(e,t){const n=new Map(e.events.map(a=>[a.id,a]));return[...t].map(a=>{const s=n.get(a.eventId);return{fav:a,event:s,status:kn(s)}}).sort((a,s)=>{var i,r;return(((i=a.event)==null?void 0:i.startUtc)??Number.MAX_SAFE_INTEGER)-(((r=s.event)==null?void 0:r.startUtc)??Number.MAX_SAFE_INTEGER)})}function Cn(e){return En(e.flatMap(t=>t.event?[t.event]:[]))}function _(e,t){const n=document.createElement(e);return n.textContent=t,n}function On(e,t){return`${nt(e.startUtc,t)} – ${cn(e.endUtc,t)}`}function Pn(e,t){if(e.status==="scheduled")return null;const n=document.createElement("span");return n.className=`schedule-badge schedule-badge--${e.status}`,e.status==="moved"&&e.originalStartUtc!==void 0?n.textContent=`Moved (was ${nt(e.originalStartUtc,t)})`:n.textContent=e.status==="moved"?"Moved":"Cancelled",n}function ye(e,t,n,a,s){const i=document.createElement("article");i.className="schedule-item",i.setAttribute("data-event-id",e.id);const r=document.createElement("h2");r.textContent=e.title,i.append(r),i.append(_("p",On(e,n)));const o=An(t,e.artistIds);i.append(_("p",o?`${qe(t,e.stageId)} · ${o}`:qe(t,e.stageId))),e.description&&i.append(_("p",e.description));const c=Pn(e,n);c&&i.append(c);const l=document.createElement("button");l.type="button",l.className="schedule-fav",l.setAttribute("aria-pressed",String(a)),l.setAttribute("aria-label",a?`Remove ${e.title} from My Schedule`:`Add ${e.title} to My Schedule`),l.textContent=a?"★":"☆";const y=f=>{l.setAttribute("aria-pressed",String(f)),l.textContent=f?"★":"☆",l.setAttribute("aria-label",f?`Remove ${e.title} from My Schedule`:`Add ${e.title} to My Schedule`)};return l.addEventListener("click",()=>{const f=l.getAttribute("aria-pressed")!=="true";y(f),s(e.id,f)}),i.append(l),i}function Vn(e,t){var G,B,H;const n=document.createElement("section");n.className="schedule-view",n.setAttribute("aria-labelledby","schedule-heading");const a=document.createElement("h1");if(a.id="schedule-heading",a.textContent="Schedule",n.append(a),!e){const g=document.createElement("div");return g.className="view-placeholder",g.append(_("p","The schedule isn't on this device yet.")),g.append(_("p","Open with internet to get festival data — the emergency floor always works meanwhile.")),n.append(g),n}const{section:s,clock:i}=e,r=new Set(e.favorites),o={day:null,stage:null,tag:null,query:"",mine:!1};if(i.outsideWarn||i.driftWarn){const g=_("p",i.driftWarn?"The device clock changed mid-session — times re-anchored. Event times below are absolute.":"The device clock looks off — Now/Next may be wrong, but the absolute event times below still read fine.");g.className="schedule-warning",g.setAttribute("role","status"),n.append(g)}const c=document.createElement("section");c.setAttribute("aria-labelledby","schedule-now-heading");const l=document.createElement("h2");l.id="schedule-now-heading",l.textContent="Now & Next",c.append(l);const y=document.createElement("div");y.className="schedule-live",c.append(y),n.append(c);const f=document.createElement("div");f.className="schedule-controls";const x=hn(s);o.day??(o.day=((G=x.find(g=>g.events.some(h=>h.startUtc<=i.now&&i.nowg.events.some(h=>h.startUtc>i.now)))==null?void 0:B.dayKey)??((H=x[0])==null?void 0:H.dayKey)??null);const b=document.createElement("div");b.className="schedule-days",b.setAttribute("role","group"),b.setAttribute("aria-label","Festival days");const V=new Map;for(const g of x){const h=document.createElement("button");h.type="button",h.className="schedule-day",h.textContent=Ke(g.anchorUtc,i.zone),h.setAttribute("aria-pressed",String(o.day===g.dayKey)),h.addEventListener("click",()=>{o.day=g.dayKey;for(const[z,F]of V)F.setAttribute("aria-pressed",String(z===g.dayKey));P()}),V.set(g.dayKey,h),b.append(h)}f.append(b);const E=document.createElement("select");E.setAttribute("aria-label","Filter by stage");const N=document.createElement("option");N.value="",N.textContent="All stages",E.append(N);for(const g of s.stages){const h=document.createElement("option");h.value=g.id,h.textContent=g.name,E.append(h)}E.addEventListener("change",()=>{o.stage=E.value||null,P()}),f.append(E);const p=document.createElement("div");p.className="schedule-tags",p.setAttribute("role","group"),p.setAttribute("aria-label","Filter by tag");const S=new Map,C=()=>{for(const[g,h]of S)h.setAttribute("aria-pressed",String(g===(o.tag??"")))},D=document.createElement("button");D.type="button",D.className="schedule-tag",D.textContent="All",D.addEventListener("click",()=>{o.tag=null,C(),P()}),S.set("",D),p.append(D);for(const g of Sn(s)){const h=document.createElement("button");h.type="button",h.className="schedule-tag",h.textContent=g,h.addEventListener("click",()=>{o.tag=o.tag===g?null:g,C(),P()}),S.set(g,h),p.append(h)}C(),f.append(p);const A=document.createElement("input");A.type="search",A.setAttribute("aria-label","Search schedule"),A.placeholder="Search artists, titles, stages…",A.addEventListener("input",()=>{o.query=A.value,P()}),f.append(A);const O=document.createElement("button");O.type="button",O.className="schedule-mine";const U=()=>{O.setAttribute("aria-pressed",String(o.mine)),O.textContent=`★ Mine (${String(r.size)})`};O.addEventListener("click",()=>{o.mine=!o.mine,U(),P()}),U(),f.append(O);const $=document.createElement("button");$.type="button",$.className="schedule-zone",$.textContent=i.useDeviceZone?"Showing device time":"Showing festival time",$.addEventListener("click",()=>{t.onZoneToggle(!i.useDeviceZone)}),f.append($),n.append(f);const d=document.createElement("div");d.className="schedule-list",n.append(d);const m=(g,h)=>{h?r.add(g):r.delete(g),U(),Promise.resolve(t.onToggleFavorite(g,h)).catch(()=>{}),v()},u=document.createElement("p");u.className="schedule-conflicts",u.setAttribute("role","status");const v=()=>{const g=Nn(s,[...r].map(F=>({eventId:F,addedAt:0}))),h=Cn(g),z=h[0];if(o.mine&&z){const[F,Q]=z;u.textContent=`Overlap in My Schedule: ${F.title} × ${Q.title}${h.length>1?` (+${String(h.length-1)} more)`:""}`}else u.textContent=""};n.append(u);function k(){return{...o.day?{dayKey:o.day}:{},...o.stage?{stageId:o.stage}:{},...o.tag?{tag:o.tag}:{},...o.query?{query:o.query}:{},...o.mine?{favoritesOnly:!0,favoriteIds:r}:{}}}function P(){var Fe;const g=k(),h=wn(s,g),z=o.stage?s.events.filter(T=>T.stageId===o.stage):s.events,F=yn(s.events,i.now),Q=vn(z,i.now,3);if(y.replaceChildren(),F.length>0){y.append(_("h3","On now"));for(const T of F)y.append(ye(T,s,i.zone,r.has(T.id),m))}if(Q.length>0){y.append(_("h3",o.stage?"Next on this stage":"Up next"));for(const T of Q)y.append(ye(T,s,i.zone,r.has(T.id),m))}if(F.length===0&&Q.length===0&&y.append(_("p","Nothing scheduled around this time.")),d.replaceChildren(),h.length===0){d.append(_("p",o.mine?"No favorites yet — tap ☆ on any event.":"No events match these filters."));return}const at=o.day??((Fe=x[0])==null?void 0:Fe.dayKey),$e=x.find(T=>T.dayKey===at);$e&&!o.mine&&!o.query&&d.append(_("h2",Ke($e.anchorUtc,i.zone)));for(const T of h)d.append(ye(T,s,i.zone,r.has(T.id),m));v()}return P(),v(),n}const ue={id:"home",path:"/",label:"Home",title:"Home"},_e=[{id:"emergency",path:"/emergency",label:"Emergency",title:"Emergency"},{id:"schedule",path:"/schedule",label:"Schedule",title:"Schedule"},{id:"map",path:"/map",label:"Map",title:"Map"},{id:"festival",path:"/festival",label:"Information",title:"Information"}],Dn=new Map([ue,..._e].map(e=>[e.path,e.id]));function J(e){var n;let t=((n=e.split("?")[0])==null?void 0:n.split("#")[0])??"/";return t.startsWith("/")||(t="/"+t),t.length>1&&t.endsWith("/")&&(t=t.slice(0,-1)),t}function De(e){const t=J(e);return Dn.get(t)??"not-found"}class Rn{constructor(t){he(this,"listeners",new Set);he(this,"currentPath");this.currentPath=J(t??(typeof location<"u"?location.pathname:"/emergency"))}getPath(){return this.currentPath}getRouteId(){return De(this.currentPath)}navigate(t,n={}){const a=J(t);a!==this.currentPath&&(this.currentPath=a,typeof history<"u"&&(n.replace?history.replaceState(null,"",a):history.pushState(null,"",a)),this.emit())}listen(t){return this.listeners.add(t),()=>this.listeners.delete(t)}attach(){const t=()=>{this.currentPath=J(typeof location<"u"?location.pathname:"/emergency"),this.emit()};return typeof window<"u"&&window.addEventListener("popstate",t),()=>{typeof window<"u"&&window.removeEventListener("popstate",t)}}emit(){const t=De(this.currentPath);for(const n of this.listeners)n(t,this.currentPath)}}function xn(){const e=document.createElement("div");e.id="lumen-shell";const t=document.createElement("a");t.href="#main-content",t.className="skip-link",t.textContent="Skip to content",e.append(t);const n=document.createElement("header");n.className="app-header",n.setAttribute("role","banner");const a=document.createElement("a");a.href=ue.path,a.className="app-header__brand",a.setAttribute("aria-label","Lumen — home"),a.setAttribute("data-route",ue.path),a.textContent="LUMEN",n.append(a);const s=document.createElement("div");s.className="app-header__status",s.setAttribute("role","status"),s.setAttribute("aria-live","polite"),s.setAttribute("aria-label","Offline status"),s.textContent="—",n.append(s),e.append(n);const i=document.createElement("main");i.id="main-content",i.className="app-main",i.setAttribute("tabindex","-1");const r=document.createElement("img");r.src="/sol_lunar_icon.svg?v=4",r.alt="",r.className="app-main__icon",i.prepend(r),e.append(i);const o=document.createElement("nav");o.className="side-nav",o.setAttribute("aria-label","Primary navigation"),o.setAttribute("role","navigation");for(const c of[ue,..._e]){const l=document.createElement("a");l.href=c.path,l.setAttribute("data-route",c.path),l.className=c.id==="emergency"?"side-nav__link side-nav__link--emergency":"side-nav__link",l.setAttribute("aria-label",c.label),l.textContent=c.label,o.append(l)}return e.append(o),{root:e,main:i,nav:o,brand:a,statusChip:s,skipLink:t}}function Tn(e,t){for(const n of e.querySelectorAll("a[data-route]")){const a=n.getAttribute("data-route");a==="/"&&t==="home"||a==="/emergency"&&t==="emergency"||a==="/schedule"&&t==="schedule"||a==="/map"&&t==="map"||a==="/festival"&&t==="festival"?n.setAttribute("aria-current","page"):n.removeAttribute("aria-current")}}const K={dates:"October 8-12, 2026",venue:"Son's Blue River Camp",location:"2769 Sherrill Rd, Kingsbury, TX 78638",mission:"A five-day gathering exploring regenerative culture, technology, consciousness, and community.",themes:[["Air","Communication, personal development, authentic relating, and networking."],["Earth","Regenerative culture, permaculture, sustainable practices, and alternative governance."],["Fire","Innovation, entrepreneurship, and technology."],["Water","Consciousness, spirituality, and integration."]],passes:["Full Access: $280 listed sale price","Steward Pass: $868+","Team Pass: $283 per person for groups of 3+","Single Day: $118 | Kids: $23 | Teen: $38","Volunteer: $174 | Parking: $59"],guidance:["All ages are welcome; guests under 18 need a parent or legal guardian.","Pack reusable containers and pack out all waste under the Leave No Trace policy.","Non-service animals are not permitted at the venue.","No alcohol is sold at the summit.","Drones are prohibited without written permission."],source:"https://solarpunksummit.com/"};function je(e,t,n){const a=document.createElement(t);a.textContent=n,e.append(a)}function ve(e,t,n){je(e,"h2",t);const a=document.createElement("ul");for(const s of n)je(a,"li",s);e.append(a)}function Ln(){const e=document.createElement("section");e.className="home-view",e.setAttribute("aria-labelledby","home-heading");const t=document.createElement("h1");t.id="home-heading",t.textContent="Lumen",e.append(t);const n=document.createElement("p");n.className="home-view__subtitle",n.textContent=`${K.dates} | ${K.venue}, ${K.location.split(", ").slice(1).join(", ")}`,e.append(n);const a=document.createElement("div");a.className="home-grid";for(const s of _e){const i=document.createElement("a");i.className=`home-tile${s.id==="emergency"?" home-tile--emergency":""}`,i.href=s.path,s.external&&(i.rel="noopener noreferrer"),i.setAttribute("data-route",s.path),i.setAttribute("aria-label",s.label),i.textContent=s.label,a.append(i)}return e.append(a),e}function R(e,t){const n=document.createElement(e);return n.textContent=t,n}function M(e,t,n=!1){const a=document.createElement("button");return a.type="button",a.className=n?"status-action status-action--primary":"status-action",a.textContent=e,a.addEventListener("click",t),a}function Ge(e){return e===void 0?"unknown":e<1024?`${String(e)} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:`${(e/(1024*1024)).toFixed(1)} MB`}function _n(e){const t=document.createElement("li");t.className="status-check";const n=document.createElement("span");n.className="status-check__mark",n.setAttribute("aria-hidden","true");const a=e.present&&e.parseable;n.textContent=a?"✓":"✗",t.classList.toggle("status-check--ok",a),t.classList.toggle("status-check--missing",!a);const s=document.createElement("span"),i=e.required?"required":"optional";return s.textContent=`${e.id} — ${a?"present":e.present?"unreadable":"missing"} (${i})`,t.append(n,s),t}function $n(e,t){const n=document.createElement("section");n.className="status-view",n.setAttribute("aria-labelledby","status-heading");const a=document.createElement("h1");a.id="status-heading",a.textContent="Offline status",n.append(a);const s=R("p",`State: ${e.state}`);if(s.className=`status-state status-state--${e.state.toLowerCase().replace("_","-")}`,n.append(s),e.reason&&n.append(R("p",`Reason: ${e.reason}`)),e.failedOp&&n.append(R("p",`Failed operation: ${e.failedOp}`)),e.missing.length>0&&n.append(R("p",`Missing: ${e.missing.join(", ")}`)),n.append(R("p",`Preparation level: ${e.level}`)),e.sections.length>0){n.append(R("h2","Sections"));const l=document.createElement("ul");l.className="status-checklist";for(const y of e.sections)l.append(_n(y));n.append(l)}const i=R("p",e.packageVersion===null?"Dataset: none":`Dataset: ${e.edition??"unknown edition"} v${String(e.packageVersion)} · generated ${e.generatedAt??"unknown"}`);i.className="status-dataset",n.append(i),e.fetchedAt!==null&&n.append(R("p",`Verified on this device: ${new Date(e.fetchedAt).toISOString()}`)),n.append(R("p",`Assets: ${String(e.assets.present)}/${String(e.assets.total)} present`)),e.floorVersion&&n.append(R("p",`Emergency floor: v${e.floorVersion} (always on this device)`));const r=e.storage,o=r===null||r.usage===void 0&&r.quota===void 0?"Storage: unavailable in this browser":`Storage: ${Ge(r.usage)} used of ${Ge(r.quota)}${e.shellCache?` · shell ${e.shellCache}`:""}`;n.append(R("p",o));const c=document.createElement("div");switch(c.className="status-actions",e.state){case"NOT_READY":c.append(M("Get festival data",t.onGetData,!0));break;case"PARTIAL":c.append(M(e.activationPending?"Staged data found — continue setup":"Continue setup",t.onGetData,!0));break;case"RECOVERY":c.append(M("Restore festival data",t.onGetData,!0)),t.onRestore&&c.append(M("Restore previous version",t.onRestore));break;case"FAILED":c.append(M("Retry",t.onCheckData,!0)),n.append(R("p","If this persists, restore festival data when online. Diagnostics stay on this device."));break;case"BASELINE_ONLY":n.append(R("p","This browser cannot save offline data (private mode, blocked website data, or no space). Install Lumen to the home screen, use a normal browsing window, or free storage — emergency info below still works offline."));break}return c.append(M("Check my data",t.onCheckData)),c.append(M("Close",t.onClose)),n.append(c),n}function Fn(e,t){const n=document.createElement("section");n.className="status-view",n.setAttribute("aria-labelledby","prep-heading");const a=document.createElement("h1");a.id="prep-heading",a.textContent="Get festival data",n.append(a),n.append(R("p","Festival data preparation needs an internet connection. Open Lumen online and return here — one-tap download with resume, verification, and OFFLINE READY confirmation arrives with the sync stage. Your emergency floor below always works, with or without it."));const s=document.createElement("div");return s.className="status-actions",s.append(M("Back to status",e,!0)),s.append(M("Close",t)),n.append(s),n}function w(e,t){const n=document.createElement(e);return n.textContent=t,n}function X(e,t){const n=document.createElement("h2");return n.id=e,n.textContent=t,n}function be(e){const t=document.createElement("ul");for(const n of e)t.append(w("li",n));return t}function we(e,t,n,a=!1){const s=document.createElement("div");if(s.className="emergency-service",s.append(w("h3",t),w("p",n)),a){const i=document.createElement("a");i.className="emergency-call",i.href=`tel:${n}`,i.textContent=`Call ${n}`,i.setAttribute("aria-label",`Call ${t} at ${n}`),s.append(i)}e.append(s)}function In(e){switch(e){case"no-dataset":return"Festival data isn't on this device yet — showing the built-in baseline. Open with internet to get festival data.";case"unsupported-schema":return"This festival data needs a newer app version — showing the built-in baseline. Update Lumen when online.";case"malformed-section":return"The festival data emergency section couldn't be read — showing the built-in baseline."}}function Mn(){const e=document.createElement("a");return e.className="emergency-map-link",e.href="/map",e.setAttribute("data-route","/map"),e.textContent="Show on map",e}function Ee(e,t,n){const a=document.createElement("li");a.append(w("span",t),w("span"," · "),Mn()),e.append(a)}function Un(e){const t=e;return typeof t=="object"&&t!==null&&(t.source==="baseline"||t.source==="dataset")}function Bn(e,t){var E,N;let n;try{n=Un(e)?e:Pe(e,t)}catch{const p=document.createElement("section");p.className="emergency-view";const S=document.createElement("h1");return S.textContent="Emergency",p.append(S),p.append(w("p","Emergency data failed to load on this device.")),p}const a=document.createElement("section");a.className="emergency-view",a.setAttribute("aria-labelledby","emergency-heading");const s=document.createElement("h1");s.id="emergency-heading",s.textContent="Emergency",a.append(s);const i=w("p",n.provenance);if(i.className="emergency-provenance",i.setAttribute("role","status"),a.append(i),n.demotion){const p=w("p",In(n.demotion));p.className="emergency-guidance",a.append(p)}const r=n.section,o=(r==null?void 0:r.services)??n.floor.services,c=(r==null?void 0:r.address)??n.floor.address,l=(E=r==null?void 0:r.procedures)!=null&&E.length?r.procedures:n.floor.procedures,y=document.createElement("div");y.className="emergency-alert",y.append(X("emergency-call-heading","Need immediate help?")),we(y,"Emergency services",o.emergencyNumber,!0),y.append(w("p","Tap to call. The number is also selectable for copying.")),a.append(y);const f=document.createElement("section");f.setAttribute("aria-labelledby","emergency-services-heading"),f.append(X("emergency-services-heading","Contacts"));const x=o.security.phone;we(f,"Security",x||"Contact event staff or venue personnel"),o.security.location&&f.append(w("p",`Security location: ${o.security.location}`)),we(f,"First aid",o.firstAid.location),o.firstAid.hours&&f.append(w("p",`First-aid hours: ${o.firstAid.hours}`)),a.append(f);const b=document.createElement("section");if(b.setAttribute("aria-labelledby","emergency-address-heading"),b.append(X("emergency-address-heading","Venue and exits")),b.append(w("p",c.lines.join(", "))),b.append(w("p",`Coordinates: ${String(c.coordinates.lat)}, ${String(c.coordinates.lon)}`)),r!=null&&r.locations){b.append(w("h3","Muster points"));const p=document.createElement("ul");for(const C of r.locations.musterPoints)Ee(p,C.name);b.append(p),b.append(w("h3","Exits"));const S=document.createElement("ul");for(const C of r.locations.exits)Ee(S,C.name);if(b.append(S),r.locations.aeds.length>0){b.append(w("h3","AEDs"));const C=document.createElement("ul");for(const D of r.locations.aeds)Ee(C,`AED near ${D.poi}`);b.append(C)}}else b.append(w("h3","Muster points")),b.append(be(n.floor.musterPoints.map(p=>p.name))),b.append(w("h3","Exits")),b.append(be(n.floor.exits.map(p=>p.name))),b.append(w("h3","AEDs")),b.append(w("p",n.floor.aedSummary));a.append(b);const V=document.createElement("section");V.setAttribute("aria-labelledby","emergency-procedures-heading"),V.append(X("emergency-procedures-heading","What to do"));for(const p of l)V.append(w("h3",p.title),be(p.steps));if(a.append(V),(N=r==null?void 0:r.notices)!=null&&N.length){const p=document.createElement("section");p.setAttribute("aria-labelledby","emergency-notices-heading"),p.append(X("emergency-notices-heading","Notices"));for(const S of r.notices)p.append(w("h3",`[${S.severity}] ${S.title}`)),p.append(w("p",S.body.map(String).join(" ")));a.append(p)}return a}function ze(e,t){const n=document.createElement(e);return n.textContent=t,n}function Kn(e){const t=document.createElement("section");t.className="map-view",t.setAttribute("aria-labelledby","map-heading");const n=document.createElement("h1");n.id="map-heading",n.textContent="Map",t.append(n);{const a=document.createElement("div");return a.className="view-placeholder",a.append(ze("p","The map isn't on this device yet.")),a.append(ze("p","Open with internet to get festival data — the emergency floor always works meanwhile.")),t.append(a),t}}function Yn(){const e=document.createElement("section");e.setAttribute("aria-labelledby","festival-heading");const t=document.createElement("h1");t.id="festival-heading",t.textContent="Solarpunk Summit",e.append(t);const n=document.createElement("p");n.textContent=K.mission,e.append(n),ve(e,"Daily themes",K.themes.map(([s,i])=>`${s}: ${i}`)),ve(e,"Passes",K.passes),ve(e,"Attendee guidance",K.guidance);const a=document.createElement("a");return a.href=K.source,a.target="_blank",a.rel="noreferrer",a.textContent="Verify current details at solarpunksummit.com",e.append(a),e}function qn(e){const t=document.createElement("section");t.setAttribute("aria-labelledby","not-found-heading");const n=document.createElement("h1");n.id="not-found-heading",n.textContent="Page not found",t.append(n);const a=document.createElement("p");a.textContent=`No page at “${e}”. Use the navigation below.`,t.append(a);const s=document.createElement("a");return s.href="/emergency",s.textContent="Go to Emergency",s.setAttribute("data-route","/emergency"),t.append(s),t}const jn={onToggleFavorite:()=>!1,onZoneToggle:()=>{}};function Gn(e,t){var a,s;switch(De(e)){case"home":return Ln();case"emergency":return Bn(t==null?void 0:t.emergency);case"schedule":return Vn(((a=t==null?void 0:t.schedule)==null?void 0:a.input)??null,((s=t==null?void 0:t.schedule)==null?void 0:s.actions)??jn);case"map":return Kn();case"festival":return Yn();case"not-found":return qn(e)}}function We(){const e=document.querySelector("#app");if(!e)throw new Error("#app host missing — shell invariant");const{root:t,main:n,nav:a,brand:s,statusChip:i}=xn();e.replaceChildren(t),e.removeAttribute("aria-busy");let r=null,o,c=null,l=new Set,y=!1,f=null;async function x(){const d=await Wt();return d.status==="ready"?Pe(d.data):d.status==="unusable"?le(d.reason):Pe()}function b(d){const m=d.activationPending?"staged data awaiting activation":d.reason??d.failedOp??d.missing[0]??void 0,u=Lt(d.state,m);i.textContent=u.text,i.className=`app-header__status app-header__status--${u.tone}`,i.setAttribute("aria-label",`${u.label} Activate for status details.`)}function V(d){f||(f=document.createElement("dialog"),f.className="status-dialog",f.setAttribute("aria-label","Offline status details"),f.addEventListener("close",()=>{i.focus({preventScroll:!0})}),t.append(f));const m=f,u=()=>{m.close()},v=()=>{r&&V(r)};m.replaceChildren($n(d,{onCheckData:()=>void E(!0),onGetData:()=>{m.replaceChildren(Fn(v,u))},onRestore:d.canRestore?()=>{Pt().catch(()=>!1).then(()=>E(!0))}:null,onClose:u})),m.open||m.showModal()}async function E(d){var u;const m=await Ut();r=m;try{o=await x()}catch{o=void 0}try{c=await sn(),l=new Set((c==null?void 0:c.favorites.map(v=>v.eventId))??[]),y=((u=c==null?void 0:c.prefs)==null?void 0:u.useDeviceTimezone)??!1}catch{c=null}!i.isConnected&&!t.isConnected||(b(m),!d&&(A.getRouteId()==="emergency"||A.getRouteId()==="schedule")&&O(),d&&V(m))}function N(){if(!c)return null;const{section:d,window:m,skew:u}=c,{now:v,driftWarn:k}=pn(gn(),u);return{section:d,favorites:l,clock:{now:v,zone:y?ln():m.timezone,festivalZone:m.timezone,useDeviceZone:y,outsideWarn:mn(v,{startUtc:m.startUtc,endUtc:m.endUtc,timezone:m.timezone},u),driftWarn:k}}}async function p(d,m){try{const u=await Ve();try{m?(l.add(d),await Qt(u,{eventId:d,addedAt:Date.now()})):(l.delete(d),await Xt(u,d))}finally{u.close()}return!0}catch{if(m)l.delete(d);else if(c)for(const u of c.favorites)u.eventId===d&&l.add(d);return!1}}async function S(d){y=d;try{const m=await Ve();try{const u=c==null?void 0:c.prefs;await tn(m,{festivalTimezone:(u==null?void 0:u.festivalTimezone)??(c==null?void 0:c.window.timezone)??"UTC",useDeviceTimezone:d,...(u==null?void 0:u.theme)!==void 0?{theme:u.theme}:{}})}finally{m.close()}}catch{}A.getRouteId()==="schedule"&&O()}function C(){return{onToggleFavorite:(d,m)=>p(d,m),onZoneToggle:d=>void S(d)}}i.textContent="…",i.setAttribute("tabindex","0"),i.setAttribute("title","View offline status details");const D=()=>{E(!0)};i.addEventListener("click",D),i.addEventListener("keydown",d=>{(d.key==="Enter"||d.key===" ")&&(d.preventDefault(),D())}),ot().catch(()=>{});const A=new Rn(J(location.pathname));function O(){var P;const d=A.getPath(),m=A.getRouteId();Tn(a,m);const u=m==="home";s.classList.toggle("app-header__brand--back",!u),s.classList.toggle("app-header__brand--active",u),u?s.removeAttribute("aria-current"):s.setAttribute("aria-current","page");const v=Gn(d,{emergency:o,schedule:{input:N(),actions:C()}});n.replaceChildren(v);const k=document.createElement("img");k.src="/sol_lunar_icon.svg?v=5",k.alt="",k.className="app-main__icon",n.prepend(k),n.focus({preventScroll:!0}),document.title=m==="not-found"?"Not found — Lumen":m==="home"?"Lumen":`${((P=v.querySelector("h1"))==null?void 0:P.textContent)??m} — Lumen`}t.addEventListener("click",d=>{var v;const m=(v=d.target)==null?void 0:v.closest("a[data-route]");if(!m)return;const u=m.getAttribute("data-route");u&&(/^https?:\/\//.test(u)||(d.preventDefault(),A.navigate(u)))});const U=A.listen(()=>{O()}),$=A.attach();return O(),E(!1),{router:A,cleanup:()=>{U(),$()}}}function zn(){if(!(typeof document>"u"||!document.getElementById("app")))if(document.readyState!=="loading")try{We()}catch{}else document.addEventListener("DOMContentLoaded",()=>{if(document.getElementById("app"))try{We()}catch{}},{once:!0})}zn(); +//# sourceMappingURL=index-CaoEjGwW.js.map diff --git a/share/assets/index-CaoEjGwW.js.map b/share/assets/index-CaoEjGwW.js.map new file mode 100644 index 0000000..b91e70d --- /dev/null +++ b/share/assets/index-CaoEjGwW.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index-CaoEjGwW.js","sources":["../../src/platform/cache/shell-cache.ts","../../src/platform/sw/register.ts","../../src/platform/idb/storage-helpers.ts","../../src/platform/idb/errors.ts","../../src/platform/idb/wrapper.ts","../../src/platform/idb/names.ts","../../src/data/system-meta/types.ts","../../src/data/system-meta/store.ts","../../src/data/slot/store.ts","../../src/data/festival-package/types.ts","../../src/data/festival-package/validation.ts","../../src/emergency-baseline/generated.ts","../../src/emergency-baseline/types.ts","../../src/emergency-baseline/loader.ts","../../src/sync/activation.ts","../../src/domain/readiness/config.ts","../../src/domain/readiness/types.ts","../../src/domain/readiness/logic.ts","../../src/app/readiness.ts","../../src/domain/emergency/logic.ts","../../src/app/emergency.ts","../../src/data/user/store.ts","../../src/app/schedule.ts","../../src/domain/clock/logic.ts","../../src/domain/clock/service.ts","../../src/app/clock.ts","../../src/domain/schedule/logic.ts","../../src/domain/favorites/logic.ts","../../src/ui/views/schedule/schedule.ts","../../src/ui/router/router.ts","../../src/app/layout.ts","../../src/content/summit.ts","../../src/ui/views/home/home.ts","../../src/ui/views/status/status.ts","../../src/ui/views/emergency/emergency.ts","../../src/ui/views/map/map.ts","../../src/ui/views/festival/festival.ts","../../src/ui/views/not-found.ts","../../src/app/main.ts"],"sourcesContent":["/**\n * Cache Storage adapter for the app shell — the only non-test place allowed\n * to touch the `caches` global (B-1). Owns shell caches only, no datasets.\n * Trace: ADR-004, IMPLEMENTATION-CONTRACT.md §18.\n */\n\n/** Delete shell caches left by an earlier session (dev-mode scrub). */\nexport async function deleteShellCaches(prefix = \"lumen-shell-\"): Promise {\n if (typeof caches === \"undefined\") return;\n const keys = await caches.keys();\n await Promise.all(keys.filter((k) => k.startsWith(prefix)).map((k) => caches.delete(k)));\n}\n\nexport interface ShellCacheCheck {\n /** A current shell cache exists and holds the shell document entry. */\n readonly ok: boolean;\n readonly cacheName: string | null;\n readonly entry: string | null;\n}\n\n/**\n * C1 light check: find the current shell cache and confirm it holds the\n * shell document. Full per-entry verification happens atomically at SW\n * install (cache.addAll fails the install); boot only needs the fast\n * presence proof. Never throws — absence/unavailability is data, not error.\n */\nexport async function checkShellCache(): Promise {\n if (typeof caches === \"undefined\") {\n return { ok: false, cacheName: null, entry: null };\n }\n let keys: readonly string[];\n try {\n keys = await caches.keys();\n } catch {\n return { ok: false, cacheName: null, entry: null };\n }\n const current =\n keys\n .filter((k) => k.startsWith(\"lumen-shell-\"))\n .sort()\n .at(-1) ?? null;\n if (!current) return { ok: false, cacheName: null, entry: null };\n let cache: Cache;\n try {\n cache = await caches.open(current);\n } catch {\n return { ok: false, cacheName: current, entry: null };\n }\n for (const entry of [\"/index.html\", \"/\"]) {\n try {\n if (await cache.match(entry)) return { ok: true, cacheName: current, entry };\n } catch {\n // try the next entry form below\n }\n }\n return { ok: false, cacheName: current, entry: null };\n}\n","/**\n * SW registration — page-side bridge, tiny.\n * No IDB, no sync, no dataset. Offline-safe, never blocks boot.\n * Trace: ARCHITECTURE-DESIGN.md §8, IMPLEMENTATION-CONTRACT.md §16\n */\n\nexport type SWRegistrationResult =\n | { status: \"unsupported\" }\n | { status: \"registered\"; registration: ServiceWorkerRegistration }\n | { status: \"failed\"; error: unknown };\n\nexport async function registerSW(): Promise {\n if (typeof navigator === \"undefined\" || !(\"serviceWorker\" in navigator)) {\n return { status: \"unsupported\" };\n }\n\n // Do not block app boot — register after load, catch errors.\n try {\n const reg = await navigator.serviceWorker.register(\"/sw.js\", { scope: \"/\" });\n return { status: \"registered\", registration: reg };\n } catch (error) {\n return { status: \"failed\", error };\n }\n}\n\n/**\n * Ask the waiting SW to activate (for \"Restart to update\" UI in later stage).\n * No-op if no waiting SW.\n */\nexport function skipWaitingForUpdate(registration?: ServiceWorkerRegistration): void {\n const waiting = registration?.waiting;\n if (waiting) waiting.postMessage({ type: \"SKIP_WAITING\" });\n}\n\n/**\n * Unregister every service worker (dev-mode scrub only — production never\n * calls this). Resolves true when a controlling SW was removed and the\n * caller should reload to pick up the un-cached shell.\n */\nexport async function unregisterAllServiceWorkers(): Promise {\n if (typeof navigator === \"undefined\" || !(\"serviceWorker\" in navigator)) return false;\n const regs = await navigator.serviceWorker.getRegistrations();\n const hadController = Boolean(navigator.serviceWorker.controller);\n const unregistered = await Promise.all(regs.map((r) => r.unregister()));\n return hadController && unregistered.some(Boolean);\n}\n","/**\n * Storage helpers — P4 free-space pre-check, P6 persist request.\n * No business logic beyond quota/space heuristics.\n * Trace: SPIKE-01 P4/P6, IMPLEMENTATION-CONTRACT.md §8\n */\n\nexport interface StorageEstimate {\n readonly quota?: number;\n readonly usage?: number;\n}\n\nexport async function getStorageEstimate(): Promise {\n try {\n const storage = (\n navigator as unknown as { storage?: { estimate?: () => Promise } }\n ).storage;\n if (!storage?.estimate) return null;\n return (await storage.estimate()) ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * P4: refuse to stage if free < 2× package size.\n * Returns false if should refuse (not enough space), true if ok or unknown.\n */\nexport async function hasEnoughSpace(requiredBytes: number): Promise {\n if (requiredBytes <= 0) return true;\n const est = await getStorageEstimate();\n if (est?.quota === undefined || est.usage === undefined) {\n // Cannot estimate — allow staging; QuotaExceededError will handle P3.\n return true;\n }\n const free = (est.quota ?? 0) - (est.usage ?? 0);\n return free >= requiredBytes * 2;\n}\n\n/**\n * P6: request persistent storage once after READY. Never rely on grant.\n */\nexport async function requestPersist(): Promise {\n try {\n const storage = (navigator as unknown as { storage?: { persist?: () => Promise } })\n .storage;\n if (!storage?.persist) return false;\n return await storage.persist();\n } catch {\n return false;\n }\n}\n","/**\n * IDB error helpers — P3 QuotaExceededError keeps active.\n * Trace: SPIKE-01 P3, IMPLEMENTATION-CONTRACT.md §8\n */\n\nexport const MAX_RECORD_BYTES = 6 * 1024 * 1024;\n\nexport function isQuotaError(err: unknown): boolean {\n if (err instanceof DOMException) {\n return err.name === \"QuotaExceededError\";\n }\n if (typeof err === \"object\" && err !== null) {\n const name = (err as { name?: unknown }).name;\n if (name === \"QuotaExceededError\") return true;\n const code = (err as { code?: unknown }).code;\n // IDB wrapped errors sometimes surface as code 22\n if (code === 22) return true;\n }\n const msg = String((err as { message?: unknown })?.message ?? err);\n return msg.includes(\"QuotaExceededError\");\n}\n\nexport class QuotaExceeded extends Error {\n override name = \"QuotaExceededError\";\n constructor(message = \"QuotaExceededError\") {\n super(message);\n }\n}\n\nexport function checkRecordSize(bytes: number): void {\n if (!Number.isInteger(bytes) || bytes < 0 || bytes > MAX_RECORD_BYTES) {\n throw new RangeError(`record bytes must be 0..6MB, got ${String(bytes)}`);\n }\n}\n","/**\n * Thin promise wrapper over raw IndexedDB — no business logic.\n * Transaction discipline P1–P8: one short txn per file, no non-IDB await inside txn.\n * Trace: SPIKE-01:140 P1–P8, ADR-004, IMPLEMENTATION-CONTRACT.md §8/§9\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unnecessary-type-assertion */\n\nimport { isQuotaError } from \"./errors.js\";\n\nfunction promisify(req: IDBRequest): Promise {\n return new Promise((resolve, reject) => {\n req.onsuccess = () => {\n resolve(req.result);\n };\n req.onerror = () => {\n reject(req.error ?? new Error(\"IDB request failed\"));\n };\n });\n}\n\nfunction txDone(tx: IDBTransaction): Promise {\n return new Promise((resolve, reject) => {\n tx.oncomplete = () => {\n resolve();\n };\n tx.onerror = () => {\n reject(tx.error ?? new Error(\"IDB transaction error\"));\n };\n tx.onabort = () => {\n reject(tx.error ?? new Error(\"IDB transaction abort\"));\n };\n });\n}\n\nexport function openDB(\n name: string,\n version: number,\n onUpgrade: (db: IDBDatabase, oldVersion: number, tx: IDBTransaction) => void,\n): Promise {\n return new Promise((resolve, reject) => {\n const req = indexedDB.open(name, version);\n req.onupgradeneeded = () => {\n const db = req.result;\n const tx = req.transaction;\n if (tx) onUpgrade(db, req.result.version ?? oldVersionFallback(req), tx);\n else onUpgrade(db, 0, null as unknown as IDBTransaction);\n };\n req.onsuccess = () => {\n resolve(req.result);\n };\n req.onerror = () => {\n reject(req.error ?? new Error(\"IDB open failed\"));\n };\n req.onblocked = () => {\n // blocked is not fatal — caller can handle; we surface error\n // For tests, blocked rarely occurs.\n };\n });\n}\n\nfunction oldVersionFallback(req: IDBOpenDBRequest): number {\n // Some fake-indexeddb versions expose oldVersion via transaction? Fallback 0.\n return (req as unknown as { oldVersion?: number }).oldVersion ?? 0;\n}\n\n/**\n * Execute a single transaction over given stores — caller must NOT await\n * non-IDB work inside the callback (P1). Callback receives live tx.\n */\nexport async function withTx(\n db: IDBDatabase,\n storeNames: string | string[],\n mode: IDBTransactionMode,\n fn: (tx: IDBTransaction) => T | Promise,\n): Promise {\n const names = Array.isArray(storeNames) ? storeNames : [storeNames];\n const tx = db.transaction(names, mode);\n let result: T;\n try {\n result = await fn(tx);\n } catch (e) {\n try {\n tx.abort();\n } catch {\n // ignore abort failure\n }\n throw e;\n }\n // Wait for commit; if quota, surface as QuotaExceededError\n try {\n await txDone(tx);\n } catch (e) {\n if (isQuotaError(e)) throw e;\n throw e;\n }\n return result;\n}\n\n// ——— Convenience single-op helpers — each is ONE short txn (P1) ———\n\nexport async function idbGet(\n db: IDBDatabase,\n store: string,\n key: IDBValidKey,\n): Promise {\n return withTx(db, store, \"readonly\", async (tx) => {\n const os = tx.objectStore(store);\n const req = os.get(key);\n return promisify(req as unknown as IDBRequest);\n });\n}\n\nexport async function idbGetAll(db: IDBDatabase, store: string): Promise {\n return withTx(db, store, \"readonly\", async (tx) => {\n const os = tx.objectStore(store);\n const req = os.getAll();\n return promisify(req as unknown as IDBRequest);\n });\n}\n\nexport async function idbGetAllKeys(db: IDBDatabase, store: string): Promise {\n return withTx(db, store, \"readonly\", async (tx) => {\n const os = tx.objectStore(store);\n const req = os.getAllKeys();\n return promisify(req as unknown as IDBRequest);\n });\n}\n\nexport async function idbCount(db: IDBDatabase, store: string): Promise {\n return withTx(db, store, \"readonly\", async (tx) => {\n const os = tx.objectStore(store);\n const req = os.count();\n return promisify(req as unknown as IDBRequest);\n });\n}\n\nexport async function idbPut(\n db: IDBDatabase,\n store: string,\n value: any,\n key?: IDBValidKey,\n): Promise {\n await withTx(db, store, \"readwrite\", async (tx) => {\n const os = tx.objectStore(store);\n const req = key !== undefined ? os.put(value, key) : os.put(value);\n await promisify(req as unknown as IDBRequest);\n });\n}\n\nexport async function idbDelete(db: IDBDatabase, store: string, key: IDBValidKey): Promise {\n await withTx(db, store, \"readwrite\", async (tx) => {\n const os = tx.objectStore(store);\n const req = os.delete(key);\n await promisify(req);\n });\n}\n\nexport async function idbClear(db: IDBDatabase, store: string): Promise {\n await withTx(db, store, \"readwrite\", async (tx) => {\n const os = tx.objectStore(store);\n const req = os.clear();\n await promisify(req);\n });\n}\n\nexport function closeDB(db: IDBDatabase): void {\n db.close();\n}\n","/**\n * Database and store names — per IMPLEMENTATION-CONTRACT.md §9.\n * Trace: ARCHITECTURE-DESIGN.md:275, SPIKE-01 §4\n */\n\nexport const DB = {\n SYSTEM: \"lumen-system\",\n SLOT_A: \"lumen-slot-a\",\n SLOT_B: \"lumen-slot-b\",\n USER: \"lumen-user\",\n} as const;\n\nexport type SlotId = \"A\" | \"B\";\n\nexport function slotDbName(slot: SlotId): string {\n return slot === \"A\" ? DB.SLOT_A : DB.SLOT_B;\n}\n\nexport const SYSTEM_STORE = \"meta\" as const;\nexport const SYSTEM_KEY = \"meta\" as const;\n\nexport const SLOT_FILES = \"files\" as const;\nexport const SLOT_ASSETS = \"assets\" as const;\nexport const SLOT_STAGING = \"staging\" as const;\n\nexport const USER_FAVS = \"favorites\" as const;\nexport const USER_PREFS = \"prefs\" as const;\nexport const USER_DIAG = \"diag\" as const;\n\nexport type DbName = (typeof DB)[keyof typeof DB];\n","/**\n * System meta — single source of truth for activation/readiness.\n * Trace: IMPLEMENTATION-CONTRACT.md §9, ARCHITECTURE-DESIGN.md:275-288, SPIKE-02\n */\n\nimport type { SlotId } from \"../../platform/idb/names.js\";\n\nexport interface VerificationRecord {\n readonly packageVersion: number;\n readonly edition: string;\n readonly manifestSha256: string;\n readonly publicKeyFingerprint: string;\n readonly verifiedAt: number; // epoch ms\n readonly appVersionAtActivation: string;\n}\n\nexport interface SystemMeta {\n readonly activeSlot: SlotId | null; // null = no active dataset (NOT_READY/BASELINE_ONLY)\n readonly activeEdition: string | null;\n readonly activePackageVersion: number | null;\n readonly verification: VerificationRecord | null; // what/when/which — F-2 SPIKE-02:89\n readonly appVersionAtActivation: string | null;\n readonly readbackPending: boolean; // F-3 SPIKE-02:97\n readonly clockSkew: {\n readonly skewMs: number;\n readonly capturedAtDevice: number;\n readonly capturedAtMono: number;\n readonly source: string;\n } | null;\n readonly bootCount: number; // for GC N≥3 heuristic\n}\n\nexport const INITIAL_SYSTEM_META: SystemMeta = {\n activeSlot: null,\n activeEdition: null,\n activePackageVersion: null,\n verification: null,\n appVersionAtActivation: null,\n readbackPending: false,\n clockSkew: null,\n bootCount: 0,\n};\n","/**\n * System-meta store — atomic activation record (P2).\n * Single transaction flips activeSlot + activePackageVersion + verification + readbackPending.\n * Trace: SPIKE-01 P2, SPIKE-02 F-2/F-3, IMPLEMENTATION-CONTRACT.md §12\n */\n\nimport { openDB, withTx, idbGet } from \"../../platform/idb/wrapper.js\";\nimport { DB, SYSTEM_STORE, SYSTEM_KEY } from \"../../platform/idb/names.js\";\nimport type { SystemMeta } from \"./types.js\";\nimport { INITIAL_SYSTEM_META } from \"./types.js\";\n\nconst VERSION = 1;\n\nfunction upgrade(db: IDBDatabase): void {\n if (!db.objectStoreNames.contains(SYSTEM_STORE)) {\n db.createObjectStore(SYSTEM_STORE);\n }\n}\n\nexport function openSystemDB(): Promise {\n return openDB(DB.SYSTEM, VERSION, (db) => {\n upgrade(db);\n });\n}\n\nexport async function readSystemMeta(db: IDBDatabase): Promise {\n const val = await idbGet(db, SYSTEM_STORE, SYSTEM_KEY);\n if (!val) return { ...INITIAL_SYSTEM_META };\n // Ignore the pre-correction system-level journal if an older database remains.\n const current = { ...val } as Record;\n delete current.staging;\n return current as unknown as SystemMeta;\n}\n\n/**\n * P2: exactly one transaction that atomically flips active pointer + verification + readbackPending.\n * Caller must prepare complete SystemMeta to commit — no partial writes.\n */\nexport async function writeSystemMeta(db: IDBDatabase, meta: SystemMeta): Promise {\n await withTx(db, SYSTEM_STORE, \"readwrite\", async (tx) => {\n const os = tx.objectStore(SYSTEM_STORE);\n const req = os.put(meta, SYSTEM_KEY);\n await new Promise((resolve, reject) => {\n req.onsuccess = () => {\n resolve();\n };\n req.onerror = () => {\n reject(req.error ?? new Error(\"IDB error\"));\n };\n });\n });\n}\n\n/**\n * Atomic activation — flips slot + version + verification in one txn (P2/F-2).\n * Sets readbackPending true so next boot can spot-check.\n */\nexport async function activateSlot(\n db: IDBDatabase,\n next: Pick<\n SystemMeta,\n | \"activeSlot\"\n | \"activeEdition\"\n | \"activePackageVersion\"\n | \"verification\"\n | \"appVersionAtActivation\"\n >,\n): Promise {\n const current = await readSystemMeta(db);\n const updated: SystemMeta = {\n ...current,\n activeSlot: next.activeSlot,\n activeEdition: next.activeEdition,\n activePackageVersion: next.activePackageVersion,\n verification: next.verification ?? null,\n appVersionAtActivation: next.appVersionAtActivation ?? null,\n readbackPending: true,\n bootCount: current.bootCount,\n };\n await writeSystemMeta(db, updated);\n return updated;\n}\n\nexport async function clearReadbackPending(db: IDBDatabase): Promise {\n const meta = await readSystemMeta(db);\n if (!meta.readbackPending) return;\n await writeSystemMeta(db, { ...meta, readbackPending: false });\n}\n\nexport async function incrementBootCount(db: IDBDatabase): Promise {\n const meta = await readSystemMeta(db);\n const next = { ...meta, bootCount: (meta.bootCount ?? 0) + 1 };\n await writeSystemMeta(db, next);\n return next.bootCount;\n}\n","/**\n * Slot stores — A/B dataset slots. Per-file atomic writes (P1), 6MB cap (P5).\n * Blobs for assets kept in same slot DB for one-failure-domain rollback.\n * Trace: SPIKE-01 P1/P5/P3, SPIKE-02, IMPLEMENTATION-CONTRACT.md §10\n */\n\nimport {\n openDB,\n withTx,\n idbGet,\n idbGetAll,\n idbCount,\n idbClear,\n} from \"../../platform/idb/wrapper.js\";\nimport { SLOT_FILES, SLOT_ASSETS, SLOT_STAGING, slotDbName } from \"../../platform/idb/names.js\";\nimport type { SlotId } from \"../../platform/idb/names.js\";\nimport type { FestivalManifest } from \"../festival-package/types.js\";\nimport { MAX_RECORD_BYTES, checkRecordSize } from \"../../platform/idb/errors.js\";\nimport type { SectionId, SlotStagingJournal } from \"./types.js\";\n\nconst SLOT_VERSION = 2;\n\nfunction upgradeSlot(db: IDBDatabase): void {\n if (!db.objectStoreNames.contains(SLOT_FILES)) {\n db.createObjectStore(SLOT_FILES);\n }\n if (!db.objectStoreNames.contains(SLOT_ASSETS)) {\n db.createObjectStore(SLOT_ASSETS);\n }\n if (!db.objectStoreNames.contains(SLOT_STAGING)) {\n db.createObjectStore(SLOT_STAGING);\n }\n}\n\nexport function openSlotDB(slot: SlotId): Promise {\n return openDB(slotDbName(slot), SLOT_VERSION, (db) => {\n upgradeSlot(db);\n });\n}\n\nconst STAGING_KEY = \"journal\" as const;\n\nfunction requestDone(request: IDBRequest): Promise {\n return new Promise((resolve, reject) => {\n request.onsuccess = () => {\n resolve();\n };\n request.onerror = () => {\n reject(request.error ?? new Error(\"IDB error\"));\n };\n });\n}\n\nexport async function initializeStaging(\n db: IDBDatabase,\n journal: Omit,\n): Promise {\n await clearSlot(db);\n const next: SlotStagingJournal = {\n ...journal,\n stagedFiles: [],\n stagedAssets: [],\n complete: false,\n };\n await withTx(db, SLOT_STAGING, \"readwrite\", async (tx) => {\n await requestDone(tx.objectStore(SLOT_STAGING).put(next, STAGING_KEY));\n });\n return next;\n}\n\nexport async function readStagingProgress(\n db: IDBDatabase,\n): Promise {\n return idbGet(db, SLOT_STAGING, STAGING_KEY);\n}\n\nfunction progressed(\n journal: SlotStagingJournal,\n file?: SectionId,\n asset?: string,\n): SlotStagingJournal {\n return {\n ...journal,\n stagedFiles:\n file && !journal.stagedFiles.includes(file)\n ? [...journal.stagedFiles, file]\n : journal.stagedFiles,\n stagedAssets:\n asset && !journal.stagedAssets.includes(asset)\n ? [...journal.stagedAssets, asset]\n : journal.stagedAssets,\n lastProgressAt: Date.now(),\n };\n}\n\nexport async function writeSlotFileWithProgress(\n db: IDBDatabase,\n sectionId: SectionId,\n record: { bytes: number; sha256: string; json: unknown },\n journal: SlotStagingJournal,\n): Promise {\n checkRecordSize(record.bytes);\n const next = progressed(journal, sectionId);\n await withTx(db, [SLOT_FILES, SLOT_STAGING], \"readwrite\", async (tx) => {\n await requestDone(tx.objectStore(SLOT_FILES).put({ id: sectionId, ...record }, sectionId));\n await requestDone(tx.objectStore(SLOT_STAGING).put(next, STAGING_KEY));\n });\n return next;\n}\n\n// ——— Files ———\n\nexport async function writeSlotFile(\n db: IDBDatabase,\n sectionId: SectionId,\n record: { bytes: number; sha256: string; json: unknown },\n): Promise {\n checkRecordSize(record.bytes);\n if (record.bytes > MAX_RECORD_BYTES) throw new RangeError(`file ${sectionId} exceeds 6MB`);\n await withTx(db, SLOT_FILES, \"readwrite\", async (tx) => {\n const os = tx.objectStore(SLOT_FILES);\n const payload = { id: sectionId, ...record };\n const req = os.put(payload, sectionId);\n await new Promise((resolve, reject) => {\n req.onsuccess = () => {\n resolve();\n };\n req.onerror = () => {\n reject(req.error ?? new Error(\"IDB error\"));\n };\n });\n });\n}\n\nexport async function readSlotFile(\n db: IDBDatabase,\n sectionId: SectionId,\n): Promise {\n const rec = await idbGet<{ json: T } & Record>(db, SLOT_FILES, sectionId);\n return (rec as { json?: T })?.json;\n}\n\nexport async function readSlotFileMeta(\n db: IDBDatabase,\n sectionId: SectionId,\n): Promise<{ bytes: number; sha256: string } | undefined> {\n const rec = await idbGet<{ bytes: number; sha256: string }>(db, SLOT_FILES, sectionId);\n return rec ? { bytes: rec.bytes, sha256: rec.sha256 } : undefined;\n}\n\nexport async function countSlotFiles(db: IDBDatabase): Promise {\n return idbCount(db, SLOT_FILES);\n}\n\nexport async function listSlotFiles(db: IDBDatabase): Promise {\n const keys = await withTx(db, SLOT_FILES, \"readonly\", async (tx) => {\n const os = tx.objectStore(SLOT_FILES);\n const req = os.getAllKeys();\n return new Promise((resolve, reject) => {\n req.onsuccess = () => {\n resolve(req.result);\n };\n req.onerror = () => {\n reject(req.error ?? new Error(\"IDB error\"));\n };\n });\n });\n return keys as SectionId[];\n}\n\nexport async function clearSlotFiles(db: IDBDatabase): Promise {\n await idbClear(db, SLOT_FILES);\n}\n\n// ——— Manifest (evidence for boot C4/C5/C6 — one short txn, P1-style) ———\n\n/** Key under which the staged manifest lives in the files store. */\nexport const SLOT_MANIFEST_KEY = \"manifest\" as const;\n\nexport async function writeSlotManifest(\n db: IDBDatabase,\n manifest: FestivalManifest,\n): Promise {\n await withTx(db, SLOT_FILES, \"readwrite\", async (tx) => {\n await requestDone(tx.objectStore(SLOT_FILES).put(manifest, SLOT_MANIFEST_KEY));\n });\n}\n\nexport async function readSlotManifest(db: IDBDatabase): Promise {\n const rec = await idbGet(db, SLOT_FILES, SLOT_MANIFEST_KEY);\n return rec ?? undefined;\n}\n\n// ——— Assets (Blobs) ———\n\nexport async function writeSlotAsset(\n db: IDBDatabase,\n assetId: string,\n record: { bytes: number; sha256: string; blob: Blob },\n): Promise {\n checkRecordSize(record.bytes);\n await withTx(db, SLOT_ASSETS, \"readwrite\", async (tx) => {\n const os = tx.objectStore(SLOT_ASSETS);\n const payload = { id: assetId, ...record };\n const req = os.put(payload, assetId);\n await new Promise((resolve, reject) => {\n req.onsuccess = () => {\n resolve();\n };\n req.onerror = () => {\n reject(req.error ?? new Error(\"IDB error\"));\n };\n });\n });\n}\n\nexport async function writeSlotAssetWithProgress(\n db: IDBDatabase,\n assetId: string,\n record: { bytes: number; sha256: string; blob: Blob },\n journal: SlotStagingJournal,\n): Promise {\n checkRecordSize(record.bytes);\n const next = progressed(journal, undefined, assetId);\n await withTx(db, [SLOT_ASSETS, SLOT_STAGING], \"readwrite\", async (tx) => {\n await requestDone(tx.objectStore(SLOT_ASSETS).put({ id: assetId, ...record }, assetId));\n await requestDone(tx.objectStore(SLOT_STAGING).put(next, STAGING_KEY));\n });\n return next;\n}\n\nexport async function markStagingComplete(db: IDBDatabase): Promise {\n const current = await readStagingProgress(db);\n if (!current) throw new Error(\"staging journal missing\");\n const next = { ...current, complete: true, lastProgressAt: Date.now() };\n await withTx(db, SLOT_STAGING, \"readwrite\", async (tx) => {\n await requestDone(tx.objectStore(SLOT_STAGING).put(next, STAGING_KEY));\n });\n return next;\n}\n\nexport async function clearIncompleteStaging(db: IDBDatabase): Promise {\n await clearSlot(db);\n}\n\nexport async function readSlotAsset(\n db: IDBDatabase,\n assetId: string,\n): Promise<{ bytes: number; sha256: string; blob: Blob } | undefined> {\n return idbGet(db, SLOT_ASSETS, assetId);\n}\n\nexport async function readAllSlotAssets(\n db: IDBDatabase,\n): Promise<{ id: string; bytes: number; sha256: string }[]> {\n const all = await idbGetAll<{ id: string; bytes: number; sha256: string }>(db, SLOT_ASSETS);\n return all;\n}\n\nexport async function clearSlotAssets(db: IDBDatabase): Promise {\n await idbClear(db, SLOT_ASSETS);\n}\n\nexport async function clearSlot(db: IDBDatabase): Promise {\n await withTx(db, [SLOT_FILES, SLOT_ASSETS, SLOT_STAGING], \"readwrite\", async (tx) => {\n const f = tx.objectStore(SLOT_FILES);\n const a = tx.objectStore(SLOT_ASSETS);\n const s = tx.objectStore(SLOT_STAGING);\n const r1 = f.clear();\n const r2 = a.clear();\n const r3 = s.clear();\n await Promise.all([\n new Promise((resolve, reject) => {\n r1.onsuccess = () => {\n resolve();\n };\n r1.onerror = () => {\n reject(r1.error ?? new Error(\"IDB error\"));\n };\n }),\n new Promise((resolve, reject) => {\n r3.onsuccess = () => {\n resolve();\n };\n r3.onerror = () => {\n reject(r3.error ?? new Error(\"IDB error\"));\n };\n }),\n new Promise((resolve, reject) => {\n r2.onsuccess = () => {\n resolve();\n };\n r2.onerror = () => {\n reject(r2.error ?? new Error(\"IDB error\"));\n };\n }),\n ]);\n });\n}\n\n/**\n * Light integrity helper — presence + size (no hashing) — for boot ≤150ms (SPIKE-05).\n * Checks that expected sections exist and bytes match manifest. Hash verified at staging only.\n */\nexport async function lightCheckSlot(\n db: IDBDatabase,\n expected: readonly { id: SectionId; bytes: number }[],\n): Promise<{ ok: true } | { ok: false; reason: string }> {\n for (const exp of expected) {\n const meta = await readSlotFileMeta(db, exp.id);\n if (!meta) return { ok: false, reason: `missing ${exp.id}` };\n if (meta.bytes !== exp.bytes) return { ok: false, reason: `size mismatch ${exp.id}` };\n }\n return { ok: true };\n}\n","/**\n * Festival Data Package v1 — typed contracts.\n * Authoritative festival data (organizer-supplied, signed, distributed).\n * Not user data, not emergency baseline (separate concerns per IMP contract §CRITICAL).\n * Trace: ADR-005, SPIKE-04 §2, ARCHITECTURE-DESIGN.md §10, §15\n */\n\nexport const PACKAGE_FORMAT = \"lumen.package/1\" as const;\nexport type PackageFormat = typeof PACKAGE_FORMAT;\n\n// ——— Manifest ———\n\nexport interface FestivalMetadata {\n readonly name: string;\n readonly timezone: string; // IANA, e.g. \"America/Chicago\"\n readonly startUtc: number; // epoch ms\n readonly endUtc: number; // epoch ms\n}\n\nexport interface AppCompatibility {\n readonly minAppVersion: string; // semver, e.g. \"1.0.0\"\n readonly maxAppVersion: string | null;\n}\n\nexport interface SectionEntry {\n readonly file: string; // e.g. \"emergency.json\"\n readonly sha256: string; // hex 64\n readonly bytes: number;\n /** default true — readiness gates on required only (SPIKE-04 F-1, SPIKE-05) */\n readonly required?: boolean;\n}\n\nexport type SectionId = \"emergency\" | \"schedule\" | \"map\" | \"info\" | \"assets\";\n\nexport type SectionMap = Record;\n\nexport interface PackageCounts {\n readonly events: number;\n readonly pois: number;\n readonly assets: number;\n}\n\nexport interface PackageLimits {\n readonly totalBytes: number;\n}\n\nexport interface FestivalManifest {\n readonly format: PackageFormat;\n readonly edition: string; // e.g. \"lumen-2026\"\n readonly packageVersion: number; // monotonic int per edition\n readonly schemaVersion: number; // int\n readonly generatedAt: string; // ISO8601 UTC\n readonly festival: FestivalMetadata;\n readonly appCompatibility: AppCompatibility;\n readonly sections: SectionMap;\n readonly counts: PackageCounts;\n readonly limits: PackageLimits;\n}\n\n// ——— Signature ———\n\nexport interface PackageSignature {\n readonly algorithm: \"ed25519\";\n readonly over: string; // \"sha256(manifest.json exact bytes)\"\n readonly manifestSha256: string; // hex 64\n readonly publicKeyFingerprint: string; // \"sha256:…\" hex\n readonly signature: string; // base64\n}\n\n// ——— Assets ———\n\nexport type AssetKind = \"map-base\" | \"poi-icon\" | \"photo\" | \"icon\";\nexport type AssetRole = string; // e.g. \"overview\" | \"detail\" | \"map-base/overview-night\"\n\nexport interface AssetEntry {\n readonly id: string; // e.g. \"map-base-overview\"\n readonly file: string; // \"assets/map-base-overview.webp\"\n readonly kind: AssetKind;\n readonly role: AssetRole;\n readonly sha256: string;\n readonly bytes: number;\n}\n\nexport interface AssetsInventory {\n readonly assets: readonly AssetEntry[];\n}\n\n// ——— Latest pointer ———\n\nexport interface LatestPointer {\n readonly edition: string;\n readonly packageVersion: number;\n readonly manifestUrl: string;\n readonly generatedAt: string;\n}\n\n// ——— Section: Emergency ———\n\nexport interface EmergencySection {\n readonly section: \"emergency\";\n readonly emergencySchemaVersion: number; // independent sub-versioning (SPIKE-04 F-3)\n readonly contentVersion: number; // monotonic\n readonly updatedAt: string; // ISO8601\n readonly services: {\n readonly emergencyNumber: string; // data-driven, e.g. \"911\"\n readonly security: { readonly phone: string; readonly location?: string };\n readonly firstAid: { readonly location: string; readonly hours?: string };\n };\n readonly locations: {\n readonly musterPoints: readonly {\n readonly id: string;\n readonly name: string;\n readonly poi: string;\n }[];\n readonly exits: readonly { readonly id: string; readonly name: string; readonly poi: string }[];\n readonly aeds: readonly { readonly poi: string }[];\n };\n readonly address: {\n readonly lines: readonly string[];\n readonly coordinates: { readonly lat: number; readonly lon: number };\n };\n readonly procedures: readonly {\n readonly id: string;\n readonly title: string;\n readonly steps: readonly string[];\n }[];\n readonly notices?: readonly {\n readonly id: string;\n readonly severity: string;\n readonly title: string;\n readonly body: readonly unknown[];\n readonly expiresAtUtc: number | null;\n }[];\n}\n\n// ——— Section: Schedule ———\n\nexport type EventStatus = \"scheduled\" | \"moved\" | \"cancelled\";\n\nexport interface ScheduleStage {\n readonly id: string;\n readonly name: string;\n}\n\nexport interface ScheduleArtist {\n readonly id: string;\n readonly name: string;\n}\n\nexport interface ScheduleEvent {\n readonly id: string; // stable, contractual\n readonly title: string;\n readonly description?: string;\n readonly stageId: string;\n readonly artistIds: readonly string[];\n readonly startUtc: number;\n readonly endUtc: number;\n readonly dayKey: string; // precomputed festival-zone date YYYY-MM-DD\n readonly tags: readonly string[];\n readonly status: EventStatus;\n readonly originalStartUtc?: number;\n}\n\nexport interface ScheduleSection {\n readonly section: \"schedule\";\n readonly stages: readonly ScheduleStage[];\n readonly artists: readonly ScheduleArtist[];\n readonly events: readonly ScheduleEvent[];\n}\n\n// ——— Section: Map ———\n\nexport interface MapLevel {\n readonly id: string; // \"overview\" | \"detail\"\n readonly assetId: string;\n readonly width: number;\n readonly height: number;\n readonly nightAssetId?: string; // optional F-4 dark variant\n}\n\nexport type PoiCategory =\n | \"stage\"\n | \"restroom\"\n | \"water\"\n | \"food\"\n | \"first-aid\"\n | \"aed\"\n | \"security\"\n | \"entrance\"\n | \"exit\"\n | \"parking\"\n | \"camping\"\n | \"vip\"\n | \"muster\"\n | \"info\"\n | \"vendor\"\n | \"other\";\n\nexport interface MapPoi {\n readonly id: string;\n readonly name: string;\n readonly category: PoiCategory;\n readonly x: number; // normalized 0..1\n readonly y: number; // normalized 0..1\n readonly description?: string;\n readonly lat: number | null;\n readonly lng: number | null;\n}\n\nexport interface MapSection {\n readonly section: \"map\";\n readonly base: { readonly levels: readonly MapLevel[] };\n readonly pois: readonly MapPoi[];\n readonly categories: readonly PoiCategory[];\n}\n\n// ——— Section: Info ———\n\nexport type InfoBlockKind =\n \"paragraph\" | \"list\" | \"link\" | \"emphasis\" | \"contact\" | \"address\" | \"hours\";\n\nexport interface InfoBlock {\n readonly id: string;\n readonly title: string;\n readonly kind: string; // data-driven category: About, Rules, FAQ...\n readonly body: readonly {\n readonly kind: InfoBlockKind;\n readonly text?: string;\n readonly items?: readonly string[];\n readonly href?: string;\n }[];\n}\n\nexport interface InfoSection {\n readonly section: \"info\";\n readonly blocks: readonly InfoBlock[];\n}\n\n// ——— Package as distributed (immutable directory) ———\n\nexport interface FestivalPackageV1 {\n readonly manifest: FestivalManifest;\n readonly signature: PackageSignature;\n readonly emergency: EmergencySection;\n readonly schedule: ScheduleSection;\n readonly map: MapSection;\n readonly info: InfoSection;\n readonly assets: AssetsInventory;\n}\n","/**\n * Festival Data Package validation — pure, no I/O, no crypto runtime (Stage 4).\n * Rejects malformed/missing/invalid; allows unknown optional fields (forward compat per SPIKE-04 gate 1).\n * Trace: ADR-005, SPIKE-04 §3, IMPLEMENTATION-CONTRACT.md §11, §15\n */\nimport type { FestivalManifest, SectionId } from \"./types.js\";\n\nexport type ValidationResult =\n { readonly ok: true } | { readonly ok: false; readonly reason: string };\n\nconst HEX64 = /^[0-9a-f]{64}$/i;\nconst SEMVER = /^\\d+\\.\\d+\\.\\d+(?:[-+].+)?$/;\nconst DAYKEY = /^\\d{4}-\\d{2}-\\d{2}$/;\nconst PRINTABLE_ID = /^[a-z0-9][a-z0-9-_]*$/i;\n\nfunction isFiniteNumber(n: unknown): n is number {\n return typeof n === \"number\" && Number.isFinite(n);\n}\n\nfunction isNonEmptyString(s: unknown): s is string {\n return typeof s === \"string\" && s.trim().length > 0;\n}\n\nexport function validateEdition(v: unknown): ValidationResult {\n if (!isNonEmptyString(v)) return { ok: false, reason: \"edition must be non-empty string\" };\n return { ok: true };\n}\n\nexport function validatePackageVersion(v: unknown): ValidationResult {\n if (!isFiniteNumber(v) || !Number.isInteger(v) || v < 1)\n return { ok: false, reason: \"packageVersion must be integer >=1\" };\n return { ok: true };\n}\n\nexport function validateSchemaVersion(v: unknown): ValidationResult {\n if (!isFiniteNumber(v) || !Number.isInteger(v) || v < 1)\n return { ok: false, reason: \"schemaVersion must be integer >=1\" };\n return { ok: true };\n}\n\nexport function validateIso8601(s: unknown): ValidationResult {\n if (typeof s !== \"string\" || Number.isNaN(Date.parse(s)))\n return { ok: false, reason: \"must be ISO8601 UTC\" };\n return { ok: true };\n}\n\nexport function validateSha256(s: unknown): ValidationResult {\n if (typeof s !== \"string\" || !HEX64.test(s))\n return { ok: false, reason: \"sha256 must be 64 hex\" };\n return { ok: true };\n}\n\nexport function validateBytes(n: unknown): ValidationResult {\n if (!isFiniteNumber(n) || !Number.isInteger(n) || n < 0 || n > 6 * 1024 * 1024)\n return { ok: false, reason: \"bytes must be 0..6MB (per-file cap)\" };\n return { ok: true };\n}\n\nexport function validateAppVersion(s: unknown): ValidationResult {\n if (typeof s !== \"string\" || !SEMVER.test(s))\n return { ok: false, reason: \"appVersion must be semver\" };\n return { ok: true };\n}\n\nexport function validateFestivalWindow(startUtc: unknown, endUtc: unknown): ValidationResult {\n if (!isFiniteNumber(startUtc) || !isFiniteNumber(endUtc))\n return { ok: false, reason: \"festival start/end must be epoch ms\" };\n if (endUtc <= startUtc) return { ok: false, reason: \"festival end must be after start\" };\n return { ok: true };\n}\n\nexport function validateManifest(m: unknown): ValidationResult {\n if (typeof m !== \"object\" || m === null) return { ok: false, reason: \"manifest must be object\" };\n const o = m as Record;\n if (o.format !== \"lumen.package/1\")\n return { ok: false, reason: \"format must be lumen.package/1\" };\n\n const edition = validateEdition(o.edition);\n if (!edition.ok) return edition;\n const pv = validatePackageVersion(o.packageVersion);\n if (!pv.ok) return pv;\n const sv = validateSchemaVersion(o.schemaVersion);\n if (!sv.ok) return sv;\n const ga = validateIso8601(o.generatedAt);\n if (!ga.ok) return { ok: false, reason: `generatedAt: ${ga.reason}` };\n\n const fest = o.festival as Record | undefined;\n if (!fest || typeof fest !== \"object\") return { ok: false, reason: \"festival missing\" };\n if (!isNonEmptyString(fest.name)) return { ok: false, reason: \"festival.name required\" };\n if (!isNonEmptyString(fest.timezone)) return { ok: false, reason: \"festival.timezone required\" };\n const win = validateFestivalWindow(fest.startUtc, fest.endUtc);\n if (!win.ok) return win;\n\n const ac = o.appCompatibility as Record | undefined;\n if (!ac || typeof ac !== \"object\") return { ok: false, reason: \"appCompatibility missing\" };\n const minV = validateAppVersion(ac.minAppVersion);\n if (!minV.ok) return minV;\n if (ac.maxAppVersion !== null) {\n const maxV = validateAppVersion(ac.maxAppVersion);\n if (!maxV.ok) return { ok: false, reason: `maxAppVersion: ${maxV.reason}` };\n }\n\n const sections = o.sections as Record | undefined;\n if (!sections) return { ok: false, reason: \"sections missing\" };\n const requiredIds: SectionId[] = [\"emergency\", \"schedule\", \"map\", \"info\", \"assets\"];\n for (const id of requiredIds) {\n const e = sections[id] as Record | undefined;\n if (!e) return { ok: false, reason: `section ${id} missing` };\n if (!isNonEmptyString(e.file)) return { ok: false, reason: `${id}.file required` };\n const sha = validateSha256(e.sha256);\n if (!sha.ok) return { ok: false, reason: `${id}.sha256: ${sha.reason}` };\n const b = validateBytes(e.bytes);\n if (!b.ok) return { ok: false, reason: `${id}.bytes: ${b.reason}` };\n if (e.required !== undefined && typeof e.required !== \"boolean\")\n return { ok: false, reason: `${id}.required must be boolean` };\n }\n\n // counts/limits presence (values validated elsewhere by budgets)\n const counts = o.counts as Record | undefined;\n if (!counts || !isFiniteNumber(counts.events) || !isFiniteNumber(counts.pois))\n return { ok: false, reason: \"counts missing or invalid\" };\n const limits = o.limits as Record | undefined;\n if (!limits || !isFiniteNumber(limits.totalBytes))\n return { ok: false, reason: \"limits.totalBytes missing\" };\n if (limits.totalBytes > 50 * 1024 * 1024)\n return { ok: false, reason: \"totalBytes exceeds 50MB hard ceiling\" };\n\n // monotonic and budgets are cross-package checks, not single-manifest — allow here\n return { ok: true };\n}\n\nexport function validateSignature(s: unknown): ValidationResult {\n if (typeof s !== \"object\" || s === null) return { ok: false, reason: \"signature must be object\" };\n const o = s as Record;\n if (o.algorithm !== \"ed25519\") return { ok: false, reason: \"algorithm must be ed25519\" };\n if (!isNonEmptyString(o.over)) return { ok: false, reason: \"over required\" };\n const sha = validateSha256(o.manifestSha256);\n if (!sha.ok) return { ok: false, reason: `manifestSha256: ${sha.reason}` };\n if (!isNonEmptyString(o.publicKeyFingerprint))\n return { ok: false, reason: \"publicKeyFingerprint required\" };\n if (!isNonEmptyString(o.signature)) return { ok: false, reason: \"signature required\" };\n return { ok: true };\n}\n\nexport function validateEventId(id: unknown): ValidationResult {\n if (!isNonEmptyString(id) || !PRINTABLE_ID.test(id) || id.length > 64)\n return { ok: false, reason: \"event id must match printable id, max 64\" };\n return { ok: true };\n}\n\nexport function validateScheduleEvent(e: unknown): ValidationResult {\n if (typeof e !== \"object\" || e === null) return { ok: false, reason: \"event must be object\" };\n const o = e as Record;\n const id = validateEventId(o.id);\n if (!id.ok) return id;\n if (!isNonEmptyString(o.title)) return { ok: false, reason: \"title required\" };\n if (!isNonEmptyString(o.stageId)) return { ok: false, reason: \"stageId required\" };\n if (!Array.isArray(o.artistIds)) return { ok: false, reason: \"artistIds must be array\" };\n if (!isFiniteNumber(o.startUtc) || !isFiniteNumber(o.endUtc))\n return { ok: false, reason: \"start/end must be epoch ms\" };\n if (o.endUtc <= o.startUtc) return { ok: false, reason: \"end must be after start\" };\n if (o.endUtc - o.startUtc > 24 * 3600 * 1000)\n return { ok: false, reason: \"event longer than 24h\" };\n if (typeof o.dayKey !== \"string\" || !DAYKEY.test(o.dayKey))\n return { ok: false, reason: \"dayKey must be YYYY-MM-DD\" };\n const status = o.status;\n if (status !== \"scheduled\" && status !== \"moved\" && status !== \"cancelled\")\n return { ok: false, reason: \"status must be scheduled|moved|cancelled\" };\n // forward-compat: allow unknown optional fields, no fail\n return { ok: true };\n}\n\nexport function validateMapPoi(p: unknown): ValidationResult {\n if (typeof p !== \"object\" || p === null) return { ok: false, reason: \"poi must be object\" };\n const o = p as Record;\n if (!isNonEmptyString(o.id)) return { ok: false, reason: \"poi id required\" };\n if (!isFiniteNumber(o.x) || o.x < 0 || o.x > 1) return { ok: false, reason: \"x must be 0..1\" };\n if (!isFiniteNumber(o.y) || o.y < 0 || o.y > 1) return { ok: false, reason: \"y must be 0..1\" };\n // lat/lng may be null (GPS optional)\n if (o.lat !== null && o.lat !== undefined && !isFiniteNumber(o.lat))\n return { ok: false, reason: \"lat must be number or null\" };\n if (o.lng !== null && o.lng !== undefined && !isFiniteNumber(o.lng))\n return { ok: false, reason: \"lng must be number or null\" };\n return { ok: true };\n}\n\nexport function isCompatible(\n manifest: FestivalManifest,\n shellSupportedRange: readonly number[],\n currentAppVersion: string,\n): boolean {\n if (!shellSupportedRange.includes(manifest.schemaVersion)) return false;\n // simple semver compare for min/max — compare major.minor.patch numerically\n const cmp = (a: string, b: string): number => {\n const pa = a.split(\".\").map((n) => Number.parseInt(n, 10));\n const pb = b.split(\".\").map((n) => Number.parseInt(n, 10));\n for (let i = 0; i < 3; i++) {\n const av = pa[i] ?? 0;\n const bv = pb[i] ?? 0;\n if (av !== bv) return av - bv;\n }\n return 0;\n };\n if (cmp(currentAppVersion, manifest.appCompatibility.minAppVersion) < 0) return false;\n if (\n manifest.appCompatibility.maxAppVersion !== null &&\n cmp(currentAppVersion, manifest.appCompatibility.maxAppVersion) > 0\n )\n return false;\n return true;\n}\n\nexport function validatePackage(pkg: unknown): ValidationResult {\n if (typeof pkg !== \"object\" || pkg === null)\n return { ok: false, reason: \"package must be object\" };\n const o = pkg as Record;\n const mv = validateManifest(o.manifest);\n if (!mv.ok) return { ok: false, reason: `manifest: ${mv.reason}` };\n const sig = validateSignature(o.signature);\n if (!sig.ok) return { ok: false, reason: `signature: ${sig.reason}` };\n // Sections presence is already via manifest sections; validate at least emergency shape\n const emergency = o.emergency as Record | undefined;\n if (emergency?.section !== \"emergency\")\n return { ok: false, reason: \"emergency section missing or wrong\" };\n if (!isFiniteNumber(emergency.emergencySchemaVersion))\n return { ok: false, reason: \"emergencySchemaVersion required\" };\n // schedule/map/info/assets validated via manifest presence; deep validation is stage pipeline\n return { ok: true };\n}\n\n// Pure helper for stable-ID check (favorites survive updates) — not a full diff, just contract\nexport function isStableId(id: string, prevIds: ReadonlySet): boolean {\n return prevIds.has(id);\n}\n\nexport function validateEmergencyFloor(floor: unknown): ValidationResult {\n if (typeof floor !== \"object\" || floor === null)\n return { ok: false, reason: \"floor must be object\" };\n const o = floor as Record;\n if (o.floor !== true) return { ok: false, reason: \"floor must be true\" };\n if (!isNonEmptyString(o.floorVersion)) return { ok: false, reason: \"floorVersion required\" };\n if (!isFiniteNumber(o.emergencySchemaVersion))\n return { ok: false, reason: \"emergencySchemaVersion required\" };\n return { ok: true };\n}\n","/**\n * GENERATED ARTIFACT — do not hand-edit.\n * Source: content/emergency/source.json, derived by pipeline/emergency.ts.\n * Trace: ADR-007, SPIKE-06 §1.\n */\nimport type { EmergencyFloor } from \"./types.js\";\n\nexport const EMERGENCY_FLOOR: EmergencyFloor = {\n floor: true,\n floorVersion: \"1.0.0+3\",\n emergencySchemaVersion: 1,\n generatedAt: \"2026-08-27T09:00:00Z\",\n sourceContentVersion: 3,\n services: {\n emergencyNumber: \"911\",\n security: { phone: \"+1-555-0142\", location: \"Main Gate Kiosk\" },\n firstAid: { location: \"Behind Stage B\", hours: \"10:00–02:00\" },\n },\n address: {\n lines: [\"123 Festival Way\", \"Austin, TX 78701\"],\n coordinates: { lat: 30.2672, lon: -97.7431 },\n },\n musterPoints: [{ name: \"North Field\" }],\n exits: [{ name: \"East Gate\" }],\n aedSummary: \"AEDs: 1 locations\",\n procedures: [\n { id: \"weather\", title: \"Severe Weather\", steps: [\"Seek shelter\", \"Follow staff\"] },\n { id: \"medical\", title: \"Medical Emergency\", steps: [\"Call 911\", \"Locate first aid\"] },\n ],\n};\n\nexport const EMERGENCY_FLOOR_JSON = JSON.stringify(EMERGENCY_FLOOR);\nexport const EMERGENCY_FLOOR_BYTES = new TextEncoder().encode(EMERGENCY_FLOOR_JSON).length;\n","/**\n * Emergency baseline (Ring-0) types — compiled into shell bytes.\n * Generated from same emergency source sheet as dataset section (no drift).\n * Trace: ADR-007, SPIKE-06 §1, IMPLEMENTATION-CONTRACT.md §14\n */\n\nimport type { EmergencySection } from \"../data/festival-package/types.js\";\n\nexport interface EmergencyFloor {\n readonly floor: true;\n readonly floorVersion: string; // e.g. \"1.0.0+2026.08.20\"\n readonly emergencySchemaVersion: number;\n readonly generatedAt: string; // ISO8601\n readonly sourceContentVersion: number;\n readonly services: EmergencySection[\"services\"];\n readonly address: EmergencySection[\"address\"];\n readonly musterPoints: readonly { readonly name: string; readonly directions?: string }[];\n readonly exits: readonly { readonly name: string }[];\n readonly aedSummary: string;\n readonly procedures: readonly {\n readonly id: string;\n readonly title: string;\n readonly steps: readonly string[];\n }[];\n}\n\n// Floor must be ≤16 KB — enforced by build gate, not by types.\nexport const FLOOR_SIZE_BUDGET = 16 * 1024;\n","/** Ring-0 loader. It has no persistence, network, or dataset dependency. */\nimport { EMERGENCY_FLOOR, EMERGENCY_FLOOR_BYTES } from \"./generated.js\";\nimport type { EmergencyFloor } from \"./types.js\";\nimport { FLOOR_SIZE_BUDGET } from \"./types.js\";\n\nexport interface EmergencyFloorLoadResult {\n readonly ok: true;\n readonly floor: EmergencyFloor;\n readonly bytes: number;\n}\n\nexport interface EmergencyFloorLoadFailure {\n readonly ok: false;\n readonly reason: string;\n}\n\nexport type EmergencyFloorLoad = EmergencyFloorLoadResult | EmergencyFloorLoadFailure;\n\nfunction record(value: unknown): Record | null {\n return typeof value === \"object\" && value !== null ? (value as Record) : null;\n}\n\nfunction nonEmpty(value: unknown): value is string {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n\nfunction validFloor(value: unknown): value is EmergencyFloor {\n const floor = record(value);\n const services = floor && record(floor.services);\n const security = services && record(services.security);\n const firstAid = services && record(services.firstAid);\n const address = floor && record(floor.address);\n const coordinates = address && record(address.coordinates);\n return Boolean(\n floor?.floor === true &&\n nonEmpty(floor.floorVersion) &&\n Number.isInteger(floor.emergencySchemaVersion) &&\n (floor.emergencySchemaVersion as number) >= 1 &&\n nonEmpty(floor.generatedAt) &&\n !Number.isNaN(Date.parse(floor.generatedAt)) &&\n Number.isInteger(floor.sourceContentVersion) &&\n (floor.sourceContentVersion as number) >= 1 &&\n services &&\n nonEmpty(services.emergencyNumber) &&\n security &&\n nonEmpty(security.phone) &&\n firstAid &&\n nonEmpty(firstAid.location) &&\n address &&\n Array.isArray(address.lines) &&\n address.lines.every(nonEmpty) &&\n coordinates &&\n typeof coordinates.lat === \"number\" &&\n Number.isFinite(coordinates.lat) &&\n typeof coordinates.lon === \"number\" &&\n Number.isFinite(coordinates.lon) &&\n Array.isArray(floor.musterPoints) &&\n floor.musterPoints.every((point) => nonEmpty(record(point)?.name)) &&\n Array.isArray(floor.exits) &&\n floor.exits.every((exit) => nonEmpty(record(exit)?.name)) &&\n nonEmpty(floor.aedSummary) &&\n Array.isArray(floor.procedures) &&\n floor.procedures.every((procedure) => {\n const item = record(procedure);\n return Boolean(\n item &&\n nonEmpty(item.id) &&\n nonEmpty(item.title) &&\n Array.isArray(item.steps) &&\n item.steps.every(nonEmpty),\n );\n }),\n );\n}\n\n/** Validate the compiled bytes before exposing them to rendering code. */\nexport function loadEmergencyFloor(): EmergencyFloorLoad {\n if (EMERGENCY_FLOOR_BYTES > FLOOR_SIZE_BUDGET)\n return { ok: false, reason: \"compiled emergency floor exceeds 16KB\" };\n if (!validFloor(EMERGENCY_FLOOR))\n return { ok: false, reason: \"compiled emergency floor is malformed\" };\n return { ok: true, floor: EMERGENCY_FLOOR, bytes: EMERGENCY_FLOOR_BYTES };\n}\n\n/** Forward-tolerant validation for an arbitrary floor-shaped value in tests/tools. */\nexport function validateEmergencyFloorBytes(value: unknown): EmergencyFloorLoad {\n if (!validFloor(value)) return { ok: false, reason: \"emergency floor is malformed\" };\n const bytes = new TextEncoder().encode(JSON.stringify(value)).length;\n if (bytes > FLOOR_SIZE_BUDGET) return { ok: false, reason: \"emergency floor exceeds 16KB\" };\n return { ok: true, floor: value, bytes };\n}\n","/**\n * Page-side A/B staging and activation coordinator.\n *\n * VerifiedPackage is already fully authenticated and integrity checked by the\n * verifier. This module only persists it, flips the system pointer, and\n * performs the post-flip readback required by the A/B protocol.\n */\nimport { isVerifiedPackage } from \"./verifier/types.js\";\nimport type { VerifiedPackage } from \"./verifier/types.js\";\nimport type { SlotId } from \"../platform/idb/names.js\";\nimport {\n initializeStaging,\n markStagingComplete,\n openSlotDB,\n readStagingProgress,\n readSlotAsset,\n readSlotFileMeta,\n lightCheckSlot,\n writeSlotAssetWithProgress,\n writeSlotFileWithProgress,\n writeSlotManifest,\n} from \"../data/slot/store.js\";\nimport type { SlotStagingJournal } from \"../data/slot/types.js\";\nimport {\n activateSlot,\n clearReadbackPending,\n openSystemDB,\n readSystemMeta,\n writeSystemMeta,\n} from \"../data/system-meta/store.js\";\nimport type { SystemMeta } from \"../data/system-meta/types.js\";\n\n/** Staging older than this is discarded (§18.2 — 7 days). Exported for boot evaluation. */\nexport const STAGING_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;\nconst REQUIRED_SECTION_IDS = [\"emergency\", \"schedule\", \"map\", \"info\", \"assets\"] as const;\n\nexport interface StagedPackage {\n readonly slot: SlotId;\n readonly journal: SlotStagingJournal;\n}\n\nexport interface ActivationResult {\n readonly ok: boolean;\n readonly slot: SlotId;\n readonly rolledBack: boolean;\n readonly reason?: string;\n}\n\nexport interface ActivationHooks {\n /** Test seam for injecting a bounded system-transaction failure. */\n readonly activate?: typeof activateSlot;\n /** Test seam for modelling corruption between flip and readback. */\n readonly afterFlip?: (slot: SlotId) => Promise;\n}\n\nfunction inactiveSlot(active: SlotId | null): SlotId {\n return active === \"A\" ? \"B\" : \"A\";\n}\n\nfunction parse(bytes: Uint8Array): unknown {\n return JSON.parse(new TextDecoder().decode(bytes));\n}\n\nfunction assetInventory(pkg: VerifiedPackage): readonly {\n id: string;\n file: string;\n bytes: number;\n sha256: string;\n}[] {\n const bytes = pkg.files.get(pkg.manifest.sections.assets.file);\n if (!bytes) throw new Error(\"verified package is missing assets inventory\");\n const value = parse(bytes) as { assets?: unknown };\n if (!Array.isArray(value.assets)) throw new Error(\"assets inventory is invalid\");\n return value.assets.map((asset) => {\n const item = asset as Record;\n if (\n typeof item.id !== \"string\" ||\n typeof item.file !== \"string\" ||\n typeof item.bytes !== \"number\" ||\n typeof item.sha256 !== \"string\"\n )\n throw new Error(\"asset inventory entry is invalid\");\n return { id: item.id, file: item.file, bytes: item.bytes, sha256: item.sha256 };\n });\n}\n\n/** Stage a verified package into the inactive slot, resuming matching work. */\nexport async function stageVerifiedPackage(pkg: VerifiedPackage): Promise {\n if (!isVerifiedPackage(pkg)) throw new Error(\"package has not passed Stage 7 verification\");\n const system = await openSystemDB();\n const meta = await readSystemMeta(system);\n const slot = inactiveSlot(meta.activeSlot);\n const db = await openSlotDB(slot);\n const now = Date.now();\n const existing = await readStagingProgress(db);\n const matches =\n existing?.edition === pkg.manifest.edition &&\n existing.packageVersion === pkg.manifest.packageVersion &&\n existing.manifestSha256 === pkg.manifestSha256 &&\n now - existing.lastProgressAt <= STAGING_MAX_AGE_MS;\n let journal = matches\n ? existing\n : await initializeStaging(db, {\n edition: pkg.manifest.edition,\n packageVersion: pkg.manifest.packageVersion,\n manifestSha256: pkg.manifestSha256,\n startedAt: now,\n lastProgressAt: now,\n publicKeyFingerprint: pkg.signature.publicKeyFingerprint,\n verifiedAt: now,\n });\n\n for (const id of REQUIRED_SECTION_IDS) {\n if (journal.stagedFiles.includes(id)) continue;\n const entry = pkg.manifest.sections[id];\n const bytes = pkg.files.get(entry.file);\n if (!bytes) throw new Error(`verified package is missing ${entry.file}`);\n journal = await writeSlotFileWithProgress(\n db,\n id,\n { bytes: entry.bytes, sha256: entry.sha256, json: parse(bytes) },\n journal,\n );\n }\n\n for (const asset of assetInventory(pkg)) {\n if (journal.stagedAssets.includes(asset.id)) continue;\n const bytes = pkg.files.get(asset.file);\n if (!bytes) throw new Error(`verified package is missing ${asset.file}`);\n journal = await writeSlotAssetWithProgress(\n db,\n asset.id,\n {\n bytes: asset.bytes,\n sha256: asset.sha256,\n blob: new Blob([bytes.buffer as ArrayBuffer]),\n },\n journal,\n );\n }\n\n // Persist the manifest itself: boot C4 (sizes), C5 (compat) and C6\n // (required flags) are evidenced from it. One short txn, P1-style.\n await writeSlotManifest(db, pkg.manifest);\n\n if (!journal.complete) journal = await markStagingComplete(db);\n system.close();\n db.close();\n return { slot, journal };\n}\n\nasync function readback(pkg: VerifiedPackage, slot: SlotId): Promise {\n const db = await openSlotDB(slot);\n const sections = REQUIRED_SECTION_IDS.map((id) => ({\n id,\n bytes: pkg.manifest.sections[id].bytes,\n }));\n const files = await lightCheckSlot(db, sections);\n if (!files.ok) {\n db.close();\n return false;\n }\n for (const asset of assetInventory(pkg)) {\n if (!(await readSlotAsset(db, asset.id))) {\n db.close();\n return false;\n }\n }\n db.close();\n return true;\n}\n\nasync function completeSlot(\n slot: SlotId,\n journal: SlotStagingJournal | undefined,\n): Promise {\n if (!journal?.complete || !REQUIRED_SECTION_IDS.every((id) => journal.stagedFiles.includes(id)))\n return false;\n const db = await openSlotDB(slot);\n for (const id of REQUIRED_SECTION_IDS) {\n if (!(await readSlotFileMeta(db, id))) {\n db.close();\n return false;\n }\n }\n for (const assetId of journal.stagedAssets) {\n if (!(await readSlotAsset(db, assetId))) {\n db.close();\n return false;\n }\n }\n db.close();\n return true;\n}\n\n/** Flip the active pointer, retry once, and restore prior metadata on readback failure. */\nexport async function activateStagedPackage(\n pkg: VerifiedPackage,\n staged: StagedPackage,\n appVersion: string,\n now = Date.now,\n hooks: ActivationHooks = {},\n): Promise {\n if (!isVerifiedPackage(pkg)) {\n return {\n ok: false,\n slot: staged.slot,\n rolledBack: false,\n reason: \"package has not passed Stage 7 verification\",\n };\n }\n if (!staged.journal.complete) {\n return { ok: false, slot: staged.slot, rolledBack: false, reason: \"staging is incomplete\" };\n }\n const system = await openSystemDB();\n const previous = await readSystemMeta(system);\n const targetDb = await openSlotDB(staged.slot);\n const targetJournal = await readStagingProgress(targetDb);\n targetDb.close();\n if (!targetJournal) {\n system.close();\n return {\n ok: false,\n slot: staged.slot,\n rolledBack: false,\n reason: \"staged package is incomplete\",\n };\n }\n if (\n targetJournal.edition !== pkg.manifest.edition ||\n targetJournal.packageVersion !== pkg.manifest.packageVersion ||\n targetJournal.manifestSha256 !== pkg.manifestSha256 ||\n !(await completeSlot(staged.slot, targetJournal))\n ) {\n system.close();\n return {\n ok: false,\n slot: staged.slot,\n rolledBack: false,\n reason: \"staged package is incomplete\",\n };\n }\n const next = {\n activeSlot: staged.slot,\n activeEdition: pkg.manifest.edition,\n activePackageVersion: pkg.manifest.packageVersion,\n verification: {\n packageVersion: pkg.manifest.packageVersion,\n edition: pkg.manifest.edition,\n manifestSha256: pkg.manifestSha256,\n publicKeyFingerprint: pkg.signature.publicKeyFingerprint,\n verifiedAt: now(),\n appVersionAtActivation: appVersion,\n },\n appVersionAtActivation: appVersion,\n } satisfies Pick<\n SystemMeta,\n | \"activeSlot\"\n | \"activeEdition\"\n | \"activePackageVersion\"\n | \"verification\"\n | \"appVersionAtActivation\"\n >;\n\n // Re-check committed slot contents immediately before the pointer flip.\n // The package was verified before staging; this prevents stale or damaged\n // staging metadata from making an incomplete slot observable as active.\n if (!(await readback(pkg, staged.slot))) {\n system.close();\n return {\n ok: false,\n slot: staged.slot,\n rolledBack: false,\n reason: \"staged package is incomplete\",\n };\n }\n\n let lastError: unknown;\n for (let attempt = 0; attempt < 2; attempt++) {\n try {\n await (hooks.activate ?? activateSlot)(system, next);\n lastError = undefined;\n break;\n } catch (error) {\n lastError = error;\n }\n }\n if (lastError !== undefined) {\n system.close();\n return {\n ok: false,\n slot: staged.slot,\n rolledBack: false,\n reason: \"activation transaction failed twice\",\n };\n }\n\n await hooks.afterFlip?.(staged.slot);\n\n if (await readback(pkg, staged.slot)) {\n await clearReadbackPending(system);\n system.close();\n return { ok: true, slot: staged.slot, rolledBack: false };\n }\n\n await writeSystemMeta(system, previous);\n system.close();\n return {\n ok: false,\n slot: staged.slot,\n rolledBack: true,\n reason: \"post-activation readback failed\",\n };\n}\n\n/** Restore the retained previous slot without touching lumen-user. */\nexport async function restorePreviousSlot(): Promise {\n const system = await openSystemDB();\n const current = await readSystemMeta(system);\n if (!current.activeSlot) {\n system.close();\n return false;\n }\n const previousSlot: SlotId = current.activeSlot === \"A\" ? \"B\" : \"A\";\n const db = await openSlotDB(previousSlot);\n const journal = await readStagingProgress(db);\n db.close();\n if (!(await completeSlot(previousSlot, journal)) || !journal) {\n system.close();\n return false;\n }\n const previous: SystemMeta = {\n ...current,\n activeSlot: previousSlot,\n activeEdition: journal.edition,\n activePackageVersion: journal.packageVersion,\n verification: {\n packageVersion: journal.packageVersion,\n edition: journal.edition,\n manifestSha256: journal.manifestSha256,\n publicKeyFingerprint:\n journal.publicKeyFingerprint ?? current.verification?.publicKeyFingerprint ?? \"\",\n verifiedAt: journal.verifiedAt ?? current.verification?.verifiedAt ?? Date.now(),\n appVersionAtActivation: current.appVersionAtActivation ?? \"\",\n },\n readbackPending: true,\n };\n await writeSystemMeta(system, previous);\n await clearReadbackPending(system);\n system.close();\n return true;\n}\n\n/** Resolve a readback left pending by a process kill on the next boot. */\nexport async function recoverPendingActivation(): Promise {\n const system = await openSystemDB();\n const current = await readSystemMeta(system);\n if (!current.readbackPending || !current.activeSlot) {\n system.close();\n return true;\n }\n const activeDb = await openSlotDB(current.activeSlot);\n const activeJournal = await readStagingProgress(activeDb);\n activeDb.close();\n if (await completeSlot(current.activeSlot, activeJournal)) {\n await clearReadbackPending(system);\n system.close();\n return true;\n }\n const fallbackSlot: SlotId = current.activeSlot === \"A\" ? \"B\" : \"A\";\n const fallbackDb = await openSlotDB(fallbackSlot);\n const fallbackJournal = await readStagingProgress(fallbackDb);\n fallbackDb.close();\n if (!(await completeSlot(fallbackSlot, fallbackJournal)) || !fallbackJournal) {\n system.close();\n return false;\n }\n await writeSystemMeta(system, {\n ...current,\n activeSlot: fallbackSlot,\n activeEdition: fallbackJournal.edition,\n activePackageVersion: fallbackJournal.packageVersion,\n verification: {\n packageVersion: fallbackJournal.packageVersion,\n edition: fallbackJournal.edition,\n manifestSha256: fallbackJournal.manifestSha256,\n publicKeyFingerprint:\n fallbackJournal.publicKeyFingerprint ?? current.verification?.publicKeyFingerprint ?? \"\",\n verifiedAt: fallbackJournal.verifiedAt ?? current.verification?.verifiedAt ?? Date.now(),\n appVersionAtActivation: current.appVersionAtActivation ?? \"\",\n },\n readbackPending: false,\n });\n system.close();\n return true;\n}\n","/**\n * Readiness build constants — the shell side of the compatibility envelope.\n * Trace: IMPLEMENTATION-CONTRACT.md §20.1 C5, §15 C-23 ordering rule.\n */\n\n/**\n * Current shell version (semver). Must match package.json — enforced by test.\n * Published datasets declare appCompatibility against this.\n */\nexport const APP_VERSION = \"0.1.0\";\n\n/**\n * Schema versions this shell can render. C-23: shells ship the range first\n * (here {1,2}), datasets using a new schema publish only afterwards.\n */\nexport const SUPPORTED_SCHEMA_RANGE: readonly number[] = [1, 2];\n","/**\n * Offline Ready — predicate inputs, six-state taxonomy, chip mapping.\n * Pure types only: no I/O, no clocks (time independence, SPIKE-05 F-1).\n * Trace: SPIKE-05 §2–§3, IMPLEMENTATION-CONTRACT.md §20.\n */\n\nexport type ReadinessState =\n \"READY\" | \"PARTIAL\" | \"NOT_READY\" | \"RECOVERY\" | \"BASELINE_ONLY\" | \"FAILED\";\n\nexport type RecoveryReason = \"missing\" | \"corrupt\" | \"incompatible\";\n\n/** Preparation levels L0–L2 (SPIKE-07): shell+floor / +emergency+schedule / full. */\nexport type PreparationLevel = \"L0\" | \"L1\" | \"L2\";\n\n/** The five canonical V1 sections. Order is the staging priority order. */\nexport const CANONICAL_SECTIONS: readonly string[] = [\n \"emergency\",\n \"schedule\",\n \"info\",\n \"map\",\n \"assets\",\n];\n\n/** Sections whose presence constitutes the L1 minimum-safe set. */\nexport const L1_SECTIONS: readonly string[] = [\"emergency\", \"schedule\"];\n\n/**\n * Evidence for the C1–C8 predicate. Assembled by the app-layer evaluator\n * from local stores only — never from the network, never from a clock.\n */\nexport interface ReadinessEvidence {\n /** C1: every precache entry present in the current shell cache. */\n readonly shellValid: boolean;\n /** C2: active slot exists for the active edition. */\n readonly datasetPresent: boolean;\n /** C3: recorded verification matches the active package (no crypto at boot). */\n readonly authenticity: \"verified\" | \"record-missing\" | \"mismatch\";\n /** C4: manifest-listed files present with matching sizes (hashes at staging). */\n readonly integrity: \"ok\" | \"files-missing\" | \"corrupt\";\n /** C5: schemaVersion in shell range and appCompatibility satisfied. */\n readonly schemaCompatible: boolean;\n /** C6: every required section present and parseable. */\n readonly requiredSectionsOk: boolean;\n /** C7: every asset referenced by required sections present. */\n readonly requiredAssetsOk: boolean;\n /** C8: embedded emergency floor present (asserted against broken builds). */\n readonly emergencyFloorOk: boolean;\n /** Required section/asset ids absent — the PARTIAL(list) enumeration. */\n readonly missing: readonly string[];\n /** A dataset was previously verified — distinguishes RECOVERY from NOT_READY. */\n readonly hadVerifiedDataset: boolean;\n /**\n * A recent incomplete staging exists in a slot with no active pointer\n * (prep interrupted). Non-null (even with empty missing = fully staged,\n * awaiting activation) maps to PARTIAL instead of NOT_READY.\n */\n readonly stagedPending: { readonly missing: readonly string[] } | null;\n /** The staged manifest is present and structurally sound (evidence for C4–C6). */\n readonly manifestOk: boolean;\n /** Schema/app incompatibility (vs generic corruption) — RECOVERY(incompatible). */\n readonly incompatible: boolean;\n /** Session operation failure, e.g. { op: \"activation\" } — FAILED(op). */\n readonly failure: { readonly op: string } | null;\n /** Required sections present (for L0–L2), regardless of trust state. */\n readonly presentSections: readonly string[];\n}\n\nexport interface ReadinessResult {\n readonly state: ReadinessState;\n /** Set for RECOVERY; null otherwise. */\n readonly reason: RecoveryReason | null;\n /** Required ids absent (PARTIAL) or the failing part; empty for READY. */\n readonly missing: readonly string[];\n /** Set for FAILED; null otherwise. */\n readonly failedOp: string | null;\n readonly level: PreparationLevel;\n}\n\nexport type ChipTone = \"ready\" | \"partial\" | \"neutral\" | \"recovery\" | \"baseline\" | \"failed\";\n\nexport interface StatusChip {\n readonly text: string;\n readonly tone: ChipTone;\n /** Accessible label for the chip (status + what it means). */\n readonly label: string;\n}\n\nexport interface SectionStatus {\n readonly id: string;\n readonly required: boolean;\n readonly present: boolean;\n readonly parseable: boolean;\n}\n\n/**\n * Full boot report: predicate verdict plus everything the Status screen\n * shows (checklist, versions, usage). Plain data — safe for ui to import.\n * Storage shape mirrors the platform estimate without importing platform.\n */\nexport interface BootReadinessReport extends ReadinessResult {\n readonly edition: string | null;\n readonly packageVersion: number | null;\n readonly generatedAt: string | null;\n /** Local progress timestamp for display — never a verdict input. */\n readonly fetchedAt: number | null;\n readonly sections: readonly SectionStatus[];\n readonly assets: {\n readonly total: number;\n readonly present: number;\n readonly missing: readonly string[];\n };\n readonly floorVersion: string | null;\n readonly storage: { readonly quota?: number; readonly usage?: number } | null;\n readonly shellCache: string | null;\n /** The retained slot could serve a user-initiated restore. */\n readonly canRestore: boolean;\n /** A complete staging exists with no active pointer (activate in Stage 15). */\n readonly activationPending: boolean;\n}\n","/**\n * Offline Ready — pure predicate evaluation (no I/O, no clocks, no network).\n * Time-independent per SPIKE-05 F-1: a wrong device clock can never demote\n * READY. Clock problems surface as warnings elsewhere (Stage 11), never here.\n * Trace: SPIKE-05 §2–§3§5, IMPLEMENTATION-CONTRACT.md §20.1–§20.2.\n */\nimport { CANONICAL_SECTIONS, L1_SECTIONS } from \"./types.js\";\nimport type {\n PreparationLevel,\n ReadinessEvidence,\n ReadinessResult,\n ReadinessState,\n StatusChip,\n} from \"./types.js\";\n\nexport function preparationLevel(present: readonly string[]): PreparationLevel {\n if (CANONICAL_SECTIONS.every((id) => present.includes(id))) return \"L2\";\n if (L1_SECTIONS.every((id) => present.includes(id))) return \"L1\";\n return \"L0\";\n}\n\n/**\n * Six-state taxonomy. Precedence is normative:\n * FAILED (session error) > shell-missing handling > NOT_READY/RECOVERY(missing)\n * > integrity-missing > integrity-corrupt > authenticity-mismatch >\n * RECOVERY(incompatible) > PARTIAL > READY.\n *\n * Integrity is checked before authenticity so that a verification record\n * that outlived its files reads as data loss (missing), not tampering:\n * files-gone dominates a mismatched record (SPIKE-05 §5).\n */\nexport function evaluateReadiness(e: ReadinessEvidence): ReadinessResult {\n const level = preparationLevel(e.presentSections);\n\n if (e.failure) {\n return {\n state: \"FAILED\",\n reason: null,\n missing: [...e.missing],\n failedOp: e.failure.op,\n level,\n };\n }\n\n if (!e.emergencyFloorOk) {\n // The floor is compiled into the shell — absence means a broken build.\n return {\n state: \"FAILED\",\n reason: null,\n missing: [...e.missing],\n failedOp: \"emergency-floor\",\n level,\n };\n }\n\n if (!e.shellValid) {\n // The running shell proves code is present; a missing cache entry only\n // endangers relaunch. With a dataset, enumerate it as PARTIAL; without,\n // the user still needs the preparation flow.\n if (e.datasetPresent) {\n return {\n state: \"PARTIAL\",\n reason: null,\n missing: [\"shell-cache\", ...e.missing],\n failedOp: null,\n level,\n };\n }\n return { state: \"NOT_READY\", reason: null, missing: [...e.missing], failedOp: null, level };\n }\n\n if (!e.datasetPresent) {\n if (e.hadVerifiedDataset) {\n return { state: \"RECOVERY\", reason: \"missing\", missing: [\"dataset\"], failedOp: null, level };\n }\n if (e.stagedPending) {\n // Prep interrupted (or fully staged, awaiting activation) — resumable,\n // so PARTIAL rather than NOT_READY. Level follows staged sections.\n return {\n state: \"PARTIAL\",\n reason: null,\n missing: [...e.stagedPending.missing],\n failedOp: null,\n level,\n };\n }\n return { state: \"NOT_READY\", reason: null, missing: [...e.missing], failedOp: null, level };\n }\n\n if (e.integrity === \"files-missing\") {\n return { state: \"RECOVERY\", reason: \"missing\", missing: [...e.missing], failedOp: null, level };\n }\n if (e.integrity === \"corrupt\") {\n return { state: \"RECOVERY\", reason: \"corrupt\", missing: [...e.missing], failedOp: null, level };\n }\n if (e.authenticity === \"mismatch\") {\n return { state: \"RECOVERY\", reason: \"corrupt\", missing: [...e.missing], failedOp: null, level };\n }\n\n if (e.manifestOk && (!e.schemaCompatible || e.incompatible)) {\n return {\n state: \"RECOVERY\",\n reason: \"incompatible\",\n missing: [...e.missing],\n failedOp: null,\n level,\n };\n }\n\n if (e.authenticity === \"record-missing\") {\n // Files present but no verification evidence — READY cannot be claimed\n // without evidence (SPIKE-05 §5). Full re-verify path, not corruption.\n return {\n state: \"PARTIAL\",\n reason: null,\n missing: [\"verification\", ...e.missing],\n failedOp: null,\n level,\n };\n }\n\n if (!e.manifestOk) {\n // Slot files exist but the manifest they are evidenced from is gone —\n // sizes/compat/required-flags cannot be proven. Re-verify path.\n const missing = e.missing.includes(\"manifest\") ? [...e.missing] : [\"manifest\", ...e.missing];\n return { state: \"PARTIAL\", reason: null, missing, failedOp: null, level };\n }\n\n if (!e.requiredSectionsOk || !e.requiredAssetsOk) {\n return { state: \"PARTIAL\", reason: null, missing: [...e.missing], failedOp: null, level };\n }\n\n return { state: \"READY\", reason: null, missing: [], failedOp: null, level };\n}\n\n/** Header chip mapping (§20.2) — text, tone, and accessible label per state. */\nexport function chipForState(state: ReadinessState, detail?: string): StatusChip {\n switch (state) {\n case \"READY\":\n return {\n text: \"OFFLINE READY ✓\",\n tone: \"ready\",\n label: \"Offline status: ready. All festival data is verified on this device.\",\n };\n case \"PARTIAL\":\n return {\n text: \"PARTIAL\",\n tone: \"partial\",\n label: `Offline status: partial. Missing: ${detail ?? \"some required data\"}.`,\n };\n case \"NOT_READY\":\n return {\n text: \"Get festival data\",\n tone: \"neutral\",\n label: \"Offline status: not ready. Open with internet to get festival data.\",\n };\n case \"RECOVERY\":\n return {\n text: `RECOVERY — ${detail ?? \"data missing\"}`,\n tone: \"recovery\",\n label: `Offline status: recovery needed (${detail ?? \"data missing\"}). Emergency info still available.`,\n };\n case \"BASELINE_ONLY\":\n return {\n text: \"SAVING UNAVAILABLE\",\n tone: \"baseline\",\n label:\n \"Offline status: this browser cannot save offline data. Emergency info still available.\",\n };\n case \"FAILED\":\n return {\n text: `FAILED — ${detail ?? \"error\"}`,\n tone: \"failed\",\n label: `Offline status: operation failed (${detail ?? \"error\"}). Emergency info still available.`,\n };\n }\n}\n","/**\n * Stage 9 boot evaluator — gathers C1–C8 evidence from local stores only and\n * maps it through the pure predicate.\n *\n * Rules honored here, not just in docs:\n * - No network, no fetch, no clocks for verdicts (time-independent READY).\n * - `now` is used ONLY for staging-age expiry and display timestamps.\n * - Never throws: every failure mode maps to a report state (no-blank-screen).\n * - Domain logic stays pure; all I/O lives in this app-layer composer (B-2).\n *\n * Trace: SPIKE-05 §2–§5, IMPLEMENTATION-CONTRACT.md §20.1–§20.5.\n */\nimport { checkShellCache } from \"../platform/cache/shell-cache.js\";\nimport { getStorageEstimate } from \"../platform/idb/storage-helpers.js\";\nimport type { StorageEstimate } from \"../platform/idb/storage-helpers.js\";\nimport type { SlotId } from \"../platform/idb/names.js\";\nimport { openSystemDB, readSystemMeta } from \"../data/system-meta/store.js\";\nimport {\n openSlotDB,\n readSlotAsset,\n readSlotFile,\n readSlotFileMeta,\n readSlotManifest,\n readStagingProgress,\n} from \"../data/slot/store.js\";\nimport type { FestivalManifest, SectionEntry } from \"../data/festival-package/types.js\";\nimport { PACKAGE_FORMAT } from \"../data/festival-package/types.js\";\nimport { isCompatible } from \"../data/festival-package/validation.js\";\nimport { loadEmergencyFloor } from \"../emergency-baseline/loader.js\";\nimport { STAGING_MAX_AGE_MS, recoverPendingActivation } from \"../sync/activation.js\";\nimport { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from \"../domain/readiness/config.js\";\nimport { CANONICAL_SECTIONS } from \"../domain/readiness/types.js\";\nimport { evaluateReadiness } from \"../domain/readiness/logic.js\";\nimport type {\n BootReadinessReport,\n ReadinessEvidence,\n SectionStatus,\n} from \"../domain/readiness/types.js\";\n\nexport interface ShellEvidence {\n readonly ok: boolean;\n readonly cacheName: string | null;\n}\n\nexport interface FloorEvidence {\n readonly ok: boolean;\n readonly version: string | null;\n readonly bytes: number;\n}\n\nexport interface BootReadinessDeps {\n readonly openSystem: () => Promise;\n readonly openSlot: (slot: SlotId) => Promise;\n readonly checkShell: () => Promise;\n readonly loadFloor: () => FloorEvidence;\n readonly estimate: () => Promise;\n readonly appVersion: string;\n readonly supportedSchemaRange: readonly number[];\n /** ONLY for staging-age expiry + display — never for verdicts. */\n readonly now: () => number;\n}\n\nexport const defaultBootDeps: BootReadinessDeps = {\n openSystem: openSystemDB,\n openSlot: openSlotDB,\n checkShell: checkShellCache,\n loadFloor: () => {\n const loaded = loadEmergencyFloor();\n return loaded.ok\n ? { ok: true, version: loaded.floor.floorVersion, bytes: loaded.bytes }\n : { ok: false, version: null, bytes: 0 };\n },\n estimate: getStorageEstimate,\n appVersion: APP_VERSION,\n supportedSchemaRange: SUPPORTED_SCHEMA_RANGE,\n now: () => Date.now(),\n};\n\n/** Re-exported for app-layer consumers — canonical home is domain/readiness. */\nexport type { BootReadinessReport, SectionStatus } from \"../domain/readiness/types.js\";\n\nfunction failedReport(\n op: string,\n floor: FloorEvidence,\n storage: StorageEstimate | null,\n): BootReadinessReport {\n return {\n ...evaluateReadiness({\n shellValid: false,\n datasetPresent: false,\n authenticity: \"record-missing\",\n integrity: \"files-missing\",\n schemaCompatible: false,\n requiredSectionsOk: false,\n requiredAssetsOk: false,\n emergencyFloorOk: floor.ok,\n missing: [],\n hadVerifiedDataset: false,\n manifestOk: false,\n incompatible: false,\n failure: { op },\n presentSections: [],\n stagedPending: null,\n }),\n edition: null,\n packageVersion: null,\n generatedAt: null,\n fetchedAt: null,\n sections: [],\n assets: { total: 0, present: 0, missing: [] },\n floorVersion: floor.version,\n storage,\n shellCache: null,\n canRestore: false,\n activationPending: false,\n };\n}\n\nfunction baselineOnlyReport(\n floor: FloorEvidence,\n storage: StorageEstimate | null,\n): BootReadinessReport {\n return {\n state: \"BASELINE_ONLY\",\n reason: null,\n missing: [],\n failedOp: null,\n level: \"L0\",\n edition: null,\n packageVersion: null,\n generatedAt: null,\n fetchedAt: null,\n sections: [],\n assets: { total: 0, present: 0, missing: [] },\n floorVersion: floor.version,\n storage,\n shellCache: null,\n canRestore: false,\n activationPending: false,\n };\n}\n\n/**\n * Forward-tolerant asset-reference narrowing for the C7 check: the inventory\n * was schema-verified at staging; boot only needs id+bytes without trusting\n * its shape. Unknown entries are ignored, never crash (I-2/Ring-0 ethos).\n */\nfunction narrowAssetRefs(value: unknown): readonly { id: string; bytes: number }[] {\n if (typeof value !== \"object\" || value === null) return [];\n const listed: unknown = (value as { assets?: unknown }).assets;\n if (!Array.isArray(listed)) return [];\n return listed\n .map((item: unknown) => {\n const entry = item as { id?: unknown; bytes?: unknown };\n return typeof entry.id === \"string\" && typeof entry.bytes === \"number\"\n ? { id: entry.id, bytes: entry.bytes }\n : null;\n })\n .filter((entry): entry is { id: string; bytes: number } => entry !== null);\n}\n\n/**\n * Structural manifest check: the staged manifest must be the one the journal\n * records (same edition + package version, V1 format). Parameter is\n * explicitly nullable so the optional chain below is type-honest.\n */\nfunction isUsableManifest(\n manifest: FestivalManifest | undefined,\n journal: { edition: string; packageVersion: number },\n): manifest is FestivalManifest {\n return (\n manifest?.format === PACKAGE_FORMAT &&\n manifest.edition === journal.edition &&\n manifest.packageVersion === journal.packageVersion\n );\n}\n\nasync function otherSlotComplete(\n openSlot: BootReadinessDeps[\"openSlot\"],\n slot: SlotId,\n): Promise {\n try {\n const db = await openSlot(slot);\n try {\n const journal = await readStagingProgress(db);\n return Boolean(\n journal?.complete &&\n CANONICAL_SECTIONS.every((id) => journal.stagedFiles.includes(id as never)),\n );\n } finally {\n db.close();\n }\n } catch {\n return false;\n }\n}\n\nasync function findRecentStaging(\n openSlot: BootReadinessDeps[\"openSlot\"],\n now: number,\n): Promise<{ slot: SlotId; staged: readonly string[]; complete: boolean } | null> {\n for (const slot of [\"A\", \"B\"] as const) {\n try {\n const db = await openSlot(slot);\n try {\n const journal = await readStagingProgress(db);\n if (journal && now - journal.lastProgressAt <= STAGING_MAX_AGE_MS) {\n return { slot, staged: journal.stagedFiles, complete: journal.complete };\n }\n } finally {\n db.close();\n }\n } catch {\n // unreadable slot — not resumable, try the next\n }\n }\n return null;\n}\n\nexport async function evaluateBootReadiness(\n deps: BootReadinessDeps = defaultBootDeps,\n): Promise {\n const floor = deps.loadFloor();\n let storage: StorageEstimate | null = null;\n try {\n storage = await deps.estimate();\n } catch {\n storage = null;\n }\n\n try {\n return await evaluate(deps, floor, storage);\n } catch {\n return failedReport(\"boot-check\", floor, storage);\n }\n}\n\nasync function evaluate(\n deps: BootReadinessDeps,\n floor: FloorEvidence,\n storage: StorageEstimate | null,\n): Promise {\n const shell = await deps.checkShell().catch(() => ({ ok: false, cacheName: null }));\n\n // Storage probe: an unopenable system DB means nothing can persist.\n let system: IDBDatabase;\n try {\n system = await deps.openSystem();\n } catch {\n return baselineOnlyReport(floor, storage);\n }\n\n try {\n let meta = await readSystemMeta(system);\n\n if (meta.readbackPending) {\n try {\n await recoverPendingActivation();\n const reopened = await deps.openSystem();\n system.close();\n system = reopened;\n meta = await readSystemMeta(system);\n } catch {\n return { ...failedReport(\"boot-check\", floor, storage), shellCache: shell.cacheName };\n }\n }\n\n if (!meta.activeSlot) {\n return await evaluateNoActivePointer(deps, floor, storage, shell, meta.verification !== null);\n }\n\n const activeSlot = meta.activeSlot;\n let slot: IDBDatabase;\n try {\n slot = await deps.openSlot(activeSlot);\n } catch {\n return { ...failedReport(\"boot-check\", floor, storage), shellCache: shell.cacheName };\n }\n\n try {\n return await evaluateActiveSlot(deps, floor, storage, shell, meta, slot, activeSlot);\n } finally {\n slot.close();\n }\n } finally {\n system.close();\n }\n}\n\nasync function evaluateNoActivePointer(\n deps: BootReadinessDeps,\n floor: FloorEvidence,\n storage: StorageEstimate | null,\n shell: ShellEvidence,\n hadVerifiedDataset: boolean,\n): Promise {\n if (hadVerifiedDataset) {\n // A verification record outlived its slots — previously verified, now gone.\n const result = evaluateReadiness(\n baseEvidence(floor, shell, {\n datasetPresent: false,\n hadVerifiedDataset: true,\n }),\n );\n return {\n ...result,\n edition: null,\n packageVersion: null,\n generatedAt: null,\n fetchedAt: null,\n sections: [],\n assets: { total: 0, present: 0, missing: [] },\n floorVersion: floor.version,\n storage,\n shellCache: shell.cacheName,\n canRestore: false,\n activationPending: false,\n };\n }\n\n const staging = await findRecentStaging(deps.openSlot, deps.now());\n if (staging) {\n const missing = CANONICAL_SECTIONS.filter((id) => !staging.staged.includes(id));\n // Fully staged but never activated (kill between staging and flip):\n // still PARTIAL with empty missing; the activationPending flag tells the\n // Status screen. Activation itself belongs to Stage 15 sync.\n const result = evaluateReadiness(\n baseEvidence(floor, shell, {\n datasetPresent: false,\n hadVerifiedDataset: false,\n requiredSectionsOk: staging.complete && missing.length === 0,\n requiredAssetsOk: staging.complete && missing.length === 0,\n missing,\n presentSections: [...staging.staged],\n stagedPending: { missing },\n }),\n );\n return {\n ...result,\n edition: null,\n packageVersion: null,\n generatedAt: null,\n fetchedAt: null,\n sections: [],\n assets: { total: 0, present: 0, missing: [] },\n floorVersion: floor.version,\n storage,\n shellCache: shell.cacheName,\n canRestore: false,\n activationPending: staging.complete && missing.length === 0,\n };\n }\n\n const result = evaluateReadiness(\n baseEvidence(floor, shell, {\n datasetPresent: false,\n hadVerifiedDataset: false,\n }),\n );\n return {\n ...result,\n edition: null,\n packageVersion: null,\n generatedAt: null,\n fetchedAt: null,\n sections: [],\n assets: { total: 0, present: 0, missing: [] },\n floorVersion: floor.version,\n storage,\n shellCache: shell.cacheName,\n canRestore: false,\n activationPending: false,\n };\n}\n\nfunction baseEvidence(\n floor: FloorEvidence,\n shell: ShellEvidence,\n patch: Partial,\n): ReadinessEvidence {\n return {\n shellValid: shell.ok,\n datasetPresent: false,\n authenticity: \"record-missing\",\n integrity: \"files-missing\",\n schemaCompatible: false,\n requiredSectionsOk: false,\n requiredAssetsOk: false,\n emergencyFloorOk: floor.ok,\n missing: [],\n hadVerifiedDataset: false,\n manifestOk: true,\n incompatible: false,\n failure: null,\n presentSections: [],\n stagedPending: null,\n ...patch,\n };\n}\n\nasync function evaluateActiveSlot(\n deps: BootReadinessDeps,\n floor: FloorEvidence,\n storage: StorageEstimate | null,\n shell: ShellEvidence,\n meta: Awaited>,\n slot: IDBDatabase,\n activeSlot: SlotId,\n): Promise {\n const fail = (op: string): BootReadinessReport => ({\n ...failedReport(op, floor, storage),\n shellCache: shell.cacheName,\n });\n\n let journal: Awaited>;\n let manifest: FestivalManifest | undefined;\n try {\n journal = await readStagingProgress(slot);\n manifest = await readSlotManifest(slot);\n } catch {\n return fail(\"boot-check\");\n }\n\n const verification = meta.verification;\n const missing: string[] = [];\n const sections: SectionStatus[] = [];\n const presentSections: string[] = [];\n\n // Journal probe first: without staging evidence the pointer cannot be\n // corroborated. After this block `journal` is defined (direct access below).\n if (!journal) {\n let anyFilePresent = false;\n for (const id of CANONICAL_SECTIONS) {\n try {\n if (await readSlotFileMeta(slot, id as never)) {\n anyFilePresent = true;\n break;\n }\n } catch {\n return fail(\"boot-check\");\n }\n }\n // Pointer claims a dataset but the slot carries no staging evidence:\n // files gone → loss; files present without a journal → inconsistency.\n const result = evaluateReadiness(\n baseEvidence(floor, shell, {\n datasetPresent: true,\n authenticity: \"mismatch\",\n integrity: anyFilePresent ? \"corrupt\" : \"files-missing\",\n hadVerifiedDataset: true,\n presentSections: [],\n }),\n );\n return {\n ...result,\n edition: meta.activeEdition,\n packageVersion: meta.activePackageVersion,\n generatedAt: null,\n fetchedAt: null,\n sections: [],\n assets: { total: 0, present: 0, missing: [] },\n floorVersion: floor.version,\n storage,\n shellCache: shell.cacheName,\n canRestore: await otherSlotComplete(deps.openSlot, activeSlot === \"A\" ? \"B\" : \"A\"),\n activationPending: false,\n };\n }\n\n // C3 authenticity: recorded verification must match the staged journal.\n // No crypto at boot — hashes were verified at staging and recorded (F-2).\n let authenticity: ReadinessEvidence[\"authenticity\"] = \"verified\";\n if (!verification) {\n authenticity = \"record-missing\";\n } else if (\n journal.edition !== meta.activeEdition ||\n journal.packageVersion !== meta.activePackageVersion ||\n journal.manifestSha256 !== verification.manifestSha256\n ) {\n authenticity = \"mismatch\";\n }\n\n // Manifest structural check — the evidence C4/C5/C6 read from.\n const manifestOk = isUsableManifest(manifest, journal);\n\n // C4 sizes + C6 presence/parseability over required sections.\n let integrity: ReadinessEvidence[\"integrity\"] = \"ok\";\n let requiredSectionsOk = true;\n if (manifestOk && manifest) {\n // Defensive view: staged manifests passed pipeline gates, but boot must\n // tolerate corrupt or future shapes the static type cannot see.\n const entries = manifest.sections as Partial>;\n for (const id of CANONICAL_SECTIONS) {\n const entry = entries[id];\n const required = entry?.required !== false;\n let present = false;\n let parseable = false;\n try {\n const fileMeta = await readSlotFileMeta(slot, id as never);\n present = Boolean(fileMeta && (!entry || fileMeta.bytes === entry.bytes));\n if (fileMeta && entry && fileMeta.bytes !== entry.bytes) integrity = \"corrupt\";\n if (!fileMeta) {\n if (required) {\n integrity = integrity === \"ok\" ? \"files-missing\" : integrity;\n }\n } else {\n const json = await readSlotFile(slot, id as never);\n parseable = typeof json === \"object\" && json !== null;\n }\n } catch {\n return fail(\"boot-check\");\n }\n if (present) presentSections.push(id);\n sections.push({ id, required, present, parseable });\n if (required && (!present || !parseable)) {\n requiredSectionsOk = false;\n if (!missing.includes(id)) missing.push(id);\n }\n }\n } else {\n requiredSectionsOk = false;\n if (!missing.includes(\"manifest\")) missing.push(\"manifest\");\n }\n\n // C7: assets referenced by the inventory, present with matching sizes.\n let requiredAssetsOk = true;\n const assetMissing: string[] = [];\n let assetTotal = 0;\n let assetPresent = 0;\n if (manifestOk) {\n try {\n const inventory = await readSlotFile(slot, \"assets\");\n const assets = narrowAssetRefs(inventory);\n assetTotal = assets.length;\n for (const asset of assets) {\n const record = await readSlotAsset(slot, asset.id);\n if (record?.bytes === asset.bytes) {\n assetPresent += 1;\n } else {\n requiredAssetsOk = false;\n assetMissing.push(`asset:${asset.id}`);\n if (record && record.bytes !== asset.bytes) integrity = \"corrupt\";\n else if (integrity === \"ok\") integrity = \"files-missing\";\n }\n }\n } catch {\n return fail(\"boot-check\");\n }\n }\n missing.push(...assetMissing);\n\n // C5 compatibility — dual check (§18.5). Decided from the staged manifest.\n let schemaCompatible = false;\n let incompatible = false;\n if (manifestOk && manifest) {\n schemaCompatible = isCompatible(manifest, deps.supportedSchemaRange, deps.appVersion);\n incompatible = !schemaCompatible;\n }\n\n const evidence: ReadinessEvidence = {\n shellValid: shell.ok,\n datasetPresent: true,\n authenticity,\n integrity,\n schemaCompatible,\n requiredSectionsOk,\n requiredAssetsOk,\n emergencyFloorOk: floor.ok,\n missing,\n hadVerifiedDataset: true,\n manifestOk,\n incompatible,\n failure: null,\n presentSections,\n stagedPending: null,\n };\n const result = evaluateReadiness(evidence);\n\n return {\n ...result,\n edition: meta.activeEdition,\n packageVersion: meta.activePackageVersion,\n generatedAt: manifestOk && manifest ? manifest.generatedAt : null,\n fetchedAt: journal.verifiedAt ?? journal.lastProgressAt,\n sections,\n assets: { total: assetTotal, present: assetPresent, missing: assetMissing },\n floorVersion: floor.version,\n storage,\n shellCache: shell.cacheName,\n canRestore: await otherSlotComplete(deps.openSlot, activeSlot === \"A\" ? \"B\" : \"A\"),\n activationPending: false,\n };\n}\n","import { loadEmergencyFloor } from \"../../emergency-baseline/loader.js\";\nimport type { EmergencyFloor } from \"../../emergency-baseline/types.js\";\nimport type { EmergencySection } from \"../../data/festival-package/types.js\";\nimport type {\n EmergencyDemotionReason,\n EmergencyViewModel,\n VerifiedEmergencyData,\n} from \"./types.js\";\n\nconst SUPPORTED_EMERGENCY_SCHEMAS = [1] as const;\n\nfunction record(value: unknown): Record | null {\n return typeof value === \"object\" && value !== null ? (value as Record) : null;\n}\n\nfunction nonEmpty(value: unknown): value is string {\n return typeof value === \"string\" && value.trim().length > 0;\n}\n\n/**\n * Forward-tolerant narrowing of a stored emergency section. Unknown fields\n * are preserved (never throw on future shapes). Only the section tag, a sane\n * schema version, and well-formed procedures (when present) are required —\n * sparse sections are usable because resolveEmergency defensively merges\n * floor fields over omissions (SPIKE-06 merge rule).\n */\nexport function narrowEmergencySection(value: unknown): EmergencySection | null {\n const section = record(value);\n if (section?.section !== \"emergency\") return null;\n const version: unknown = section.emergencySchemaVersion;\n if (typeof version !== \"number\" || !Number.isInteger(version) || version < 1) {\n return null;\n }\n if (section.services !== undefined && !record(section.services)) return null;\n if (\n section.procedures !== undefined &&\n (!Array.isArray(section.procedures) ||\n !section.procedures.every((p) => {\n const item = record(p);\n return Boolean(\n item && nonEmpty(item.id) && nonEmpty(item.title) && Array.isArray(item.steps),\n );\n }))\n ) {\n return null;\n }\n return value as EmergencySection;\n}\n\nfunction mergeFloorFields(section: EmergencySection, floor: EmergencyFloor): EmergencySection {\n const candidate = section as unknown as Partial;\n const candidateServices = record(candidate.services);\n const emergencyNumber = candidateServices?.emergencyNumber;\n return {\n ...section,\n services: {\n emergencyNumber: nonEmpty(emergencyNumber) ? emergencyNumber : floor.services.emergencyNumber,\n security: candidate.services?.security ?? floor.services.security,\n firstAid: candidate.services?.firstAid ?? floor.services.firstAid,\n },\n address: candidate.address ?? floor.address,\n procedures: candidate.procedures?.length ? candidate.procedures : floor.procedures,\n };\n}\n\n/**\n * Notices with a past expiresAtUtc are hidden — expiry hides the notice,\n * never critical data (SPIKE-04 F-2). Null/undefined means unexpiring.\n * This is the one sanctioned wall-clock read in emergency display;\n * readiness verdicts never consult clocks.\n */\nexport function visibleNotices(\n section: EmergencySection,\n now: number = Date.now(),\n): EmergencySection {\n if (!section.notices?.length) return section;\n return {\n ...section,\n // Null AND undefined both mean unexpiring (forward tolerance for staged\n // JSON that omits the field); only a past timestamp hides the notice.\n notices: section.notices.filter((notice) => (notice.expiresAtUtc ?? Infinity) > now),\n };\n}\n\nexport function resolveEmergency(\n dataset?: VerifiedEmergencyData,\n now: number = Date.now(),\n): EmergencyViewModel {\n const loaded = loadEmergencyFloor();\n if (!loaded.ok) throw new Error(loaded.reason);\n\n if (dataset === undefined) return demotedBaseline(\"no-dataset\", loaded.floor);\n if (!isSupportedEmergencySchema(dataset.emergencySchemaVersion)) {\n return demotedBaseline(\"unsupported-schema\", loaded.floor);\n }\n const narrowed = narrowEmergencySection(dataset.section);\n if (narrowed?.emergencySchemaVersion !== dataset.emergencySchemaVersion) {\n return demotedBaseline(\"malformed-section\", loaded.floor);\n }\n const merged = visibleNotices(mergeFloorFields(narrowed, loaded.floor), now);\n return {\n source: \"dataset\",\n provenance:\n `FESTIVAL DATA v${String(dataset.packageVersion)} · generated ${dataset.generatedAt}` +\n (typeof narrowed.updatedAt === \"string\" && narrowed.updatedAt !== dataset.generatedAt\n ? ` · updated ${narrowed.updatedAt}`\n : \"\"),\n floor: loaded.floor,\n section: merged,\n demotion: null,\n };\n}\n\n/** Shell-side support gate for the independent emergency schema sub-version. */\nexport function isSupportedEmergencySchema(version: unknown): version is number {\n return typeof version === \"number\" && SUPPORTED_EMERGENCY_SCHEMAS.includes(version as 1);\n}\n\n/** Floor viewmodel with an explicit demotion reason (loader-driven path). */\nexport function demotedBaseline(\n demotion: EmergencyDemotionReason,\n floor?: EmergencyFloor,\n): EmergencyViewModel {\n const resolved = floor ?? loadEmergencyFloorOrThrow();\n return {\n source: \"baseline\",\n provenance: `BASELINE v${resolved.floorVersion}`,\n floor: resolved,\n section: null,\n demotion,\n };\n}\n\nfunction loadEmergencyFloorOrThrow(): EmergencyFloor {\n const loaded = loadEmergencyFloor();\n if (!loaded.ok) throw new Error(loaded.reason);\n return loaded.floor;\n}\n","/**\n * Stage 10 app-layer loader — reads the active slot's emergency section for\n * Tier 2 rendering. Local IDB only, never network. Never throws: any failure\n * degrades to the floor (the view's resolveEmergency handles the rest).\n * Trace: IMPLEMENTATION-CONTRACT.md §14, SPIKE-06.\n */\nimport { openSystemDB, readSystemMeta } from \"../data/system-meta/store.js\";\nimport { openSlotDB, readSlotFile, readSlotManifest } from \"../data/slot/store.js\";\nimport { isSupportedEmergencySchema, narrowEmergencySection } from \"../domain/emergency/logic.js\";\nimport type { VerifiedEmergencyData } from \"../domain/emergency/types.js\";\n\nexport type DatasetEmergencyLoad =\n /** No active dataset — floor with no-dataset guidance. */\n | { readonly status: \"absent\" }\n /** Active dataset but its emergency section is unusable. */\n | { readonly status: \"unusable\"; readonly reason: \"unsupported-schema\" | \"malformed-section\" }\n /** Verified-shape section ready for Tier 2 resolution. */\n | { readonly status: \"ready\"; readonly data: VerifiedEmergencyData };\n\nexport async function loadDatasetEmergency(): Promise {\n try {\n const system = await openSystemDB();\n let activeSlot;\n let packageVersion: number | null;\n try {\n const meta = await readSystemMeta(system);\n activeSlot = meta.activeSlot;\n packageVersion = meta.activePackageVersion;\n } finally {\n system.close();\n }\n if (!activeSlot || packageVersion === null) return { status: \"absent\" };\n\n const slot = await openSlotDB(activeSlot);\n try {\n const json = await readSlotFile(slot, \"emergency\");\n const section = narrowEmergencySection(json);\n if (!section) return { status: \"unusable\", reason: \"malformed-section\" };\n if (!isSupportedEmergencySchema(section.emergencySchemaVersion)) {\n return { status: \"unusable\", reason: \"unsupported-schema\" };\n }\n const manifest = await readSlotManifest(slot).catch(() => undefined);\n return {\n status: \"ready\",\n data: {\n section,\n packageVersion,\n generatedAt: manifest?.generatedAt ?? \"unknown date\",\n emergencySchemaVersion: section.emergencySchemaVersion,\n },\n };\n } finally {\n slot.close();\n }\n } catch {\n // Storage errors degrade to the floor — emergency never blanks (I-1).\n return { status: \"absent\" };\n }\n}\n","/**\n * User store — favorites, prefs, diag. Never touched by dataset updates/GC (B-6).\n * Own idempotent migrations (package schema separate SPIKE-04 F-4).\n * Trace: IMPLEMENTATION-CONTRACT.md §9 B-6, SPIKE-02 X3\n */\n\nimport {\n openDB,\n withTx,\n idbGet,\n idbGetAll,\n idbPut,\n idbDelete,\n idbCount,\n} from \"../../platform/idb/wrapper.js\";\nimport { DB, USER_FAVS, USER_PREFS, USER_DIAG } from \"../../platform/idb/names.js\";\nimport type { FavoriteEntry, UserPrefs, DiagEntry } from \"./types.js\";\n\nconst USER_VERSION = 1;\n\nfunction upgradeUser(db: IDBDatabase): void {\n if (!db.objectStoreNames.contains(USER_FAVS)) {\n db.createObjectStore(USER_FAVS);\n }\n if (!db.objectStoreNames.contains(USER_PREFS)) {\n db.createObjectStore(USER_PREFS);\n }\n if (!db.objectStoreNames.contains(USER_DIAG)) {\n db.createObjectStore(USER_DIAG);\n }\n}\n\nexport function openUserDB(): Promise {\n return openDB(DB.USER, USER_VERSION, (db) => {\n upgradeUser(db);\n });\n}\n\n// ——— Favorites — keyed by stable eventId (contractual SPIKE-04:154) ———\n\nexport async function addFavorite(db: IDBDatabase, entry: FavoriteEntry): Promise {\n await idbPut(db, USER_FAVS, entry, entry.eventId);\n}\n\nexport async function removeFavorite(db: IDBDatabase, eventId: string): Promise {\n await idbDelete(db, USER_FAVS, eventId);\n}\n\nexport async function hasFavorite(db: IDBDatabase, eventId: string): Promise {\n const v = await idbGet(db, USER_FAVS, eventId);\n return v !== undefined;\n}\n\nexport async function listFavorites(db: IDBDatabase): Promise {\n return idbGetAll(db, USER_FAVS);\n}\n\nexport async function countFavorites(db: IDBDatabase): Promise {\n return idbCount(db, USER_FAVS);\n}\n\nexport async function clearFavorites(db: IDBDatabase): Promise {\n const all = await listFavorites(db);\n await withTx(db, USER_FAVS, \"readwrite\", async (tx) => {\n const os = tx.objectStore(USER_FAVS);\n for (const f of all) {\n const req = os.delete(f.eventId);\n await new Promise((resolve, reject) => {\n req.onsuccess = () => {\n resolve();\n };\n req.onerror = () => {\n reject(req.error ?? new Error(\"IDB error\"));\n };\n });\n }\n });\n}\n\n// ——— Prefs — singleton key \"prefs\" ———\n\nconst PREFS_KEY = \"prefs\";\n\nexport async function getPrefs(db: IDBDatabase): Promise {\n return idbGet(db, USER_PREFS, PREFS_KEY);\n}\n\nexport async function putPrefs(db: IDBDatabase, prefs: UserPrefs): Promise {\n await idbPut(db, USER_PREFS, prefs, PREFS_KEY);\n}\n\n// ——— Diag — ring buffer, scrubbed, manual share only (§29) ———\n\nconst DIAG_MAX = 100;\n\nexport async function pushDiag(db: IDBDatabase, entry: DiagEntry): Promise {\n await withTx(db, USER_DIAG, \"readwrite\", async (tx) => {\n const os = tx.objectStore(USER_DIAG);\n // Use at + random to avoid collisions\n const key = `${String(entry.at)}-${Math.random().toString(36).slice(2, 6)}`;\n const req = os.put(entry, key);\n await new Promise((resolve, reject) => {\n req.onsuccess = () => {\n resolve();\n };\n req.onerror = () => {\n reject(req.error ?? new Error(\"IDB error\"));\n };\n });\n // Trim oldest if over max — count then delete oldest keys\n const countReq = os.count();\n const count: number = await new Promise((resolve, reject) => {\n countReq.onsuccess = () => {\n resolve(countReq.result);\n };\n countReq.onerror = () => {\n reject(countReq.error ?? new Error(\"IDB error\"));\n };\n });\n if (count > DIAG_MAX) {\n const toDelete = count - DIAG_MAX;\n const cursorReq = os.openCursor();\n let deleted = 0;\n await new Promise((resolve, reject) => {\n cursorReq.onsuccess = () => {\n const cursor = cursorReq.result;\n if (!cursor || deleted >= toDelete) {\n resolve();\n return;\n }\n const del = cursor.delete();\n del.onsuccess = () => {\n deleted++;\n cursor.continue();\n };\n del.onerror = () => {\n reject(del.error ?? new Error(\"IDB error\"));\n };\n };\n cursorReq.onerror = () => {\n reject(cursorReq.error ?? new Error(\"IDB error\"));\n };\n });\n }\n });\n}\n\nexport async function listDiag(db: IDBDatabase): Promise {\n return idbGetAll(db, USER_DIAG);\n}\n\nexport async function clearDiag(db: IDBDatabase): Promise {\n const { idbClear } = await import(\"../../platform/idb/wrapper.js\");\n await idbClear(db, USER_DIAG);\n}\n","/**\n * Stage 11 app-layer loader — schedule section, manifest window, favorites,\n * prefs, and persisted clock skew for the schedule view. Local reads only,\n * never network. Never throws: null means \"no schedule on this device\".\n * Trace: IMPLEMENTATION-CONTRACT.md §21, §24.\n */\nimport { openSystemDB, readSystemMeta } from \"../data/system-meta/store.js\";\nimport { openSlotDB, readSlotFile, readSlotManifest } from \"../data/slot/store.js\";\nimport { openUserDB, listFavorites, getPrefs } from \"../data/user/store.js\";\nimport type { FavoriteEntry, UserPrefs } from \"../data/user/types.js\";\nimport type { ScheduleSection } from \"../data/festival-package/types.js\";\nimport type { ClockSkew } from \"../domain/clock/types.js\";\n\nexport interface ScheduleWindow {\n readonly timezone: string;\n readonly startUtc: number;\n readonly endUtc: number;\n}\n\nexport interface ScheduleData {\n readonly section: ScheduleSection;\n readonly window: ScheduleWindow;\n readonly favorites: readonly FavoriteEntry[];\n readonly prefs: UserPrefs | undefined;\n readonly skew: ClockSkew | null;\n}\n\nfunction record(value: unknown): Record | null {\n return typeof value === \"object\" && value !== null ? (value as Record) : null;\n}\n\n/** Structural schedule check — per-event tolerance lives in query helpers. */\nexport function narrowScheduleSection(value: unknown): ScheduleSection | null {\n const section = record(value);\n if (section?.section !== \"schedule\") return null;\n if (!Array.isArray(section.stages) || !Array.isArray(section.artists)) return null;\n if (!Array.isArray(section.events)) return null;\n return value as ScheduleSection;\n}\n\nexport async function loadScheduleData(): Promise {\n try {\n const system = await openSystemDB();\n let activeSlot;\n let skew: ClockSkew | null;\n try {\n const meta = await readSystemMeta(system);\n activeSlot = meta.activeSlot;\n skew = meta.clockSkew;\n } finally {\n system.close();\n }\n if (!activeSlot) return null;\n\n const slot = await openSlotDB(activeSlot);\n let section: ScheduleSection | null = null;\n let window: ScheduleWindow | null = null;\n try {\n section = narrowScheduleSection(await readSlotFile(slot, \"schedule\"));\n const manifest = await readSlotManifest(slot).catch(() => undefined);\n if (manifest && typeof manifest.festival?.timezone === \"string\") {\n window = {\n timezone: manifest.festival.timezone,\n startUtc: manifest.festival.startUtc,\n endUtc: manifest.festival.endUtc,\n };\n }\n } finally {\n slot.close();\n }\n if (!section || !window) return null;\n\n const user = await openUserDB();\n try {\n return {\n section,\n window,\n favorites: await listFavorites(user),\n prefs: await getPrefs(user),\n skew,\n };\n } finally {\n user.close();\n }\n } catch {\n // Storage errors degrade to \"no schedule\" — never blank, never throw (I-8).\n return null;\n }\n}\n","/**\n * Clock logic — pure, no browser globals except where injected.\n * EXP-1 authoritative behavior is reproduced here.\n * Trace: SPIKE-08 §3, experiments/exp1-time-model.mjs\n */\n\nimport type { ClockOptions, ClockSkew, FestivalWindow } from \"./types.js\";\n\nexport const DEFAULT_WINDOW_DAYS = 45;\nconst MS_PER_DAY = 24 * 3600 * 1000;\n\n/** Render epoch ms in festival IANA zone via Intl (offline, ECMA-402). */\nexport function renderInZone(epochMs: number, timeZone: string): string {\n return new Intl.DateTimeFormat(\"en-US\", {\n timeZone,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n second: \"2-digit\",\n hour12: false,\n }).format(new Date(epochMs));\n}\n\n/**\n * F-4 display formats — Intl.DateTimeFormat with an explicit timeZone is the\n * ONLY time-rendering path. No Date.toLocaleString() without options, no\n * manual offsets, no wall-clock strings.\n */\nexport function formatEventTime(epochMs: number, timeZone: string): string {\n return new Intl.DateTimeFormat(\"en-US\", {\n timeZone,\n weekday: \"short\",\n hour: \"numeric\",\n minute: \"2-digit\",\n }).format(new Date(epochMs));\n}\n\nexport function formatDayLabel(epochMs: number, timeZone: string): string {\n return new Intl.DateTimeFormat(\"en-US\", {\n timeZone,\n weekday: \"long\",\n month: \"long\",\n day: \"numeric\",\n }).format(new Date(epochMs));\n}\n\n/** Time-only end of a range (\"2:30 PM\") — same explicit-zone rule. */\nexport function formatClockTime(epochMs: number, timeZone: string): string {\n return new Intl.DateTimeFormat(\"en-US\", {\n timeZone,\n hour: \"numeric\",\n minute: \"2-digit\",\n }).format(new Date(epochMs));\n}\n\n/** Device IANA zone for the display toggle — UTC fallback when unresolvable. */\nexport function deviceTimeZone(): string {\n try {\n return new Intl.DateTimeFormat().resolvedOptions().timeZone ?? \"UTC\";\n } catch {\n return \"UTC\";\n }\n}\n\n/** Precomputed dayKey at publish time: calendar date in festival zone. */\nexport function dayKeyFor(epochMs: number, timeZone: string): string {\n const parts = new Intl.DateTimeFormat(\"en-US\", {\n timeZone,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n }).formatToParts(new Date(epochMs));\n const get = (type: Intl.DateTimeFormatPartTypes): string =>\n parts.find((p) => p.type === type)?.value ?? \"\";\n return `${get(\"year\")}-${get(\"month\")}-${get(\"day\")}`;\n}\n\n/** Corrected now: device + skew if present. */\nexport function correctedNow(deviceNow: number, skew: ClockSkew | null): number {\n return skew ? deviceNow + skew.skewMs : deviceNow;\n}\n\n/** Monotonic drift detection: |observed - (base + monoElapsed)| > threshold */\nexport function hasDrift(\n baseDevice: number,\n monoElapsed: number,\n observedDevice: number,\n thresholdMs = 120_000,\n): boolean {\n const expected = baseDevice + monoElapsed;\n return Math.abs(observedDevice - expected) > thresholdMs;\n}\n\n/** Sanity window check with F-3 suppression (SPIKE-08 F-3). */\nexport function shouldWarnOutsideWindow(\n nowCorrected: number,\n win: FestivalWindow,\n skew: ClockSkew | null,\n opts: ClockOptions = { windowDays: DEFAULT_WINDOW_DAYS },\n): boolean {\n const windowMs = opts.windowDays * MS_PER_DAY;\n const outside = nowCorrected < win.startUtc - windowMs || nowCorrected > win.endUtc + windowMs;\n if (!outside) return false;\n // F-3: suppress until near festival or evidence of skew\n if (nowCorrected >= win.startUtc - windowMs) return true;\n if (skew !== null) return true;\n return false;\n}\n\n/** Now/Next classification — epoch-ms only, no Intl. */\nexport interface SchedEventRef {\n readonly id: string;\n readonly startUtc: number;\n readonly endUtc: number;\n}\n\nexport function classifyNowNext(\n events: readonly T[],\n now: number,\n): { readonly now: readonly T[]; readonly next: readonly T[] } {\n const nowEvents = events.filter((e) => e.startUtc <= now && now < e.endUtc);\n const next = events\n .filter((e) => e.startUtc > now)\n .slice()\n .sort((a, b) => a.startUtc - b.startUtc)\n .slice(0, 2);\n return { now: nowEvents, next };\n}\n","/**\n * ClockService session state — pure transitions over injected readings.\n * Persisted skew lives in lumen-system (app layer); the monotonic session\n * base lives in memory only (performance.now resets every page load).\n * No timers, no background work (C-19); everything computes on demand.\n * Trace: ADR-009, SPIKE-08 §3.5/§3.8, IMPLEMENTATION-CONTRACT.md §21.\n */\nimport { correctedNow, hasDrift } from \"./logic.js\";\nimport type { ClockSkew } from \"./types.js\";\n\nexport interface ClockReading {\n /** Wall clock as the device reports it. */\n readonly deviceNow: number;\n /** Session monotonic clock (performance.now equivalent). */\n readonly monoNow: number;\n}\n\nexport interface ClockSession {\n /** Device reading at session start (or last re-derive). */\n readonly baseDevice: number;\n /** Monotonic reading at session start (or last re-derive). */\n readonly baseMono: number;\n}\n\nexport interface ResolvedClock {\n /** Corrected now: device + persisted skew when present. */\n readonly now: number;\n /** Mid-session device-clock jump detected; base re-derived, skew kept. */\n readonly driftWarn: boolean;\n readonly session: ClockSession;\n}\n\n/**\n * Capture a skew sample from a sync HTTP `Date` header:\n * skew = serverNow − deviceNow. Pure; persistence is the app layer's job.\n */\nexport function captureSkew(serverNowMs: number, reading: ClockReading, source: string): ClockSkew {\n return {\n skewMs: serverNowMs - reading.deviceNow,\n capturedAtDevice: reading.deviceNow,\n capturedAtMono: reading.monoNow,\n source,\n };\n}\n\n/**\n * Resolve \"now\" for one render: apply persisted skew, then guard against\n * mid-session device-clock jumps with the monotonic anchor. On drift the\n * base re-derives (warn once); the server skew is kept when present.\n */\nexport function resolveClock(\n reading: ClockReading,\n skew: ClockSkew | null,\n session: ClockSession | null,\n): ResolvedClock {\n const base: ClockSession = session ?? {\n baseDevice: reading.deviceNow,\n baseMono: reading.monoNow,\n };\n const drifted = hasDrift(base.baseDevice, reading.monoNow - base.baseMono, reading.deviceNow);\n const settled: ClockSession = drifted\n ? { baseDevice: reading.deviceNow, baseMono: reading.monoNow }\n : base;\n return { now: correctedNow(reading.deviceNow, skew), driftWarn: drifted, session: settled };\n}\n","/**\n * App clock — session monotonic base plus persisted-skew access.\n * The base lives in module memory (performance.now resets per page load);\n * skew persists in lumen-system across boots. No timers (C-19).\n * Trace: IMPLEMENTATION-CONTRACT.md §21, SPIKE-08 §3.5.\n */\nimport { openSystemDB, readSystemMeta, writeSystemMeta } from \"../data/system-meta/store.js\";\nimport { resolveClock } from \"../domain/clock/service.js\";\nimport type { ClockReading, ClockSession } from \"../domain/clock/service.js\";\nimport type { ClockSkew } from \"../domain/clock/types.js\";\n\nlet session: ClockSession | null = null;\n\n/** Test seam — drop the in-memory monotonic anchor. */\nexport function resetClockSession(): void {\n session = null;\n}\n\nexport function currentClock(\n reading: ClockReading,\n skew: ClockSkew | null,\n): { readonly now: number; readonly driftWarn: boolean } {\n const resolved = resolveClock(reading, skew, session);\n session = resolved.session;\n return resolved;\n}\n\n/** Device wall clock + monotonic reading for the current render. */\nexport function deviceReading(): ClockReading {\n return { deviceNow: Date.now(), monoNow: performance.now() };\n}\n\nexport async function loadPersistedSkew(): Promise {\n try {\n const db = await openSystemDB();\n try {\n return (await readSystemMeta(db)).clockSkew;\n } finally {\n db.close();\n }\n } catch {\n return null;\n }\n}\n\n/** Persist a captured skew sample (Stage 15 sync calls this per response). */\nexport async function persistSkew(skew: ClockSkew): Promise {\n const db = await openSystemDB();\n try {\n const meta = await readSystemMeta(db);\n await writeSystemMeta(db, { ...meta, clockSkew: skew });\n } finally {\n db.close();\n }\n}\n","/**\n * Schedule query model — pure views over a ScheduleSection. All predicates\n * run on UTC epoch ms; grouping uses the publish-time dayKey (never client\n * day math); rendering uses explicit-zone Intl (F-4).\n * Trace: IMPLEMENTATION-CONTRACT.md §21, ARCHITECTURE-DESIGN.md:508.\n */\nimport type { ScheduleEvent, ScheduleSection } from \"../../data/festival-package/types.js\";\nimport type { DayGroup, ScheduleFilter } from \"./types.js\";\n\nexport function sortByStart(events: readonly ScheduleEvent[]): ScheduleEvent[] {\n return [...events].sort((a, b) => a.startUtc - b.startUtc || a.title.localeCompare(b.title));\n}\n\n/** Group by opaque publish-time dayKey — no client day math, DST-safe. */\nexport function groupByDay(section: ScheduleSection): DayGroup[] {\n const groups = new Map();\n for (const event of section.events) {\n const list = groups.get(event.dayKey) ?? [];\n list.push(event);\n groups.set(event.dayKey, list);\n }\n return [...groups.entries()]\n .map(([dayKey, events]) => {\n const sorted = sortByStart(events);\n return { dayKey, anchorUtc: sorted[0]?.startUtc ?? 0, events: sorted };\n })\n .sort((a, b) => (a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0));\n}\n\n/** Running now: startUtc ≤ now < endUtc. Overlaps all show (SPIKE-08 T7a). */\nexport function nowEvents(events: readonly ScheduleEvent[], now: number): ScheduleEvent[] {\n return sortByStart(events.filter((e) => e.startUtc <= now && now < e.endUtc));\n}\n\n/** Up next: startUtc > now, start-time order, first N (SPIKE-08 T7b–d). */\nexport function upNext(events: readonly ScheduleEvent[], now: number, count = 3): ScheduleEvent[] {\n return sortByStart(events.filter((e) => e.startUtc > now)).slice(0, Math.max(0, count));\n}\n\nexport function upNextByStage(\n section: ScheduleSection,\n now: number,\n count = 1,\n): ReadonlyMap {\n const byStage = new Map();\n for (const event of section.events) {\n const list = byStage.get(event.stageId) ?? [];\n list.push(event);\n byStage.set(event.stageId, list);\n }\n const out = new Map();\n for (const [stageId, events] of byStage) out.set(stageId, upNext(events, now, count));\n return out;\n}\n\nfunction matchesQuery(event: ScheduleEvent, section: ScheduleSection, query: string): boolean {\n const needle = query.trim().toLowerCase();\n if (!needle) return true;\n const stage = section.stages.find((s) => s.id === event.stageId)?.name ?? \"\";\n const artists = (event.artistIds ?? [])\n .map((id) => section.artists.find((a) => a.id === id)?.name ?? \"\")\n .join(\" \");\n const haystack = `${event.title} ${artists} ${event.description ?? \"\"} ${stage}`.toLowerCase();\n return haystack.includes(needle);\n}\n\nexport function filterEvents(section: ScheduleSection, filter: ScheduleFilter): ScheduleEvent[] {\n const query = filter.query ?? \"\";\n return sortByStart(\n section.events.filter((event) => {\n if (filter.dayKey !== undefined && event.dayKey !== filter.dayKey) return false;\n if (filter.stageId !== undefined && event.stageId !== filter.stageId) return false;\n if (filter.tag !== undefined && !(event.tags ?? []).includes(filter.tag)) return false;\n if (filter.statuses !== undefined && !filter.statuses.includes(event.status)) return false;\n if (filter.favoritesOnly && !(filter.favoriteIds?.has(event.id) ?? false)) return false;\n if (!matchesQuery(event, section, query)) return false;\n return true;\n }),\n );\n}\n\n/** Overlapping attendable pairs (cancelled excluded) for conflict surfacing. */\nexport function detectOverlaps(events: readonly ScheduleEvent[]): [ScheduleEvent, ScheduleEvent][] {\n const live = sortByStart(events.filter((e) => e.status !== \"cancelled\"));\n const pairs: [ScheduleEvent, ScheduleEvent][] = [];\n for (let i = 0; i < live.length; i++) {\n const a = live[i];\n if (!a) continue;\n for (let j = i + 1; j < live.length; j++) {\n const b = live[j];\n if (!b || b.startUtc >= a.endUtc) break;\n pairs.push([a, b]);\n }\n }\n return pairs;\n}\n\nexport function stageName(section: ScheduleSection, stageId: string): string {\n return section.stages.find((s) => s.id === stageId)?.name ?? stageId;\n}\n\nexport function artistNames(section: ScheduleSection, artistIds: readonly string[]): string {\n return artistIds.map((id) => section.artists.find((a) => a.id === id)?.name ?? id).join(\", \");\n}\n\n/** Distinct tags across the section, sorted — for filter chips. */\nexport function allTags(section: ScheduleSection): string[] {\n const tags = new Set();\n for (const event of section.events) for (const tag of event.tags ?? []) tags.add(tag);\n return [...tags].sort((a, b) => a.localeCompare(b));\n}\n","/**\n * Favorites domain — My Schedule assembly over device-local favorites.\n * Favorites are keyed by stable event id (contractual across dataset\n * versions); updates/rollback/GC never touch them (B-6, SPIKE-02 X3).\n * Trace: IMPLEMENTATION-CONTRACT.md §24, DISCOVERY.md:545 §15.8.\n */\nimport type { ScheduleEvent, ScheduleSection } from \"../../data/festival-package/types.js\";\nimport type { FavoriteEntry } from \"../../data/user/types.js\";\nimport type { FavoriteStatus, MyScheduleItem } from \"./types.js\";\nimport { detectOverlaps } from \"../schedule/logic.js\";\n\nexport function favoriteStatus(event: ScheduleEvent | undefined): FavoriteStatus {\n if (!event) return \"orphan\";\n return event.status;\n}\n\n/**\n * Join favorites against the active section, scheduled-first by startUtc,\n * orphans last (kept, never dropped, never crash).\n */\nexport function buildMySchedule(\n section: ScheduleSection,\n favorites: readonly FavoriteEntry[],\n): MyScheduleItem[] {\n const byId = new Map(section.events.map((e) => [e.id, e]));\n return [...favorites]\n .map((fav) => {\n const event = byId.get(fav.eventId);\n return { fav, event, status: favoriteStatus(event) };\n })\n .sort(\n (a, b) =>\n (a.event?.startUtc ?? Number.MAX_SAFE_INTEGER) -\n (b.event?.startUtc ?? Number.MAX_SAFE_INTEGER),\n );\n}\n\n/** Overlapping pairs within My Schedule (cancelled excluded by detectOverlaps). */\nexport function myScheduleConflicts(\n items: readonly MyScheduleItem[],\n): [ScheduleEvent, ScheduleEvent][] {\n return detectOverlaps(items.flatMap((item) => (item.event ? [item.event] : [])));\n}\n","/**\n * Schedule view — offline day browsing, Now/Next, filters, search, favorites.\n * Pure rendering over loaded section data: filter/day/search state lives in\n * the view (in-place list updates, no route churn); persistence and zone\n * preference flow out through caller callbacks (B-1 — no IDB here).\n * All text via textContent (C-22/B-7); times via explicit-zone Intl (F-4).\n * Trace: IMPLEMENTATION-CONTRACT.md §21.\n */\nimport {\n allTags,\n artistNames,\n filterEvents,\n groupByDay,\n nowEvents,\n stageName,\n upNext,\n} from \"../../../domain/schedule/logic.js\";\nimport type { ScheduleFilter } from \"../../../domain/schedule/types.js\";\nimport { buildMySchedule, myScheduleConflicts } from \"../../../domain/favorites/logic.js\";\nimport { formatClockTime, formatDayLabel, formatEventTime } from \"../../../domain/clock/logic.js\";\nimport type { ScheduleEvent, ScheduleSection } from \"../../../data/festival-package/types.js\";\nimport type { FavoriteEntry } from \"../../../data/user/types.js\";\n\nexport interface ScheduleClock {\n readonly now: number;\n /** Display zone (festival zone or device zone per toggle). */\n readonly zone: string;\n readonly festivalZone: string;\n readonly useDeviceZone: boolean;\n /** F-3 sanity warning — absolute times stay readable regardless. */\n readonly outsideWarn: boolean;\n readonly driftWarn: boolean;\n}\n\nexport interface ScheduleViewInput {\n readonly section: ScheduleSection;\n readonly favorites: ReadonlySet;\n readonly clock: ScheduleClock;\n}\n\nexport interface ScheduleViewActions {\n /**\n * Persist a favorite toggle. Resolves true when write-through succeeded;\n * the view rolls its optimistic flip back on false (no memory-only truth).\n */\n readonly onToggleFavorite: (eventId: string, favored: boolean) => boolean | Promise;\n readonly onZoneToggle: (useDeviceZone: boolean) => void;\n}\n\nfunction text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement {\n const element = document.createElement(tag);\n element.textContent = value;\n return element;\n}\n\nfunction eventTime(event: ScheduleEvent, zone: string): string {\n return `${formatEventTime(event.startUtc, zone)} – ${formatClockTime(event.endUtc, zone)}`;\n}\n\nfunction statusBadge(event: ScheduleEvent, zone: string): HTMLSpanElement | null {\n if (event.status === \"scheduled\") return null;\n const badge = document.createElement(\"span\");\n badge.className = `schedule-badge schedule-badge--${event.status}`;\n if (event.status === \"moved\" && event.originalStartUtc !== undefined) {\n badge.textContent = `Moved (was ${formatEventTime(event.originalStartUtc, zone)})`;\n } else {\n badge.textContent = event.status === \"moved\" ? \"Moved\" : \"Cancelled\";\n }\n return badge;\n}\n\nfunction eventCard(\n event: ScheduleEvent,\n section: ScheduleSection,\n zone: string,\n favored: boolean,\n onToggleFavorite: (eventId: string, favored: boolean) => void,\n): HTMLElement {\n const card = document.createElement(\"article\");\n card.className = \"schedule-item\";\n card.setAttribute(\"data-event-id\", event.id);\n\n const title = document.createElement(\"h2\");\n title.textContent = event.title;\n card.append(title);\n\n card.append(text(\"p\", eventTime(event, zone)));\n const names = artistNames(section, event.artistIds);\n card.append(\n text(\n \"p\",\n names ? `${stageName(section, event.stageId)} · ${names}` : stageName(section, event.stageId),\n ),\n );\n if (event.description) card.append(text(\"p\", event.description));\n\n const badge = statusBadge(event, zone);\n if (badge) card.append(badge);\n\n const fav = document.createElement(\"button\");\n fav.type = \"button\";\n fav.className = \"schedule-fav\";\n fav.setAttribute(\"aria-pressed\", String(favored));\n fav.setAttribute(\n \"aria-label\",\n favored ? `Remove ${event.title} from My Schedule` : `Add ${event.title} to My Schedule`,\n );\n fav.textContent = favored ? \"★\" : \"☆\";\n const paintFav = (value: boolean): void => {\n fav.setAttribute(\"aria-pressed\", String(value));\n fav.textContent = value ? \"★\" : \"☆\";\n fav.setAttribute(\n \"aria-label\",\n value ? `Remove ${event.title} from My Schedule` : `Add ${event.title} to My Schedule`,\n );\n };\n fav.addEventListener(\"click\", () => {\n const next = fav.getAttribute(\"aria-pressed\") !== \"true\";\n paintFav(next);\n onToggleFavorite(event.id, next);\n });\n card.append(fav);\n return card;\n}\n\n/**\n * Render the schedule. With null input the view shows the honest\n * not-downloaded card (PARTIAL/NOT_READY states) — never blank.\n */\nexport function createScheduleView(\n input: ScheduleViewInput | null,\n actions: ScheduleViewActions,\n): HTMLElement {\n const section = document.createElement(\"section\");\n section.className = \"schedule-view\";\n section.setAttribute(\"aria-labelledby\", \"schedule-heading\");\n\n const h1 = document.createElement(\"h1\");\n h1.id = \"schedule-heading\";\n h1.textContent = \"Schedule\";\n section.append(h1);\n\n if (!input) {\n const card = document.createElement(\"div\");\n card.className = \"view-placeholder\";\n card.append(text(\"p\", \"The schedule isn't on this device yet.\"));\n card.append(\n text(\n \"p\",\n \"Open with internet to get festival data — the emergency floor always works meanwhile.\",\n ),\n );\n section.append(card);\n return section;\n }\n\n const { section: data, clock } = input;\n const favs = new Set(input.favorites);\n const state: {\n day: string | null;\n stage: string | null;\n tag: string | null;\n query: string;\n mine: boolean;\n } = {\n day: null,\n stage: null,\n tag: null,\n query: \"\",\n mine: false,\n };\n\n if (clock.outsideWarn || clock.driftWarn) {\n const warn = text(\n \"p\",\n clock.driftWarn\n ? \"The device clock changed mid-session — times re-anchored. Event times below are absolute.\"\n : \"The device clock looks off — Now/Next may be wrong, but the absolute event times below still read fine.\",\n );\n warn.className = \"schedule-warning\";\n warn.setAttribute(\"role\", \"status\");\n section.append(warn);\n }\n\n // Now / Up Next (epoch predicates only — SPIKE-08 T7).\n const live = document.createElement(\"section\");\n live.setAttribute(\"aria-labelledby\", \"schedule-now-heading\");\n const liveHeading = document.createElement(\"h2\");\n liveHeading.id = \"schedule-now-heading\";\n liveHeading.textContent = \"Now & Next\";\n live.append(liveHeading);\n const liveList = document.createElement(\"div\");\n liveList.className = \"schedule-live\";\n live.append(liveList);\n section.append(live);\n\n // Controls: day tabs, stage select, tag chips, favorites, search.\n const controls = document.createElement(\"div\");\n controls.className = \"schedule-controls\";\n\n const days = groupByDay(data);\n state.day ??=\n days.find((d) => d.events.some((e) => e.startUtc <= clock.now && clock.now < e.endUtc))\n ?.dayKey ??\n days.find((d) => d.events.some((e) => e.startUtc > clock.now))?.dayKey ??\n days[0]?.dayKey ??\n null;\n const dayBar = document.createElement(\"div\");\n dayBar.className = \"schedule-days\";\n dayBar.setAttribute(\"role\", \"group\");\n dayBar.setAttribute(\"aria-label\", \"Festival days\");\n const dayButtons = new Map();\n for (const day of days) {\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.className = \"schedule-day\";\n button.textContent = formatDayLabel(day.anchorUtc, clock.zone);\n button.setAttribute(\"aria-pressed\", String(state.day === day.dayKey));\n button.addEventListener(\"click\", () => {\n state.day = day.dayKey;\n for (const [key, other] of dayButtons)\n other.setAttribute(\"aria-pressed\", String(key === day.dayKey));\n refresh();\n });\n dayButtons.set(day.dayKey, button);\n dayBar.append(button);\n }\n controls.append(dayBar);\n\n const stageSelect = document.createElement(\"select\");\n stageSelect.setAttribute(\"aria-label\", \"Filter by stage\");\n const allStages = document.createElement(\"option\");\n allStages.value = \"\";\n allStages.textContent = \"All stages\";\n stageSelect.append(allStages);\n for (const stage of data.stages) {\n const option = document.createElement(\"option\");\n option.value = stage.id;\n option.textContent = stage.name;\n stageSelect.append(option);\n }\n stageSelect.addEventListener(\"change\", () => {\n state.stage = stageSelect.value || null;\n refresh();\n });\n controls.append(stageSelect);\n\n const tagBar = document.createElement(\"div\");\n tagBar.className = \"schedule-tags\";\n tagBar.setAttribute(\"role\", \"group\");\n tagBar.setAttribute(\"aria-label\", \"Filter by tag\");\n const tagButtons = new Map();\n const paintTags = (): void => {\n for (const [key, other] of tagButtons)\n other.setAttribute(\"aria-pressed\", String(key === (state.tag ?? \"\")));\n };\n const allTagsButton = document.createElement(\"button\");\n allTagsButton.type = \"button\";\n allTagsButton.className = \"schedule-tag\";\n allTagsButton.textContent = \"All\";\n allTagsButton.addEventListener(\"click\", () => {\n state.tag = null;\n paintTags();\n refresh();\n });\n tagButtons.set(\"\", allTagsButton);\n tagBar.append(allTagsButton);\n for (const tag of allTags(data)) {\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.className = \"schedule-tag\";\n button.textContent = tag;\n button.addEventListener(\"click\", () => {\n state.tag = state.tag === tag ? null : tag;\n paintTags();\n refresh();\n });\n tagButtons.set(tag, button);\n tagBar.append(button);\n }\n paintTags();\n controls.append(tagBar);\n\n const search = document.createElement(\"input\");\n search.type = \"search\";\n search.setAttribute(\"aria-label\", \"Search schedule\");\n search.placeholder = \"Search artists, titles, stages…\";\n search.addEventListener(\"input\", () => {\n state.query = search.value;\n refresh();\n });\n controls.append(search);\n\n const mineToggle = document.createElement(\"button\");\n mineToggle.type = \"button\";\n mineToggle.className = \"schedule-mine\";\n const paintMine = (): void => {\n mineToggle.setAttribute(\"aria-pressed\", String(state.mine));\n mineToggle.textContent = `★ Mine (${String(favs.size)})`;\n };\n mineToggle.addEventListener(\"click\", () => {\n state.mine = !state.mine;\n paintMine();\n refresh();\n });\n paintMine();\n controls.append(mineToggle);\n\n const zoneToggle = document.createElement(\"button\");\n zoneToggle.type = \"button\";\n zoneToggle.className = \"schedule-zone\";\n zoneToggle.textContent = clock.useDeviceZone ? \"Showing device time\" : \"Showing festival time\";\n zoneToggle.addEventListener(\"click\", () => {\n actions.onZoneToggle(!clock.useDeviceZone);\n });\n controls.append(zoneToggle);\n\n section.append(controls);\n\n const list = document.createElement(\"div\");\n list.className = \"schedule-list\";\n section.append(list);\n\n const toggleFavorite = (eventId: string, favored: boolean): void => {\n if (favored) favs.add(eventId);\n else favs.delete(eventId);\n paintMine();\n // Persistence may be async — failure only affects the next render, which\n // re-reads the store (the view never owns truth).\n void Promise.resolve(actions.onToggleFavorite(eventId, favored)).catch(() => {\n // Persistence failure is silent here by design — see above.\n });\n refreshConflicts();\n };\n\n const conflictsNote = document.createElement(\"p\");\n conflictsNote.className = \"schedule-conflicts\";\n conflictsNote.setAttribute(\"role\", \"status\");\n const refreshConflicts = (): void => {\n const items = buildMySchedule(\n data,\n [...favs].map((eventId): FavoriteEntry => ({ eventId, addedAt: 0 })),\n );\n const conflicts = myScheduleConflicts(items);\n const first = conflicts[0];\n if (state.mine && first) {\n const [a, b] = first;\n conflictsNote.textContent = `Overlap in My Schedule: ${a.title} × ${b.title}${conflicts.length > 1 ? ` (+${String(conflicts.length - 1)} more)` : \"\"}`;\n } else {\n conflictsNote.textContent = \"\";\n }\n };\n section.append(conflictsNote);\n\n function currentFilter(): ScheduleFilter {\n // Conditional spreads satisfy exactOptionalPropertyTypes — absent means unfiltered.\n return {\n ...(state.day ? { dayKey: state.day } : {}),\n ...(state.stage ? { stageId: state.stage } : {}),\n ...(state.tag ? { tag: state.tag } : {}),\n ...(state.query ? { query: state.query } : {}),\n ...(state.mine ? { favoritesOnly: true as const, favoriteIds: favs } : {}),\n };\n }\n\n function refresh(): void {\n const filter = currentFilter();\n const events = filterEvents(data, filter);\n // Up Next respects the stage filter (per-stage up-next); Now stays global.\n const scoped = state.stage ? data.events.filter((e) => e.stageId === state.stage) : data.events;\n const happening = nowEvents(data.events, clock.now);\n const upcoming = upNext(scoped, clock.now, 3);\n\n liveList.replaceChildren();\n if (happening.length > 0) {\n liveList.append(text(\"h3\", \"On now\"));\n for (const event of happening) {\n liveList.append(eventCard(event, data, clock.zone, favs.has(event.id), toggleFavorite));\n }\n }\n if (upcoming.length > 0) {\n liveList.append(text(\"h3\", state.stage ? \"Next on this stage\" : \"Up next\"));\n for (const event of upcoming) {\n liveList.append(eventCard(event, data, clock.zone, favs.has(event.id), toggleFavorite));\n }\n }\n if (happening.length === 0 && upcoming.length === 0) {\n liveList.append(text(\"p\", \"Nothing scheduled around this time.\"));\n }\n\n list.replaceChildren();\n if (events.length === 0) {\n list.append(\n text(\n \"p\",\n state.mine ? \"No favorites yet — tap ☆ on any event.\" : \"No events match these filters.\",\n ),\n );\n return;\n }\n const activeDay = state.day ?? days[0]?.dayKey;\n const dayLabel = days.find((d) => d.dayKey === activeDay);\n if (dayLabel && !state.mine && !state.query) {\n list.append(text(\"h2\", formatDayLabel(dayLabel.anchorUtc, clock.zone)));\n }\n for (const event of events) {\n list.append(eventCard(event, data, clock.zone, favs.has(event.id), toggleFavorite));\n }\n refreshConflicts();\n }\n\n refresh();\n refreshConflicts();\n return section;\n}\n","/**\n * Minimal history-API router — offline-safe deep links.\n * No network, no persistence, no framework.\n * Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207\n */\n\nexport type RouteId = \"home\" | \"emergency\" | \"schedule\" | \"map\" | \"festival\" | \"not-found\";\n\nexport interface Route {\n id: RouteId;\n path: string;\n label: string;\n title: string;\n external?: boolean;\n}\n\n/** Launcher landing page — the four destination tiles. */\nexport const HOME_ROUTE: Route = { id: \"home\", path: \"/\", label: \"Home\", title: \"Home\" };\n\nexport const ROUTES: readonly Route[] = [\n { id: \"emergency\", path: \"/emergency\", label: \"Emergency\", title: \"Emergency\" },\n { id: \"schedule\", path: \"/schedule\", label: \"Schedule\", title: \"Schedule\" },\n { id: \"map\", path: \"/map\", label: \"Map\", title: \"Map\" },\n { id: \"festival\", path: \"/festival\", label: \"Information\", title: \"Information\" },\n] as const;\n\nconst PATH_TO_ID = new Map([HOME_ROUTE, ...ROUTES].map((r) => [r.path, r.id]));\n\nexport function normalizePath(path: string): string {\n // strip query/hash, ensure leading slash, drop trailing slash (except root)\n let p = path.split(\"?\")[0]?.split(\"#\")[0] ?? \"/\";\n if (!p.startsWith(\"/\")) p = \"/\" + p;\n if (p.length > 1 && p.endsWith(\"/\")) p = p.slice(0, -1);\n // root is the home launcher\n return p;\n}\n\nexport function routeForPath(path: string): RouteId {\n const normalized = normalizePath(path);\n return PATH_TO_ID.get(normalized) ?? \"not-found\";\n}\n\nexport interface NavigateOptions {\n replace?: boolean;\n}\n\nexport class Router {\n private listeners = new Set<(id: RouteId, path: string) => void>();\n private currentPath: string;\n\n constructor(initialPath?: string) {\n this.currentPath = normalizePath(\n initialPath ?? (typeof location !== \"undefined\" ? location.pathname : \"/emergency\"),\n );\n }\n\n getPath(): string {\n return this.currentPath;\n }\n\n getRouteId(): RouteId {\n return routeForPath(this.currentPath);\n }\n\n navigate(path: string, opts: NavigateOptions = {}): void {\n const next = normalizePath(path);\n if (next === this.currentPath) return;\n this.currentPath = next;\n if (typeof history !== \"undefined\") {\n if (opts.replace) history.replaceState(null, \"\", next);\n else history.pushState(null, \"\", next);\n }\n this.emit();\n }\n\n listen(handler: (id: RouteId, path: string) => void): () => void {\n this.listeners.add(handler);\n return () => this.listeners.delete(handler);\n }\n\n attach(): () => void {\n const onPop = () => {\n this.currentPath = normalizePath(\n typeof location !== \"undefined\" ? location.pathname : \"/emergency\",\n );\n this.emit();\n };\n if (typeof window !== \"undefined\") {\n window.addEventListener(\"popstate\", onPop);\n }\n return () => {\n if (typeof window !== \"undefined\") window.removeEventListener(\"popstate\", onPop);\n };\n }\n\n private emit(): void {\n const id = routeForPath(this.currentPath);\n for (const l of this.listeners) l(id, this.currentPath);\n }\n}\n","/**\n * Shared app layout — phone-first shell.\n * Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207/783\n */\nimport { HOME_ROUTE, ROUTES, type RouteId } from \"../ui/router/router.js\";\n\nexport function createLayout(): {\n root: HTMLDivElement;\n main: HTMLElement;\n nav: HTMLElement;\n brand: HTMLAnchorElement;\n statusChip: HTMLDivElement;\n skipLink: HTMLAnchorElement;\n} {\n const root = document.createElement(\"div\");\n root.id = \"lumen-shell\";\n\n const skipLink = document.createElement(\"a\");\n skipLink.href = \"#main-content\";\n skipLink.className = \"skip-link\";\n skipLink.textContent = \"Skip to content\";\n root.append(skipLink);\n\n const header = document.createElement(\"header\");\n header.className = \"app-header\";\n header.setAttribute(\"role\", \"banner\");\n\n // Brand doubles as the home/back button — visible on every destination page.\n const brand = document.createElement(\"a\");\n brand.href = HOME_ROUTE.path;\n brand.className = \"app-header__brand\";\n brand.setAttribute(\"aria-label\", \"Lumen — home\");\n brand.setAttribute(\"data-route\", HOME_ROUTE.path);\n brand.textContent = \"LUMEN\";\n header.append(brand);\n\n const statusChip = document.createElement(\"div\");\n statusChip.className = \"app-header__status\";\n statusChip.setAttribute(\"role\", \"status\");\n statusChip.setAttribute(\"aria-live\", \"polite\");\n statusChip.setAttribute(\"aria-label\", \"Offline status\");\n statusChip.textContent = \"—\";\n header.append(statusChip);\n\n root.append(header);\n\n const main = document.createElement(\"main\");\n main.id = \"main-content\";\n main.className = \"app-main\";\n main.setAttribute(\"tabindex\", \"-1\");\n const pageIcon = document.createElement(\"img\");\n pageIcon.src = \"/sol_lunar_icon.svg?v=4\";\n pageIcon.alt = \"\";\n pageIcon.className = \"app-main__icon\";\n main.prepend(pageIcon);\n root.append(main);\n\n // Primary navigation: Home launcher + four destinations. Fixed 240px\n // sidebar on desktop, bottom bar on small screens (see styles.css).\n // Emergency stays visually dominant via the --emergency modifier.\n const nav = document.createElement(\"nav\");\n nav.className = \"side-nav\";\n nav.setAttribute(\"aria-label\", \"Primary navigation\");\n nav.setAttribute(\"role\", \"navigation\");\n\n for (const r of [HOME_ROUTE, ...ROUTES]) {\n const a = document.createElement(\"a\");\n a.href = r.path;\n a.setAttribute(\"data-route\", r.path);\n a.className =\n r.id === \"emergency\" ? \"side-nav__link side-nav__link--emergency\" : \"side-nav__link\";\n a.setAttribute(\"aria-label\", r.label);\n a.textContent = r.label;\n nav.append(a);\n }\n\n root.append(nav);\n\n return { root, main, nav, brand, statusChip, skipLink };\n}\n\nexport function setActiveNav(nav: HTMLElement, activeId: RouteId): void {\n for (const a of nav.querySelectorAll(\"a[data-route]\")) {\n const route = a.getAttribute(\"data-route\");\n const isActive =\n (route === \"/\" && activeId === \"home\") ||\n (route === \"/emergency\" && activeId === \"emergency\") ||\n (route === \"/schedule\" && activeId === \"schedule\") ||\n (route === \"/map\" && activeId === \"map\") ||\n (route === \"/festival\" && activeId === \"festival\");\n if (isActive) a.setAttribute(\"aria-current\", \"page\");\n else a.removeAttribute(\"aria-current\");\n }\n}\n","export const summit = {\n name: \"Solarpunk Summit: The Love Dimension\",\n dates: \"October 8-12, 2026\",\n venue: \"Son's Blue River Camp\",\n location: \"2769 Sherrill Rd, Kingsbury, TX 78638\",\n mission:\n \"A five-day gathering exploring regenerative culture, technology, consciousness, and community.\",\n themes: [\n [\"Air\", \"Communication, personal development, authentic relating, and networking.\"],\n [\n \"Earth\",\n \"Regenerative culture, permaculture, sustainable practices, and alternative governance.\",\n ],\n [\"Fire\", \"Innovation, entrepreneurship, and technology.\"],\n [\"Water\", \"Consciousness, spirituality, and integration.\"],\n ],\n passes: [\n \"Full Access: $280 listed sale price\",\n \"Steward Pass: $868+\",\n \"Team Pass: $283 per person for groups of 3+\",\n \"Single Day: $118 | Kids: $23 | Teen: $38\",\n \"Volunteer: $174 | Parking: $59\",\n ],\n guidance: [\n \"All ages are welcome; guests under 18 need a parent or legal guardian.\",\n \"Pack reusable containers and pack out all waste under the Leave No Trace policy.\",\n \"Non-service animals are not permitted at the venue.\",\n \"No alcohol is sold at the summit.\",\n \"Drones are prohibited without written permission.\",\n ],\n source: \"https://solarpunksummit.com/\",\n} as const;\n\nfunction appendText(parent: HTMLElement, tag: keyof HTMLElementTagNameMap, value: string): void {\n const element = document.createElement(tag);\n element.textContent = value;\n parent.append(element);\n}\n\nexport function createSummitCard(): HTMLElement {\n const card = document.createElement(\"article\");\n card.className = \"summit-card\";\n appendText(card, \"p\", summit.dates + \" | \" + summit.location);\n appendText(card, \"p\", summit.mission);\n const link = document.createElement(\"a\");\n link.href = summit.source;\n link.target = \"_blank\";\n link.rel = \"noreferrer\";\n link.textContent = \"Source: solarpunksummit.com\";\n card.append(link);\n return card;\n}\n\nexport function appendListSection(\n parent: HTMLElement,\n title: string,\n items: readonly string[],\n): void {\n appendText(parent, \"h2\", title);\n const list = document.createElement(\"ul\");\n for (const item of items) appendText(list, \"li\", item);\n parent.append(list);\n}\n","import { ROUTES } from \"../../router/router.js\";\nimport { summit } from \"../../../content/summit.js\";\n\n/**\n * Home — the launcher landing page.\n * A 2×2 grid of the four primary destinations; tapping one opens that\n * destination's content. The header brand / Home nav act as the back button.\n */\nexport function createHomeView(): HTMLElement {\n const section = document.createElement(\"section\");\n section.className = \"home-view\";\n section.setAttribute(\"aria-labelledby\", \"home-heading\");\n\n const h1 = document.createElement(\"h1\");\n h1.id = \"home-heading\";\n h1.textContent = \"Lumen\";\n section.append(h1);\n\n const sub = document.createElement(\"p\");\n sub.className = \"home-view__subtitle\";\n sub.textContent = `${summit.dates} | ${summit.venue}, ${summit.location.split(\", \").slice(1).join(\", \")}`;\n section.append(sub);\n\n const grid = document.createElement(\"div\");\n grid.className = \"home-grid\";\n\n for (const r of ROUTES) {\n const a = document.createElement(\"a\");\n a.className = `home-tile${r.id === \"emergency\" ? \" home-tile--emergency\" : \"\"}`;\n a.href = r.path;\n if (r.external) {\n a.rel = \"noopener noreferrer\";\n }\n a.setAttribute(\"data-route\", r.path);\n a.setAttribute(\"aria-label\", r.label);\n a.textContent = r.label;\n grid.append(a);\n }\n\n section.append(grid);\n return section;\n}\n","/**\n * Status screen — readiness detail for the header chip (§20.5).\n * Pure rendering over a BootReadinessReport: per-section checklist, dataset\n * version, storage usage, and state-appropriate actions. No persistence, no\n * fetch, no clocks for verdicts — actions are caller-provided callbacks.\n * All text via textContent (C-22/B-7).\n * Trace: IMPLEMENTATION-CONTRACT.md §20.5, SPIKE-05 §3.\n */\nimport type { BootReadinessReport, SectionStatus } from \"../../../domain/readiness/types.js\";\n\nexport interface StatusActions {\n /** Re-run the readiness evaluation (\"Check my data\" / \"Retry\"). */\n readonly onCheckData: () => void;\n /** Preparation guidance (\"Get festival data\" / \"Continue setup\" / \"Restore\"). */\n readonly onGetData: () => void;\n /** User-initiated restore of the retained slot; null when unavailable. */\n readonly onRestore: (() => void) | null;\n readonly onClose: () => void;\n}\n\nfunction text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement {\n const element = document.createElement(tag);\n element.textContent = value;\n return element;\n}\n\nfunction actionButton(label: string, handler: () => void, primary = false): HTMLButtonElement {\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.className = primary ? \"status-action status-action--primary\" : \"status-action\";\n button.textContent = label;\n button.addEventListener(\"click\", handler);\n return button;\n}\n\nfunction formatBytes(bytes: number | undefined): string {\n if (bytes === undefined) return \"unknown\";\n if (bytes < 1024) return `${String(bytes)} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction sectionRow(section: SectionStatus): HTMLLIElement {\n const item = document.createElement(\"li\");\n item.className = \"status-check\";\n const mark = document.createElement(\"span\");\n mark.className = \"status-check__mark\";\n mark.setAttribute(\"aria-hidden\", \"true\");\n const ok = section.present && section.parseable;\n mark.textContent = ok ? \"✓\" : \"✗\";\n item.classList.toggle(\"status-check--ok\", ok);\n item.classList.toggle(\"status-check--missing\", !ok);\n const label = document.createElement(\"span\");\n const req = section.required ? \"required\" : \"optional\";\n label.textContent = `${section.id} — ${ok ? \"present\" : section.present ? \"unreadable\" : \"missing\"} (${req})`;\n item.append(mark, label);\n return item;\n}\n\n/**\n * Render the status detail for a readiness report. The caller owns\n * dialog/overlay lifecycle; this returns fresh content on every call so the\n * caller can re-render after re-evaluation.\n */\nexport function createStatusView(report: BootReadinessReport, actions: StatusActions): HTMLElement {\n const section = document.createElement(\"section\");\n section.className = \"status-view\";\n section.setAttribute(\"aria-labelledby\", \"status-heading\");\n\n const h1 = document.createElement(\"h1\");\n h1.id = \"status-heading\";\n h1.textContent = \"Offline status\";\n section.append(h1);\n\n const stateLine = text(\"p\", `State: ${report.state}`);\n stateLine.className = `status-state status-state--${report.state.toLowerCase().replace(\"_\", \"-\")}`;\n section.append(stateLine);\n\n if (report.reason) section.append(text(\"p\", `Reason: ${report.reason}`));\n if (report.failedOp) section.append(text(\"p\", `Failed operation: ${report.failedOp}`));\n if (report.missing.length > 0) {\n section.append(text(\"p\", `Missing: ${report.missing.join(\", \")}`));\n }\n section.append(text(\"p\", `Preparation level: ${report.level}`));\n\n if (report.sections.length > 0) {\n section.append(text(\"h2\", \"Sections\"));\n const list = document.createElement(\"ul\");\n list.className = \"status-checklist\";\n for (const item of report.sections) list.append(sectionRow(item));\n section.append(list);\n }\n\n const dataset = text(\n \"p\",\n report.packageVersion === null\n ? \"Dataset: none\"\n : `Dataset: ${report.edition ?? \"unknown edition\"} v${String(report.packageVersion)} · generated ${report.generatedAt ?? \"unknown\"}`,\n );\n dataset.className = \"status-dataset\";\n section.append(dataset);\n\n if (report.fetchedAt !== null) {\n section.append(\n text(\"p\", `Verified on this device: ${new Date(report.fetchedAt).toISOString()}`),\n );\n }\n section.append(\n text(\"p\", `Assets: ${String(report.assets.present)}/${String(report.assets.total)} present`),\n );\n if (report.floorVersion) {\n section.append(text(\"p\", `Emergency floor: v${report.floorVersion} (always on this device)`));\n }\n const usage = report.storage;\n const storageText =\n usage === null || (usage.usage === undefined && usage.quota === undefined)\n ? \"Storage: unavailable in this browser\"\n : `Storage: ${formatBytes(usage.usage)} used of ${formatBytes(usage.quota)}${report.shellCache ? ` · shell ${report.shellCache}` : \"\"}`;\n section.append(text(\"p\", storageText));\n\n const buttons = document.createElement(\"div\");\n buttons.className = \"status-actions\";\n\n switch (report.state) {\n case \"NOT_READY\":\n buttons.append(actionButton(\"Get festival data\", actions.onGetData, true));\n break;\n case \"PARTIAL\":\n buttons.append(\n actionButton(\n report.activationPending ? \"Staged data found — continue setup\" : \"Continue setup\",\n actions.onGetData,\n true,\n ),\n );\n break;\n case \"RECOVERY\":\n buttons.append(actionButton(\"Restore festival data\", actions.onGetData, true));\n if (actions.onRestore) {\n buttons.append(actionButton(\"Restore previous version\", actions.onRestore));\n }\n break;\n case \"FAILED\":\n buttons.append(actionButton(\"Retry\", actions.onCheckData, true));\n section.append(\n text(\n \"p\",\n \"If this persists, restore festival data when online. Diagnostics stay on this device.\",\n ),\n );\n break;\n case \"BASELINE_ONLY\":\n section.append(\n text(\n \"p\",\n \"This browser cannot save offline data (private mode, blocked website data, or no space). Install Lumen to the home screen, use a normal browsing window, or free storage — emergency info below still works offline.\",\n ),\n );\n break;\n case \"READY\":\n break;\n }\n buttons.append(actionButton(\"Check my data\", actions.onCheckData));\n buttons.append(actionButton(\"Close\", actions.onClose));\n section.append(buttons);\n\n return section;\n}\n\n/** Preparation guidance shown for Get/Continue/Restore — sync flow lands in Stage 15. */\nexport function createPrepGuidanceView(onBack: () => void, onClose: () => void): HTMLElement {\n const section = document.createElement(\"section\");\n section.className = \"status-view\";\n section.setAttribute(\"aria-labelledby\", \"prep-heading\");\n const h1 = document.createElement(\"h1\");\n h1.id = \"prep-heading\";\n h1.textContent = \"Get festival data\";\n section.append(h1);\n section.append(\n text(\n \"p\",\n \"Festival data preparation needs an internet connection. Open Lumen online and return here — one-tap download with resume, verification, and OFFLINE READY confirmation arrives with the sync stage. Your emergency floor below always works, with or without it.\",\n ),\n );\n const buttons = document.createElement(\"div\");\n buttons.className = \"status-actions\";\n buttons.append(actionButton(\"Back to status\", onBack, true));\n buttons.append(actionButton(\"Close\", onClose));\n section.append(buttons);\n return section;\n}\n","/**\n * Emergency view — Ring-0 safe DOM rendering over the resolved Tier 1/2 model.\n * Tier 2 (dataset section) renders full locations, contacts, procedures and\n * notices; any demotion renders the floor plus honest guidance. No network,\n * no IDB, no raw HTML — all text via textContent (C-22/B-7).\n * Trace: IMPLEMENTATION-CONTRACT.md §14, SPIKE-06.\n */\nimport { resolveEmergency } from \"../../../domain/emergency/logic.js\";\nimport type {\n EmergencyDemotionReason,\n EmergencyViewModel,\n VerifiedEmergencyData,\n} from \"../../../domain/emergency/types.js\";\n\nfunction text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement {\n const element = document.createElement(tag);\n element.textContent = value;\n return element;\n}\n\nfunction heading(id: string, value: string): HTMLHeadingElement {\n const element = document.createElement(\"h2\");\n element.id = id;\n element.textContent = value;\n return element;\n}\n\nfunction list(items: readonly string[]): HTMLUListElement {\n const element = document.createElement(\"ul\");\n for (const item of items) element.append(text(\"li\", item));\n return element;\n}\n\nfunction addService(section: HTMLElement, label: string, value: string, dial = false): void {\n const wrapper = document.createElement(\"div\");\n wrapper.className = \"emergency-service\";\n wrapper.append(text(\"h3\", label), text(\"p\", value));\n if (dial) {\n const link = document.createElement(\"a\");\n link.className = \"emergency-call\";\n link.href = `tel:${value}`;\n link.textContent = `Call ${value}`;\n link.setAttribute(\"aria-label\", `Call ${label} at ${value}`);\n wrapper.append(link);\n }\n section.append(wrapper);\n}\n\nfunction demotionGuidance(reason: EmergencyDemotionReason): string {\n switch (reason) {\n case \"no-dataset\":\n return \"Festival data isn't on this device yet — showing the built-in baseline. Open with internet to get festival data.\";\n case \"unsupported-schema\":\n return \"This festival data needs a newer app version — showing the built-in baseline. Update Lumen when online.\";\n case \"malformed-section\":\n return \"The festival data emergency section couldn't be read — showing the built-in baseline.\";\n }\n}\n\n/** \"Show on map\" handoff into the map destination (offline, same shell). */\nfunction mapLink(): HTMLAnchorElement {\n const link = document.createElement(\"a\");\n link.className = \"emergency-map-link\";\n link.href = \"/map\";\n link.setAttribute(\"data-route\", \"/map\");\n link.textContent = \"Show on map\";\n return link;\n}\n\nfunction addPoiRow(listEl: HTMLUListElement, name: string, extra?: string): void {\n const item = document.createElement(\"li\");\n item.append(text(\"span\", extra ? `${name} — ${extra}` : name), text(\"span\", \" · \"), mapLink());\n listEl.append(item);\n}\n\nfunction isViewModel(value: unknown): value is EmergencyViewModel {\n const record = value as { source?: unknown } | null;\n return (\n typeof record === \"object\" &&\n record !== null &&\n (record.source === \"baseline\" || record.source === \"dataset\")\n );\n}\n\n/**\n * @param dataset tier-2 input, or a pre-resolved viewmodel (lets the app\n * convey precise demotion reasons the dataset alone cannot express).\n * With no argument the compiled floor renders with zero IDB access (Ring-0).\n */\nexport function createEmergencyView(\n dataset?: VerifiedEmergencyData | EmergencyViewModel,\n now?: number,\n): HTMLElement {\n let resolved;\n try {\n resolved = isViewModel(dataset) ? dataset : resolveEmergency(dataset, now);\n } catch {\n // Broken floor build must still render something honest, never blank.\n const fallback = document.createElement(\"section\");\n fallback.className = \"emergency-view\";\n const h1 = document.createElement(\"h1\");\n h1.textContent = \"Emergency\";\n fallback.append(h1);\n fallback.append(text(\"p\", \"Emergency data failed to load on this device.\"));\n return fallback;\n }\n const section = document.createElement(\"section\");\n section.className = \"emergency-view\";\n section.setAttribute(\"aria-labelledby\", \"emergency-heading\");\n\n const h1 = document.createElement(\"h1\");\n h1.id = \"emergency-heading\";\n h1.textContent = \"Emergency\";\n section.append(h1);\n\n const provenance = text(\"p\", resolved.provenance);\n provenance.className = \"emergency-provenance\";\n provenance.setAttribute(\"role\", \"status\");\n section.append(provenance);\n\n if (resolved.demotion) {\n const guidance = text(\"p\", demotionGuidance(resolved.demotion));\n guidance.className = \"emergency-guidance\";\n section.append(guidance);\n }\n\n const detail = resolved.section;\n const services = detail?.services ?? resolved.floor.services;\n const address = detail?.address ?? resolved.floor.address;\n const procedures = detail?.procedures?.length ? detail.procedures : resolved.floor.procedures;\n\n const urgent = document.createElement(\"div\");\n urgent.className = \"emergency-alert\";\n urgent.append(heading(\"emergency-call-heading\", \"Need immediate help?\"));\n addService(urgent, \"Emergency services\", services.emergencyNumber, true);\n urgent.append(text(\"p\", \"Tap to call. The number is also selectable for copying.\"));\n section.append(urgent);\n\n const contacts = document.createElement(\"section\");\n contacts.setAttribute(\"aria-labelledby\", \"emergency-services-heading\");\n contacts.append(heading(\"emergency-services-heading\", \"Contacts\"));\n const securityPhone = services.security.phone;\n addService(contacts, \"Security\", securityPhone || \"Contact event staff or venue personnel\");\n if (services.security.location) {\n contacts.append(text(\"p\", `Security location: ${services.security.location}`));\n }\n addService(contacts, \"First aid\", services.firstAid.location);\n if (services.firstAid.hours) {\n contacts.append(text(\"p\", `First-aid hours: ${services.firstAid.hours}`));\n }\n section.append(contacts);\n\n // Locations: dataset tier carries POI-linked muster/exit/AED detail with a\n // map handoff; the floor carries name summaries (same-source, no drift).\n const places = document.createElement(\"section\");\n places.setAttribute(\"aria-labelledby\", \"emergency-address-heading\");\n places.append(heading(\"emergency-address-heading\", \"Venue and exits\"));\n places.append(text(\"p\", address.lines.join(\", \")));\n places.append(\n text(\n \"p\",\n `Coordinates: ${String(address.coordinates.lat)}, ${String(address.coordinates.lon)}`,\n ),\n );\n if (detail?.locations) {\n places.append(text(\"h3\", \"Muster points\"));\n const muster = document.createElement(\"ul\");\n for (const point of detail.locations.musterPoints) {\n addPoiRow(muster, point.name, undefined);\n }\n places.append(muster);\n places.append(text(\"h3\", \"Exits\"));\n const exits = document.createElement(\"ul\");\n for (const exit of detail.locations.exits) {\n addPoiRow(exits, exit.name, undefined);\n }\n places.append(exits);\n if (detail.locations.aeds.length > 0) {\n places.append(text(\"h3\", \"AEDs\"));\n const aeds = document.createElement(\"ul\");\n for (const aed of detail.locations.aeds) {\n addPoiRow(aeds, `AED near ${aed.poi}`, undefined);\n }\n places.append(aeds);\n }\n } else {\n places.append(text(\"h3\", \"Muster points\"));\n places.append(list(resolved.floor.musterPoints.map((point) => point.name)));\n places.append(text(\"h3\", \"Exits\"));\n places.append(list(resolved.floor.exits.map((exit) => exit.name)));\n places.append(text(\"h3\", \"AEDs\"));\n places.append(text(\"p\", resolved.floor.aedSummary));\n }\n section.append(places);\n\n const proceduresSection = document.createElement(\"section\");\n proceduresSection.setAttribute(\"aria-labelledby\", \"emergency-procedures-heading\");\n proceduresSection.append(heading(\"emergency-procedures-heading\", \"What to do\"));\n for (const procedure of procedures) {\n proceduresSection.append(text(\"h3\", procedure.title), list(procedure.steps));\n }\n section.append(proceduresSection);\n\n if (detail?.notices?.length) {\n const notices = document.createElement(\"section\");\n notices.setAttribute(\"aria-labelledby\", \"emergency-notices-heading\");\n notices.append(heading(\"emergency-notices-heading\", \"Notices\"));\n for (const notice of detail.notices) {\n notices.append(text(\"h3\", `[${notice.severity}] ${notice.title}`));\n notices.append(text(\"p\", notice.body.map(String).join(\" \")));\n }\n section.append(notices);\n }\n\n return section;\n}\n","/**\n * Map view — offline raster base + DOM POI overlay + Facilities list.\n * Pan/zoom is a single CSS transform on the world layer; markers\n * counter-scale via --map-scale so targets stay constant size. The Facilities\n * list is first-class (screen-reader and GPS-free path), never a fallback.\n * No network, no GPS, no raw HTML — all text via textContent (C-22/B-7).\n * Trace: IMPLEMENTATION-CONTRACT.md §22, SPIKE-03.\n */\nimport {\n DETAIL_ZOOM_THRESHOLD,\n filterPois,\n groupFacilities,\n poiPosition,\n presentCategories,\n} from \"../../../domain/map/logic.js\";\nimport type {\n MapLevelView,\n MapPoi,\n MapViewData,\n PoiCategory,\n} from \"../../../domain/map/types.js\";\n\nconst MIN_SCALE = 1;\nconst MAX_SCALE = 4;\nconst PAN_STEP = 40;\n\nfunction text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement {\n const element = document.createElement(tag);\n element.textContent = value;\n return element;\n}\n\nfunction levelImages(level: MapLevelView): HTMLElement[] {\n const day = document.createElement(\"img\");\n day.className = \"map-base\";\n day.src = level.url;\n day.alt = `Festival grounds map (${level.id})`;\n day.draggable = false;\n if (!level.nightUrl) return [day];\n const night = document.createElement(\"img\");\n night.className = \"map-base map-base--night\";\n night.src = level.nightUrl;\n night.alt = `Festival grounds map at night (${level.id})`;\n night.draggable = false;\n return [day, night];\n}\n\n/**\n * Render the map. Null input (nothing staged) shows the honest\n * not-downloaded card — never blank. With POIs but no base image the\n * Facilities list still renders (SPIKE-05 cross-check).\n */\nexport function createMapView(input: MapViewData | null): HTMLElement {\n const section = document.createElement(\"section\");\n section.className = \"map-view\";\n section.setAttribute(\"aria-labelledby\", \"map-heading\");\n\n const h1 = document.createElement(\"h1\");\n h1.id = \"map-heading\";\n h1.textContent = \"Map\";\n section.append(h1);\n\n if (!input) {\n const card = document.createElement(\"div\");\n card.className = \"view-placeholder\";\n card.append(text(\"p\", \"The map isn't on this device yet.\"));\n card.append(\n text(\"p\", \"Open with internet to get festival data — the emergency floor always works meanwhile.\"),\n );\n section.append(card);\n return section;\n }\n\n const state = {\n scale: 1,\n x: 0,\n y: 0,\n level: \"overview\",\n categories: new Set(),\n query: \"\",\n };\n\n // Detail panel (polite live region announces selections).\n const detail = document.createElement(\"section\");\n detail.className = \"map-detail\";\n detail.setAttribute(\"aria-live\", \"polite\");\n\n function selectPoi(poi: MapPoi | null): void {\n detail.replaceChildren();\n if (!poi) {\n detail.append(text(\"p\", \"Select a facility on the map or list for details.\"));\n return;\n }\n const heading = document.createElement(\"h2\");\n heading.textContent = poi.name;\n detail.append(heading);\n detail.append(text(\"p\", `Category: ${poi.category}`));\n detail.append(text(\"p\", poi.description ?? \"No description.\"));\n }\n selectPoi(null);\n\n if (input.mapAvailable && input.overview) {\n const toolbar = document.createElement(\"div\");\n toolbar.className = \"map-toolbar\";\n\n const zoomOut = document.createElement(\"button\");\n zoomOut.type = \"button\";\n zoomOut.className = \"map-zoom\";\n zoomOut.setAttribute(\"aria-label\", \"Zoom out\");\n zoomOut.textContent = \"−\";\n const zoomIn = document.createElement(\"button\");\n zoomIn.type = \"button\";\n zoomIn.className = \"map-zoom\";\n zoomIn.setAttribute(\"aria-label\", \"Zoom in\");\n zoomIn.textContent = \"+\";\n const reset = document.createElement(\"button\");\n reset.type = \"button\";\n reset.className = \"map-zoom\";\n reset.textContent = \"Reset\";\n reset.setAttribute(\"aria-label\", \"Reset map view\");\n const levelLabel = text(\"span\", \"Overview\");\n levelLabel.className = \"map-level-label\";\n levelLabel.setAttribute(\"role\", \"status\");\n toolbar.append(zoomOut, zoomIn, reset, levelLabel);\n section.append(toolbar);\n\n const stage = document.createElement(\"div\");\n stage.className = \"map-stage\";\n stage.tabIndex = 0;\n stage.setAttribute(\"aria-label\", \"Festival map. Drag to pan, use the zoom buttons, or arrow keys. Full facility list follows.\");\n stage.style.touchAction = \"none\";\n\n const world = document.createElement(\"div\");\n world.className = \"map-world\";\n const levelSlot = document.createElement(\"span\");\n levelSlot.className = \"map-level\";\n world.append(levelSlot);\n\n const markers = new Map();\n for (const poi of input.pois) {\n const marker = document.createElement(\"button\");\n marker.type = \"button\";\n marker.className = \"map-poi\";\n marker.setAttribute(\"data-poi-id\", poi.id);\n marker.setAttribute(\"aria-label\", `${poi.name}, ${poi.category}`);\n marker.title = poi.name;\n const pos = poiPosition(poi);\n marker.style.left = pos.left;\n marker.style.top = pos.top;\n marker.append(text(\"span\", poi.name));\n marker.addEventListener(\"click\", () => {\n selectPoi(poi);\n });\n markers.set(poi.id, marker);\n world.append(marker);\n }\n stage.append(world);\n section.append(stage);\n\n const renderLevel = (): void => {\n const level = state.level === \"detail\" && input.detail ? input.detail : input.overview;\n if (!level) return;\n levelSlot.replaceChildren(...levelImages(level));\n levelLabel.textContent = input.detail && state.level === \"detail\" ? \"Detail\" : \"Overview\";\n };\n\n const clampPan = (): void => {\n const w = stage.clientWidth;\n const h = stage.clientHeight;\n if (!w || !h) return;\n state.x = Math.min(0, Math.max(w - w * state.scale, state.x));\n state.y = Math.min(0, Math.max(h - h * state.scale, state.y));\n };\n\n const apply = (): void => {\n world.style.transform = `translate(${String(state.x)}px, ${String(state.y)}px) scale(${String(state.scale)})`;\n world.style.setProperty(\"--map-scale\", String(state.scale));\n const want = state.scale >= DETAIL_ZOOM_THRESHOLD && input.detail ? \"detail\" : \"overview\";\n if (want !== state.level) {\n state.level = want;\n renderLevel();\n }\n };\n\n const zoomAt = (cx: number, cy: number, factor: number): void => {\n const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale * factor));\n const k = next / state.scale;\n state.x = cx - (cx - state.x) * k;\n state.y = cy - (cy - state.y) * k;\n state.scale = next;\n clampPan();\n apply();\n };\n\n const center = (): { x: number; y: number } => {\n const rect = stage.getBoundingClientRect();\n return { x: rect.width / 2, y: rect.height / 2 };\n };\n\n zoomIn.addEventListener(\"click\", () => {\n const c = center();\n zoomAt(c.x, c.y, 1.5);\n });\n zoomOut.addEventListener(\"click\", () => {\n const c = center();\n zoomAt(c.x, c.y, 1 / 1.5);\n });\n reset.addEventListener(\"click\", () => {\n state.scale = 1;\n state.x = 0;\n state.y = 0;\n apply();\n });\n stage.addEventListener(\n \"wheel\",\n (event) => {\n event.preventDefault();\n const rect = stage.getBoundingClientRect();\n zoomAt(event.clientX - rect.left, event.clientY - rect.top, event.deltaY < 0 ? 1.25 : 1 / 1.25);\n },\n { passive: false },\n );\n stage.addEventListener(\"keydown\", (event) => {\n const step = event.shiftKey ? PAN_STEP * 3 : PAN_STEP;\n if (event.key === \"ArrowLeft\") {\n state.x += step;\n clampPan();\n apply();\n event.preventDefault();\n } else if (event.key === \"ArrowRight\") {\n state.x -= step;\n clampPan();\n apply();\n event.preventDefault();\n } else if (event.key === \"ArrowUp\") {\n state.y += step;\n clampPan();\n apply();\n event.preventDefault();\n } else if (event.key === \"ArrowDown\") {\n state.y -= step;\n clampPan();\n apply();\n event.preventDefault();\n } else if (event.key === \"+\" || event.key === \"=\") {\n const c = center();\n zoomAt(c.x, c.y, 1.25);\n event.preventDefault();\n } else if (event.key === \"-\") {\n const c = center();\n zoomAt(c.x, c.y, 1 / 1.25);\n event.preventDefault();\n }\n });\n\n // Drag pan + two-pointer pinch, all pointer types.\n const pointers = new Map();\n let pinchDistance = 0;\n stage.addEventListener(\"pointerdown\", (event) => {\n pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });\n if (pointers.size === 2) {\n const [a, b] = [...pointers.values()];\n if (a && b) pinchDistance = Math.hypot(a.x - b.x, a.y - b.y);\n }\n try {\n stage.setPointerCapture?.(event.pointerId);\n } catch {\n // jsdom and old browsers lack capture — tracking still works.\n }\n });\n stage.addEventListener(\"pointermove\", (event) => {\n const prev = pointers.get(event.pointerId);\n if (!prev) return;\n const next = { x: event.clientX, y: event.clientY };\n pointers.set(event.pointerId, next);\n if (pointers.size === 1) {\n state.x += next.x - prev.x;\n state.y += next.y - prev.y;\n clampPan();\n apply();\n } else if (pointers.size === 2) {\n const [a, b] = [...pointers.values()];\n if (!a || !b) return;\n const distance = Math.hypot(a.x - b.x, a.y - b.y);\n const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };\n const rect = stage.getBoundingClientRect();\n if (pinchDistance > 0 && distance > 0) {\n zoomAt(mid.x - rect.left, mid.y - rect.top, distance / pinchDistance);\n }\n pinchDistance = distance;\n }\n });\n const release = (event: PointerEvent): void => {\n pointers.delete(event.pointerId);\n pinchDistance = 0;\n };\n stage.addEventListener(\"pointerup\", release);\n stage.addEventListener(\"pointercancel\", release);\n\n // Filter application shared by chips + search.\n const applyMarkerVisibility = (): void => {\n const visible = new Set(\n filterPois(input.pois, { categories: state.categories, query: state.query }).map((p) => p.id),\n );\n for (const [id, marker] of markers) marker.hidden = !visible.has(id);\n };\n stage.addEventListener(\"mapfilter\", () => {\n applyMarkerVisibility();\n });\n\n renderLevel();\n apply();\n } else {\n const card = document.createElement(\"div\");\n card.className = \"view-placeholder\";\n card.append(text(\"p\", \"The map image isn't downloaded — the facilities list below still works.\"));\n section.append(card);\n }\n\n section.append(detail);\n\n // Category chips + search drive both the markers (via event) and the list.\n const controls = document.createElement(\"div\");\n controls.className = \"map-controls\";\n const chipBar = document.createElement(\"div\");\n chipBar.className = \"map-chips\";\n chipBar.setAttribute(\"role\", \"group\");\n chipBar.setAttribute(\"aria-label\", \"Filter facilities by category\");\n const chipButtons: HTMLButtonElement[] = [];\n const paintChips = (): void => {\n for (const button of chipButtons) {\n const category = button.getAttribute(\"data-category\") ?? \"\";\n button.setAttribute(\"aria-pressed\", String(category === \"\" ? state.categories.size === 0 : state.categories.has(category as PoiCategory)));\n }\n };\n const allButton = document.createElement(\"button\");\n allButton.type = \"button\";\n allButton.className = \"map-chip\";\n allButton.textContent = \"All\";\n allButton.setAttribute(\"data-category\", \"\");\n allButton.addEventListener(\"click\", () => {\n state.categories.clear();\n paintChips();\n refreshFacilities();\n section.dispatchEvent(new Event(\"mapfilter\"));\n });\n chipButtons.push(allButton);\n chipBar.append(allButton);\n for (const category of presentCategories(input.pois, input.categories)) {\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.className = \"map-chip\";\n button.textContent = category;\n button.setAttribute(\"data-category\", category);\n button.addEventListener(\"click\", () => {\n if (state.categories.has(category)) state.categories.delete(category);\n else state.categories.add(category);\n paintChips();\n refreshFacilities();\n section.dispatchEvent(new Event(\"mapfilter\"));\n });\n chipButtons.push(button);\n chipBar.append(button);\n }\n paintChips();\n controls.append(chipBar);\n\n const search = document.createElement(\"input\");\n search.type = \"search\";\n search.setAttribute(\"aria-label\", \"Search facilities\");\n search.placeholder = \"Search facilities…\";\n search.addEventListener(\"input\", () => {\n state.query = search.value;\n refreshFacilities();\n section.dispatchEvent(new Event(\"mapfilter\"));\n });\n controls.append(search);\n section.append(controls);\n\n // First-class Facilities list.\n const facilities = document.createElement(\"div\");\n facilities.className = \"map-facilities\";\n section.append(facilities);\n\n function refreshFacilities(): void {\n facilities.replaceChildren();\n const visible = filterPois(input!.pois, { categories: state.categories, query: state.query });\n if (visible.length === 0) {\n facilities.append(text(\"p\", input!.pois.length === 0 ? \"No facilities listed.\" : \"No facilities match.\"));\n return;\n }\n for (const group of groupFacilities(visible, input!.categories)) {\n const heading = document.createElement(\"h2\");\n heading.textContent = `${group.category} (${String(group.pois.length)})`;\n facilities.append(heading);\n const list = document.createElement(\"ul\");\n list.className = \"map-facility-list\";\n for (const poi of group.pois) {\n const item = document.createElement(\"li\");\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.className = \"map-facility\";\n button.setAttribute(\"data-poi-id\", poi.id);\n button.textContent = poi.name;\n button.addEventListener(\"click\", () => {\n selectPoi(poi);\n });\n item.append(button);\n list.append(item);\n }\n facilities.append(list);\n }\n }\n refreshFacilities();\n\n return section;\n}\n","import { appendListSection, summit } from \"../../../content/summit.js\";\n\nexport function createFestivalView(): HTMLElement {\n const section = document.createElement(\"section\");\n section.setAttribute(\"aria-labelledby\", \"festival-heading\");\n const h1 = document.createElement(\"h1\");\n h1.id = \"festival-heading\";\n h1.textContent = \"Solarpunk Summit\";\n section.append(h1);\n const p = document.createElement(\"p\");\n p.textContent = summit.mission;\n section.append(p);\n appendListSection(\n section,\n \"Daily themes\",\n summit.themes.map(([name, description]) => `${name}: ${description}`),\n );\n appendListSection(section, \"Passes\", summit.passes);\n appendListSection(section, \"Attendee guidance\", summit.guidance);\n const source = document.createElement(\"a\");\n source.href = summit.source;\n source.target = \"_blank\";\n source.rel = \"noreferrer\";\n source.textContent = \"Verify current details at solarpunksummit.com\";\n section.append(source);\n return section;\n}\n","export function createNotFoundView(path: string): HTMLElement {\n const section = document.createElement(\"section\");\n section.setAttribute(\"aria-labelledby\", \"not-found-heading\");\n const h1 = document.createElement(\"h1\");\n h1.id = \"not-found-heading\";\n h1.textContent = \"Page not found\";\n section.append(h1);\n const p = document.createElement(\"p\");\n p.textContent = `No page at “${path}”. Use the navigation below.`;\n section.append(p);\n const a = document.createElement(\"a\");\n a.href = \"/emergency\";\n a.textContent = \"Go to Emergency\";\n a.setAttribute(\"data-route\", \"/emergency\");\n section.append(a);\n return section;\n}\n","/**\n * Lumen — Stage 9 application shell entry point.\n * Boot: layout + router + views immediately (never blocked on network),\n * then async local readiness evaluation (C1…C8) drives the header chip and\n * the Status screen. Evaluation reads Cache Storage + IndexedDB only —\n * going offline can never break it (I-8).\n */\nimport \"./styles.css\";\nimport { deleteShellCaches } from \"../platform/cache/shell-cache.js\";\nimport { registerSW, unregisterAllServiceWorkers } from \"../platform/sw/register.js\";\nimport { evaluateBootReadiness } from \"./readiness.js\";\nimport type { BootReadinessReport } from \"../domain/readiness/types.js\";\nimport { chipForState } from \"../domain/readiness/logic.js\";\nimport { loadDatasetEmergency } from \"./emergency.js\";\nimport type { EmergencyViewModel } from \"../domain/emergency/types.js\";\nimport { demotedBaseline, resolveEmergency } from \"../domain/emergency/logic.js\";\nimport { loadScheduleData } from \"./schedule.js\";\nimport type { ScheduleData } from \"./schedule.js\";\nimport { currentClock, deviceReading } from \"./clock.js\";\nimport { deviceTimeZone } from \"../domain/clock/logic.js\";\nimport { shouldWarnOutsideWindow } from \"../domain/clock/logic.js\";\nimport { openUserDB, addFavorite, removeFavorite, putPrefs } from \"../data/user/store.js\";\nimport { createScheduleView } from \"../ui/views/schedule/schedule.js\";\nimport type { ScheduleViewActions, ScheduleViewInput } from \"../ui/views/schedule/schedule.js\";\nimport { restorePreviousSlot } from \"../sync/activation.js\";\nimport { createLayout, setActiveNav } from \"./layout.js\";\nimport { Router, routeForPath, normalizePath } from \"../ui/router/router.js\";\nimport { createHomeView } from \"../ui/views/home/home.js\";\nimport { createStatusView, createPrepGuidanceView } from \"../ui/views/status/status.js\";\nimport { createEmergencyView } from \"../ui/views/emergency/emergency.js\";\nimport { createMapView } from \"../ui/views/map/map.js\";\nimport { createFestivalView } from \"../ui/views/festival/festival.js\";\nimport { createNotFoundView } from \"../ui/views/not-found.js\";\n\nexport interface RouteData {\n readonly emergency?: EmergencyViewModel | undefined;\n readonly schedule?:\n | {\n readonly input: ScheduleViewInput | null;\n readonly actions: ScheduleViewActions;\n }\n | undefined;\n}\n\nconst noopScheduleActions: ScheduleViewActions = {\n onToggleFavorite: () => false,\n // No schedule payload exists on this path, so the zone toggle never renders.\n onZoneToggle: () => undefined,\n};\n\nfunction renderView(path: string, data?: RouteData): HTMLElement {\n const id = routeForPath(path);\n switch (id) {\n case \"home\":\n return createHomeView();\n case \"emergency\":\n return createEmergencyView(data?.emergency);\n case \"schedule\":\n return createScheduleView(\n data?.schedule?.input ?? null,\n data?.schedule?.actions ?? noopScheduleActions,\n );\n case \"map\":\n return createMapView(null);\n case \"festival\":\n return createFestivalView();\n case \"not-found\":\n return createNotFoundView(path);\n }\n}\n\nfunction mount(): { router: Router; cleanup: () => void } {\n const appHost = document.querySelector(\"#app\");\n if (!appHost) throw new Error(\"#app host missing — shell invariant\");\n\n const { root, main, nav, brand, statusChip } = createLayout();\n appHost.replaceChildren(root);\n appHost.removeAttribute(\"aria-busy\");\n\n // Stage 9 readiness: evaluate local evidence (never network) and paint the\n // chip; the chip opens the Status screen. Boot render never waits for this.\n // Stage 10 emergency: the dataset tier resolves alongside readiness; the\n // route renders the floor instantly and upgrades when Tier 2 lands.\n // Stage 11 schedule: section + favorites + clock snapshot resolve the same\n // way; the route renders the not-downloaded card until data lands.\n let latestReport: BootReadinessReport | null = null;\n let latestEmergency: EmergencyViewModel | undefined;\n let latestSchedule: ScheduleData | null = null;\n let favIds = new Set();\n let useDeviceZone = false;\n let statusDialog: HTMLDialogElement | null = null;\n\n /** Resolve Tier 2 from the active slot — floor covers every failure. */\n async function resolveDatasetEmergency(): Promise {\n const loaded = await loadDatasetEmergency();\n if (loaded.status === \"ready\") return resolveEmergency(loaded.data);\n if (loaded.status === \"unusable\") return demotedBaseline(loaded.reason);\n return resolveEmergency();\n }\n\n function paintChip(report: BootReadinessReport): void {\n const detail = report.activationPending\n ? \"staged data awaiting activation\"\n : (report.reason ?? report.failedOp ?? report.missing[0] ?? undefined);\n const chip = chipForState(report.state, detail);\n statusChip.textContent = chip.text;\n statusChip.className = `app-header__status app-header__status--${chip.tone}`;\n statusChip.setAttribute(\"aria-label\", `${chip.label} Activate for status details.`);\n }\n\n function showStatus(report: BootReadinessReport): void {\n if (!statusDialog) {\n statusDialog = document.createElement(\"dialog\");\n statusDialog.className = \"status-dialog\";\n statusDialog.setAttribute(\"aria-label\", \"Offline status details\");\n // Native ESC/cancel bypasses our Close button — still return focus.\n statusDialog.addEventListener(\"close\", () => {\n statusChip.focus({ preventScroll: true });\n });\n root.append(statusDialog);\n }\n const dialog = statusDialog;\n const close = (): void => {\n dialog.close();\n };\n const back = (): void => {\n if (latestReport) showStatus(latestReport);\n };\n dialog.replaceChildren(\n createStatusView(report, {\n onCheckData: () => void refreshReadiness(true),\n onGetData: () => {\n dialog.replaceChildren(createPrepGuidanceView(back, close));\n },\n onRestore: report.canRestore\n ? () => {\n void restorePreviousSlot()\n .catch(() => false)\n .then(() => refreshReadiness(true));\n }\n : null,\n onClose: close,\n }),\n );\n if (!dialog.open) dialog.showModal();\n }\n\n async function refreshReadiness(openAfter: boolean): Promise {\n const report = await evaluateBootReadiness();\n latestReport = report;\n try {\n latestEmergency = await resolveDatasetEmergency();\n } catch {\n latestEmergency = undefined;\n }\n try {\n latestSchedule = await loadScheduleData();\n favIds = new Set(latestSchedule?.favorites.map((f) => f.eventId) ?? []);\n useDeviceZone = latestSchedule?.prefs?.useDeviceTimezone ?? false;\n } catch {\n latestSchedule = null;\n }\n // The shell may have been torn down (tests) — never throw from boot.\n if (!statusChip.isConnected && !root.isConnected) return;\n paintChip(report);\n // Upgrade data routes in place when their payload lands after boot.\n // Safe: refresh always resumes after mount's synchronous body completes,\n // so `router`/`render` below are initialized before this line can run.\n if (!openAfter && (router.getRouteId() === \"emergency\" || router.getRouteId() === \"schedule\")) {\n render();\n }\n if (openAfter) showStatus(report);\n }\n\n /** Fresh clock snapshot + zone for the schedule view (per render, no timers). */\n function buildScheduleInput(): ScheduleViewInput | null {\n if (!latestSchedule) return null;\n const { section, window, skew } = latestSchedule;\n const { now, driftWarn } = currentClock(deviceReading(), skew);\n return {\n section,\n favorites: favIds,\n clock: {\n now,\n zone: useDeviceZone ? deviceTimeZone() : window.timezone,\n festivalZone: window.timezone,\n useDeviceZone,\n outsideWarn: shouldWarnOutsideWindow(\n now,\n { startUtc: window.startUtc, endUtc: window.endUtc, timezone: window.timezone },\n skew,\n ),\n driftWarn,\n },\n };\n }\n\n /** Write-through favorite toggle — resolves false when storage refuses. */\n async function persistFavorite(eventId: string, favored: boolean): Promise {\n try {\n const db = await openUserDB();\n try {\n if (favored) {\n favIds.add(eventId);\n await addFavorite(db, { eventId, addedAt: Date.now() });\n } else {\n favIds.delete(eventId);\n await removeFavorite(db, eventId);\n }\n } finally {\n db.close();\n }\n return true;\n } catch {\n if (favored) favIds.delete(eventId);\n else if (latestSchedule) {\n for (const fav of latestSchedule.favorites) {\n if (fav.eventId === eventId) favIds.add(eventId);\n }\n }\n return false;\n }\n }\n\n async function persistZoneMode(useDevice: boolean): Promise {\n useDeviceZone = useDevice;\n try {\n const db = await openUserDB();\n try {\n const prefs = latestSchedule?.prefs;\n await putPrefs(db, {\n festivalTimezone: prefs?.festivalTimezone ?? latestSchedule?.window.timezone ?? \"UTC\",\n useDeviceTimezone: useDevice,\n ...(prefs?.theme !== undefined ? { theme: prefs.theme } : {}),\n });\n } finally {\n db.close();\n }\n } catch {\n // Zone preference is enhancement — the toggle still applies this session.\n }\n if (router.getRouteId() === \"schedule\") render();\n }\n\n function scheduleActions(): ScheduleViewActions {\n return {\n onToggleFavorite: (eventId, favored) => persistFavorite(eventId, favored),\n onZoneToggle: (useDevice) => void persistZoneMode(useDevice),\n };\n }\n\n statusChip.textContent = \"…\";\n statusChip.setAttribute(\"tabindex\", \"0\");\n statusChip.setAttribute(\"title\", \"View offline status details\");\n // Always re-evaluate on open: the boot-time report may be stale (eviction,\n // updates, or failures since boot must show, never a cached verdict).\n const openFromChip = (): void => {\n void refreshReadiness(true);\n };\n statusChip.addEventListener(\"click\", openFromChip);\n statusChip.addEventListener(\"keydown\", (event) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n openFromChip();\n }\n });\n\n if (import.meta.env.DEV) {\n // Dev mode: the SW's cache-first strategy would otherwise freeze dev\n // sources (unhashed /src/* URLs) at their first-fetched version — stale\n // shells/styles keep \"reappearing\" after edits. Never register in dev,\n // and scrub any shell cache + SW left by an earlier dev session.\n // Raw browser globals stay behind the platform seam (B-1).\n void unregisterAllServiceWorkers().then((shouldReload) => {\n if (shouldReload) location.reload();\n });\n void deleteShellCaches();\n } else {\n // Stage 3: register SW after boot — never blocks rendering (offline-safe)\n void registerSW().catch(() => {\n // registration failure is non-blocking — shell remains usable\n });\n }\n\n // Ensure the deterministic deep-link set exists: all four destinations must be reachable\n const router = new Router(normalizePath(location.pathname));\n\n function render(): void {\n const path = router.getPath();\n const id = router.getRouteId();\n setActiveNav(nav, id);\n // Brand is the home/back button: highlighted at home, chevron on destination pages.\n const isHome = id === \"home\";\n brand.classList.toggle(\"app-header__brand--back\", !isHome);\n brand.classList.toggle(\"app-header__brand--active\", isHome);\n if (isHome) brand.removeAttribute(\"aria-current\");\n else brand.setAttribute(\"aria-current\", \"page\");\n const view = renderView(path, {\n emergency: latestEmergency,\n schedule: { input: buildScheduleInput(), actions: scheduleActions() },\n });\n main.replaceChildren(view);\n const pageIcon = document.createElement(\"img\");\n pageIcon.src = \"/sol_lunar_icon.svg?v=5\";\n pageIcon.alt = \"\";\n pageIcon.className = \"app-main__icon\";\n main.prepend(pageIcon);\n // Move focus to main for screen readers after navigation\n main.focus({ preventScroll: true });\n document.title =\n id === \"not-found\"\n ? \"Not found — Lumen\"\n : id === \"home\"\n ? \"Lumen\"\n : `${view.querySelector(\"h1\")?.textContent ?? id} — Lumen`;\n }\n\n // Click interception for data-route links (offline-safe, no full reload)\n root.addEventListener(\"click\", (ev) => {\n const target = (ev.target as HTMLElement | null)?.closest(\"a[data-route]\");\n if (!target) return;\n const href = target.getAttribute(\"data-route\");\n if (!href) return;\n if (/^https?:\\/\\//.test(href)) return;\n ev.preventDefault();\n router.navigate(href);\n });\n\n const unsubscribe = router.listen(() => {\n render();\n });\n const detachPopstate = router.attach();\n\n render();\n // Boot evaluation runs after first render — the shell never waits for it.\n void refreshReadiness(false);\n\n return {\n router,\n cleanup: () => {\n unsubscribe();\n detachPopstate();\n },\n };\n}\n\n// Export for tests (boot without needing full page load)\nexport { mount, renderView };\n\n// Auto-mount in browser — Stage 2 shell only when host exists\nfunction autoMount(): void {\n if (typeof document === \"undefined\" || !document.getElementById(\"app\")) return;\n if (document.readyState !== \"loading\") {\n try {\n mount();\n } catch {\n // host may appear later — ignore during tests\n }\n } else {\n document.addEventListener(\n \"DOMContentLoaded\",\n () => {\n if (document.getElementById(\"app\")) {\n try {\n mount();\n } catch {\n // ignore\n }\n }\n },\n { once: true },\n );\n }\n}\nautoMount();\n"],"names":["checkShellCache","keys","current","k","cache","entry","registerSW","error","getStorageEstimate","storage","isQuotaError","err","promisify","req","resolve","reject","txDone","tx","openDB","name","version","onUpgrade","db","oldVersionFallback","withTx","storeNames","mode","fn","names","result","e","idbGet","store","key","idbGetAll","idbPut","value","os","idbDelete","DB","slotDbName","slot","SYSTEM_STORE","SYSTEM_KEY","SLOT_FILES","SLOT_ASSETS","SLOT_STAGING","USER_FAVS","USER_PREFS","USER_DIAG","INITIAL_SYSTEM_META","VERSION","upgrade","openSystemDB","readSystemMeta","val","writeSystemMeta","meta","clearReadbackPending","SLOT_VERSION","upgradeSlot","openSlotDB","STAGING_KEY","readStagingProgress","readSlotFile","sectionId","rec","readSlotFileMeta","SLOT_MANIFEST_KEY","readSlotManifest","readSlotAsset","assetId","PACKAGE_FORMAT","isCompatible","manifest","shellSupportedRange","currentAppVersion","cmp","a","b","pa","n","pb","i","av","bv","EMERGENCY_FLOOR","EMERGENCY_FLOOR_JSON","EMERGENCY_FLOOR_BYTES","FLOOR_SIZE_BUDGET","record","nonEmpty","validFloor","floor","services","security","firstAid","address","coordinates","point","exit","procedure","item","loadEmergencyFloor","STAGING_MAX_AGE_MS","REQUIRED_SECTION_IDS","completeSlot","journal","id","restorePreviousSlot","system","previousSlot","previous","_a","_b","recoverPendingActivation","activeDb","activeJournal","fallbackSlot","fallbackDb","fallbackJournal","APP_VERSION","SUPPORTED_SCHEMA_RANGE","CANONICAL_SECTIONS","L1_SECTIONS","preparationLevel","present","evaluateReadiness","level","chipForState","state","detail","defaultBootDeps","loaded","failedReport","op","baselineOnlyReport","narrowAssetRefs","listed","isUsableManifest","otherSlotComplete","openSlot","findRecentStaging","now","evaluateBootReadiness","deps","evaluate","shell","reopened","evaluateNoActivePointer","activeSlot","evaluateActiveSlot","hadVerifiedDataset","baseEvidence","staging","missing","patch","fail","verification","sections","presentSections","anyFilePresent","authenticity","manifestOk","integrity","requiredSectionsOk","entries","required","parseable","fileMeta","json","requiredAssetsOk","assetMissing","assetTotal","assetPresent","inventory","assets","asset","schemaCompatible","incompatible","evidence","SUPPORTED_EMERGENCY_SCHEMAS","narrowEmergencySection","section","p","mergeFloorFields","candidate","candidateServices","emergencyNumber","_c","visibleNotices","notice","resolveEmergency","dataset","demotedBaseline","isSupportedEmergencySchema","narrowed","merged","demotion","resolved","loadEmergencyFloorOrThrow","loadDatasetEmergency","packageVersion","USER_VERSION","upgradeUser","openUserDB","addFavorite","removeFavorite","eventId","listFavorites","PREFS_KEY","getPrefs","putPrefs","prefs","narrowScheduleSection","loadScheduleData","skew","window","user","DEFAULT_WINDOW_DAYS","MS_PER_DAY","formatEventTime","epochMs","timeZone","formatDayLabel","formatClockTime","deviceTimeZone","correctedNow","deviceNow","hasDrift","baseDevice","monoElapsed","observedDevice","thresholdMs","expected","shouldWarnOutsideWindow","nowCorrected","win","opts","windowMs","resolveClock","reading","session","base","drifted","settled","currentClock","deviceReading","sortByStart","events","groupByDay","groups","event","list","dayKey","sorted","nowEvents","upNext","count","matchesQuery","query","needle","stage","s","artists","filterEvents","filter","detectOverlaps","live","pairs","j","stageName","stageId","artistNames","artistIds","allTags","tags","tag","favoriteStatus","buildMySchedule","favorites","byId","fav","myScheduleConflicts","items","text","element","eventTime","zone","statusBadge","badge","eventCard","favored","onToggleFavorite","card","title","paintFav","next","createScheduleView","input","actions","h1","data","clock","favs","warn","liveHeading","liveList","controls","days","d","dayBar","dayButtons","day","button","other","refresh","stageSelect","allStages","option","tagBar","tagButtons","paintTags","allTagsButton","search","mineToggle","paintMine","zoneToggle","toggleFavorite","refreshConflicts","conflictsNote","conflicts","first","currentFilter","scoped","happening","upcoming","activeDay","dayLabel","HOME_ROUTE","ROUTES","PATH_TO_ID","r","normalizePath","path","routeForPath","normalized","Router","initialPath","__publicField","handler","onPop","l","createLayout","root","skipLink","header","brand","statusChip","main","pageIcon","nav","setActiveNav","activeId","route","summit","appendText","parent","appendListSection","createHomeView","sub","grid","actionButton","label","primary","formatBytes","bytes","sectionRow","mark","ok","createStatusView","report","stateLine","usage","storageText","buttons","createPrepGuidanceView","onBack","onClose","heading","addService","dial","wrapper","link","demotionGuidance","reason","mapLink","addPoiRow","listEl","extra","isViewModel","createEmergencyView","fallback","provenance","guidance","procedures","urgent","contacts","securityPhone","places","muster","exits","aeds","aed","proceduresSection","notices","createMapView","createFestivalView","description","source","createNotFoundView","noopScheduleActions","renderView","mount","appHost","latestReport","latestEmergency","latestSchedule","favIds","useDeviceZone","statusDialog","resolveDatasetEmergency","paintChip","chip","showStatus","dialog","close","back","refreshReadiness","openAfter","f","router","render","buildScheduleInput","driftWarn","persistFavorite","persistZoneMode","useDevice","scheduleActions","openFromChip","isHome","view","ev","target","href","unsubscribe","detachPopstate","autoMount"],"mappings":"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"} \ No newline at end of file diff --git a/share/assets/style-D9rb7TfV.css b/share/assets/style-D9rb7TfV.css new file mode 100644 index 0000000..51c413a --- /dev/null +++ b/share/assets/style-D9rb7TfV.css @@ -0,0 +1 @@ +:root{--deck-bg: #f6f9f7;--deck-fg: #172621;--deck-fg-muted: #576b63;--deck-surface: rgba(20, 30, 25, .04);--deck-surface-2: rgba(20, 30, 25, .06);--deck-line: rgba(20, 30, 25, .12);--deck-line-2: rgba(20, 30, 25, .2);--deck-pine: #1f7a5f;--deck-indigo: #683ecc;--deck-rose: #d3224b;--deck-clay: #bf4622;--deck-amber: #b8610a;--deck-on-accent: #0f1411;--bg: var(--deck-bg);--fg: var(--deck-fg);--muted: var(--deck-fg-muted);--accent: var(--deck-pine);--accent-contrast: #f6f9f7;--accent-soft: rgba(31, 122, 95, .09);--surface: var(--deck-surface);--border: var(--deck-line);--border-accent: rgba(31, 122, 95, .32);--highlight-bg: rgba(184, 97, 10, .07);--highlight-border: rgba(184, 97, 10, .3);--chip-pink-bg: rgba(211, 34, 75, .08);--chip-pink-fg: #b5244a;--chip-green-bg: rgba(31, 122, 95, .09);--chip-green-fg: #1f7a5f;--chip-indigo-bg: rgba(104, 62, 204, .08);--chip-indigo-fg: #683ecc;--chip-amber-bg: rgba(184, 97, 10, .09);--chip-amber-fg: #96500a;--focus: var(--deck-pine);--emergency: #d3224b;--emergency-contrast: #ffffff;--font-display: Georgia, "Times New Roman", serif;--radius: 1rem;--radius-sm: .75rem;--nav-h: 64px;--header-h: 56px;--content-max: 48rem}@media(prefers-color-scheme:dark){:root{--deck-bg: #0b1411;--deck-fg: #ede9de;--deck-fg-muted: #96a69e;--deck-surface: rgba(255, 255, 255, .035);--deck-surface-2: rgba(255, 255, 255, .055);--deck-line: rgba(255, 255, 255, .1);--deck-line-2: rgba(255, 255, 255, .16);--deck-pine: #75c7a9;--deck-indigo: #af97f7;--deck-rose: #fc88ab;--deck-clay: #e47958;--deck-amber: #fcc669;--bg: var(--deck-bg);--fg: var(--deck-fg);--muted: var(--deck-fg-muted);--accent: var(--deck-pine);--accent-contrast: var(--deck-on-accent);--accent-soft: rgba(117, 199, 169, .12);--surface: var(--deck-surface);--border: var(--deck-line);--border-accent: rgba(117, 199, 169, .32);--highlight-bg: rgba(252, 198, 105, .07);--highlight-border: rgba(252, 198, 105, .3);--chip-pink-bg: rgba(252, 136, 171, .12);--chip-pink-fg: #fc88ab;--chip-green-bg: rgba(117, 199, 169, .12);--chip-green-fg: #75c7a9;--chip-indigo-bg: rgba(175, 151, 247, .12);--chip-indigo-fg: #af97f7;--chip-amber-bg: rgba(252, 198, 105, .12);--chip-amber-fg: #fcc669;--focus: var(--deck-pine);--emergency: #fc88ab;--emergency-contrast: var(--deck-on-accent)}}*{box-sizing:border-box}html{color-scheme:light dark;scroll-behavior:smooth}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{animation-duration:.01ms!important;transition-duration:.01ms!important}}body{margin:0;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:var(--fg);line-height:1.6;-webkit-tap-highlight-color:transparent;background:radial-gradient(120% 80% at 80% -10%,rgba(188,230,217,.7),transparent 60%),radial-gradient(90% 70% at -10% 110%,rgba(249,219,184,.6),transparent 55%),var(--bg);background-attachment:fixed}@media(prefers-color-scheme:dark){body{background:radial-gradient(120% 80% at 80% -10%,rgba(14,57,44,.55),transparent 60%),radial-gradient(90% 70% at -10% 110%,rgba(83,48,9,.35),transparent 55%),var(--bg)}}a{color:inherit}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}h1,h2,h3{font-family:var(--font-display);font-weight:700;line-height:1.15;letter-spacing:-.01em;text-wrap:balance}.eyebrow{display:block;text-transform:uppercase;letter-spacing:.25em;font-size:.75rem;font-weight:600;color:var(--muted);margin-bottom:.5rem}.summit-card{margin:1.5rem 0;padding:1.25rem;background:var(--surface);border:1px solid var(--border-accent);border-radius:var(--radius)}.summit-card a{color:var(--accent);font-weight:700}.summit-card img{display:block;width:100%;height:auto;margin-bottom:1.25rem;border-radius:var(--radius-sm)}.summit-card img[role=button]{cursor:zoom-in}.map-viewer{width:min(96vw,1100px);height:min(96vh,900px);padding:.75rem;color:var(--fg);background:var(--bg);border:1px solid var(--border);border-radius:var(--radius)}.map-viewer::backdrop{background:#000c}.map-viewer__close{display:block;min-height:48px;margin-left:auto;padding:.5rem .75rem;color:var(--fg);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);font:inherit;font-weight:700}.map-viewer img{display:block;width:100%;height:calc(100% - 60px);margin-top:.75rem;object-fit:contain;overflow:auto;touch-action:pinch-zoom}.schedule-item+.schedule-item{margin-top:1rem;padding-top:1rem;border-top:1px solid var(--border)}.schedule-item h2{margin:0;font-size:1.1rem}.schedule-item p{margin-bottom:0}.skip-link{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden}.skip-link:focus{left:1rem;top:1rem;width:auto;height:auto;background:var(--bg);padding:.5rem .75rem;border:2px solid var(--focus);z-index:100}#app{min-height:100dvh;display:flex;flex-direction:column}.app-header{position:sticky;top:0;z-index:10;display:flex;align-items:center;justify-content:space-between;height:var(--header-h);padding:0 max(1rem,env(safe-area-inset-right)) 0 max(1rem,env(safe-area-inset-left));background:color-mix(in srgb,var(--bg) 78%,transparent);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--border)}.app-header__brand{display:inline-flex;align-items:center;gap:.5rem;min-height:40px;padding:.375rem .75rem;font-weight:800;letter-spacing:.14em;font-size:1rem;text-decoration:none;text-transform:uppercase;color:var(--fg);background:var(--surface);border:1px solid var(--border);border-radius:999px;transition:background-color .2s ease,border-color .2s ease,color .2s ease}.app-header__icon{width:1.5rem;height:1.5rem;object-fit:contain;display:block}.app-header__brand:hover{background:var(--deck-surface-2);border-color:var(--deck-line-2)}.app-header__brand--back{color:var(--accent);border-color:var(--accent)}.app-header__brand--back:before{content:"←";font-weight:800;font-size:1.05em;line-height:1}.app-header__status{font-size:.8125rem;font-weight:600;letter-spacing:.08em;padding:.3125rem .625rem;border-radius:999px;border:1px solid var(--border);background:var(--surface);color:var(--muted);cursor:pointer;font-family:inherit}.app-header__status--ready{background:var(--chip-green-bg);color:var(--chip-green-fg);border-color:var(--accent)}.app-header__status--partial{background:var(--chip-amber-bg);color:var(--chip-amber-fg);border-color:var(--highlight-border)}.app-header__status--recovery,.app-header__status--failed{background:var(--chip-pink-bg);color:var(--chip-pink-fg);border-color:var(--emergency)}.status-dialog{width:min(92vw,36rem);max-height:min(88dvh,56rem);overflow:auto;padding:1.25rem;color:var(--fg);background:var(--bg);border:1px solid var(--border);border-radius:var(--radius)}.status-dialog::backdrop{background:#0000008c}.status-view h1{font-size:1.5rem;margin:0 0 .75rem}.status-view h2{font-size:1.125rem;margin:1rem 0 .5rem}.status-view p{margin:.375rem 0}.status-state{font-weight:800;letter-spacing:.08em}.status-checklist{list-style:none;margin:.5rem 0;padding:0}.status-check{display:flex;gap:.625rem;align-items:baseline;min-height:48px;padding:.625rem .75rem;border:1px solid var(--border);border-radius:var(--radius-sm);margin-block:.375rem}.status-check--ok{border-color:var(--border-accent)}.status-check--missing{border-color:var(--highlight-border)}.status-check__mark{font-weight:800}.status-check--ok .status-check__mark{color:var(--chip-green-fg)}.status-check--missing .status-check__mark{color:var(--chip-pink-fg)}.status-actions{display:flex;flex-wrap:wrap;gap:.625rem;margin-top:1rem}.status-action{flex:1 1 12rem;min-height:48px;padding:.625rem 1rem;font:inherit;font-weight:700;color:var(--fg);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer}.status-action--primary{background:var(--accent);border-color:var(--accent);color:var(--accent-contrast)}.app-main{flex:1;max-width:none;padding:1.5rem max(1rem,env(safe-area-inset-right)) 1.5rem max(1rem,env(safe-area-inset-left))}.app-main__icon{display:block;width:8rem;height:8rem;margin:5% auto -.5rem;object-fit:contain;filter:invert(1)}@media(prefers-color-scheme:dark){.app-main__icon{filter:none}}.app-main h1{font-size:2rem;margin:0 0 .875rem}.app-main p{max-width:36rem}.side-nav{position:fixed;inset:auto 0 0 0;z-index:10;display:flex;gap:.5rem;padding:.625rem max(1rem,env(safe-area-inset-right)) max(.625rem,env(safe-area-inset-bottom)) max(1rem,env(safe-area-inset-left));background:color-mix(in srgb,var(--bg) 88%,transparent);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-top:1px solid var(--border)}.side-nav__link{flex:1;display:flex;align-items:center;justify-content:center;min-height:48px;text-decoration:none;color:var(--fg);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);font-weight:600;font-size:.8125rem;letter-spacing:.12em;text-transform:uppercase;text-align:center;padding:.75rem;transition:background-color .2s ease,border-color .2s ease,color .2s ease}.side-nav__link[aria-current=page]{border-color:var(--accent);color:var(--accent)}.side-nav__link--emergency{background:var(--emergency);border-color:var(--emergency);color:var(--emergency-contrast)}.side-nav__link--emergency[aria-current=page]{color:var(--emergency-contrast)}@media(min-width:701px){.side-nav{inset:0 auto 0 0;width:240px;flex-direction:column;justify-content:space-evenly;padding:1rem;border-top:none;border-right:1px solid var(--border)}.side-nav__link{flex:none;min-height:64px;font-size:1rem}.app-main{margin-left:240px}}@media(max-width:700px){.app-main{padding-bottom:calc(var(--nav-h) + 1.5rem)}}@media(max-width:700px){.app-main{max-width:var(--content-max);width:100%;margin-right:auto}}.emergency-view section{margin-block:1.25rem}.emergency-view h2{font-size:1.375rem;margin:0 0 .625rem}.emergency-view h3{font-size:1.0625rem;margin:.75rem 0 .25rem}.emergency-view ul{margin:.25rem 0 0;padding-inline-start:1.25rem}.emergency-provenance{color:var(--muted);font-size:.875rem;font-weight:600}.emergency-alert{background:var(--emergency);border:1px solid var(--emergency);border-radius:var(--radius);color:var(--emergency-contrast);padding:1rem}.emergency-alert h2{margin-top:0}.emergency-service p{margin:0}.emergency-call{align-items:center;background:var(--emergency-contrast);border-radius:999px;color:var(--emergency);display:inline-flex;font-size:1.25rem;font-weight:800;justify-content:center;margin-top:.75rem;min-height:48px;padding:.625rem 1.25rem;text-decoration:none}.emergency-view>section{padding-bottom:1rem}.home-view{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:calc(100dvh - var(--header-h));transform:translateY(-25%);text-align:center}.home-view h1{font-size:2.5rem;margin:0 0 .25rem}.home-view__subtitle{color:var(--muted);margin:0 0 2rem}.home-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;width:min(100%,28rem)}.home-tile{display:flex;align-items:center;justify-content:center;min-height:120px;padding:1.25rem;text-decoration:none;text-transform:uppercase;letter-spacing:.12em;font-weight:700;font-size:1.0625rem;color:var(--fg);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);transition:background-color .2s ease,border-color .2s ease,transform .2s ease,color .2s ease}.home-tile:hover{background:var(--deck-surface-2);border-color:var(--deck-line-2);transform:translateY(-2px)}.home-tile:focus-visible{outline-offset:3px}.home-tile--emergency{background:var(--emergency);border-color:var(--emergency);color:var(--emergency-contrast)}.home-tile--emergency:hover{background:var(--emergency);border-color:var(--emergency)}@media(max-width:700px){.home-tile{min-height:96px}}.schedule-view h1{margin-bottom:.5rem}.schedule-warning{background:var(--highlight-bg);border:1px solid var(--highlight-border);border-radius:var(--radius-sm);padding:.625rem .875rem;font-weight:600}.schedule-controls{display:flex;flex-direction:column;gap:.625rem;margin-block:1rem}.schedule-days,.schedule-tags{display:flex;flex-wrap:wrap;gap:.5rem}.schedule-day,.schedule-tag,.schedule-mine,.schedule-zone{min-height:48px;padding:.625rem .875rem;font:inherit;font-weight:600;color:var(--fg);background:var(--surface);border:1px solid var(--border);border-radius:999px;cursor:pointer}.schedule-day[aria-pressed=true],.schedule-tag[aria-pressed=true],.schedule-mine[aria-pressed=true]{border-color:var(--accent);color:var(--accent)}.schedule-controls select,.schedule-controls input[type=search]{min-height:48px;padding:.625rem .875rem;font:inherit;color:var(--fg);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.schedule-item{position:relative;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1rem 3.5rem 1rem 1.25rem;margin-block:.75rem}.schedule-item h2{margin:0 0 .25rem;font-size:1.125rem}.schedule-item p{margin:.25rem 0}.schedule-badge{display:inline-block;margin-top:.375rem;padding:.25rem .625rem;border-radius:999px;font-size:.8125rem;font-weight:700;letter-spacing:.04em}.schedule-badge--moved{background:var(--chip-amber-bg);color:var(--chip-amber-fg)}.schedule-badge--cancelled{background:var(--chip-pink-bg);color:var(--chip-pink-fg);text-decoration:line-through}.schedule-fav{position:absolute;top:.75rem;right:.75rem;width:48px;height:48px;font-size:1.5rem;line-height:1;color:var(--fg);background:transparent;border:1px solid var(--border);border-radius:50%;cursor:pointer}.schedule-fav[aria-pressed=true]{color:var(--chip-amber-fg);border-color:var(--highlight-border)}.schedule-conflicts{font-weight:700;color:var(--chip-amber-fg)}.schedule-conflicts:empty{display:none}.view-placeholder{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.25rem;margin-block:1rem}.status-card{background:var(--highlight-bg);border:1px solid var(--highlight-border);border-radius:var(--radius);padding:.875rem 1rem;margin-block:1rem}.chip{display:inline-flex;align-items:center;gap:.375rem;border-radius:999px;padding:.25rem .75rem;font-size:.8125rem;font-weight:700;letter-spacing:.04em}.chip--pink{background:var(--chip-pink-bg);color:var(--chip-pink-fg)}.chip--green,.chip--blue{background:var(--chip-green-bg);color:var(--chip-green-fg)}.chip--indigo{background:var(--chip-indigo-bg);color:var(--chip-indigo-fg)}.chip--amber{background:var(--chip-amber-bg);color:var(--chip-amber-fg)} diff --git a/share/fallback.html b/share/fallback.html new file mode 100644 index 0000000..7000b7e --- /dev/null +++ b/share/fallback.html @@ -0,0 +1,33 @@ + + + + + + Lumen — offline + + + + +

Lumen

+ +

When you are back online, open Lumen to reload the app shell.

+ + diff --git a/share/index.html b/share/index.html new file mode 100644 index 0000000..ab11259 --- /dev/null +++ b/share/index.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + Lumen + + + + +
Loading…
+ + + diff --git a/share/manifest.webmanifest b/share/manifest.webmanifest new file mode 100644 index 0000000..0ea8645 --- /dev/null +++ b/share/manifest.webmanifest @@ -0,0 +1,30 @@ +{ + "name": "Lumen", + "short_name": "Lumen", + "description": "Offline-first festival companion — Emergency, Schedule, Map, Festival", + "display": "standalone", + "scope": "/", + "start_url": "/", + "background_color": "#0b1411", + "theme_color": "#f6f9f7", + "icons": [ + { + "src": "/sol_lunar_icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/share/precache-manifest.json b/share/precache-manifest.json new file mode 100644 index 0000000..88b1bbc --- /dev/null +++ b/share/precache-manifest.json @@ -0,0 +1,16 @@ +{ + "cacheName": "lumen-shell-0.1.0-fcc18ddc", + "precache": [ + "/", + "/index.html", + "/fallback.html", + "/manifest.webmanifest", + "/sol_lunar_icon.svg", + "/icon.png", + "/icon-192.png", + "/icon-512.png", + "/assets/index-CaoEjGwW.js", + "/assets/index-CaoEjGwW.js.map", + "/assets/style-D9rb7TfV.css" + ] +} \ No newline at end of file diff --git a/share/sol_lunar_icon.svg b/share/sol_lunar_icon.svg new file mode 100644 index 0000000..1a9a9ca --- /dev/null +++ b/share/sol_lunar_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/share/start.sh b/share/start.sh new file mode 100755 index 0000000..3191448 --- /dev/null +++ b/share/start.sh @@ -0,0 +1,3 @@ +#!/bin/bash +python3 -m http.server 8080 --directory . +echo "Open http://localhost:8080 in your browser" diff --git a/share/sw.js b/share/sw.js new file mode 100644 index 0000000..cbb1bfa --- /dev/null +++ b/share/sw.js @@ -0,0 +1,140 @@ +/** + * Lumen — minimal hand-written service worker (Stage 3) + * Scope: application shell only. No IDB, no dataset, no sync. + * Trace: ARCHITECTURE-DESIGN.md §8, IMPLEMENTATION-CONTRACT.md §16–§18 + */ + +/* eslint-disable no-restricted-globals -- SW global scope is intentional */ + +/** Cache version — replaced at build time via Vite define/plugin. Deterministic per shell build. */ +const CACHE_NAME = "lumen-shell-0.1.0-fcc18ddc"; + +/** + * Precache list — replaced at build time with versioned shell list + * (index.html, hashed JS/CSS, manifest, icons, fallback.html). + * Placeholder is replaced by Vite plugin after bundle. + */ +const PRECACHE_URLS = [ + "/", + "/index.html", + "/fallback.html", + "/manifest.webmanifest", + "/sol_lunar_icon.svg", + "/icon.png", + "/icon-192.png", + "/icon-512.png", + "/assets/index-CaoEjGwW.js", + "/assets/index-CaoEjGwW.js.map", + "/assets/style-D9rb7TfV.css" +]; + +self.addEventListener("install", (event) => { + // Safe population: atomically cache all shell URLs; if any fails, install fails + // and the new cache is not treated as valid (existing shell remains usable). + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(PRECACHE_URLS)) + // Do NOT call skipWaiting here — wait for explicit message or next full start + // per ARCHITECTURE-DESIGN.md:255 (no mid-session clients.claim). + .catch((err) => { + // Ensure failed install does not leave partial cache as valid: + // delete the newly created cache on failure. + caches.delete(CACHE_NAME).finally(() => { + // re-throw to signal install failure to the browser + throw err; + }); + }), + ); +}); + +self.addEventListener("activate", (event) => { + // Safe replacement: delete old shell caches only after new shell is installed. + // Old shell remains usable until this runs (next full start). + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((k) => k.startsWith("lumen-shell-") && k !== CACHE_NAME) + .map((k) => caches.delete(k)), + ), + ), + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + const url = new URL(req.url); + + // Only handle same-origin GET + if (req.method !== "GET" || url.origin !== self.location.origin) return; + + // Package endpoints are NOT intercepted — app-layer sync controls staging (C-21) + if (url.pathname === "/latest.json" || url.pathname.startsWith("/editions/")) { + return; // let network handle it + } + + // Navigation requests — cache-first against shell cache, fallback to network, then fallback.html + const isNavigation = + req.mode === "navigate" || + req.destination === "document" || + req.headers.get("accept")?.includes("text/html"); + + if (isNavigation) { + event.respondWith( + caches.match(req).then((cached) => { + if (cached) return cached; + return fetch(req) + .then((res) => { + // Optional: opportunistically re-fill cache with fresh index.html on navigation fetch + // but do not block response on cache put. + if (res.ok) { + const clone = res.clone(); + caches.open(CACHE_NAME).then((c) => c.put(req, clone)); + } + return res; + }) + .catch(() => + caches + .match("/fallback.html") + .then((fb) => fb ?? new Response("Offline", { status: 503 })), + ); + }), + ); + return; + } + + // Hashed shell assets (/assets/*) — cache-first (immutable, 1 year per ARCH §24) + if (url.pathname.startsWith("/assets/")) { + event.respondWith( + caches.match(req).then((cached) => { + if (cached) return cached; + return fetch(req).then((res) => { + if (res.ok) { + const clone = res.clone(); + caches.open(CACHE_NAME).then((c) => c.put(req, clone)); + } + return res; + }); + }), + ); + return; + } + + // Other same-origin GETs (manifest, icons) — cache-first with network fallback + event.respondWith( + caches.match(req).then((cached) => { + if (cached) return cached; + return fetch(req).catch(() => caches.match(req)); + }), + ); +}); + +self.addEventListener("message", (event) => { + const data = event.data; + if (typeof data === "object" && data !== null && "type" in data && data.type === "SKIP_WAITING") { + self.skipWaiting(); + } +}); diff --git a/src/app/clock.ts b/src/app/clock.ts new file mode 100644 index 0000000..fa88611 --- /dev/null +++ b/src/app/clock.ts @@ -0,0 +1,55 @@ +/** + * App clock — session monotonic base plus persisted-skew access. + * The base lives in module memory (performance.now resets per page load); + * skew persists in lumen-system across boots. No timers (C-19). + * Trace: IMPLEMENTATION-CONTRACT.md §21, SPIKE-08 §3.5. + */ +import { openSystemDB, readSystemMeta, writeSystemMeta } from "../data/system-meta/store.js"; +import { resolveClock } from "../domain/clock/service.js"; +import type { ClockReading, ClockSession } from "../domain/clock/service.js"; +import type { ClockSkew } from "../domain/clock/types.js"; + +let session: ClockSession | null = null; + +/** Test seam — drop the in-memory monotonic anchor. */ +export function resetClockSession(): void { + session = null; +} + +export function currentClock( + reading: ClockReading, + skew: ClockSkew | null, +): { readonly now: number; readonly driftWarn: boolean } { + const resolved = resolveClock(reading, skew, session); + session = resolved.session; + return resolved; +} + +/** Device wall clock + monotonic reading for the current render. */ +export function deviceReading(): ClockReading { + return { deviceNow: Date.now(), monoNow: performance.now() }; +} + +export async function loadPersistedSkew(): Promise { + try { + const db = await openSystemDB(); + try { + return (await readSystemMeta(db)).clockSkew; + } finally { + db.close(); + } + } catch { + return null; + } +} + +/** Persist a captured skew sample (Stage 15 sync calls this per response). */ +export async function persistSkew(skew: ClockSkew): Promise { + const db = await openSystemDB(); + try { + const meta = await readSystemMeta(db); + await writeSystemMeta(db, { ...meta, clockSkew: skew }); + } finally { + db.close(); + } +} diff --git a/src/app/emergency.ts b/src/app/emergency.ts new file mode 100644 index 0000000..8c70be8 --- /dev/null +++ b/src/app/emergency.ts @@ -0,0 +1,59 @@ +/** + * Stage 10 app-layer loader — reads the active slot's emergency section for + * Tier 2 rendering. Local IDB only, never network. Never throws: any failure + * degrades to the floor (the view's resolveEmergency handles the rest). + * Trace: IMPLEMENTATION-CONTRACT.md §14, SPIKE-06. + */ +import { openSystemDB, readSystemMeta } from "../data/system-meta/store.js"; +import { openSlotDB, readSlotFile, readSlotManifest } from "../data/slot/store.js"; +import { isSupportedEmergencySchema, narrowEmergencySection } from "../domain/emergency/logic.js"; +import type { VerifiedEmergencyData } from "../domain/emergency/types.js"; + +export type DatasetEmergencyLoad = + /** No active dataset — floor with no-dataset guidance. */ + | { readonly status: "absent" } + /** Active dataset but its emergency section is unusable. */ + | { readonly status: "unusable"; readonly reason: "unsupported-schema" | "malformed-section" } + /** Verified-shape section ready for Tier 2 resolution. */ + | { readonly status: "ready"; readonly data: VerifiedEmergencyData }; + +export async function loadDatasetEmergency(): Promise { + try { + const system = await openSystemDB(); + let activeSlot; + let packageVersion: number | null; + try { + const meta = await readSystemMeta(system); + activeSlot = meta.activeSlot; + packageVersion = meta.activePackageVersion; + } finally { + system.close(); + } + if (!activeSlot || packageVersion === null) return { status: "absent" }; + + const slot = await openSlotDB(activeSlot); + try { + const json = await readSlotFile(slot, "emergency"); + const section = narrowEmergencySection(json); + if (!section) return { status: "unusable", reason: "malformed-section" }; + if (!isSupportedEmergencySchema(section.emergencySchemaVersion)) { + return { status: "unusable", reason: "unsupported-schema" }; + } + const manifest = await readSlotManifest(slot).catch(() => undefined); + return { + status: "ready", + data: { + section, + packageVersion, + generatedAt: manifest?.generatedAt ?? "unknown date", + emergencySchemaVersion: section.emergencySchemaVersion, + }, + }; + } finally { + slot.close(); + } + } catch { + // Storage errors degrade to the floor — emergency never blanks (I-1). + return { status: "absent" }; + } +} diff --git a/src/app/layout.ts b/src/app/layout.ts index e5aa37b..21cb5a7 100644 --- a/src/app/layout.ts +++ b/src/app/layout.ts @@ -2,11 +2,12 @@ * Shared app layout — phone-first shell. * Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207/783 */ -import { HOME_ROUTE, type RouteId } from "../ui/router/router.js"; +import { HOME_ROUTE, ROUTES, type RouteId } from "../ui/router/router.js"; export function createLayout(): { root: HTMLDivElement; main: HTMLElement; + nav: HTMLElement; brand: HTMLAnchorElement; statusChip: HTMLDivElement; skipLink: HTMLAnchorElement; @@ -28,11 +29,25 @@ export function createLayout(): { const brand = document.createElement("a"); brand.href = HOME_ROUTE.path; brand.className = "app-header__brand"; - brand.setAttribute("aria-label", "Lumen — home"); + brand.setAttribute("aria-label", "Solarpunk — home"); brand.setAttribute("data-route", HOME_ROUTE.path); - brand.textContent = "LUMEN"; + brand.textContent = "SOLARPUNK"; header.append(brand); + const themeBtn = document.createElement("button"); + themeBtn.className = "theme-btn"; + themeBtn.textContent = "☀"; + themeBtn.setAttribute("aria-label", "Toggle light/dark theme"); + themeBtn.onclick = () => { + const html = document.documentElement; + if (html.classList.contains("dark")) { html.classList.remove("dark"); html.classList.add("light"); localStorage.setItem("theme","light"); themeBtn.textContent="☀"; } + else { document.documentElement.classList.remove("light"); document.documentElement.classList.add("dark"); localStorage.setItem("theme","dark"); themeBtn.textContent="◐"; } + }; + // Apply saved theme on load + if (localStorage.getItem("theme") === "dark") { document.documentElement.classList.add("dark"); document.documentElement.classList.remove("light"); themeBtn.textContent="◐"; } + else { document.documentElement.classList.add("light"); document.documentElement.classList.remove("dark"); themeBtn.textContent="☀"; } + header.append(themeBtn); + const statusChip = document.createElement("div"); statusChip.className = "app-header__status"; statusChip.setAttribute("role", "status"); @@ -54,7 +69,14 @@ export function createLayout(): { main.prepend(pageIcon); root.append(main); - return { root, main, brand, statusChip, skipLink }; + const nav = document.createElement("nav"); + nav.className = "side-nav"; + nav.setAttribute("aria-label", "Primary navigation"); + nav.setAttribute("role", "navigation"); + // Bottom navigation removed — redundant with main-screen buttons. + root.append(nav); + + return { root, main, nav, brand, statusChip, skipLink }; } export function setActiveNav(nav: HTMLElement, activeId: RouteId): void { diff --git a/src/app/main.ts b/src/app/main.ts index 50eeff6..c80e177 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -1,30 +1,67 @@ /** - * Lumen — Stage 2/3 application shell entry point. - * Minimal boot: layout + router + four placeholder views + SW registration (Stage 3). - * No IDB / dataset / sync per Stage 3 SCOPE — SW is shell-only. + * Lumen — Stage 9 application shell entry point. + * Boot: layout + router + views immediately (never blocked on network), + * then async local readiness evaluation (C1…C8) drives the header chip and + * the Status screen. Evaluation reads Cache Storage + IndexedDB only — + * going offline can never break it (I-8). */ import "./styles.css"; -import { registerSW } from "../platform/sw/register.js"; -import { createLayout } from "./layout.js"; +import { deleteShellCaches } from "../platform/cache/shell-cache.js"; +import { registerSW, unregisterAllServiceWorkers } from "../platform/sw/register.js"; +import { evaluateBootReadiness } from "./readiness.js"; +import type { BootReadinessReport } from "../domain/readiness/types.js"; +import { chipForState } from "../domain/readiness/logic.js"; +import { loadDatasetEmergency } from "./emergency.js"; +import type { EmergencyViewModel } from "../domain/emergency/types.js"; +import { demotedBaseline, resolveEmergency } from "../domain/emergency/logic.js"; +import { loadScheduleData } from "./schedule.js"; +import type { ScheduleData } from "./schedule.js"; +import { currentClock, deviceReading } from "./clock.js"; +import { deviceTimeZone } from "../domain/clock/logic.js"; +import { shouldWarnOutsideWindow } from "../domain/clock/logic.js"; +import { openUserDB, addFavorite, removeFavorite, putPrefs } from "../data/user/store.js"; +import { createScheduleView } from "../ui/views/schedule/schedule.js"; +import type { ScheduleViewActions, ScheduleViewInput } from "../ui/views/schedule/schedule.js"; +import { restorePreviousSlot } from "../sync/activation.js"; +import { createLayout, setActiveNav } from "./layout.js"; import { Router, routeForPath, normalizePath } from "../ui/router/router.js"; import { createHomeView } from "../ui/views/home/home.js"; +import { createStatusView, createPrepGuidanceView } from "../ui/views/status/status.js"; import { createEmergencyView } from "../ui/views/emergency/emergency.js"; -import { createScheduleView } from "../ui/views/schedule/schedule.js"; import { createMapView } from "../ui/views/map/map.js"; import { createFestivalView } from "../ui/views/festival/festival.js"; import { createNotFoundView } from "../ui/views/not-found.js"; -function renderView(path: string): HTMLElement { +export interface RouteData { + readonly emergency?: EmergencyViewModel | undefined; + readonly schedule?: + | { + readonly input: ScheduleViewInput | null; + readonly actions: ScheduleViewActions; + } + | undefined; +} + +const noopScheduleActions: ScheduleViewActions = { + onToggleFavorite: () => false, + // No schedule payload exists on this path, so the zone toggle never renders. + onZoneToggle: () => undefined, +}; + +function renderView(path: string, data?: RouteData): HTMLElement { const id = routeForPath(path); switch (id) { case "home": return createHomeView(); case "emergency": - return createEmergencyView(); + return createEmergencyView(data?.emergency); case "schedule": - return createScheduleView(); + return createScheduleView( + data?.schedule?.input ?? null, + data?.schedule?.actions ?? noopScheduleActions, + ); case "map": - return createMapView(); + return createMapView(null); case "festival": return createFestivalView(); case "not-found": @@ -36,35 +73,208 @@ function mount(): { router: Router; cleanup: () => void } { const appHost = document.querySelector("#app"); if (!appHost) throw new Error("#app host missing — shell invariant"); - const { root, main, brand, statusChip } = createLayout(); + const { root, main, nav, brand, statusChip } = createLayout(); appHost.replaceChildren(root); appHost.removeAttribute("aria-busy"); - // Stage 2/3 status placeholder — real readiness C1…C8 comes in Stage 9 (readiness) - statusChip.textContent = "Shell"; - statusChip.setAttribute("aria-label", "Offline status: shell only"); + // Stage 9 readiness: evaluate local evidence (never network) and paint the + // chip; the chip opens the Status screen. Boot render never waits for this. + // Stage 10 emergency: the dataset tier resolves alongside readiness; the + // route renders the floor instantly and upgrades when Tier 2 lands. + // Stage 11 schedule: section + favorites + clock snapshot resolve the same + // way; the route renders the not-downloaded card until data lands. + let latestReport: BootReadinessReport | null = null; + let latestEmergency: EmergencyViewModel | undefined; + let latestSchedule: ScheduleData | null = null; + let favIds = new Set(); + let useDeviceZone = false; + let statusDialog: HTMLDialogElement | null = null; + + /** Resolve Tier 2 from the active slot — floor covers every failure. */ + async function resolveDatasetEmergency(): Promise { + const loaded = await loadDatasetEmergency(); + if (loaded.status === "ready") return resolveEmergency(loaded.data); + if (loaded.status === "unusable") return demotedBaseline(loaded.reason); + return resolveEmergency(); + } + + function paintChip(report: BootReadinessReport): void { + const detail = report.activationPending + ? "staged data awaiting activation" + : (report.reason ?? report.failedOp ?? report.missing[0] ?? undefined); + const chip = chipForState(report.state, detail); + statusChip.textContent = chip.text; + statusChip.className = `app-header__status app-header__status--${chip.tone}`; + statusChip.setAttribute("aria-label", `${chip.label} Activate for status details.`); + } + + function showStatus(report: BootReadinessReport): void { + if (!statusDialog) { + statusDialog = document.createElement("dialog"); + statusDialog.className = "status-dialog"; + statusDialog.setAttribute("aria-label", "Offline status details"); + // Native ESC/cancel bypasses our Close button — still return focus. + statusDialog.addEventListener("close", () => { + statusChip.focus({ preventScroll: true }); + }); + root.append(statusDialog); + } + const dialog = statusDialog; + const close = (): void => { + dialog.close(); + }; + const back = (): void => { + if (latestReport) showStatus(latestReport); + }; + dialog.replaceChildren( + createStatusView(report, { + onCheckData: () => void refreshReadiness(true), + onGetData: () => { + dialog.replaceChildren(createPrepGuidanceView(back, close)); + }, + onRestore: report.canRestore + ? () => { + void restorePreviousSlot() + .catch(() => false) + .then(() => refreshReadiness(true)); + } + : null, + onClose: close, + }), + ); + if (!dialog.open) dialog.showModal(); + } + + async function refreshReadiness(openAfter: boolean): Promise { + const report = await evaluateBootReadiness(); + latestReport = report; + try { + latestEmergency = await resolveDatasetEmergency(); + } catch { + latestEmergency = undefined; + } + try { + latestSchedule = await loadScheduleData(); + favIds = new Set(latestSchedule?.favorites.map((f) => f.eventId) ?? []); + useDeviceZone = latestSchedule?.prefs?.useDeviceTimezone ?? false; + } catch { + latestSchedule = null; + } + // The shell may have been torn down (tests) — never throw from boot. + if (!statusChip.isConnected && !root.isConnected) return; + paintChip(report); + // Upgrade data routes in place when their payload lands after boot. + // Safe: refresh always resumes after mount's synchronous body completes, + // so `router`/`render` below are initialized before this line can run. + if (!openAfter && (router.getRouteId() === "emergency" || router.getRouteId() === "schedule")) { + render(); + } + if (openAfter) showStatus(report); + } + + /** Fresh clock snapshot + zone for the schedule view (per render, no timers). */ + function buildScheduleInput(): ScheduleViewInput | null { + if (!latestSchedule) return null; + const { section, window, skew } = latestSchedule; + const { now, driftWarn } = currentClock(deviceReading(), skew); + return { + section, + favorites: favIds, + clock: { + now, + zone: useDeviceZone ? deviceTimeZone() : window.timezone, + festivalZone: window.timezone, + useDeviceZone, + outsideWarn: shouldWarnOutsideWindow( + now, + { startUtc: window.startUtc, endUtc: window.endUtc, timezone: window.timezone }, + skew, + ), + driftWarn, + }, + }; + } + + /** Write-through favorite toggle — resolves false when storage refuses. */ + async function persistFavorite(eventId: string, favored: boolean): Promise { + try { + const db = await openUserDB(); + try { + if (favored) { + favIds.add(eventId); + await addFavorite(db, { eventId, addedAt: Date.now() }); + } else { + favIds.delete(eventId); + await removeFavorite(db, eventId); + } + } finally { + db.close(); + } + return true; + } catch { + if (favored) favIds.delete(eventId); + else if (latestSchedule) { + for (const fav of latestSchedule.favorites) { + if (fav.eventId === eventId) favIds.add(eventId); + } + } + return false; + } + } + + async function persistZoneMode(useDevice: boolean): Promise { + useDeviceZone = useDevice; + try { + const db = await openUserDB(); + try { + const prefs = latestSchedule?.prefs; + await putPrefs(db, { + festivalTimezone: prefs?.festivalTimezone ?? latestSchedule?.window.timezone ?? "UTC", + useDeviceTimezone: useDevice, + ...(prefs?.theme !== undefined ? { theme: prefs.theme } : {}), + }); + } finally { + db.close(); + } + } catch { + // Zone preference is enhancement — the toggle still applies this session. + } + if (router.getRouteId() === "schedule") render(); + } + + function scheduleActions(): ScheduleViewActions { + return { + onToggleFavorite: (eventId, favored) => persistFavorite(eventId, favored), + onZoneToggle: (useDevice) => void persistZoneMode(useDevice), + }; + } + + statusChip.textContent = "…"; + statusChip.setAttribute("tabindex", "0"); + statusChip.setAttribute("title", "View offline status details"); + // Always re-evaluate on open: the boot-time report may be stale (eviction, + // updates, or failures since boot must show, never a cached verdict). + const openFromChip = (): void => { + void refreshReadiness(true); + }; + statusChip.addEventListener("click", openFromChip); + statusChip.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openFromChip(); + } + }); if (import.meta.env.DEV) { // Dev mode: the SW's cache-first strategy would otherwise freeze dev // sources (unhashed /src/* URLs) at their first-fetched version — stale // shells/styles keep "reappearing" after edits. Never register in dev, // and scrub any shell cache + SW left by an earlier dev session. - if (typeof navigator !== "undefined" && "serviceWorker" in navigator) { - void navigator.serviceWorker - .getRegistrations() - .then(async (regs) => { - const hadController = Boolean(navigator.serviceWorker.controller); - const unregistered = await Promise.all(regs.map((r) => r.unregister())); - if (hadController && unregistered.some(Boolean)) location.reload(); - }); - } - if (typeof caches !== "undefined") { - void caches - .keys() - .then((keys) => - Promise.all(keys.filter((k) => k.startsWith("lumen-shell-")).map((k) => caches.delete(k))), - ); - } + // Raw browser globals stay behind the platform seam (B-1). + void unregisterAllServiceWorkers().then((shouldReload) => { + if (shouldReload) location.reload(); + }); + void deleteShellCaches(); } else { // Stage 3: register SW after boot — never blocks rendering (offline-safe) void registerSW().catch(() => { @@ -78,13 +288,17 @@ function mount(): { router: Router; cleanup: () => void } { function render(): void { const path = router.getPath(); const id = router.getRouteId(); + setActiveNav(nav, id); // Brand is the home/back button: highlighted at home, chevron on destination pages. const isHome = id === "home"; brand.classList.toggle("app-header__brand--back", !isHome); brand.classList.toggle("app-header__brand--active", isHome); if (isHome) brand.removeAttribute("aria-current"); else brand.setAttribute("aria-current", "page"); - const view = renderView(path); + const view = renderView(path, { + emergency: latestEmergency, + schedule: { input: buildScheduleInput(), actions: scheduleActions() }, + }); main.replaceChildren(view); const pageIcon = document.createElement("img"); pageIcon.src = "/sol_lunar_icon.svg?v=5"; @@ -118,6 +332,8 @@ function mount(): { router: Router; cleanup: () => void } { const detachPopstate = router.attach(); render(); + // Boot evaluation runs after first render — the shell never waits for it. + void refreshReadiness(false); return { router, diff --git a/src/app/map.ts b/src/app/map.ts new file mode 100644 index 0000000..3f6364c --- /dev/null +++ b/src/app/map.ts @@ -0,0 +1,135 @@ +/** + * Stage 12 app-layer loader — map section plus Blob→object-URL levels. + * Local IDB only, never network. Never throws: null means "no map on this + * device" and partial failures degrade to overview-only or list-only. + * URL lifecycle (SPIKE-03 F-3): the loader creates per-level URLs; the + * caller owns revoke() and calls it when the handle is superseded. + * Trace: IMPLEMENTATION-CONTRACT.md §22. + */ +import { openSystemDB, readSystemMeta } from "../data/system-meta/store.js"; +import { openSlotDB, readSlotAsset, readSlotFile } from "../data/slot/store.js"; +import { narrowMapSection, selectLevels } from "../domain/map/logic.js"; +import type { MapLevelView, MapViewData } from "../domain/map/types.js"; + +export interface MapUrlFactory { + readonly createUrl: (blob: Blob) => string; + readonly revokeUrl: (url: string) => void; +} + +const defaultUrls: MapUrlFactory = { + createUrl: (blob) => URL.createObjectURL(blob), + revokeUrl: (url) => URL.revokeObjectURL(url), +}; + +export interface MapHandle { + readonly data: MapViewData | null; + readonly revoke: () => void; +} + +export async function loadMapData(urls: MapUrlFactory = defaultUrls): Promise { + const created: string[] = []; + const revoke = (): void => { + for (const url of created) { + try { + urls.revokeUrl(url); + } catch { + // Revocation is best-effort cleanup — never fail the app for it. + } + } + created.length = 0; + }; + try { + const data = await load(urls, created); + return { data, revoke }; + } catch { + revoke(); + return { data: null, revoke: () => {} }; + } +} + +async function levelView( + urls: MapUrlFactory, + created: string[], + open: (assetId: string) => Promise, + assetId: string, + nightAssetId: string | undefined, + width: number, + height: number, + id: string, +): Promise { + const blob = await open(assetId); + if (!blob) return null; + const url = urls.createUrl(blob); + created.push(url); + let nightUrl: string | null = null; + if (nightAssetId) { + const night = await open(nightAssetId); + if (night) { + nightUrl = urls.createUrl(night); + created.push(nightUrl); + } + } + return { id, url, nightUrl, width, height }; +} + +async function load(urls: MapUrlFactory, created: string[]): Promise { + const system = await openSystemDB(); + let activeSlot; + try { + activeSlot = (await readSystemMeta(system)).activeSlot; + } finally { + system.close(); + } + if (!activeSlot) return null; + + const slot = await openSlotDB(activeSlot); + try { + const section = narrowMapSection(await readSlotFile(slot, "map")); + if (!section) return null; + const selection = selectLevels(section); + const open = async (assetId: string): Promise => { + try { + return (await readSlotAsset(slot, assetId))?.blob ?? null; + } catch { + return null; + } + }; + + let overview: MapLevelView | null = null; + if (selection.overview) { + overview = await levelView( + urls, + created, + open, + selection.overview.assetId, + selection.overview.nightAssetId, + selection.overview.width, + selection.overview.height, + selection.overview.id, + ); + } + let detail: MapLevelView | null = null; + if (overview && selection.detail) { + detail = await levelView( + urls, + created, + open, + selection.detail.assetId, + selection.detail.nightAssetId, + selection.detail.width, + selection.detail.height, + selection.detail.id, + ); + } + return { + mapAvailable: overview !== null, + overview, + detail, + pois: section.pois, + categories: section.categories, + overviewOnly: overview !== null && detail === null, + }; + } finally { + slot.close(); + } +} diff --git a/src/app/readiness.ts b/src/app/readiness.ts new file mode 100644 index 0000000..af1128b --- /dev/null +++ b/src/app/readiness.ts @@ -0,0 +1,593 @@ +/** + * Stage 9 boot evaluator — gathers C1–C8 evidence from local stores only and + * maps it through the pure predicate. + * + * Rules honored here, not just in docs: + * - No network, no fetch, no clocks for verdicts (time-independent READY). + * - `now` is used ONLY for staging-age expiry and display timestamps. + * - Never throws: every failure mode maps to a report state (no-blank-screen). + * - Domain logic stays pure; all I/O lives in this app-layer composer (B-2). + * + * Trace: SPIKE-05 §2–§5, IMPLEMENTATION-CONTRACT.md §20.1–§20.5. + */ +import { checkShellCache } from "../platform/cache/shell-cache.js"; +import { getStorageEstimate } from "../platform/idb/storage-helpers.js"; +import type { StorageEstimate } from "../platform/idb/storage-helpers.js"; +import type { SlotId } from "../platform/idb/names.js"; +import { openSystemDB, readSystemMeta } from "../data/system-meta/store.js"; +import { + openSlotDB, + readSlotAsset, + readSlotFile, + readSlotFileMeta, + readSlotManifest, + readStagingProgress, +} from "../data/slot/store.js"; +import type { FestivalManifest, SectionEntry } from "../data/festival-package/types.js"; +import { PACKAGE_FORMAT } from "../data/festival-package/types.js"; +import { isCompatible } from "../data/festival-package/validation.js"; +import { loadEmergencyFloor } from "../emergency-baseline/loader.js"; +import { STAGING_MAX_AGE_MS, recoverPendingActivation } from "../sync/activation.js"; +import { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from "../domain/readiness/config.js"; +import { CANONICAL_SECTIONS } from "../domain/readiness/types.js"; +import { evaluateReadiness } from "../domain/readiness/logic.js"; +import type { + BootReadinessReport, + ReadinessEvidence, + SectionStatus, +} from "../domain/readiness/types.js"; + +export interface ShellEvidence { + readonly ok: boolean; + readonly cacheName: string | null; +} + +export interface FloorEvidence { + readonly ok: boolean; + readonly version: string | null; + readonly bytes: number; +} + +export interface BootReadinessDeps { + readonly openSystem: () => Promise; + readonly openSlot: (slot: SlotId) => Promise; + readonly checkShell: () => Promise; + readonly loadFloor: () => FloorEvidence; + readonly estimate: () => Promise; + readonly appVersion: string; + readonly supportedSchemaRange: readonly number[]; + /** ONLY for staging-age expiry + display — never for verdicts. */ + readonly now: () => number; +} + +export const defaultBootDeps: BootReadinessDeps = { + openSystem: openSystemDB, + openSlot: openSlotDB, + checkShell: checkShellCache, + loadFloor: () => { + const loaded = loadEmergencyFloor(); + return loaded.ok + ? { ok: true, version: loaded.floor.floorVersion, bytes: loaded.bytes } + : { ok: false, version: null, bytes: 0 }; + }, + estimate: getStorageEstimate, + appVersion: APP_VERSION, + supportedSchemaRange: SUPPORTED_SCHEMA_RANGE, + now: () => Date.now(), +}; + +/** Re-exported for app-layer consumers — canonical home is domain/readiness. */ +export type { BootReadinessReport, SectionStatus } from "../domain/readiness/types.js"; + +function failedReport( + op: string, + floor: FloorEvidence, + storage: StorageEstimate | null, +): BootReadinessReport { + return { + ...evaluateReadiness({ + shellValid: false, + datasetPresent: false, + authenticity: "record-missing", + integrity: "files-missing", + schemaCompatible: false, + requiredSectionsOk: false, + requiredAssetsOk: false, + emergencyFloorOk: floor.ok, + missing: [], + hadVerifiedDataset: false, + manifestOk: false, + incompatible: false, + failure: { op }, + presentSections: [], + stagedPending: null, + }), + edition: null, + packageVersion: null, + generatedAt: null, + fetchedAt: null, + sections: [], + assets: { total: 0, present: 0, missing: [] }, + floorVersion: floor.version, + storage, + shellCache: null, + canRestore: false, + activationPending: false, + }; +} + +function baselineOnlyReport( + floor: FloorEvidence, + storage: StorageEstimate | null, +): BootReadinessReport { + return { + state: "BASELINE_ONLY", + reason: null, + missing: [], + failedOp: null, + level: "L0", + edition: null, + packageVersion: null, + generatedAt: null, + fetchedAt: null, + sections: [], + assets: { total: 0, present: 0, missing: [] }, + floorVersion: floor.version, + storage, + shellCache: null, + canRestore: false, + activationPending: false, + }; +} + +/** + * Forward-tolerant asset-reference narrowing for the C7 check: the inventory + * was schema-verified at staging; boot only needs id+bytes without trusting + * its shape. Unknown entries are ignored, never crash (I-2/Ring-0 ethos). + */ +function narrowAssetRefs(value: unknown): readonly { id: string; bytes: number }[] { + if (typeof value !== "object" || value === null) return []; + const listed: unknown = (value as { assets?: unknown }).assets; + if (!Array.isArray(listed)) return []; + return listed + .map((item: unknown) => { + const entry = item as { id?: unknown; bytes?: unknown }; + return typeof entry.id === "string" && typeof entry.bytes === "number" + ? { id: entry.id, bytes: entry.bytes } + : null; + }) + .filter((entry): entry is { id: string; bytes: number } => entry !== null); +} + +/** + * Structural manifest check: the staged manifest must be the one the journal + * records (same edition + package version, V1 format). Parameter is + * explicitly nullable so the optional chain below is type-honest. + */ +function isUsableManifest( + manifest: FestivalManifest | undefined, + journal: { edition: string; packageVersion: number }, +): manifest is FestivalManifest { + return ( + manifest?.format === PACKAGE_FORMAT && + manifest.edition === journal.edition && + manifest.packageVersion === journal.packageVersion + ); +} + +async function otherSlotComplete( + openSlot: BootReadinessDeps["openSlot"], + slot: SlotId, +): Promise { + try { + const db = await openSlot(slot); + try { + const journal = await readStagingProgress(db); + return Boolean( + journal?.complete && + CANONICAL_SECTIONS.every((id) => journal.stagedFiles.includes(id as never)), + ); + } finally { + db.close(); + } + } catch { + return false; + } +} + +async function findRecentStaging( + openSlot: BootReadinessDeps["openSlot"], + now: number, +): Promise<{ slot: SlotId; staged: readonly string[]; complete: boolean } | null> { + for (const slot of ["A", "B"] as const) { + try { + const db = await openSlot(slot); + try { + const journal = await readStagingProgress(db); + if (journal && now - journal.lastProgressAt <= STAGING_MAX_AGE_MS) { + return { slot, staged: journal.stagedFiles, complete: journal.complete }; + } + } finally { + db.close(); + } + } catch { + // unreadable slot — not resumable, try the next + } + } + return null; +} + +export async function evaluateBootReadiness( + deps: BootReadinessDeps = defaultBootDeps, +): Promise { + const floor = deps.loadFloor(); + let storage: StorageEstimate | null = null; + try { + storage = await deps.estimate(); + } catch { + storage = null; + } + + try { + return await evaluate(deps, floor, storage); + } catch { + return failedReport("boot-check", floor, storage); + } +} + +async function evaluate( + deps: BootReadinessDeps, + floor: FloorEvidence, + storage: StorageEstimate | null, +): Promise { + const shell = await deps.checkShell().catch(() => ({ ok: false, cacheName: null })); + + // Storage probe: an unopenable system DB means nothing can persist. + let system: IDBDatabase; + try { + system = await deps.openSystem(); + } catch { + return baselineOnlyReport(floor, storage); + } + + try { + let meta = await readSystemMeta(system); + + if (meta.readbackPending) { + try { + await recoverPendingActivation(); + const reopened = await deps.openSystem(); + system.close(); + system = reopened; + meta = await readSystemMeta(system); + } catch { + return { ...failedReport("boot-check", floor, storage), shellCache: shell.cacheName }; + } + } + + if (!meta.activeSlot) { + return await evaluateNoActivePointer(deps, floor, storage, shell, meta.verification !== null); + } + + const activeSlot = meta.activeSlot; + let slot: IDBDatabase; + try { + slot = await deps.openSlot(activeSlot); + } catch { + return { ...failedReport("boot-check", floor, storage), shellCache: shell.cacheName }; + } + + try { + return await evaluateActiveSlot(deps, floor, storage, shell, meta, slot, activeSlot); + } finally { + slot.close(); + } + } finally { + system.close(); + } +} + +async function evaluateNoActivePointer( + deps: BootReadinessDeps, + floor: FloorEvidence, + storage: StorageEstimate | null, + shell: ShellEvidence, + hadVerifiedDataset: boolean, +): Promise { + if (hadVerifiedDataset) { + // A verification record outlived its slots — previously verified, now gone. + const result = evaluateReadiness( + baseEvidence(floor, shell, { + datasetPresent: false, + hadVerifiedDataset: true, + }), + ); + return { + ...result, + edition: null, + packageVersion: null, + generatedAt: null, + fetchedAt: null, + sections: [], + assets: { total: 0, present: 0, missing: [] }, + floorVersion: floor.version, + storage, + shellCache: shell.cacheName, + canRestore: false, + activationPending: false, + }; + } + + const staging = await findRecentStaging(deps.openSlot, deps.now()); + if (staging) { + const missing = CANONICAL_SECTIONS.filter((id) => !staging.staged.includes(id)); + // Fully staged but never activated (kill between staging and flip): + // still PARTIAL with empty missing; the activationPending flag tells the + // Status screen. Activation itself belongs to Stage 15 sync. + const result = evaluateReadiness( + baseEvidence(floor, shell, { + datasetPresent: false, + hadVerifiedDataset: false, + requiredSectionsOk: staging.complete && missing.length === 0, + requiredAssetsOk: staging.complete && missing.length === 0, + missing, + presentSections: [...staging.staged], + stagedPending: { missing }, + }), + ); + return { + ...result, + edition: null, + packageVersion: null, + generatedAt: null, + fetchedAt: null, + sections: [], + assets: { total: 0, present: 0, missing: [] }, + floorVersion: floor.version, + storage, + shellCache: shell.cacheName, + canRestore: false, + activationPending: staging.complete && missing.length === 0, + }; + } + + const result = evaluateReadiness( + baseEvidence(floor, shell, { + datasetPresent: false, + hadVerifiedDataset: false, + }), + ); + return { + ...result, + edition: null, + packageVersion: null, + generatedAt: null, + fetchedAt: null, + sections: [], + assets: { total: 0, present: 0, missing: [] }, + floorVersion: floor.version, + storage, + shellCache: shell.cacheName, + canRestore: false, + activationPending: false, + }; +} + +function baseEvidence( + floor: FloorEvidence, + shell: ShellEvidence, + patch: Partial, +): ReadinessEvidence { + return { + shellValid: shell.ok, + datasetPresent: false, + authenticity: "record-missing", + integrity: "files-missing", + schemaCompatible: false, + requiredSectionsOk: false, + requiredAssetsOk: false, + emergencyFloorOk: floor.ok, + missing: [], + hadVerifiedDataset: false, + manifestOk: true, + incompatible: false, + failure: null, + presentSections: [], + stagedPending: null, + ...patch, + }; +} + +async function evaluateActiveSlot( + deps: BootReadinessDeps, + floor: FloorEvidence, + storage: StorageEstimate | null, + shell: ShellEvidence, + meta: Awaited>, + slot: IDBDatabase, + activeSlot: SlotId, +): Promise { + const fail = (op: string): BootReadinessReport => ({ + ...failedReport(op, floor, storage), + shellCache: shell.cacheName, + }); + + let journal: Awaited>; + let manifest: FestivalManifest | undefined; + try { + journal = await readStagingProgress(slot); + manifest = await readSlotManifest(slot); + } catch { + return fail("boot-check"); + } + + const verification = meta.verification; + const missing: string[] = []; + const sections: SectionStatus[] = []; + const presentSections: string[] = []; + + // Journal probe first: without staging evidence the pointer cannot be + // corroborated. After this block `journal` is defined (direct access below). + if (!journal) { + let anyFilePresent = false; + for (const id of CANONICAL_SECTIONS) { + try { + if (await readSlotFileMeta(slot, id as never)) { + anyFilePresent = true; + break; + } + } catch { + return fail("boot-check"); + } + } + // Pointer claims a dataset but the slot carries no staging evidence: + // files gone → loss; files present without a journal → inconsistency. + const result = evaluateReadiness( + baseEvidence(floor, shell, { + datasetPresent: true, + authenticity: "mismatch", + integrity: anyFilePresent ? "corrupt" : "files-missing", + hadVerifiedDataset: true, + presentSections: [], + }), + ); + return { + ...result, + edition: meta.activeEdition, + packageVersion: meta.activePackageVersion, + generatedAt: null, + fetchedAt: null, + sections: [], + assets: { total: 0, present: 0, missing: [] }, + floorVersion: floor.version, + storage, + shellCache: shell.cacheName, + canRestore: await otherSlotComplete(deps.openSlot, activeSlot === "A" ? "B" : "A"), + activationPending: false, + }; + } + + // C3 authenticity: recorded verification must match the staged journal. + // No crypto at boot — hashes were verified at staging and recorded (F-2). + let authenticity: ReadinessEvidence["authenticity"] = "verified"; + if (!verification) { + authenticity = "record-missing"; + } else if ( + journal.edition !== meta.activeEdition || + journal.packageVersion !== meta.activePackageVersion || + journal.manifestSha256 !== verification.manifestSha256 + ) { + authenticity = "mismatch"; + } + + // Manifest structural check — the evidence C4/C5/C6 read from. + const manifestOk = isUsableManifest(manifest, journal); + + // C4 sizes + C6 presence/parseability over required sections. + let integrity: ReadinessEvidence["integrity"] = "ok"; + let requiredSectionsOk = true; + if (manifestOk && manifest) { + // Defensive view: staged manifests passed pipeline gates, but boot must + // tolerate corrupt or future shapes the static type cannot see. + const entries = manifest.sections as Partial>; + for (const id of CANONICAL_SECTIONS) { + const entry = entries[id]; + const required = entry?.required !== false; + let present = false; + let parseable = false; + try { + const fileMeta = await readSlotFileMeta(slot, id as never); + present = Boolean(fileMeta && (!entry || fileMeta.bytes === entry.bytes)); + if (fileMeta && entry && fileMeta.bytes !== entry.bytes) integrity = "corrupt"; + if (!fileMeta) { + if (required) { + integrity = integrity === "ok" ? "files-missing" : integrity; + } + } else { + const json = await readSlotFile(slot, id as never); + parseable = typeof json === "object" && json !== null; + } + } catch { + return fail("boot-check"); + } + if (present) presentSections.push(id); + sections.push({ id, required, present, parseable }); + if (required && (!present || !parseable)) { + requiredSectionsOk = false; + if (!missing.includes(id)) missing.push(id); + } + } + } else { + requiredSectionsOk = false; + if (!missing.includes("manifest")) missing.push("manifest"); + } + + // C7: assets referenced by the inventory, present with matching sizes. + let requiredAssetsOk = true; + const assetMissing: string[] = []; + let assetTotal = 0; + let assetPresent = 0; + if (manifestOk) { + try { + const inventory = await readSlotFile(slot, "assets"); + const assets = narrowAssetRefs(inventory); + assetTotal = assets.length; + for (const asset of assets) { + const record = await readSlotAsset(slot, asset.id); + if (record?.bytes === asset.bytes) { + assetPresent += 1; + } else { + requiredAssetsOk = false; + assetMissing.push(`asset:${asset.id}`); + if (record && record.bytes !== asset.bytes) integrity = "corrupt"; + else if (integrity === "ok") integrity = "files-missing"; + } + } + } catch { + return fail("boot-check"); + } + } + missing.push(...assetMissing); + + // C5 compatibility — dual check (§18.5). Decided from the staged manifest. + let schemaCompatible = false; + let incompatible = false; + if (manifestOk && manifest) { + schemaCompatible = isCompatible(manifest, deps.supportedSchemaRange, deps.appVersion); + incompatible = !schemaCompatible; + } + + const evidence: ReadinessEvidence = { + shellValid: shell.ok, + datasetPresent: true, + authenticity, + integrity, + schemaCompatible, + requiredSectionsOk, + requiredAssetsOk, + emergencyFloorOk: floor.ok, + missing, + hadVerifiedDataset: true, + manifestOk, + incompatible, + failure: null, + presentSections, + stagedPending: null, + }; + const result = evaluateReadiness(evidence); + + return { + ...result, + edition: meta.activeEdition, + packageVersion: meta.activePackageVersion, + generatedAt: manifestOk && manifest ? manifest.generatedAt : null, + fetchedAt: journal.verifiedAt ?? journal.lastProgressAt, + sections, + assets: { total: assetTotal, present: assetPresent, missing: assetMissing }, + floorVersion: floor.version, + storage, + shellCache: shell.cacheName, + canRestore: await otherSlotComplete(deps.openSlot, activeSlot === "A" ? "B" : "A"), + activationPending: false, + }; +} diff --git a/src/app/schedule.ts b/src/app/schedule.ts new file mode 100644 index 0000000..3b3fb2e --- /dev/null +++ b/src/app/schedule.ts @@ -0,0 +1,89 @@ +/** + * Stage 11 app-layer loader — schedule section, manifest window, favorites, + * prefs, and persisted clock skew for the schedule view. Local reads only, + * never network. Never throws: null means "no schedule on this device". + * Trace: IMPLEMENTATION-CONTRACT.md §21, §24. + */ +import { openSystemDB, readSystemMeta } from "../data/system-meta/store.js"; +import { openSlotDB, readSlotFile, readSlotManifest } from "../data/slot/store.js"; +import { openUserDB, listFavorites, getPrefs } from "../data/user/store.js"; +import type { FavoriteEntry, UserPrefs } from "../data/user/types.js"; +import type { ScheduleSection } from "../data/festival-package/types.js"; +import type { ClockSkew } from "../domain/clock/types.js"; + +export interface ScheduleWindow { + readonly timezone: string; + readonly startUtc: number; + readonly endUtc: number; +} + +export interface ScheduleData { + readonly section: ScheduleSection; + readonly window: ScheduleWindow; + readonly favorites: readonly FavoriteEntry[]; + readonly prefs: UserPrefs | undefined; + readonly skew: ClockSkew | null; +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +/** Structural schedule check — per-event tolerance lives in query helpers. */ +export function narrowScheduleSection(value: unknown): ScheduleSection | null { + const section = record(value); + if (section?.section !== "schedule") return null; + if (!Array.isArray(section.stages) || !Array.isArray(section.artists)) return null; + if (!Array.isArray(section.events)) return null; + return value as ScheduleSection; +} + +export async function loadScheduleData(): Promise { + try { + const system = await openSystemDB(); + let activeSlot; + let skew: ClockSkew | null; + try { + const meta = await readSystemMeta(system); + activeSlot = meta.activeSlot; + skew = meta.clockSkew; + } finally { + system.close(); + } + if (!activeSlot) return null; + + const slot = await openSlotDB(activeSlot); + let section: ScheduleSection | null = null; + let window: ScheduleWindow | null = null; + try { + section = narrowScheduleSection(await readSlotFile(slot, "schedule")); + const manifest = await readSlotManifest(slot).catch(() => undefined); + if (manifest && typeof manifest.festival?.timezone === "string") { + window = { + timezone: manifest.festival.timezone, + startUtc: manifest.festival.startUtc, + endUtc: manifest.festival.endUtc, + }; + } + } finally { + slot.close(); + } + if (!section || !window) return null; + + const user = await openUserDB(); + try { + return { + section, + window, + favorites: await listFavorites(user), + prefs: await getPrefs(user), + skew, + }; + } finally { + user.close(); + } + } catch { + // Storage errors degrade to "no schedule" — never blank, never throw (I-8). + return null; + } +} diff --git a/src/app/styles.css b/src/app/styles.css index 1db47d5..b5412a7 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -109,6 +109,42 @@ html { color-scheme: light dark; scroll-behavior: smooth; } +html.dark { + --bg: #0b1411; --fg: #ede9de; --deck-bg: #0b1411; --deck-fg: #ede9de; --deck-surface: rgba(255,255,255,0.035); --deck-line: rgba(255,255,255,0.1); --accent: #75c7a9; --surface: rgba(255,255,255,0.035); --border: rgba(255,255,255,0.1); +} +html.light :root, +html.light body { + --deck-bg: #f6f9f7; --deck-fg: #172621; --deck-fg-muted: #576b63; + --deck-surface: rgba(20,30,25,0.04); --deck-surface-2: rgba(20,30,25,0.06); --deck-line: rgba(20,30,25,0.12); --deck-line-2: rgba(20,30,25,0.2); + --deck-pine: #1f7a5f; --deck-indigo: #683ecc; --deck-rose: #d3224b; --deck-clay: #bf4622; --deck-amber: #b8610a; + --bg: #f6f9f7; --fg: #172621; --muted: #576b63; --accent: #1f7a5f; --surface: rgba(20,30,25,0.04); --border: rgba(20,30,25,0.12); + --highlight-bg: rgba(184,97,10,0.07); --highlight-border: rgba(184,97,10,0.3); + --chip-pink-bg: rgba(211,34,75,0.08); --chip-pink-fg: #b5244a; + --chip-green-bg: rgba(31,122,95,0.09); --chip-green-fg: #1f7a5f; + --chip-indigo-bg: rgba(104,62,204,0.08); --chip-indigo-fg: #683ecc; + --chip-amber-bg: rgba(184,97,10,0.09); --chip-amber-fg: #96500a; + --focus: #1f7a5f; --emergency: #d3224b; --emergency-contrast: #fff; +} +html.dark :root, +html.dark body { + --deck-bg: #0b1411; --deck-fg: #ede9de; --deck-fg-muted: #96a69e; + --deck-surface: rgba(255,255,255,0.035); --deck-surface-2: rgba(255,255,255,0.055); + --deck-line: rgba(255,255,255,0.1); --deck-line-2: rgba(255,255,255,0.16); + --deck-pine: #75c7a9; --deck-indigo: #af97f7; --deck-rose: #fc88ab; --deck-clay: #e47958; --deck-amber: #fcc669; + --bg: var(--deck-bg); --fg: var(--deck-fg); --muted: var(--deck-fg-muted); + --accent: var(--deck-pine); --accent-contrast: var(--deck-on-accent); + --accent-soft: rgba(117,199,169,0.12); + --surface: var(--deck-surface); --border: var(--deck-line); --border-accent: rgba(117,199,169,0.32); + --highlight-bg: rgba(252,198,105,0.07); --highlight-border: rgba(252,198,105,0.3); + --chip-pink-bg: rgba(252,136,171,0.12); --chip-pink-fg: #fc88ab; + --chip-green-bg: rgba(117,199,169,0.12); --chip-green-fg: #75c7a9; + --chip-indigo-bg: rgba(175,151,247,0.12); --chip-indigo-fg: #af97f7; + --chip-amber-bg: rgba(252,198,105,0.12); --chip-amber-fg: #fcc669; + --focus: var(--deck-pine); --emergency: #fc88ab; --emergency-contrast: var(--deck-on-accent); +} +html.dark body { + background: radial-gradient(120% 80% at 80% -10%, rgba(14,57,44,0.55), transparent 60%), radial-gradient(90% 70% at -10% 110%, rgba(83,48,9,0.35), transparent 55%), var(--bg); +} @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; @@ -322,6 +358,9 @@ h3 { font-size: 1.05em; line-height: 1; } +.theme-btn { + min-height: 36px; padding: 0.25rem 0.5rem; font: inherit; background: var(--surface); border: 1px solid var(--border); border-radius: 999px; cursor: pointer; color: var(--fg); +} .app-header__status { font-size: 0.8125rem; font-weight: 600; @@ -331,6 +370,107 @@ h3 { border: 1px solid var(--border); background: var(--surface); color: var(--muted); + cursor: pointer; + font-family: inherit; +} +.app-header__status--ready { + background: var(--chip-green-bg); + color: var(--chip-green-fg); + border-color: var(--accent); +} +.app-header__status--partial { + background: var(--chip-amber-bg); + color: var(--chip-amber-fg); + border-color: var(--highlight-border); +} +.app-header__status--recovery, +.app-header__status--failed { + background: var(--chip-pink-bg); + color: var(--chip-pink-fg); + border-color: var(--emergency); +} + +/* Status screen — readiness detail dialog (§20.5) */ +.status-dialog { + width: min(92vw, 36rem); + max-height: min(88dvh, 56rem); + overflow: auto; + padding: 1.25rem; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.status-dialog::backdrop { + background: rgba(0, 0, 0, 0.55); +} +.status-view h1 { + font-size: 1.5rem; + margin: 0 0 0.75rem; +} +.status-view h2 { + font-size: 1.125rem; + margin: 1rem 0 0.5rem; +} +.status-view p { + margin: 0.375rem 0; +} +.status-state { + font-weight: 800; + letter-spacing: 0.08em; +} +.status-checklist { + list-style: none; + margin: 0.5rem 0; + padding: 0; +} +.status-check { + display: flex; + gap: 0.625rem; + align-items: baseline; + min-height: 48px; + padding: 0.625rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + margin-block: 0.375rem; +} +.status-check--ok { + border-color: var(--border-accent); +} +.status-check--missing { + border-color: var(--highlight-border); +} +.status-check__mark { + font-weight: 800; +} +.status-check--ok .status-check__mark { + color: var(--chip-green-fg); +} +.status-check--missing .status-check__mark { + color: var(--chip-pink-fg); +} +.status-actions { + display: flex; + flex-wrap: wrap; + gap: 0.625rem; + margin-top: 1rem; +} +.status-action { + flex: 1 1 12rem; + min-height: 48px; + padding: 0.625rem 1rem; + font: inherit; + font-weight: 700; + color: var(--fg); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; +} +.status-action--primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-contrast); } .app-main { flex: 1; @@ -346,10 +486,8 @@ h3 { filter: invert(1); } -@media (prefers-color-scheme: dark) { - .app-main__icon { - filter: none; - } +html.dark .app-main__icon { + filter: none; } .app-main h1 { font-size: 2rem; @@ -359,6 +497,20 @@ h3 { max-width: 36rem; } +/* Primary navigation — fixed bottom bar on small screens, 240px sidebar on desktop */ +.side-nav { + position: fixed; + inset: auto 0 0 0; + z-index: 10; + display: flex; + gap: 0.5rem; + padding: 0.625rem max(1rem, env(safe-area-inset-right)) max(0.625rem, env(safe-area-inset-bottom)) + max(1rem, env(safe-area-inset-left)); + background: color-mix(in srgb, var(--bg) 88%, transparent); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + border-top: 1px solid var(--border); +} .side-nav__link { flex: 1; display: flex; @@ -381,6 +533,42 @@ h3 { border-color 0.2s ease, color 0.2s ease; } +.side-nav__link[aria-current="page"] { + border-color: var(--accent); + color: var(--accent); +} +.side-nav__link--emergency { + background: var(--emergency); + border-color: var(--emergency); + color: var(--emergency-contrast); +} +.side-nav__link--emergency[aria-current="page"] { + color: var(--emergency-contrast); +} +@media (min-width: 701px) { + .side-nav { + inset: 0 auto 0 0; + width: 240px; + flex-direction: column; + justify-content: space-evenly; + padding: 1rem; + border-top: none; + border-right: 1px solid var(--border); + } + .side-nav__link { + flex: none; + min-height: 64px; + font-size: 1rem; + } + .app-main { + margin-left: 240px; + } +} +@media (max-width: 700px) { + .app-main { + padding-bottom: calc(var(--nav-h) + 1.5rem); + } +} @media (max-width: 700px) { .app-main { @@ -514,6 +702,118 @@ h3 { } } +/* Schedule view */ +.schedule-view h1 { + margin-bottom: 0.5rem; +} +.schedule-warning { + background: var(--highlight-bg); + border: 1px solid var(--highlight-border); + border-radius: var(--radius-sm); + padding: 0.625rem 0.875rem; + font-weight: 600; +} +.schedule-controls { + display: flex; + flex-direction: column; + gap: 0.625rem; + margin-block: 1rem; +} +.schedule-days, +.schedule-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.schedule-day, +.schedule-tag, +.schedule-mine, +.schedule-zone { + min-height: 48px; + padding: 0.625rem 0.875rem; + font: inherit; + font-weight: 600; + color: var(--fg); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + cursor: pointer; +} +.schedule-day[aria-pressed="true"], +.schedule-tag[aria-pressed="true"], +.schedule-mine[aria-pressed="true"] { + border-color: var(--accent); + color: var(--accent); +} +.schedule-controls select, +.schedule-controls input[type="search"] { + min-height: 48px; + padding: 0.625rem 0.875rem; + font: inherit; + color: var(--fg); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.schedule-item { + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 3.5rem 1rem 1.25rem; + margin-block: 0.75rem; +} +.schedule-item h2 { + margin: 0 0 0.25rem; + font-size: 1.125rem; +} +.schedule-item p { + margin: 0.25rem 0; +} +.schedule-badge { + display: inline-block; + margin-top: 0.375rem; + padding: 0.25rem 0.625rem; + border-radius: 999px; + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: 0.04em; +} +.schedule-badge--moved { + background: var(--chip-amber-bg); + color: var(--chip-amber-fg); +} +.schedule-badge--cancelled { + background: var(--chip-pink-bg); + color: var(--chip-pink-fg); + text-decoration: line-through; +} +.schedule-fav { + position: absolute; + top: 0.75rem; + right: 0.75rem; + width: 48px; + height: 48px; + font-size: 1.5rem; + line-height: 1; + color: var(--fg); + background: transparent; + border: 1px solid var(--border); + border-radius: 50%; + cursor: pointer; +} +.schedule-fav[aria-pressed="true"] { + color: var(--chip-amber-fg); + border-color: var(--highlight-border); +} +.schedule-conflicts { + font-weight: 700; + color: var(--chip-amber-fg); +} +.schedule-conflicts:empty { + display: none; +} + /* Cards — translucent surface, hairline border, 1rem radius (SolLunar signature) */ .view-placeholder { background: var(--surface); diff --git a/src/data/slot/store.ts b/src/data/slot/store.ts index 602babf..6570749 100644 --- a/src/data/slot/store.ts +++ b/src/data/slot/store.ts @@ -14,6 +14,7 @@ import { } from "../../platform/idb/wrapper.js"; import { SLOT_FILES, SLOT_ASSETS, SLOT_STAGING, slotDbName } from "../../platform/idb/names.js"; import type { SlotId } from "../../platform/idb/names.js"; +import type { FestivalManifest } from "../festival-package/types.js"; import { MAX_RECORD_BYTES, checkRecordSize } from "../../platform/idb/errors.js"; import type { SectionId, SlotStagingJournal } from "./types.js"; @@ -171,6 +172,25 @@ export async function clearSlotFiles(db: IDBDatabase): Promise { await idbClear(db, SLOT_FILES); } +// ——— Manifest (evidence for boot C4/C5/C6 — one short txn, P1-style) ——— + +/** Key under which the staged manifest lives in the files store. */ +export const SLOT_MANIFEST_KEY = "manifest" as const; + +export async function writeSlotManifest( + db: IDBDatabase, + manifest: FestivalManifest, +): Promise { + await withTx(db, SLOT_FILES, "readwrite", async (tx) => { + await requestDone(tx.objectStore(SLOT_FILES).put(manifest, SLOT_MANIFEST_KEY)); + }); +} + +export async function readSlotManifest(db: IDBDatabase): Promise { + const rec = await idbGet(db, SLOT_FILES, SLOT_MANIFEST_KEY); + return rec ?? undefined; +} + // ——— Assets (Blobs) ——— export async function writeSlotAsset( diff --git a/src/domain/clock/logic.ts b/src/domain/clock/logic.ts index 6691da7..b053a07 100644 --- a/src/domain/clock/logic.ts +++ b/src/domain/clock/logic.ts @@ -9,6 +9,20 @@ import type { ClockOptions, ClockSkew, FestivalWindow } from "./types.js"; export const DEFAULT_WINDOW_DAYS = 45; const MS_PER_DAY = 24 * 3600 * 1000; +/* ClockService — pure, no network, no persistence. Pure time-model logic per SPIKE-08. */ +export interface ClockService { + correctedNow(deviceNow: number, skew: ClockSkew | null): number; + hasDrift(baseDevice: number, monoElapsed: number, observedDevice: number, thresholdMs?: number): boolean; + shouldWarnOutsideWindow(nowCorrected: number, win: FestivalWindow, skew: ClockSkew | null, opts?: ClockOptions): boolean; + renderInZone(epochMs: number, timeZone: string): string; + formatEventTime(epochMs: number, timeZone: string): string; + formatDayLabel(epochMs: number, timeZone: string): string; + formatClockTime(epochMs: number, timeZone: string): string; + deviceTimeZone(): string; + dayKeyFor(epochMs: number, timeZone: string): string; + classifyNowNext(events: readonly T[], now: number): { now: readonly T[]; next: readonly T[] }; +} + /** Render epoch ms in festival IANA zone via Intl (offline, ECMA-402). */ export function renderInZone(epochMs: number, timeZone: string): string { return new Intl.DateTimeFormat("en-US", { @@ -23,6 +37,47 @@ export function renderInZone(epochMs: number, timeZone: string): string { }).format(new Date(epochMs)); } +/** + * F-4 display formats — Intl.DateTimeFormat with an explicit timeZone is the + * ONLY time-rendering path. No Date.toLocaleString() without options, no + * manual offsets, no wall-clock strings. + */ +export function formatEventTime(epochMs: number, timeZone: string): string { + return new Intl.DateTimeFormat("en-US", { + timeZone, + weekday: "short", + hour: "numeric", + minute: "2-digit", + }).format(new Date(epochMs)); +} + +export function formatDayLabel(epochMs: number, timeZone: string): string { + return new Intl.DateTimeFormat("en-US", { + timeZone, + weekday: "long", + month: "long", + day: "numeric", + }).format(new Date(epochMs)); +} + +/** Time-only end of a range ("2:30 PM") — same explicit-zone rule. */ +export function formatClockTime(epochMs: number, timeZone: string): string { + return new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "numeric", + minute: "2-digit", + }).format(new Date(epochMs)); +} + +/** Device IANA zone for the display toggle — UTC fallback when unresolvable. */ +export function deviceTimeZone(): string { + try { + return new Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC"; + } catch { + return "UTC"; + } +} + /** Precomputed dayKey at publish time: calendar date in festival zone. */ export function dayKeyFor(epochMs: number, timeZone: string): string { const parts = new Intl.DateTimeFormat("en-US", { diff --git a/src/domain/clock/service.ts b/src/domain/clock/service.ts new file mode 100644 index 0000000..6d14ad6 --- /dev/null +++ b/src/domain/clock/service.ts @@ -0,0 +1,65 @@ +/** + * ClockService session state — pure transitions over injected readings. + * Persisted skew lives in lumen-system (app layer); the monotonic session + * base lives in memory only (performance.now resets every page load). + * No timers, no background work (C-19); everything computes on demand. + * Trace: ADR-009, SPIKE-08 §3.5/§3.8, IMPLEMENTATION-CONTRACT.md §21. + */ +import { correctedNow, hasDrift } from "./logic.js"; +import type { ClockSkew } from "./types.js"; + +export interface ClockReading { + /** Wall clock as the device reports it. */ + readonly deviceNow: number; + /** Session monotonic clock (performance.now equivalent). */ + readonly monoNow: number; +} + +export interface ClockSession { + /** Device reading at session start (or last re-derive). */ + readonly baseDevice: number; + /** Monotonic reading at session start (or last re-derive). */ + readonly baseMono: number; +} + +export interface ResolvedClock { + /** Corrected now: device + persisted skew when present. */ + readonly now: number; + /** Mid-session device-clock jump detected; base re-derived, skew kept. */ + readonly driftWarn: boolean; + readonly session: ClockSession; +} + +/** + * Capture a skew sample from a sync HTTP `Date` header: + * skew = serverNow − deviceNow. Pure; persistence is the app layer's job. + */ +export function captureSkew(serverNowMs: number, reading: ClockReading, source: string): ClockSkew { + return { + skewMs: serverNowMs - reading.deviceNow, + capturedAtDevice: reading.deviceNow, + capturedAtMono: reading.monoNow, + source, + }; +} + +/** + * Resolve "now" for one render: apply persisted skew, then guard against + * mid-session device-clock jumps with the monotonic anchor. On drift the + * base re-derives (warn once); the server skew is kept when present. + */ +export function resolveClock( + reading: ClockReading, + skew: ClockSkew | null, + session: ClockSession | null, +): ResolvedClock { + const base: ClockSession = session ?? { + baseDevice: reading.deviceNow, + baseMono: reading.monoNow, + }; + const drifted = hasDrift(base.baseDevice, reading.monoNow - base.baseMono, reading.deviceNow); + const settled: ClockSession = drifted + ? { baseDevice: reading.deviceNow, baseMono: reading.monoNow } + : base; + return { now: correctedNow(reading.deviceNow, skew), driftWarn: drifted, session: settled }; +} diff --git a/src/domain/emergency/logic.ts b/src/domain/emergency/logic.ts index cbb5910..3a0d494 100644 --- a/src/domain/emergency/logic.ts +++ b/src/domain/emergency/logic.ts @@ -1,39 +1,138 @@ import { loadEmergencyFloor } from "../../emergency-baseline/loader.js"; import type { EmergencyFloor } from "../../emergency-baseline/types.js"; import type { EmergencySection } from "../../data/festival-package/types.js"; -import type { EmergencyViewModel, VerifiedEmergencyData } from "./types.js"; +import type { + EmergencyDemotionReason, + EmergencyViewModel, + VerifiedEmergencyData, +} from "./types.js"; const SUPPORTED_EMERGENCY_SCHEMAS = [1] as const; +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +function nonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +/** + * Forward-tolerant narrowing of a stored emergency section. Unknown fields + * are preserved (never throw on future shapes). Only the section tag, a sane + * schema version, and well-formed procedures (when present) are required — + * sparse sections are usable because resolveEmergency defensively merges + * floor fields over omissions (SPIKE-06 merge rule). + */ +export function narrowEmergencySection(value: unknown): EmergencySection | null { + const section = record(value); + if (section?.section !== "emergency") return null; + const version: unknown = section.emergencySchemaVersion; + if (typeof version !== "number" || !Number.isInteger(version) || version < 1) { + return null; + } + if (section.services !== undefined && !record(section.services)) return null; + if ( + section.procedures !== undefined && + (!Array.isArray(section.procedures) || + !section.procedures.every((p) => { + const item = record(p); + return Boolean( + item && nonEmpty(item.id) && nonEmpty(item.title) && Array.isArray(item.steps), + ); + })) + ) { + return null; + } + return value as EmergencySection; +} + function mergeFloorFields(section: EmergencySection, floor: EmergencyFloor): EmergencySection { const candidate = section as unknown as Partial; + const candidateServices = record(candidate.services); + const emergencyNumber = candidateServices?.emergencyNumber; return { ...section, - services: candidate.services ?? floor.services, + services: { + emergencyNumber: nonEmpty(emergencyNumber) ? emergencyNumber : floor.services.emergencyNumber, + security: candidate.services?.security ?? floor.services.security, + firstAid: candidate.services?.firstAid ?? floor.services.firstAid, + }, address: candidate.address ?? floor.address, procedures: candidate.procedures?.length ? candidate.procedures : floor.procedures, }; } -export function resolveEmergency(dataset?: VerifiedEmergencyData): EmergencyViewModel { - const loaded = loadEmergencyFloor(); - if (!loaded.ok) throw new Error(loaded.reason); - const floor = loaded.floor; - const usable = - dataset !== undefined && - SUPPORTED_EMERGENCY_SCHEMAS.includes(dataset.emergencySchemaVersion as 1) && - dataset.section.emergencySchemaVersion === dataset.emergencySchemaVersion; - if (!usable) - return { - source: "baseline", - provenance: `BASELINE v${floor.floorVersion}`, - floor, - section: null, - }; +/** + * Notices with a past expiresAtUtc are hidden — expiry hides the notice, + * never critical data (SPIKE-04 F-2). Null/undefined means unexpiring. + * This is the one sanctioned wall-clock read in emergency display; + * readiness verdicts never consult clocks. + */ +export function visibleNotices( + section: EmergencySection, + now: number = Date.now(), +): EmergencySection { + if (!section.notices?.length) return section; return { - source: "dataset", - provenance: `FESTIVAL DATA v${String(dataset.packageVersion)} · generated ${dataset.generatedAt}`, - floor, - section: mergeFloorFields(dataset.section, floor), + ...section, + // Null AND undefined both mean unexpiring (forward tolerance for staged + // JSON that omits the field); only a past timestamp hides the notice. + notices: section.notices.filter((notice) => (notice.expiresAtUtc ?? Infinity) > now), }; } + +export function resolveEmergency( + dataset?: VerifiedEmergencyData, + now: number = Date.now(), +): EmergencyViewModel { + const loaded = loadEmergencyFloor(); + if (!loaded.ok) throw new Error(loaded.reason); + + if (dataset === undefined) return demotedBaseline("no-dataset", loaded.floor); + if (!isSupportedEmergencySchema(dataset.emergencySchemaVersion)) { + return demotedBaseline("unsupported-schema", loaded.floor); + } + const narrowed = narrowEmergencySection(dataset.section); + if (narrowed?.emergencySchemaVersion !== dataset.emergencySchemaVersion) { + return demotedBaseline("malformed-section", loaded.floor); + } + const merged = visibleNotices(mergeFloorFields(narrowed, loaded.floor), now); + return { + source: "dataset", + provenance: + `FESTIVAL DATA v${String(dataset.packageVersion)} · generated ${dataset.generatedAt}` + + (typeof narrowed.updatedAt === "string" && narrowed.updatedAt !== dataset.generatedAt + ? ` · updated ${narrowed.updatedAt}` + : ""), + floor: loaded.floor, + section: merged, + demotion: null, + }; +} + +/** Shell-side support gate for the independent emergency schema sub-version. */ +export function isSupportedEmergencySchema(version: unknown): version is number { + return typeof version === "number" && SUPPORTED_EMERGENCY_SCHEMAS.includes(version as 1); +} + +/** Floor viewmodel with an explicit demotion reason (loader-driven path). */ +export function demotedBaseline( + demotion: EmergencyDemotionReason, + floor?: EmergencyFloor, +): EmergencyViewModel { + const resolved = floor ?? loadEmergencyFloorOrThrow(); + return { + source: "baseline", + provenance: `BASELINE v${resolved.floorVersion}`, + floor: resolved, + section: null, + demotion, + }; +} + +function loadEmergencyFloorOrThrow(): EmergencyFloor { + const loaded = loadEmergencyFloor(); + if (!loaded.ok) throw new Error(loaded.reason); + return loaded.floor; +} diff --git a/src/domain/emergency/types.ts b/src/domain/emergency/types.ts index 065ead2..80ce949 100644 --- a/src/domain/emergency/types.ts +++ b/src/domain/emergency/types.ts @@ -8,9 +8,14 @@ export interface VerifiedEmergencyData { readonly emergencySchemaVersion: number; } +/** Why the view fell back to the floor despite a dataset being present. */ +export type EmergencyDemotionReason = "no-dataset" | "unsupported-schema" | "malformed-section"; + export interface EmergencyViewModel { readonly source: "baseline" | "dataset"; readonly provenance: string; readonly floor: EmergencyFloor; readonly section: EmergencySection | null; + /** Null when nothing demoted the dataset tier (or no dataset exists quietly). */ + readonly demotion: EmergencyDemotionReason | null; } diff --git a/src/domain/favorites/README.md b/src/domain/favorites/README.md index 8279715..efa6e50 100644 --- a/src/domain/favorites/README.md +++ b/src/domain/favorites/README.md @@ -1,5 +1,8 @@ # domain/favorites -FavoritesService over UserStore (B-6). Stable ids. DISCOVERY §15.8. Never dataset package. +My Schedule assembly over device-local favorites (stable event ids). +Updates/rollback/GC never touch favorites (B-6); orphans are kept, never +dropped. Write-through persistence lives in data/user + app callbacks. -Stage 1: directory exists with .gitkeep; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1. See IMPLEMENTATION-CONTRACT.md §4 responsibilities and §6 B-1…B-7. +Stage 11: `types.ts` (FavoriteStatus, MyScheduleItem), `logic.ts` +(buildMySchedule, myScheduleConflicts). See IMPLEMENTATION-CONTRACT.md §24. diff --git a/src/domain/favorites/logic.ts b/src/domain/favorites/logic.ts new file mode 100644 index 0000000..8b0f055 --- /dev/null +++ b/src/domain/favorites/logic.ts @@ -0,0 +1,43 @@ +/** + * Favorites domain — My Schedule assembly over device-local favorites. + * Favorites are keyed by stable event id (contractual across dataset + * versions); updates/rollback/GC never touch them (B-6, SPIKE-02 X3). + * Trace: IMPLEMENTATION-CONTRACT.md §24, DISCOVERY.md:545 §15.8. + */ +import type { ScheduleEvent, ScheduleSection } from "../../data/festival-package/types.js"; +import type { FavoriteEntry } from "../../data/user/types.js"; +import type { FavoriteStatus, MyScheduleItem } from "./types.js"; +import { detectOverlaps } from "../schedule/logic.js"; + +export function favoriteStatus(event: ScheduleEvent | undefined): FavoriteStatus { + if (!event) return "orphan"; + return event.status; +} + +/** + * Join favorites against the active section, scheduled-first by startUtc, + * orphans last (kept, never dropped, never crash). + */ +export function buildMySchedule( + section: ScheduleSection, + favorites: readonly FavoriteEntry[], +): MyScheduleItem[] { + const byId = new Map(section.events.map((e) => [e.id, e])); + return [...favorites] + .map((fav) => { + const event = byId.get(fav.eventId); + return { fav, event, status: favoriteStatus(event) }; + }) + .sort( + (a, b) => + (a.event?.startUtc ?? Number.MAX_SAFE_INTEGER) - + (b.event?.startUtc ?? Number.MAX_SAFE_INTEGER), + ); +} + +/** Overlapping pairs within My Schedule (cancelled excluded by detectOverlaps). */ +export function myScheduleConflicts( + items: readonly MyScheduleItem[], +): [ScheduleEvent, ScheduleEvent][] { + return detectOverlaps(items.flatMap((item) => (item.event ? [item.event] : []))); +} diff --git a/src/domain/favorites/types.ts b/src/domain/favorites/types.ts new file mode 100644 index 0000000..f38636d --- /dev/null +++ b/src/domain/favorites/types.ts @@ -0,0 +1,15 @@ +/** + * Favorites domain types. Pure data — see logic.ts for evaluation. + * Trace: IMPLEMENTATION-CONTRACT.md §24. + */ +import type { ScheduleEvent } from "../../data/festival-package/types.js"; +import type { FavoriteEntry } from "../../data/user/types.js"; + +export type FavoriteStatus = "scheduled" | "moved" | "cancelled" | "orphan"; + +export interface MyScheduleItem { + readonly fav: FavoriteEntry; + /** Undefined when the event vanished without a cancellation marker. */ + readonly event: ScheduleEvent | undefined; + readonly status: FavoriteStatus; +} diff --git a/src/domain/map/logic.ts b/src/domain/map/logic.ts new file mode 100644 index 0000000..445c616 --- /dev/null +++ b/src/domain/map/logic.ts @@ -0,0 +1,156 @@ +/** + * Map domain — level selection under decode budgets, POI math, facilities + * queries. All geometry is normalized 0..1 (D-02); the view converts to %. + * No GPS, no network, no persistence here. + * Trace: IMPLEMENTATION-CONTRACT.md §22, SPIKE-03 F-1. + */ +import type { MapPoi, MapSection, PoiCategory } from "../../data/festival-package/types.js"; +import type { FacilityGroup, LevelSelection } from "./types.js"; + +/** Longest-edge caps (px) — SPIKE-03 F-1 replaces the prior 4096 cap. */ +export const OVERVIEW_MAX_EDGE = 1600; +export const DETAIL_MAX_EDGE = 3072; + +/** Scale factor where the detail level takes over from the overview. */ +export const DETAIL_ZOOM_THRESHOLD = 2; + +function longestEdge(width: number, height: number): number { + return Math.max(width, height); +} + +/** + * Pick renderable levels. Overview over cap (or absent) means the base is + * unrenderable — the Facilities list still works from POI data. An oversized + * detail drops to the predefined overview-only fallback (never a blank map + * when the overview itself is fine). + */ +export function selectLevels(section: MapSection): LevelSelection { + const dropped: string[] = []; + const byId = new Map(section.base.levels.map((level) => [level.id, level])); + const overview = byId.get("overview") ?? section.base.levels[0] ?? null; + const detail = byId.get("detail") ?? null; + + let usableOverview = overview; + if (overview && longestEdge(overview.width, overview.height) > OVERVIEW_MAX_EDGE) { + dropped.push(overview.assetId); + usableOverview = null; + } + let usableDetail = detail && detail.assetId !== usableOverview?.assetId ? detail : null; + if (usableDetail && longestEdge(usableDetail.width, usableDetail.height) > DETAIL_MAX_EDGE) { + dropped.push(usableDetail.assetId); + usableDetail = null; + } + return { overview: usableOverview, detail: usableDetail, dropped }; +} + +/** Normalized 0..1 → CSS % position, clamped (bad authoring never escapes). */ +export function poiPosition(poi: MapPoi): { readonly left: string; readonly top: string } { + const x = Math.min(1, Math.max(0, poi.x)); + const y = Math.min(1, Math.max(0, poi.y)); + return { left: `${String(x * 100)}%`, top: `${String(y * 100)}%` }; +} + +export interface PoiFilter { + readonly categories?: ReadonlySet; + readonly query?: string; +} + +function matchesQuery(poi: MapPoi, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + return `${poi.name} ${poi.category} ${poi.description ?? ""}`.toLowerCase().includes(needle); +} + +export function filterPois(pois: readonly MapPoi[], filter: PoiFilter): MapPoi[] { + const query = filter.query ?? ""; + return pois.filter((poi) => { + if (filter.categories && filter.categories.size > 0 && !filter.categories.has(poi.category)) { + return false; + } + return matchesQuery(poi, query); + }); +} + +/** + * First-class Facilities list: grouped by the section's category order, + * extras last, POIs alphabetical — the screen-reader and GPS-free path. + */ +export function groupFacilities( + pois: readonly MapPoi[], + categories: readonly PoiCategory[], +): FacilityGroup[] { + const order = new Map(categories.map((category, index) => [category, index])); + const groups = new Map(); + for (const poi of pois) { + const list = groups.get(poi.category) ?? []; + list.push(poi); + groups.set(poi.category, list); + } + return [...groups.entries()] + .map(([category, items]) => ({ + category, + pois: [...items].sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => (order.get(a.category) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.category) ?? Number.MAX_SAFE_INTEGER)); +} + +/** Distinct categories present, in section order — for filter chips. */ +export function presentCategories( + pois: readonly MapPoi[], + categories: readonly PoiCategory[], +): PoiCategory[] { + const seen: PoiCategory[] = []; + for (const category of categories) { + if (pois.some((poi) => poi.category === category) && !seen.includes(category)) { + seen.push(category); + } + } + for (const poi of pois) { + if (!seen.includes(poi.category)) seen.push(poi.category); + } + return seen; +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +function validLevel(value: unknown): boolean { + const level = record(value); + return Boolean( + level && + typeof level.id === "string" && + typeof level.assetId === "string" && + typeof level.width === "number" && + Number.isFinite(level.width) && + level.width > 0 && + typeof level.height === "number" && + Number.isFinite(level.height) && + level.height > 0, + ); +} + +function validPoi(value: unknown): boolean { + const poi = record(value); + return Boolean( + poi && + typeof poi.id === "string" && + typeof poi.name === "string" && + typeof poi.category === "string" && + typeof poi.x === "number" && + Number.isFinite(poi.x) && + typeof poi.y === "number" && + Number.isFinite(poi.y), + ); +} + +/** Structural map check — per-POI tolerance lives in the queries above. */ +export function narrowMapSection(value: unknown): MapSection | null { + const section = record(value); + if (!section || section.section !== "map") return null; + const base = record(section.base); + if (!base || !Array.isArray(base.levels) || !base.levels.every(validLevel)) return null; + if (!Array.isArray(section.pois) || !section.pois.every(validPoi)) return null; + if (!Array.isArray(section.categories)) return null; + return value as MapSection; +} diff --git a/src/domain/map/types.ts b/src/domain/map/types.ts new file mode 100644 index 0000000..ca31c7c --- /dev/null +++ b/src/domain/map/types.ts @@ -0,0 +1,52 @@ +/** + * Map domain types. Pure data — see logic.ts for evaluation. + * Coordinate space is normalized 0..1 relative to the base image (D-02); + * optional lat/lng is a hook for future positioning only (no GPS in V1). + * Trace: IMPLEMENTATION-CONTRACT.md §22, SPIKE-03. + */ +import type { MapLevel, MapPoi, MapSection, PoiCategory } from "../../data/festival-package/types.js"; + +export type { MapPoi, PoiCategory }; + +/** A level cleared for rendering (within dimension caps). */ +export interface UsableLevel { + readonly id: string; + readonly assetId: string; + readonly width: number; + readonly height: number; + readonly nightAssetId?: string; +} + +export interface LevelSelection { + /** Null when no overview clears the caps — base unrenderable (list still works). */ + readonly overview: MapLevel | null; + readonly detail: MapLevel | null; + readonly dropped: readonly string[]; +} + +export interface FacilityGroup { + readonly category: PoiCategory; + readonly pois: readonly MapPoi[]; +} + +/** A level cleared for display, backed by object URLs (owned by the app layer). */ +export interface MapLevelView { + readonly id: string; + readonly url: string; + readonly nightUrl: string | null; + readonly width: number; + readonly height: number; +} + +/** Loader output — plain data, safe for ui to import (B-2). */ +export interface MapViewData { + /** False when no overview clears the caps/blob check — list still renders. */ + readonly mapAvailable: boolean; + readonly overview: MapLevelView | null; + readonly detail: MapLevelView | null; + readonly pois: readonly MapPoi[]; + readonly categories: readonly PoiCategory[]; + readonly overviewOnly: boolean; +} + +export type { MapSection }; diff --git a/src/domain/readiness/README.md b/src/domain/readiness/README.md index 3cbb0e1..9506913 100644 --- a/src/domain/readiness/README.md +++ b/src/domain/readiness/README.md @@ -2,4 +2,7 @@ Predicate C1–C8 + six-state taxonomy, time-independent. Imports data/system. SPIKE-05. -Stage 1: directory exists with .gitkeep; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1. See IMPLEMENTATION-CONTRACT.md §4 responsibilities and §6 B-1…B-7. +Stage 9: `config.ts` (APP_VERSION, SUPPORTED_SCHEMA_RANGE), `types.ts` +(evidence, six states, chip, report), `logic.ts` (pure evaluation — no I/O, +no clocks). Boot evidence gathering lives in `src/app/readiness.ts` (app +layer, per B-2). See IMPLEMENTATION-CONTRACT.md §20. diff --git a/src/domain/readiness/config.ts b/src/domain/readiness/config.ts new file mode 100644 index 0000000..4a24975 --- /dev/null +++ b/src/domain/readiness/config.ts @@ -0,0 +1,16 @@ +/** + * Readiness build constants — the shell side of the compatibility envelope. + * Trace: IMPLEMENTATION-CONTRACT.md §20.1 C5, §15 C-23 ordering rule. + */ + +/** + * Current shell version (semver). Must match package.json — enforced by test. + * Published datasets declare appCompatibility against this. + */ +export const APP_VERSION = "0.1.0"; + +/** + * Schema versions this shell can render. C-23: shells ship the range first + * (here {1,2}), datasets using a new schema publish only afterwards. + */ +export const SUPPORTED_SCHEMA_RANGE: readonly number[] = [1, 2]; diff --git a/src/domain/readiness/logic.ts b/src/domain/readiness/logic.ts new file mode 100644 index 0000000..982e498 --- /dev/null +++ b/src/domain/readiness/logic.ts @@ -0,0 +1,177 @@ +/** + * Offline Ready — pure predicate evaluation (no I/O, no clocks, no network). + * Time-independent per SPIKE-05 F-1: a wrong device clock can never demote + * READY. Clock problems surface as warnings elsewhere (Stage 11), never here. + * Trace: SPIKE-05 §2–§3§5, IMPLEMENTATION-CONTRACT.md §20.1–§20.2. + */ +import { CANONICAL_SECTIONS, L1_SECTIONS } from "./types.js"; +import type { + PreparationLevel, + ReadinessEvidence, + ReadinessResult, + ReadinessState, + StatusChip, +} from "./types.js"; + +export function preparationLevel(present: readonly string[]): PreparationLevel { + if (CANONICAL_SECTIONS.every((id) => present.includes(id))) return "L2"; + if (L1_SECTIONS.every((id) => present.includes(id))) return "L1"; + return "L0"; +} + +/** + * Six-state taxonomy. Precedence is normative: + * FAILED (session error) > shell-missing handling > NOT_READY/RECOVERY(missing) + * > integrity-missing > integrity-corrupt > authenticity-mismatch > + * RECOVERY(incompatible) > PARTIAL > READY. + * + * Integrity is checked before authenticity so that a verification record + * that outlived its files reads as data loss (missing), not tampering: + * files-gone dominates a mismatched record (SPIKE-05 §5). + */ +export function evaluateReadiness(e: ReadinessEvidence): ReadinessResult { + const level = preparationLevel(e.presentSections); + + if (e.failure) { + return { + state: "FAILED", + reason: null, + missing: [...e.missing], + failedOp: e.failure.op, + level, + }; + } + + if (!e.emergencyFloorOk) { + // The floor is compiled into the shell — absence means a broken build. + return { + state: "FAILED", + reason: null, + missing: [...e.missing], + failedOp: "emergency-floor", + level, + }; + } + + if (!e.shellValid) { + // The running shell proves code is present; a missing cache entry only + // endangers relaunch. With a dataset, enumerate it as PARTIAL; without, + // the user still needs the preparation flow. + if (e.datasetPresent) { + return { + state: "PARTIAL", + reason: null, + missing: ["shell-cache", ...e.missing], + failedOp: null, + level, + }; + } + return { state: "NOT_READY", reason: null, missing: [...e.missing], failedOp: null, level }; + } + + if (!e.datasetPresent) { + if (e.hadVerifiedDataset) { + return { state: "RECOVERY", reason: "missing", missing: ["dataset"], failedOp: null, level }; + } + if (e.stagedPending) { + // Prep interrupted (or fully staged, awaiting activation) — resumable, + // so PARTIAL rather than NOT_READY. Level follows staged sections. + return { + state: "PARTIAL", + reason: null, + missing: [...e.stagedPending.missing], + failedOp: null, + level, + }; + } + return { state: "NOT_READY", reason: null, missing: [...e.missing], failedOp: null, level }; + } + + if (e.integrity === "files-missing") { + return { state: "RECOVERY", reason: "missing", missing: [...e.missing], failedOp: null, level }; + } + if (e.integrity === "corrupt") { + return { state: "RECOVERY", reason: "corrupt", missing: [...e.missing], failedOp: null, level }; + } + if (e.authenticity === "mismatch") { + return { state: "RECOVERY", reason: "corrupt", missing: [...e.missing], failedOp: null, level }; + } + + if (e.manifestOk && (!e.schemaCompatible || e.incompatible)) { + return { + state: "RECOVERY", + reason: "incompatible", + missing: [...e.missing], + failedOp: null, + level, + }; + } + + if (e.authenticity === "record-missing") { + // Files present but no verification evidence — READY cannot be claimed + // without evidence (SPIKE-05 §5). Full re-verify path, not corruption. + return { + state: "PARTIAL", + reason: null, + missing: ["verification", ...e.missing], + failedOp: null, + level, + }; + } + + if (!e.manifestOk) { + // Slot files exist but the manifest they are evidenced from is gone — + // sizes/compat/required-flags cannot be proven. Re-verify path. + const missing = e.missing.includes("manifest") ? [...e.missing] : ["manifest", ...e.missing]; + return { state: "PARTIAL", reason: null, missing, failedOp: null, level }; + } + + if (!e.requiredSectionsOk || !e.requiredAssetsOk) { + return { state: "PARTIAL", reason: null, missing: [...e.missing], failedOp: null, level }; + } + + return { state: "READY", reason: null, missing: [], failedOp: null, level }; +} + +/** Header chip mapping (§20.2) — text, tone, and accessible label per state. */ +export function chipForState(state: ReadinessState, detail?: string): StatusChip { + switch (state) { + case "READY": + return { + text: "OFFLINE READY ✓", + tone: "ready", + label: "Offline status: ready. All festival data is verified on this device.", + }; + case "PARTIAL": + return { + text: "PARTIAL", + tone: "partial", + label: `Offline status: partial. Missing: ${detail ?? "some required data"}.`, + }; + case "NOT_READY": + return { + text: "Get festival data", + tone: "neutral", + label: "Offline status: not ready. Open with internet to get festival data.", + }; + case "RECOVERY": + return { + text: `RECOVERY — ${detail ?? "data missing"}`, + tone: "recovery", + label: `Offline status: recovery needed (${detail ?? "data missing"}). Emergency info still available.`, + }; + case "BASELINE_ONLY": + return { + text: "SAVING UNAVAILABLE", + tone: "baseline", + label: + "Offline status: this browser cannot save offline data. Emergency info still available.", + }; + case "FAILED": + return { + text: `FAILED — ${detail ?? "error"}`, + tone: "failed", + label: `Offline status: operation failed (${detail ?? "error"}). Emergency info still available.`, + }; + } +} diff --git a/src/domain/readiness/types.ts b/src/domain/readiness/types.ts index 46934d5..0d46692 100644 --- a/src/domain/readiness/types.ts +++ b/src/domain/readiness/types.ts @@ -1,40 +1,119 @@ /** - * Offline Ready — inputs for predicate C1…C8. - * Pure types, no I/O. - * Trace: SPIKE-05, IMPLEMENTATION-CONTRACT.md §20 + * Offline Ready — predicate inputs, six-state taxonomy, chip mapping. + * Pure types only: no I/O, no clocks (time independence, SPIKE-05 F-1). + * Trace: SPIKE-05 §2–§3, IMPLEMENTATION-CONTRACT.md §20. */ -export interface ReadinessInput { - readonly shellValid: boolean; // C1 - readonly datasetPresent: boolean; // C2 - readonly authenticityOk: boolean; // C3 - readonly integrityOk: boolean; // C4 - readonly schemaCompatible: boolean; // C5 - readonly requiredSectionsOk: boolean; // C6 - readonly requiredAssetsOk: boolean; // C7 - readonly emergencyFloorOk: boolean; // C8 -} - export type ReadinessState = "READY" | "PARTIAL" | "NOT_READY" | "RECOVERY" | "BASELINE_ONLY" | "FAILED"; -export function computeReadiness(i: ReadinessInput): ReadinessState { - if (!i.shellValid) return "FAILED"; // shell itself invalid — not a normal state but keep - // All true => READY (time-independent per SPIKE-05 F-1) - if ( - i.datasetPresent && - i.authenticityOk && - i.integrityOk && - i.schemaCompatible && - i.requiredSectionsOk && - i.requiredAssetsOk && - i.emergencyFloorOk - ) { - return "READY"; - } - // If any of C2…C7 false due to missing data but shell ok, PARTIAL/NOT_READY - // For Stage 4 we distinguish only READY vs not-READY; detailed taxonomy is Stage 9. - // Return PARTIAL as representative for missing required data. - if (!i.datasetPresent) return "NOT_READY"; - return "PARTIAL"; +export type RecoveryReason = "missing" | "corrupt" | "incompatible"; + +/** Preparation levels L0–L2 (SPIKE-07): shell+floor / +emergency+schedule / full. */ +export type PreparationLevel = "L0" | "L1" | "L2"; + +/** The five canonical V1 sections. Order is the staging priority order. */ +export const CANONICAL_SECTIONS: readonly string[] = [ + "emergency", + "schedule", + "info", + "map", + "assets", +]; + +/** Sections whose presence constitutes the L1 minimum-safe set. */ +export const L1_SECTIONS: readonly string[] = ["emergency", "schedule"]; + +/** + * Evidence for the C1–C8 predicate. Assembled by the app-layer evaluator + * from local stores only — never from the network, never from a clock. + */ +export interface ReadinessEvidence { + /** C1: every precache entry present in the current shell cache. */ + readonly shellValid: boolean; + /** C2: active slot exists for the active edition. */ + readonly datasetPresent: boolean; + /** C3: recorded verification matches the active package (no crypto at boot). */ + readonly authenticity: "verified" | "record-missing" | "mismatch"; + /** C4: manifest-listed files present with matching sizes (hashes at staging). */ + readonly integrity: "ok" | "files-missing" | "corrupt"; + /** C5: schemaVersion in shell range and appCompatibility satisfied. */ + readonly schemaCompatible: boolean; + /** C6: every required section present and parseable. */ + readonly requiredSectionsOk: boolean; + /** C7: every asset referenced by required sections present. */ + readonly requiredAssetsOk: boolean; + /** C8: embedded emergency floor present (asserted against broken builds). */ + readonly emergencyFloorOk: boolean; + /** Required section/asset ids absent — the PARTIAL(list) enumeration. */ + readonly missing: readonly string[]; + /** A dataset was previously verified — distinguishes RECOVERY from NOT_READY. */ + readonly hadVerifiedDataset: boolean; + /** + * A recent incomplete staging exists in a slot with no active pointer + * (prep interrupted). Non-null (even with empty missing = fully staged, + * awaiting activation) maps to PARTIAL instead of NOT_READY. + */ + readonly stagedPending: { readonly missing: readonly string[] } | null; + /** The staged manifest is present and structurally sound (evidence for C4–C6). */ + readonly manifestOk: boolean; + /** Schema/app incompatibility (vs generic corruption) — RECOVERY(incompatible). */ + readonly incompatible: boolean; + /** Session operation failure, e.g. { op: "activation" } — FAILED(op). */ + readonly failure: { readonly op: string } | null; + /** Required sections present (for L0–L2), regardless of trust state. */ + readonly presentSections: readonly string[]; +} + +export interface ReadinessResult { + readonly state: ReadinessState; + /** Set for RECOVERY; null otherwise. */ + readonly reason: RecoveryReason | null; + /** Required ids absent (PARTIAL) or the failing part; empty for READY. */ + readonly missing: readonly string[]; + /** Set for FAILED; null otherwise. */ + readonly failedOp: string | null; + readonly level: PreparationLevel; +} + +export type ChipTone = "ready" | "partial" | "neutral" | "recovery" | "baseline" | "failed"; + +export interface StatusChip { + readonly text: string; + readonly tone: ChipTone; + /** Accessible label for the chip (status + what it means). */ + readonly label: string; +} + +export interface SectionStatus { + readonly id: string; + readonly required: boolean; + readonly present: boolean; + readonly parseable: boolean; +} + +/** + * Full boot report: predicate verdict plus everything the Status screen + * shows (checklist, versions, usage). Plain data — safe for ui to import. + * Storage shape mirrors the platform estimate without importing platform. + */ +export interface BootReadinessReport extends ReadinessResult { + readonly edition: string | null; + readonly packageVersion: number | null; + readonly generatedAt: string | null; + /** Local progress timestamp for display — never a verdict input. */ + readonly fetchedAt: number | null; + readonly sections: readonly SectionStatus[]; + readonly assets: { + readonly total: number; + readonly present: number; + readonly missing: readonly string[]; + }; + readonly floorVersion: string | null; + readonly storage: { readonly quota?: number; readonly usage?: number } | null; + readonly shellCache: string | null; + /** The retained slot could serve a user-initiated restore. */ + readonly canRestore: boolean; + /** A complete staging exists with no active pointer (activate in Stage 15). */ + readonly activationPending: boolean; } diff --git a/src/domain/schedule/README.md b/src/domain/schedule/README.md index e63499c..ae8d6db 100644 --- a/src/domain/schedule/README.md +++ b/src/domain/schedule/README.md @@ -1,5 +1,8 @@ # domain/schedule -Queries: Now/Next, filters/search, conflict. Imports data + clock. ADR-009, SPIKE-08. +Schedule queries over the active section: day groups (publish-time dayKey), +Now/Next (epoch predicates), stage/tag/status/favorites/search filters, +overlap detection, name lookups. Pure, offline, no clocks for verdicts. -Stage 1: directory exists with .gitkeep; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1. See IMPLEMENTATION-CONTRACT.md §4 responsibilities and §6 B-1…B-7. +Stage 11: `types.ts` (ScheduleFilter, DayGroup), `logic.ts` (queries). +Rendering uses explicit-zone Intl only (F-4). See IMPLEMENTATION-CONTRACT.md §21. diff --git a/src/domain/schedule/logic.ts b/src/domain/schedule/logic.ts new file mode 100644 index 0000000..7465870 --- /dev/null +++ b/src/domain/schedule/logic.ts @@ -0,0 +1,111 @@ +/** + * Schedule query model — pure views over a ScheduleSection. All predicates + * run on UTC epoch ms; grouping uses the publish-time dayKey (never client + * day math); rendering uses explicit-zone Intl (F-4). + * Trace: IMPLEMENTATION-CONTRACT.md §21, ARCHITECTURE-DESIGN.md:508. + */ +import type { ScheduleEvent, ScheduleSection } from "../../data/festival-package/types.js"; +import type { DayGroup, ScheduleFilter } from "./types.js"; + +export function sortByStart(events: readonly ScheduleEvent[]): ScheduleEvent[] { + return [...events].sort((a, b) => a.startUtc - b.startUtc || a.title.localeCompare(b.title)); +} + +/** Group by opaque publish-time dayKey — no client day math, DST-safe. */ +export function groupByDay(section: ScheduleSection): DayGroup[] { + const groups = new Map(); + for (const event of section.events) { + const list = groups.get(event.dayKey) ?? []; + list.push(event); + groups.set(event.dayKey, list); + } + return [...groups.entries()] + .map(([dayKey, events]) => { + const sorted = sortByStart(events); + return { dayKey, anchorUtc: sorted[0]?.startUtc ?? 0, events: sorted }; + }) + .sort((a, b) => (a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0)); +} + +/** Running now: startUtc ≤ now < endUtc. Overlaps all show (SPIKE-08 T7a). */ +export function nowEvents(events: readonly ScheduleEvent[], now: number): ScheduleEvent[] { + return sortByStart(events.filter((e) => e.startUtc <= now && now < e.endUtc)); +} + +/** Up next: startUtc > now, start-time order, first N (SPIKE-08 T7b–d). */ +export function upNext(events: readonly ScheduleEvent[], now: number, count = 3): ScheduleEvent[] { + return sortByStart(events.filter((e) => e.startUtc > now)).slice(0, Math.max(0, count)); +} + +export function upNextByStage( + section: ScheduleSection, + now: number, + count = 1, +): ReadonlyMap { + const byStage = new Map(); + for (const event of section.events) { + const list = byStage.get(event.stageId) ?? []; + list.push(event); + byStage.set(event.stageId, list); + } + const out = new Map(); + for (const [stageId, events] of byStage) out.set(stageId, upNext(events, now, count)); + return out; +} + +function matchesQuery(event: ScheduleEvent, section: ScheduleSection, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + const stage = section.stages.find((s) => s.id === event.stageId)?.name ?? ""; + const artists = (event.artistIds ?? []) + .map((id) => section.artists.find((a) => a.id === id)?.name ?? "") + .join(" "); + const haystack = `${event.title} ${artists} ${event.description ?? ""} ${stage}`.toLowerCase(); + return haystack.includes(needle); +} + +export function filterEvents(section: ScheduleSection, filter: ScheduleFilter): ScheduleEvent[] { + const query = filter.query ?? ""; + return sortByStart( + section.events.filter((event) => { + if (filter.dayKey !== undefined && event.dayKey !== filter.dayKey) return false; + if (filter.stageId !== undefined && event.stageId !== filter.stageId) return false; + if (filter.tag !== undefined && !(event.tags ?? []).includes(filter.tag)) return false; + if (filter.statuses !== undefined && !filter.statuses.includes(event.status)) return false; + if (filter.favoritesOnly && !(filter.favoriteIds?.has(event.id) ?? false)) return false; + if (!matchesQuery(event, section, query)) return false; + return true; + }), + ); +} + +/** Overlapping attendable pairs (cancelled excluded) for conflict surfacing. */ +export function detectOverlaps(events: readonly ScheduleEvent[]): [ScheduleEvent, ScheduleEvent][] { + const live = sortByStart(events.filter((e) => e.status !== "cancelled")); + const pairs: [ScheduleEvent, ScheduleEvent][] = []; + for (let i = 0; i < live.length; i++) { + const a = live[i]; + if (!a) continue; + for (let j = i + 1; j < live.length; j++) { + const b = live[j]; + if (!b || b.startUtc >= a.endUtc) break; + pairs.push([a, b]); + } + } + return pairs; +} + +export function stageName(section: ScheduleSection, stageId: string): string { + return section.stages.find((s) => s.id === stageId)?.name ?? stageId; +} + +export function artistNames(section: ScheduleSection, artistIds: readonly string[]): string { + return artistIds.map((id) => section.artists.find((a) => a.id === id)?.name ?? id).join(", "); +} + +/** Distinct tags across the section, sorted — for filter chips. */ +export function allTags(section: ScheduleSection): string[] { + const tags = new Set(); + for (const event of section.events) for (const tag of event.tags ?? []) tags.add(tag); + return [...tags].sort((a, b) => a.localeCompare(b)); +} diff --git a/src/domain/schedule/types.ts b/src/domain/schedule/types.ts new file mode 100644 index 0000000..06118a1 --- /dev/null +++ b/src/domain/schedule/types.ts @@ -0,0 +1,24 @@ +/** + * Schedule query types. Pure data — see logic.ts for evaluation. + * Trace: IMPLEMENTATION-CONTRACT.md §21. + */ +import type { EventStatus, ScheduleEvent } from "../../data/festival-package/types.js"; + +export interface ScheduleFilter { + readonly dayKey?: string; + readonly stageId?: string; + readonly tag?: string; + /** Defaults to all statuses (cancelled renders distinctly, never hidden). */ + readonly statuses?: readonly EventStatus[]; + readonly favoritesOnly?: boolean; + readonly favoriteIds?: ReadonlySet; + /** Case-insensitive substring over title, artists, description, stage. */ + readonly query?: string; +} + +export interface DayGroup { + readonly dayKey: string; + /** Anchor instant for zone-correct day labels (min startUtc). */ + readonly anchorUtc: number; + readonly events: readonly ScheduleEvent[]; +} diff --git a/src/platform/cache/shell-cache.ts b/src/platform/cache/shell-cache.ts new file mode 100644 index 0000000..6bfdcb2 --- /dev/null +++ b/src/platform/cache/shell-cache.ts @@ -0,0 +1,57 @@ +/** + * Cache Storage adapter for the app shell — the only non-test place allowed + * to touch the `caches` global (B-1). Owns shell caches only, no datasets. + * Trace: ADR-004, IMPLEMENTATION-CONTRACT.md §18. + */ + +/** Delete shell caches left by an earlier session (dev-mode scrub). */ +export async function deleteShellCaches(prefix = "lumen-shell-"): Promise { + if (typeof caches === "undefined") return; + const keys = await caches.keys(); + await Promise.all(keys.filter((k) => k.startsWith(prefix)).map((k) => caches.delete(k))); +} + +export interface ShellCacheCheck { + /** A current shell cache exists and holds the shell document entry. */ + readonly ok: boolean; + readonly cacheName: string | null; + readonly entry: string | null; +} + +/** + * C1 light check: find the current shell cache and confirm it holds the + * shell document. Full per-entry verification happens atomically at SW + * install (cache.addAll fails the install); boot only needs the fast + * presence proof. Never throws — absence/unavailability is data, not error. + */ +export async function checkShellCache(): Promise { + if (typeof caches === "undefined") { + return { ok: false, cacheName: null, entry: null }; + } + let keys: readonly string[]; + try { + keys = await caches.keys(); + } catch { + return { ok: false, cacheName: null, entry: null }; + } + const current = + keys + .filter((k) => k.startsWith("lumen-shell-")) + .sort() + .at(-1) ?? null; + if (!current) return { ok: false, cacheName: null, entry: null }; + let cache: Cache; + try { + cache = await caches.open(current); + } catch { + return { ok: false, cacheName: current, entry: null }; + } + for (const entry of ["/index.html", "/"]) { + try { + if (await cache.match(entry)) return { ok: true, cacheName: current, entry }; + } catch { + // try the next entry form below + } + } + return { ok: false, cacheName: current, entry: null }; +} diff --git a/src/platform/sw/register.ts b/src/platform/sw/register.ts index 7c2141b..a2ca05b 100644 --- a/src/platform/sw/register.ts +++ b/src/platform/sw/register.ts @@ -31,3 +31,16 @@ export function skipWaitingForUpdate(registration?: ServiceWorkerRegistration): const waiting = registration?.waiting; if (waiting) waiting.postMessage({ type: "SKIP_WAITING" }); } + +/** + * Unregister every service worker (dev-mode scrub only — production never + * calls this). Resolves true when a controlling SW was removed and the + * caller should reload to pick up the un-cached shell. + */ +export async function unregisterAllServiceWorkers(): Promise { + if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return false; + const regs = await navigator.serviceWorker.getRegistrations(); + const hadController = Boolean(navigator.serviceWorker.controller); + const unregistered = await Promise.all(regs.map((r) => r.unregister())); + return hadController && unregistered.some(Boolean); +} diff --git a/src/sync/activation.ts b/src/sync/activation.ts index 36b0ee6..ab296dd 100644 --- a/src/sync/activation.ts +++ b/src/sync/activation.ts @@ -18,6 +18,7 @@ import { lightCheckSlot, writeSlotAssetWithProgress, writeSlotFileWithProgress, + writeSlotManifest, } from "../data/slot/store.js"; import type { SlotStagingJournal } from "../data/slot/types.js"; import { @@ -29,7 +30,8 @@ import { } from "../data/system-meta/store.js"; import type { SystemMeta } from "../data/system-meta/types.js"; -const STAGING_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +/** Staging older than this is discarded (§18.2 — 7 days). Exported for boot evaluation. */ +export const STAGING_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const REQUIRED_SECTION_IDS = ["emergency", "schedule", "map", "info", "assets"] as const; export interface StagedPackage { @@ -137,6 +139,10 @@ export async function stageVerifiedPackage(pkg: VerifiedPackage): Promise([HOME_ROUTE, ...ROUTES].map((r) => [r.path, r.id])); diff --git a/src/ui/views/emergency/emergency.ts b/src/ui/views/emergency/emergency.ts index af9f2e4..4438e42 100644 --- a/src/ui/views/emergency/emergency.ts +++ b/src/ui/views/emergency/emergency.ts @@ -1,6 +1,16 @@ -/** Emergency view — Ring-0 safe DOM rendering, with optional verified Tier 2 data. */ +/** + * Emergency view — Ring-0 safe DOM rendering over the resolved Tier 1/2 model. + * Tier 2 (dataset section) renders full locations, contacts, procedures and + * notices; any demotion renders the floor plus honest guidance. No network, + * no IDB, no raw HTML — all text via textContent (C-22/B-7). + * Trace: IMPLEMENTATION-CONTRACT.md §14, SPIKE-06. + */ import { resolveEmergency } from "../../../domain/emergency/logic.js"; -import type { VerifiedEmergencyData } from "../../../domain/emergency/types.js"; +import type { + EmergencyDemotionReason, + EmergencyViewModel, + VerifiedEmergencyData, +} from "../../../domain/emergency/types.js"; function text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement { const element = document.createElement(tag); @@ -36,8 +46,64 @@ function addService(section: HTMLElement, label: string, value: string, dial = f section.append(wrapper); } -export function createEmergencyView(dataset?: VerifiedEmergencyData): HTMLElement { - const resolved = resolveEmergency(dataset); +function demotionGuidance(reason: EmergencyDemotionReason): string { + switch (reason) { + case "no-dataset": + return "Festival data isn't on this device yet — showing the built-in baseline. Open with internet to get festival data."; + case "unsupported-schema": + return "This festival data needs a newer app version — showing the built-in baseline. Update Lumen when online."; + case "malformed-section": + return "The festival data emergency section couldn't be read — showing the built-in baseline."; + } +} + +/** "Show on map" handoff into the map destination (offline, same shell). */ +function mapLink(): HTMLAnchorElement { + const link = document.createElement("a"); + link.className = "emergency-map-link"; + link.href = "/map"; + link.setAttribute("data-route", "/map"); + link.textContent = "Show on map"; + return link; +} + +function addPoiRow(listEl: HTMLUListElement, name: string, extra?: string): void { + const item = document.createElement("li"); + item.append(text("span", extra ? `${name} — ${extra}` : name), text("span", " · "), mapLink()); + listEl.append(item); +} + +function isViewModel(value: unknown): value is EmergencyViewModel { + const record = value as { source?: unknown } | null; + return ( + typeof record === "object" && + record !== null && + (record.source === "baseline" || record.source === "dataset") + ); +} + +/** + * @param dataset tier-2 input, or a pre-resolved viewmodel (lets the app + * convey precise demotion reasons the dataset alone cannot express). + * With no argument the compiled floor renders with zero IDB access (Ring-0). + */ +export function createEmergencyView( + dataset?: VerifiedEmergencyData | EmergencyViewModel, + now?: number, +): HTMLElement { + let resolved; + try { + resolved = isViewModel(dataset) ? dataset : resolveEmergency(dataset, now); + } catch { + // Broken floor build must still render something honest, never blank. + const fallback = document.createElement("section"); + fallback.className = "emergency-view"; + const h1 = document.createElement("h1"); + h1.textContent = "Emergency"; + fallback.append(h1); + fallback.append(text("p", "Emergency data failed to load on this device.")); + return fallback; + } const section = document.createElement("section"); section.className = "emergency-view"; section.setAttribute("aria-labelledby", "emergency-heading"); @@ -52,56 +118,96 @@ export function createEmergencyView(dataset?: VerifiedEmergencyData): HTMLElemen provenance.setAttribute("role", "status"); section.append(provenance); + if (resolved.demotion) { + const guidance = text("p", demotionGuidance(resolved.demotion)); + guidance.className = "emergency-guidance"; + section.append(guidance); + } + + const detail = resolved.section; + const services = detail?.services ?? resolved.floor.services; + const address = detail?.address ?? resolved.floor.address; + const procedures = detail?.procedures?.length ? detail.procedures : resolved.floor.procedures; + const urgent = document.createElement("div"); urgent.className = "emergency-alert"; urgent.append(heading("emergency-call-heading", "Need immediate help?")); - addService(urgent, "Emergency services", resolved.floor.services.emergencyNumber, true); + addService(urgent, "Emergency services", services.emergencyNumber, true); urgent.append(text("p", "Tap to call. The number is also selectable for copying.")); section.append(urgent); - const services = document.createElement("section"); - services.setAttribute("aria-labelledby", "emergency-services-heading"); - services.append(heading("emergency-services-heading", "Contacts")); - addService( - services, - "Security", - resolved.floor.services.security.phone ?? "Contact event staff or venue personnel", - ); - addService(services, "First aid", resolved.floor.services.firstAid.location); - section.append(services); + const contacts = document.createElement("section"); + contacts.setAttribute("aria-labelledby", "emergency-services-heading"); + contacts.append(heading("emergency-services-heading", "Contacts")); + const securityPhone = services.security.phone; + addService(contacts, "Security", securityPhone || "Contact event staff or venue personnel"); + if (services.security.location) { + contacts.append(text("p", `Security location: ${services.security.location}`)); + } + addService(contacts, "First aid", services.firstAid.location); + if (services.firstAid.hours) { + contacts.append(text("p", `First-aid hours: ${services.firstAid.hours}`)); + } + section.append(contacts); - const address = document.createElement("section"); - address.setAttribute("aria-labelledby", "emergency-address-heading"); - address.append(heading("emergency-address-heading", "Venue and exits")); - address.append(text("p", resolved.floor.address.lines.join(", "))); - address.append( + // Locations: dataset tier carries POI-linked muster/exit/AED detail with a + // map handoff; the floor carries name summaries (same-source, no drift). + const places = document.createElement("section"); + places.setAttribute("aria-labelledby", "emergency-address-heading"); + places.append(heading("emergency-address-heading", "Venue and exits")); + places.append(text("p", address.lines.join(", "))); + places.append( text( "p", - `Coordinates: ${String(resolved.floor.address.coordinates.lat)}, ${String(resolved.floor.address.coordinates.lon)}`, + `Coordinates: ${String(address.coordinates.lat)}, ${String(address.coordinates.lon)}`, ), - text("h3", "Muster points"), - list(resolved.floor.musterPoints.map((point) => point.name)), - text("h3", "Exits"), - list(resolved.floor.exits.map((exit) => exit.name)), - text("h3", "AEDs"), - text("p", resolved.floor.aedSummary), ); - section.append(address); - - const procedures = document.createElement("section"); - procedures.setAttribute("aria-labelledby", "emergency-procedures-heading"); - procedures.append(heading("emergency-procedures-heading", "What to do")); - for (const procedure of resolved.floor.procedures) { - procedures.append(text("h3", procedure.title), list(procedure.steps)); + if (detail?.locations) { + places.append(text("h3", "Muster points")); + const muster = document.createElement("ul"); + for (const point of detail.locations.musterPoints) { + addPoiRow(muster, point.name, undefined); + } + places.append(muster); + places.append(text("h3", "Exits")); + const exits = document.createElement("ul"); + for (const exit of detail.locations.exits) { + addPoiRow(exits, exit.name, undefined); + } + places.append(exits); + if (detail.locations.aeds.length > 0) { + places.append(text("h3", "AEDs")); + const aeds = document.createElement("ul"); + for (const aed of detail.locations.aeds) { + addPoiRow(aeds, `AED near ${aed.poi}`, undefined); + } + places.append(aeds); + } + } else { + places.append(text("h3", "Muster points")); + places.append(list(resolved.floor.musterPoints.map((point) => point.name))); + places.append(text("h3", "Exits")); + places.append(list(resolved.floor.exits.map((exit) => exit.name))); + places.append(text("h3", "AEDs")); + places.append(text("p", resolved.floor.aedSummary)); } - section.append(procedures); + section.append(places); - if (resolved.section?.notices?.length) { + const proceduresSection = document.createElement("section"); + proceduresSection.setAttribute("aria-labelledby", "emergency-procedures-heading"); + proceduresSection.append(heading("emergency-procedures-heading", "What to do")); + for (const procedure of procedures) { + proceduresSection.append(text("h3", procedure.title), list(procedure.steps)); + } + section.append(proceduresSection); + + if (detail?.notices?.length) { const notices = document.createElement("section"); notices.setAttribute("aria-labelledby", "emergency-notices-heading"); notices.append(heading("emergency-notices-heading", "Notices")); - for (const notice of resolved.section.notices) { - notices.append(text("h3", notice.title), text("p", notice.body.map(String).join(" "))); + for (const notice of detail.notices) { + notices.append(text("h3", `[${notice.severity}] ${notice.title}`)); + notices.append(text("p", notice.body.map(String).join(" "))); } section.append(notices); } diff --git a/src/ui/views/map/map.ts b/src/ui/views/map/map.ts index e8269bb..dc6cb19 100644 --- a/src/ui/views/map/map.ts +++ b/src/ui/views/map/map.ts @@ -1,56 +1,417 @@ -import { summit } from "../../../content/summit.js"; +/** + * Map view — offline raster base + DOM POI overlay + Facilities list. + * Pan/zoom is a single CSS transform on the world layer; markers + * counter-scale via --map-scale so targets stay constant size. The Facilities + * list is first-class (screen-reader and GPS-free path), never a fallback. + * No network, no GPS, no raw HTML — all text via textContent (C-22/B-7). + * Trace: IMPLEMENTATION-CONTRACT.md §22, SPIKE-03. + */ +import { + DETAIL_ZOOM_THRESHOLD, + filterPois, + groupFacilities, + poiPosition, + presentCategories, +} from "../../../domain/map/logic.js"; +import type { + MapLevelView, + MapPoi, + MapViewData, + PoiCategory, +} from "../../../domain/map/types.js"; -export function createMapView(): HTMLElement { +const MIN_SCALE = 1; +const MAX_SCALE = 4; +const PAN_STEP = 40; + +function text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement { + const element = document.createElement(tag); + element.textContent = value; + return element; +} + +function levelImages(level: MapLevelView): HTMLElement[] { + const day = document.createElement("img"); + day.className = "map-base"; + day.src = level.url; + day.alt = `Festival grounds map (${level.id})`; + day.draggable = false; + if (!level.nightUrl) return [day]; + const night = document.createElement("img"); + night.className = "map-base map-base--night"; + night.src = level.nightUrl; + night.alt = `Festival grounds map at night (${level.id})`; + night.draggable = false; + return [day, night]; +} + +/** + * Render the map. Null input (nothing staged) shows the honest + * not-downloaded card — never blank. With POIs but no base image the + * Facilities list still renders (SPIKE-05 cross-check). + */ +export function createMapView(input: MapViewData | null): HTMLElement { const section = document.createElement("section"); + section.className = "map-view"; section.setAttribute("aria-labelledby", "map-heading"); + const h1 = document.createElement("h1"); h1.id = "map-heading"; h1.textContent = "Map"; section.append(h1); - const p = document.createElement("p"); - p.textContent = "Official grounds map and venue information for the 2026 summit."; - section.append(p); - const card = document.createElement("div"); - card.className = "summit-card"; - const map = document.createElement("img"); - map.src = "https://solarpunksummit.com/wp-content/uploads/2024/10/map-2024-new-min.jpg"; - map.alt = "Solarpunk Summit grounds map"; - map.loading = "lazy"; - map.tabIndex = 0; - map.setAttribute("role", "button"); - map.setAttribute("aria-label", "Open grounds map full screen"); - card.append(map); - const address = document.createElement("h2"); - address.textContent = summit.venue; - const details = document.createElement("p"); - details.textContent = `${summit.location}. The venue is along the San Marcos River.`; - const link = document.createElement("a"); - link.href = "https://solarpunksummit.com/map/"; - link.target = "_blank"; - link.rel = "noreferrer"; - link.textContent = "Open official grounds map"; - card.append(address, details, link); - section.append(card); - const viewer = document.createElement("dialog"); - viewer.className = "map-viewer"; - viewer.setAttribute("aria-label", "Full-screen grounds map"); - const close = document.createElement("button"); - close.type = "button"; - close.className = "map-viewer__close"; - close.textContent = "Close map"; - close.addEventListener("click", () => viewer.close()); - const enlarged = document.createElement("img"); - enlarged.src = map.src; - enlarged.alt = map.alt; - viewer.append(close, enlarged); - map.addEventListener("click", () => viewer.showModal()); - map.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - viewer.showModal(); + if (!input) { + const card = document.createElement("div"); + card.className = "view-placeholder"; + card.append(text("p", "The map isn't on this device yet.")); + card.append( + text("p", "Open with internet to get festival data — the emergency floor always works meanwhile."), + ); + section.append(card); + return section; + } + + const state = { + scale: 1, + x: 0, + y: 0, + level: "overview", + categories: new Set(), + query: "", + }; + + // Detail panel (polite live region announces selections). + const detail = document.createElement("section"); + detail.className = "map-detail"; + detail.setAttribute("aria-live", "polite"); + + function selectPoi(poi: MapPoi | null): void { + detail.replaceChildren(); + if (!poi) { + detail.append(text("p", "Select a facility on the map or list for details.")); + return; } + const heading = document.createElement("h2"); + heading.textContent = poi.name; + detail.append(heading); + detail.append(text("p", `Category: ${poi.category}`)); + detail.append(text("p", poi.description ?? "No description.")); + } + selectPoi(null); + + if (input.mapAvailable && input.overview) { + const toolbar = document.createElement("div"); + toolbar.className = "map-toolbar"; + + const zoomOut = document.createElement("button"); + zoomOut.type = "button"; + zoomOut.className = "map-zoom"; + zoomOut.setAttribute("aria-label", "Zoom out"); + zoomOut.textContent = "−"; + const zoomIn = document.createElement("button"); + zoomIn.type = "button"; + zoomIn.className = "map-zoom"; + zoomIn.setAttribute("aria-label", "Zoom in"); + zoomIn.textContent = "+"; + const reset = document.createElement("button"); + reset.type = "button"; + reset.className = "map-zoom"; + reset.textContent = "Reset"; + reset.setAttribute("aria-label", "Reset map view"); + const levelLabel = text("span", "Overview"); + levelLabel.className = "map-level-label"; + levelLabel.setAttribute("role", "status"); + toolbar.append(zoomOut, zoomIn, reset, levelLabel); + section.append(toolbar); + + const stage = document.createElement("div"); + stage.className = "map-stage"; + stage.tabIndex = 0; + stage.setAttribute("aria-label", "Festival map. Drag to pan, use the zoom buttons, or arrow keys. Full facility list follows."); + stage.style.touchAction = "none"; + + const world = document.createElement("div"); + world.className = "map-world"; + const levelSlot = document.createElement("span"); + levelSlot.className = "map-level"; + world.append(levelSlot); + + const markers = new Map(); + for (const poi of input.pois) { + const marker = document.createElement("button"); + marker.type = "button"; + marker.className = "map-poi"; + marker.setAttribute("data-poi-id", poi.id); + marker.setAttribute("aria-label", `${poi.name}, ${poi.category}`); + marker.title = poi.name; + const pos = poiPosition(poi); + marker.style.left = pos.left; + marker.style.top = pos.top; + marker.append(text("span", poi.name)); + marker.addEventListener("click", () => { + selectPoi(poi); + }); + markers.set(poi.id, marker); + world.append(marker); + } + stage.append(world); + section.append(stage); + + const renderLevel = (): void => { + const level = state.level === "detail" && input.detail ? input.detail : input.overview; + if (!level) return; + levelSlot.replaceChildren(...levelImages(level)); + levelLabel.textContent = input.detail && state.level === "detail" ? "Detail" : "Overview"; + }; + + const clampPan = (): void => { + const w = stage.clientWidth; + const h = stage.clientHeight; + if (!w || !h) return; + state.x = Math.min(0, Math.max(w - w * state.scale, state.x)); + state.y = Math.min(0, Math.max(h - h * state.scale, state.y)); + }; + + const apply = (): void => { + world.style.transform = `translate(${String(state.x)}px, ${String(state.y)}px) scale(${String(state.scale)})`; + world.style.setProperty("--map-scale", String(state.scale)); + const want = state.scale >= DETAIL_ZOOM_THRESHOLD && input.detail ? "detail" : "overview"; + if (want !== state.level) { + state.level = want; + renderLevel(); + } + }; + + const zoomAt = (cx: number, cy: number, factor: number): void => { + const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale * factor)); + const k = next / state.scale; + state.x = cx - (cx - state.x) * k; + state.y = cy - (cy - state.y) * k; + state.scale = next; + clampPan(); + apply(); + }; + + const center = (): { x: number; y: number } => { + const rect = stage.getBoundingClientRect(); + return { x: rect.width / 2, y: rect.height / 2 }; + }; + + zoomIn.addEventListener("click", () => { + const c = center(); + zoomAt(c.x, c.y, 1.5); + }); + zoomOut.addEventListener("click", () => { + const c = center(); + zoomAt(c.x, c.y, 1 / 1.5); + }); + reset.addEventListener("click", () => { + state.scale = 1; + state.x = 0; + state.y = 0; + apply(); + }); + stage.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const rect = stage.getBoundingClientRect(); + zoomAt(event.clientX - rect.left, event.clientY - rect.top, event.deltaY < 0 ? 1.25 : 1 / 1.25); + }, + { passive: false }, + ); + stage.addEventListener("keydown", (event) => { + const step = event.shiftKey ? PAN_STEP * 3 : PAN_STEP; + if (event.key === "ArrowLeft") { + state.x += step; + clampPan(); + apply(); + event.preventDefault(); + } else if (event.key === "ArrowRight") { + state.x -= step; + clampPan(); + apply(); + event.preventDefault(); + } else if (event.key === "ArrowUp") { + state.y += step; + clampPan(); + apply(); + event.preventDefault(); + } else if (event.key === "ArrowDown") { + state.y -= step; + clampPan(); + apply(); + event.preventDefault(); + } else if (event.key === "+" || event.key === "=") { + const c = center(); + zoomAt(c.x, c.y, 1.25); + event.preventDefault(); + } else if (event.key === "-") { + const c = center(); + zoomAt(c.x, c.y, 1 / 1.25); + event.preventDefault(); + } + }); + + // Drag pan + two-pointer pinch, all pointer types. + const pointers = new Map(); + let pinchDistance = 0; + stage.addEventListener("pointerdown", (event) => { + pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (pointers.size === 2) { + const [a, b] = [...pointers.values()]; + if (a && b) pinchDistance = Math.hypot(a.x - b.x, a.y - b.y); + } + try { + stage.setPointerCapture?.(event.pointerId); + } catch { + // jsdom and old browsers lack capture — tracking still works. + } + }); + stage.addEventListener("pointermove", (event) => { + const prev = pointers.get(event.pointerId); + if (!prev) return; + const next = { x: event.clientX, y: event.clientY }; + pointers.set(event.pointerId, next); + if (pointers.size === 1) { + state.x += next.x - prev.x; + state.y += next.y - prev.y; + clampPan(); + apply(); + } else if (pointers.size === 2) { + const [a, b] = [...pointers.values()]; + if (!a || !b) return; + const distance = Math.hypot(a.x - b.x, a.y - b.y); + const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + const rect = stage.getBoundingClientRect(); + if (pinchDistance > 0 && distance > 0) { + zoomAt(mid.x - rect.left, mid.y - rect.top, distance / pinchDistance); + } + pinchDistance = distance; + } + }); + const release = (event: PointerEvent): void => { + pointers.delete(event.pointerId); + pinchDistance = 0; + }; + stage.addEventListener("pointerup", release); + stage.addEventListener("pointercancel", release); + + // Filter application shared by chips + search. + const applyMarkerVisibility = (): void => { + const visible = new Set( + filterPois(input.pois, { categories: state.categories, query: state.query }).map((p) => p.id), + ); + for (const [id, marker] of markers) marker.hidden = !visible.has(id); + }; + stage.addEventListener("mapfilter", () => { + applyMarkerVisibility(); + }); + + renderLevel(); + apply(); + } else { + const card = document.createElement("div"); + card.className = "view-placeholder"; + card.append(text("p", "The map image isn't downloaded — the facilities list below still works.")); + section.append(card); + } + + section.append(detail); + + // Category chips + search drive both the markers (via event) and the list. + const controls = document.createElement("div"); + controls.className = "map-controls"; + const chipBar = document.createElement("div"); + chipBar.className = "map-chips"; + chipBar.setAttribute("role", "group"); + chipBar.setAttribute("aria-label", "Filter facilities by category"); + const chipButtons: HTMLButtonElement[] = []; + const paintChips = (): void => { + for (const button of chipButtons) { + const category = button.getAttribute("data-category") ?? ""; + button.setAttribute("aria-pressed", String(category === "" ? state.categories.size === 0 : state.categories.has(category as PoiCategory))); + } + }; + const allButton = document.createElement("button"); + allButton.type = "button"; + allButton.className = "map-chip"; + allButton.textContent = "All"; + allButton.setAttribute("data-category", ""); + allButton.addEventListener("click", () => { + state.categories.clear(); + paintChips(); + refreshFacilities(); + section.dispatchEvent(new Event("mapfilter")); }); - section.append(viewer); + chipButtons.push(allButton); + chipBar.append(allButton); + for (const category of presentCategories(input.pois, input.categories)) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "map-chip"; + button.textContent = category; + button.setAttribute("data-category", category); + button.addEventListener("click", () => { + if (state.categories.has(category)) state.categories.delete(category); + else state.categories.add(category); + paintChips(); + refreshFacilities(); + section.dispatchEvent(new Event("mapfilter")); + }); + chipButtons.push(button); + chipBar.append(button); + } + paintChips(); + controls.append(chipBar); + + const search = document.createElement("input"); + search.type = "search"; + search.setAttribute("aria-label", "Search facilities"); + search.placeholder = "Search facilities…"; + search.addEventListener("input", () => { + state.query = search.value; + refreshFacilities(); + section.dispatchEvent(new Event("mapfilter")); + }); + controls.append(search); + section.append(controls); + + // First-class Facilities list. + const facilities = document.createElement("div"); + facilities.className = "map-facilities"; + section.append(facilities); + + function refreshFacilities(): void { + facilities.replaceChildren(); + const visible = filterPois(input!.pois, { categories: state.categories, query: state.query }); + if (visible.length === 0) { + facilities.append(text("p", input!.pois.length === 0 ? "No facilities listed." : "No facilities match.")); + return; + } + for (const group of groupFacilities(visible, input!.categories)) { + const heading = document.createElement("h2"); + heading.textContent = `${group.category} (${String(group.pois.length)})`; + facilities.append(heading); + const list = document.createElement("ul"); + list.className = "map-facility-list"; + for (const poi of group.pois) { + const item = document.createElement("li"); + const button = document.createElement("button"); + button.type = "button"; + button.className = "map-facility"; + button.setAttribute("data-poi-id", poi.id); + button.textContent = poi.name; + button.addEventListener("click", () => { + selectPoi(poi); + }); + item.append(button); + list.append(item); + } + facilities.append(list); + } + } + refreshFacilities(); + return section; } diff --git a/src/ui/views/schedule/schedule.ts b/src/ui/views/schedule/schedule.ts index d326f77..fca0740 100644 --- a/src/ui/views/schedule/schedule.ts +++ b/src/ui/views/schedule/schedule.ts @@ -1,34 +1,415 @@ -import { summit } from "../../../content/summit.js"; +/** + * Schedule view — offline day browsing, Now/Next, filters, search, favorites. + * Pure rendering over loaded section data: filter/day/search state lives in + * the view (in-place list updates, no route churn); persistence and zone + * preference flow out through caller callbacks (B-1 — no IDB here). + * All text via textContent (C-22/B-7); times via explicit-zone Intl (F-4). + * Trace: IMPLEMENTATION-CONTRACT.md §21. + */ +import { + allTags, + artistNames, + filterEvents, + groupByDay, + nowEvents, + stageName, + upNext, +} from "../../../domain/schedule/logic.js"; +import type { ScheduleFilter } from "../../../domain/schedule/types.js"; +import { buildMySchedule, myScheduleConflicts } from "../../../domain/favorites/logic.js"; +import { formatClockTime, formatDayLabel, formatEventTime } from "../../../domain/clock/logic.js"; +import type { ScheduleEvent, ScheduleSection } from "../../../data/festival-package/types.js"; +import type { FavoriteEntry } from "../../../data/user/types.js"; -export function createScheduleView(): HTMLElement { +export interface ScheduleClock { + readonly now: number; + /** Display zone (festival zone or device zone per toggle). */ + readonly zone: string; + readonly festivalZone: string; + readonly useDeviceZone: boolean; + /** F-3 sanity warning — absolute times stay readable regardless. */ + readonly outsideWarn: boolean; + readonly driftWarn: boolean; +} + +export interface ScheduleViewInput { + readonly section: ScheduleSection; + readonly favorites: ReadonlySet; + readonly clock: ScheduleClock; +} + +export interface ScheduleViewActions { + /** + * Persist a favorite toggle. Resolves true when write-through succeeded; + * the view rolls its optimistic flip back on false (no memory-only truth). + */ + readonly onToggleFavorite: (eventId: string, favored: boolean) => boolean | Promise; + readonly onZoneToggle: (useDeviceZone: boolean) => void; +} + +function text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement { + const element = document.createElement(tag); + element.textContent = value; + return element; +} + +function eventTime(event: ScheduleEvent, zone: string): string { + return `${formatEventTime(event.startUtc, zone)} – ${formatClockTime(event.endUtc, zone)}`; +} + +function statusBadge(event: ScheduleEvent, zone: string): HTMLSpanElement | null { + if (event.status === "scheduled") return null; + const badge = document.createElement("span"); + badge.className = `schedule-badge schedule-badge--${event.status}`; + if (event.status === "moved" && event.originalStartUtc !== undefined) { + badge.textContent = `Moved (was ${formatEventTime(event.originalStartUtc, zone)})`; + } else { + badge.textContent = event.status === "moved" ? "Moved" : "Cancelled"; + } + return badge; +} + +function eventCard( + event: ScheduleEvent, + section: ScheduleSection, + zone: string, + favored: boolean, + onToggleFavorite: (eventId: string, favored: boolean) => void, +): HTMLElement { + const card = document.createElement("article"); + card.className = "schedule-item"; + card.setAttribute("data-event-id", event.id); + + const title = document.createElement("h2"); + title.textContent = event.title; + card.append(title); + + card.append(text("p", eventTime(event, zone))); + const names = artistNames(section, event.artistIds); + card.append( + text( + "p", + names ? `${stageName(section, event.stageId)} · ${names}` : stageName(section, event.stageId), + ), + ); + if (event.description) card.append(text("p", event.description)); + + const badge = statusBadge(event, zone); + if (badge) card.append(badge); + + const fav = document.createElement("button"); + fav.type = "button"; + fav.className = "schedule-fav"; + fav.setAttribute("aria-pressed", String(favored)); + fav.setAttribute( + "aria-label", + favored ? `Remove ${event.title} from My Schedule` : `Add ${event.title} to My Schedule`, + ); + fav.textContent = favored ? "★" : "☆"; + const paintFav = (value: boolean): void => { + fav.setAttribute("aria-pressed", String(value)); + fav.textContent = value ? "★" : "☆"; + fav.setAttribute( + "aria-label", + value ? `Remove ${event.title} from My Schedule` : `Add ${event.title} to My Schedule`, + ); + }; + fav.addEventListener("click", () => { + const next = fav.getAttribute("aria-pressed") !== "true"; + paintFav(next); + onToggleFavorite(event.id, next); + }); + card.append(fav); + return card; +} + +/** + * Render the schedule. With null input the view shows the honest + * not-downloaded card (PARTIAL/NOT_READY states) — never blank. + */ +export function createScheduleView( + input: ScheduleViewInput | null, + actions: ScheduleViewActions, +): HTMLElement { const section = document.createElement("section"); + section.className = "schedule-view"; section.setAttribute("aria-labelledby", "schedule-heading"); + const h1 = document.createElement("h1"); h1.id = "schedule-heading"; h1.textContent = "Schedule"; section.append(h1); - const p = document.createElement("p"); - p.textContent = `${summit.dates}: the public site organizes the summit around four themed days.`; - section.append(p); - const card = document.createElement("div"); - card.className = "summit-card"; - const themes: readonly [string, string, string][] = [ - ["Thursday, October 8", "Air", "Communication and personal development"], - ["Friday, October 9", "Earth", "Regenerative culture and permaculture"], - ["Saturday, October 10", "Fire", "Innovation, entrepreneurship, and technology"], - ["Sunday, October 11", "Water", "Consciousness, spirituality, and integration"], - ["Monday, October 12", "Closing", "Community reflection and carry-forward"], - ]; - for (const [day, name, description] of themes) { - const item = document.createElement("div"); - item.className = "schedule-item"; - const title = document.createElement("h2"); - title.textContent = `${day}: ${name}`; - const detail = document.createElement("p"); - detail.textContent = description; - item.append(title, detail); - card.append(item); + + if (!input) { + const card = document.createElement("div"); + card.className = "view-placeholder"; + card.append(text("p", "The schedule isn't on this device yet.")); + card.append( + text( + "p", + "Open with internet to get festival data — the emergency floor always works meanwhile.", + ), + ); + section.append(card); + return section; } - section.append(card); + + const { section: data, clock } = input; + const favs = new Set(input.favorites); + const state: { + day: string | null; + stage: string | null; + tag: string | null; + query: string; + mine: boolean; + } = { + day: null, + stage: null, + tag: null, + query: "", + mine: false, + }; + + if (clock.outsideWarn || clock.driftWarn) { + const warn = text( + "p", + clock.driftWarn + ? "The device clock changed mid-session — times re-anchored. Event times below are absolute." + : "The device clock looks off — Now/Next may be wrong, but the absolute event times below still read fine.", + ); + warn.className = "schedule-warning"; + warn.setAttribute("role", "status"); + section.append(warn); + } + + // Now / Up Next (epoch predicates only — SPIKE-08 T7). + const live = document.createElement("section"); + live.setAttribute("aria-labelledby", "schedule-now-heading"); + const liveHeading = document.createElement("h2"); + liveHeading.id = "schedule-now-heading"; + liveHeading.textContent = "Now & Next"; + live.append(liveHeading); + const liveList = document.createElement("div"); + liveList.className = "schedule-live"; + live.append(liveList); + section.append(live); + + // Controls: day tabs, stage select, tag chips, favorites, search. + const controls = document.createElement("div"); + controls.className = "schedule-controls"; + + const days = groupByDay(data); + state.day ??= + days.find((d) => d.events.some((e) => e.startUtc <= clock.now && clock.now < e.endUtc)) + ?.dayKey ?? + days.find((d) => d.events.some((e) => e.startUtc > clock.now))?.dayKey ?? + days[0]?.dayKey ?? + null; + const dayBar = document.createElement("div"); + dayBar.className = "schedule-days"; + dayBar.setAttribute("role", "group"); + dayBar.setAttribute("aria-label", "Festival days"); + const dayButtons = new Map(); + for (const day of days) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "schedule-day"; + button.textContent = formatDayLabel(day.anchorUtc, clock.zone); + button.setAttribute("aria-pressed", String(state.day === day.dayKey)); + button.addEventListener("click", () => { + state.day = day.dayKey; + for (const [key, other] of dayButtons) + other.setAttribute("aria-pressed", String(key === day.dayKey)); + refresh(); + }); + dayButtons.set(day.dayKey, button); + dayBar.append(button); + } + controls.append(dayBar); + + const stageSelect = document.createElement("select"); + stageSelect.setAttribute("aria-label", "Filter by stage"); + const allStages = document.createElement("option"); + allStages.value = ""; + allStages.textContent = "All stages"; + stageSelect.append(allStages); + for (const stage of data.stages) { + const option = document.createElement("option"); + option.value = stage.id; + option.textContent = stage.name; + stageSelect.append(option); + } + stageSelect.addEventListener("change", () => { + state.stage = stageSelect.value || null; + refresh(); + }); + controls.append(stageSelect); + + const tagBar = document.createElement("div"); + tagBar.className = "schedule-tags"; + tagBar.setAttribute("role", "group"); + tagBar.setAttribute("aria-label", "Filter by tag"); + const tagButtons = new Map(); + const paintTags = (): void => { + for (const [key, other] of tagButtons) + other.setAttribute("aria-pressed", String(key === (state.tag ?? ""))); + }; + const allTagsButton = document.createElement("button"); + allTagsButton.type = "button"; + allTagsButton.className = "schedule-tag"; + allTagsButton.textContent = "All"; + allTagsButton.addEventListener("click", () => { + state.tag = null; + paintTags(); + refresh(); + }); + tagButtons.set("", allTagsButton); + tagBar.append(allTagsButton); + for (const tag of allTags(data)) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "schedule-tag"; + button.textContent = tag; + button.addEventListener("click", () => { + state.tag = state.tag === tag ? null : tag; + paintTags(); + refresh(); + }); + tagButtons.set(tag, button); + tagBar.append(button); + } + paintTags(); + controls.append(tagBar); + + const search = document.createElement("input"); + search.type = "search"; + search.setAttribute("aria-label", "Search schedule"); + search.placeholder = "Search artists, titles, stages…"; + search.addEventListener("input", () => { + state.query = search.value; + refresh(); + }); + controls.append(search); + + const mineToggle = document.createElement("button"); + mineToggle.type = "button"; + mineToggle.className = "schedule-mine"; + const paintMine = (): void => { + mineToggle.setAttribute("aria-pressed", String(state.mine)); + mineToggle.textContent = `★ Mine (${String(favs.size)})`; + }; + mineToggle.addEventListener("click", () => { + state.mine = !state.mine; + paintMine(); + refresh(); + }); + paintMine(); + controls.append(mineToggle); + + const zoneToggle = document.createElement("button"); + zoneToggle.type = "button"; + zoneToggle.className = "schedule-zone"; + zoneToggle.textContent = clock.useDeviceZone ? "Showing device time" : "Showing festival time"; + zoneToggle.addEventListener("click", () => { + actions.onZoneToggle(!clock.useDeviceZone); + }); + controls.append(zoneToggle); + + section.append(controls); + + const list = document.createElement("div"); + list.className = "schedule-list"; + section.append(list); + + const toggleFavorite = (eventId: string, favored: boolean): void => { + if (favored) favs.add(eventId); + else favs.delete(eventId); + paintMine(); + // Persistence may be async — failure only affects the next render, which + // re-reads the store (the view never owns truth). + void Promise.resolve(actions.onToggleFavorite(eventId, favored)).catch(() => { + // Persistence failure is silent here by design — see above. + }); + refreshConflicts(); + }; + + const conflictsNote = document.createElement("p"); + conflictsNote.className = "schedule-conflicts"; + conflictsNote.setAttribute("role", "status"); + const refreshConflicts = (): void => { + const items = buildMySchedule( + data, + [...favs].map((eventId): FavoriteEntry => ({ eventId, addedAt: 0 })), + ); + const conflicts = myScheduleConflicts(items); + const first = conflicts[0]; + if (state.mine && first) { + const [a, b] = first; + conflictsNote.textContent = `Overlap in My Schedule: ${a.title} × ${b.title}${conflicts.length > 1 ? ` (+${String(conflicts.length - 1)} more)` : ""}`; + } else { + conflictsNote.textContent = ""; + } + }; + section.append(conflictsNote); + + function currentFilter(): ScheduleFilter { + // Conditional spreads satisfy exactOptionalPropertyTypes — absent means unfiltered. + return { + ...(state.day ? { dayKey: state.day } : {}), + ...(state.stage ? { stageId: state.stage } : {}), + ...(state.tag ? { tag: state.tag } : {}), + ...(state.query ? { query: state.query } : {}), + ...(state.mine ? { favoritesOnly: true as const, favoriteIds: favs } : {}), + }; + } + + function refresh(): void { + const filter = currentFilter(); + const events = filterEvents(data, filter); + // Up Next respects the stage filter (per-stage up-next); Now stays global. + const scoped = state.stage ? data.events.filter((e) => e.stageId === state.stage) : data.events; + const happening = nowEvents(data.events, clock.now); + const upcoming = upNext(scoped, clock.now, 3); + + liveList.replaceChildren(); + if (happening.length > 0) { + liveList.append(text("h3", "On now")); + for (const event of happening) { + liveList.append(eventCard(event, data, clock.zone, favs.has(event.id), toggleFavorite)); + } + } + if (upcoming.length > 0) { + liveList.append(text("h3", state.stage ? "Next on this stage" : "Up next")); + for (const event of upcoming) { + liveList.append(eventCard(event, data, clock.zone, favs.has(event.id), toggleFavorite)); + } + } + if (happening.length === 0 && upcoming.length === 0) { + liveList.append(text("p", "Nothing scheduled around this time.")); + } + + list.replaceChildren(); + if (events.length === 0) { + list.append( + text( + "p", + state.mine ? "No favorites yet — tap ☆ on any event." : "No events match these filters.", + ), + ); + return; + } + const activeDay = state.day ?? days[0]?.dayKey; + const dayLabel = days.find((d) => d.dayKey === activeDay); + if (dayLabel && !state.mine && !state.query) { + list.append(text("h2", formatDayLabel(dayLabel.anchorUtc, clock.zone))); + } + for (const event of events) { + list.append(eventCard(event, data, clock.zone, favs.has(event.id), toggleFavorite)); + } + refreshConflicts(); + } + + refresh(); + refreshConflicts(); return section; } diff --git a/src/ui/views/status/README.md b/src/ui/views/status/README.md index 64daa1a..5e440cc 100644 --- a/src/ui/views/status/README.md +++ b/src/ui/views/status/README.md @@ -1,2 +1,7 @@ # ui/views/status -View for status — may import domain only. B-1, §7. Stage 1: no code. + +Status screen (§20.5) — pure rendering over a BootReadinessReport plus +caller-provided action callbacks. May import domain only. B-1, §7. + +Stage 9: `status.ts` (`createStatusView`, `createPrepGuidanceView`). All text +via textContent (C-22/B-7). Dialog lifecycle is owned by `src/app/main.ts`. diff --git a/src/ui/views/status/status.ts b/src/ui/views/status/status.ts new file mode 100644 index 0000000..c850da6 --- /dev/null +++ b/src/ui/views/status/status.ts @@ -0,0 +1,191 @@ +/** + * Status screen — readiness detail for the header chip (§20.5). + * Pure rendering over a BootReadinessReport: per-section checklist, dataset + * version, storage usage, and state-appropriate actions. No persistence, no + * fetch, no clocks for verdicts — actions are caller-provided callbacks. + * All text via textContent (C-22/B-7). + * Trace: IMPLEMENTATION-CONTRACT.md §20.5, SPIKE-05 §3. + */ +import type { BootReadinessReport, SectionStatus } from "../../../domain/readiness/types.js"; + +export interface StatusActions { + /** Re-run the readiness evaluation ("Check my data" / "Retry"). */ + readonly onCheckData: () => void; + /** Preparation guidance ("Get festival data" / "Continue setup" / "Restore"). */ + readonly onGetData: () => void; + /** User-initiated restore of the retained slot; null when unavailable. */ + readonly onRestore: (() => void) | null; + readonly onClose: () => void; +} + +function text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement { + const element = document.createElement(tag); + element.textContent = value; + return element; +} + +function actionButton(label: string, handler: () => void, primary = false): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = primary ? "status-action status-action--primary" : "status-action"; + button.textContent = label; + button.addEventListener("click", handler); + return button; +} + +function formatBytes(bytes: number | undefined): string { + if (bytes === undefined) return "unknown"; + if (bytes < 1024) return `${String(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function sectionRow(section: SectionStatus): HTMLLIElement { + const item = document.createElement("li"); + item.className = "status-check"; + const mark = document.createElement("span"); + mark.className = "status-check__mark"; + mark.setAttribute("aria-hidden", "true"); + const ok = section.present && section.parseable; + mark.textContent = ok ? "✓" : "✗"; + item.classList.toggle("status-check--ok", ok); + item.classList.toggle("status-check--missing", !ok); + const label = document.createElement("span"); + const req = section.required ? "required" : "optional"; + label.textContent = `${section.id} — ${ok ? "present" : section.present ? "unreadable" : "missing"} (${req})`; + item.append(mark, label); + return item; +} + +/** + * Render the status detail for a readiness report. The caller owns + * dialog/overlay lifecycle; this returns fresh content on every call so the + * caller can re-render after re-evaluation. + */ +export function createStatusView(report: BootReadinessReport, actions: StatusActions): HTMLElement { + const section = document.createElement("section"); + section.className = "status-view"; + section.setAttribute("aria-labelledby", "status-heading"); + + const h1 = document.createElement("h1"); + h1.id = "status-heading"; + h1.textContent = "Offline status"; + section.append(h1); + + const stateLine = text("p", `State: ${report.state}`); + stateLine.className = `status-state status-state--${report.state.toLowerCase().replace("_", "-")}`; + section.append(stateLine); + + if (report.reason) section.append(text("p", `Reason: ${report.reason}`)); + if (report.failedOp) section.append(text("p", `Failed operation: ${report.failedOp}`)); + if (report.missing.length > 0) { + section.append(text("p", `Missing: ${report.missing.join(", ")}`)); + } + section.append(text("p", `Preparation level: ${report.level}`)); + + if (report.sections.length > 0) { + section.append(text("h2", "Sections")); + const list = document.createElement("ul"); + list.className = "status-checklist"; + for (const item of report.sections) list.append(sectionRow(item)); + section.append(list); + } + + const dataset = text( + "p", + report.packageVersion === null + ? "Dataset: none" + : `Dataset: ${report.edition ?? "unknown edition"} v${String(report.packageVersion)} · generated ${report.generatedAt ?? "unknown"}`, + ); + dataset.className = "status-dataset"; + section.append(dataset); + + if (report.fetchedAt !== null) { + section.append( + text("p", `Verified on this device: ${new Date(report.fetchedAt).toISOString()}`), + ); + } + section.append( + text("p", `Assets: ${String(report.assets.present)}/${String(report.assets.total)} present`), + ); + if (report.floorVersion) { + section.append(text("p", `Emergency floor: v${report.floorVersion} (always on this device)`)); + } + const usage = report.storage; + const storageText = + usage === null || (usage.usage === undefined && usage.quota === undefined) + ? "Storage: unavailable in this browser" + : `Storage: ${formatBytes(usage.usage)} used of ${formatBytes(usage.quota)}${report.shellCache ? ` · shell ${report.shellCache}` : ""}`; + section.append(text("p", storageText)); + + const buttons = document.createElement("div"); + buttons.className = "status-actions"; + + switch (report.state) { + case "NOT_READY": + buttons.append(actionButton("Get festival data", actions.onGetData, true)); + break; + case "PARTIAL": + buttons.append( + actionButton( + report.activationPending ? "Staged data found — continue setup" : "Continue setup", + actions.onGetData, + true, + ), + ); + break; + case "RECOVERY": + buttons.append(actionButton("Restore festival data", actions.onGetData, true)); + if (actions.onRestore) { + buttons.append(actionButton("Restore previous version", actions.onRestore)); + } + break; + case "FAILED": + buttons.append(actionButton("Retry", actions.onCheckData, true)); + section.append( + text( + "p", + "If this persists, restore festival data when online. Diagnostics stay on this device.", + ), + ); + break; + case "BASELINE_ONLY": + section.append( + text( + "p", + "This browser cannot save offline data (private mode, blocked website data, or no space). Install Lumen to the home screen, use a normal browsing window, or free storage — emergency info below still works offline.", + ), + ); + break; + case "READY": + break; + } + buttons.append(actionButton("Check my data", actions.onCheckData)); + buttons.append(actionButton("Close", actions.onClose)); + section.append(buttons); + + return section; +} + +/** Preparation guidance shown for Get/Continue/Restore — sync flow lands in Stage 15. */ +export function createPrepGuidanceView(onBack: () => void, onClose: () => void): HTMLElement { + const section = document.createElement("section"); + section.className = "status-view"; + section.setAttribute("aria-labelledby", "prep-heading"); + const h1 = document.createElement("h1"); + h1.id = "prep-heading"; + h1.textContent = "Get festival data"; + section.append(h1); + section.append( + text( + "p", + "Festival data preparation needs an internet connection. Open Lumen online and return here — one-tap download with resume, verification, and OFFLINE READY confirmation arrives with the sync stage. Your emergency floor below always works, with or without it.", + ), + ); + const buttons = document.createElement("div"); + buttons.className = "status-actions"; + buttons.append(actionButton("Back to status", onBack, true)); + buttons.append(actionButton("Close", onClose)); + section.append(buttons); + return section; +} diff --git a/tests/unit/data-package.test.ts b/tests/unit/data-package.test.ts index d11d63f..e9eb77a 100644 --- a/tests/unit/data-package.test.ts +++ b/tests/unit/data-package.test.ts @@ -341,30 +341,37 @@ describe("dataset metadata", () => { describe("readiness inputs (time-independent per SPIKE-05)", () => { it("readiness is data-driven, not clock", async () => { - const { computeReadiness } = await import("../../src/domain/readiness/types.js"); + const { evaluateReadiness } = await import("../../src/domain/readiness/logic.js"); + const allOk = { + shellValid: true, + datasetPresent: true, + authenticity: "verified" as const, + integrity: "ok" as const, + schemaCompatible: true, + requiredSectionsOk: true, + requiredAssetsOk: true, + emergencyFloorOk: true, + missing: [] as readonly string[], + hadVerifiedDataset: true, + manifestOk: true, + incompatible: false, + failure: null, + presentSections: ["emergency", "schedule", "info", "map", "assets"] as readonly string[], + stagedPending: null, + }; + expect(evaluateReadiness(allOk).state).toBe("READY"); expect( - computeReadiness({ - shellValid: true, - datasetPresent: true, - authenticityOk: true, - integrityOk: true, - schemaCompatible: true, - requiredSectionsOk: true, - requiredAssetsOk: true, - emergencyFloorOk: true, - }), - ).toBe("READY"); - expect( - computeReadiness({ - shellValid: true, + evaluateReadiness({ + ...allOk, datasetPresent: false, - authenticityOk: false, - integrityOk: false, + authenticity: "record-missing", + integrity: "files-missing", schemaCompatible: false, requiredSectionsOk: false, requiredAssetsOk: false, - emergencyFloorOk: true, - }), + hadVerifiedDataset: false, + presentSections: [], + }).state, ).toBe("NOT_READY"); }); }); diff --git a/tests/unit/emergency.test.ts b/tests/unit/emergency.test.ts new file mode 100644 index 0000000..8a9370a --- /dev/null +++ b/tests/unit/emergency.test.ts @@ -0,0 +1,350 @@ +/** + * @vitest-environment jsdom + * Stage 10 — Emergency three tiers: resolution matrix, Tier 2 rendering, + * demotion guidance, zero-IDB floor, loader, and boot wiring. + * Trace: IMPLEMENTATION-CONTRACT.md §14, SPIKE-06. + */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBFactory from "fake-indexeddb/lib/FDBFactory"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange"; +import { mount as boot } from "../../src/app/main.js"; +import { loadDatasetEmergency } from "../../src/app/emergency.js"; +import { + demotedBaseline, + isSupportedEmergencySchema, + narrowEmergencySection, + resolveEmergency, + visibleNotices, +} from "../../src/domain/emergency/logic.js"; +import type { VerifiedEmergencyData } from "../../src/domain/emergency/types.js"; +import type { EmergencySection } from "../../src/data/festival-package/types.js"; +import { createEmergencyView } from "../../src/ui/views/emergency/emergency.js"; +import { buildPackage } from "../../pipeline/package.js"; +import { generateTestKeyPair } from "../../pipeline/sign.js"; +import { makeValidInput } from "../../pipeline/fixtures.js"; +import { canonicalJson } from "../../pipeline/canonical-json.js"; +import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js"; +import { verifyPackage } from "../../src/sync/verifier/package.js"; +import { activateStagedPackage, stageVerifiedPackage } from "../../src/sync/activation.js"; +import { openSystemDB } from "../../src/data/system-meta/store.js"; +import { openSlotDB, writeSlotFile } from "../../src/data/slot/store.js"; +import { DB } from "../../src/platform/idb/names.js"; + +const g = globalThis as unknown as Record; +const NOW = 1_750_000_000_000; + +function section(patch: Record = {}): EmergencySection { + return { + section: "emergency", + emergencySchemaVersion: 1, + contentVersion: 2, + updatedAt: "2026-09-01T00:00:00.000Z", + services: { + emergencyNumber: "911", + security: { phone: "555-0100", location: "North gate" }, + firstAid: { location: "Medical tent", hours: "10:00–22:00" }, + }, + locations: { + musterPoints: [{ id: "m1", name: "North field", poi: "poi-muster" }], + exits: [{ id: "e1", name: "Main gate", poi: "poi-exit" }], + aeds: [{ poi: "poi-aed" }], + }, + address: { lines: ["1 Festival Way"], coordinates: { lat: 29.9, lon: -97.9 } }, + procedures: [{ id: "medical", title: "Medical", steps: ["Call 911", "Find staff"] }], + notices: [ + { + id: "n1", + severity: "info", + title: "Gates open", + body: ["Gates open at 9am"], + expiresAtUtc: null, + }, + ], + ...patch, + }; +} + +function dataset(patch: Partial = {}): VerifiedEmergencyData { + return { + section: section(), + packageVersion: 3, + generatedAt: "2026-08-20T00:00:00.000Z", + emergencySchemaVersion: 1, + ...patch, + }; +} + +describe("resolveEmergency — tier matrix (SPIKE-06)", () => { + it("no dataset → baseline with no-dataset demotion", () => { + const model = resolveEmergency(undefined, NOW); + expect(model.source).toBe("baseline"); + expect(model.demotion).toBe("no-dataset"); + expect(model.provenance).toMatch(/^BASELINE v/); + expect(model.section).toBeNull(); + }); + + it("valid dataset → dataset tier with merged floor fields and provenance", () => { + const sparse = section(); + const { services: _dropServices, ...withoutServices } = sparse; + void _dropServices; + const model = resolveEmergency(dataset({ section: withoutServices as EmergencySection }), NOW); + expect(model.source).toBe("dataset"); + expect(model.demotion).toBeNull(); + // Floor-only services backfill the sparse section (defensive merge). + expect(model.section?.services.emergencyNumber).toBeTruthy(); + expect(model.provenance).toContain("FESTIVAL DATA v3"); + expect(model.provenance).toContain("generated 2026-08-20"); + expect(model.provenance).toContain("updated 2026-09-01"); + }); + + it("sparse sections merge floor fields instead of demoting", () => { + const sparse = section({ services: {}, procedures: [] }); + const model = resolveEmergency(dataset({ section: sparse }), NOW); + expect(model.source).toBe("dataset"); + expect(model.section?.services.emergencyNumber).toBeTruthy(); + expect(model.section?.procedures.length).toBeGreaterThan(0); + }); + + it("unsupported schema → baseline with unsupported-schema demotion", () => { + const model = resolveEmergency(dataset({ emergencySchemaVersion: 99 }), NOW); + expect(model.source).toBe("baseline"); + expect(model.demotion).toBe("unsupported-schema"); + expect(isSupportedEmergencySchema(99)).toBe(false); + expect(isSupportedEmergencySchema(1)).toBe(true); + }); + + it("malformed section → baseline with malformed-section demotion", () => { + const badValues: unknown[] = [ + "nope", + 42, + null, + {}, + { section: "emergency" }, + { section: "emergency", emergencySchemaVersion: 0 }, + { section: "emergency", emergencySchemaVersion: "1" }, + { section: "emergency", emergencySchemaVersion: 1, services: "911" }, + { + section: "emergency", + emergencySchemaVersion: 1, + procedures: [{ id: "x" }], + }, + ]; + for (const bad of badValues) { + const model = resolveEmergency(dataset({ section: bad as EmergencySection }), NOW); + expect(model.source).toBe("baseline"); + expect(model.demotion).toBe("malformed-section"); + } + }); + + it("unknown future fields never throw (forward tolerance)", () => { + const future = { + ...section(), + brandNewField: { deep: [1, 2, 3] }, + services: { ...section().services, another: "x" }, + }; + const model = resolveEmergency(dataset({ section: future }), NOW); + expect(model.source).toBe("dataset"); + }); + + it("expired notices hide; unexpiring and future notices show", () => { + const withNotices = section({ + notices: [ + { id: "old", severity: "info", title: "Old", body: ["x"], expiresAtUtc: NOW - 1000 }, + { id: "live", severity: "urgent", title: "Live", body: ["y"], expiresAtUtc: NOW + 1000 }, + { id: "forever", severity: "info", title: "Forever", body: ["z"], expiresAtUtc: null }, + ], + }); + const filtered = visibleNotices(withNotices, NOW); + expect(filtered.notices?.map((n) => n.id)).toEqual(["live", "forever"]); + const model = resolveEmergency(dataset({ section: withNotices }), NOW); + expect(model.section?.notices?.map((n) => n.id)).toEqual(["live", "forever"]); + }); + + it("demotedBaseline carries the reason with floor provenance", () => { + const model = demotedBaseline("unsupported-schema"); + expect(model.source).toBe("baseline"); + expect(model.demotion).toBe("unsupported-schema"); + expect(model.provenance).toMatch(/^BASELINE v/); + }); +}); + +describe("narrowEmergencySection", () => { + it("accepts a valid section, rejects everything else without throwing", () => { + expect(narrowEmergencySection(section())).toBeTruthy(); + for (const bad of [undefined, null, 7, "x", [], {}, { section: "schedule" }]) { + expect(narrowEmergencySection(bad)).toBeNull(); + } + }); +}); + +describe("emergency view", () => { + it("floor tier renders number, dial link, procedures, and guidance", () => { + const view = createEmergencyView(undefined, NOW); + expect(view.querySelector("h1")?.textContent).toBe("Emergency"); + expect(view.querySelector('a[href^="tel:"]')).toBeTruthy(); + expect(view.textContent).toContain("BASELINE v"); + expect(view.textContent).toContain("isn't on this device yet"); + }); + + it("dataset tier renders locations with map handoffs and notices", () => { + const view = createEmergencyView(dataset(), NOW); + expect(view.textContent).toContain("FESTIVAL DATA v3"); + expect(view.textContent).not.toContain("isn't on this device yet"); + expect(view.textContent).toContain("North field"); + expect(view.textContent).toContain("[info] Gates open"); + const mapLinks = [...view.querySelectorAll('a[data-route="/map"]')]; + expect(mapLinks.length).toBeGreaterThan(0); + expect(view.textContent).toContain("First-aid hours: 10:00–22:00"); + }); + + it("each demotion reason renders its own guidance", () => { + for (const [reason, hint] of [ + ["no-dataset", "isn't on this device yet"], + ["unsupported-schema", "newer app version"], + ["malformed-section", "couldn't be read"], + ] as const) { + const view = createEmergencyView(demotedBaseline(reason), NOW); + expect(view.textContent).toContain("BASELINE v"); + expect(view.textContent).toContain(hint); + } + }); + + it("renders with zero storage access (Ring-0: no indexedDB, no caches)", () => { + const idx = g.indexedDB; + const cachesGlobal = (globalThis as Record).caches; + // Ring-0 proof — storage must not exist here. + delete g.indexedDB; + delete (globalThis as Record).caches; + try { + const view = createEmergencyView(undefined, NOW); + expect(view.querySelector("h1")?.textContent).toBe("Emergency"); + expect(view.querySelector('a[href^="tel:"]')).toBeTruthy(); + } finally { + g.indexedDB = idx; + if (cachesGlobal !== undefined) { + (globalThis as Record).caches = cachesGlobal; + } + } + }); + + it("never renders raw HTML from content", () => { + const evil = section({ procedures: [{ id: "x", title: "", steps: ["do"] }] }); + const view = createEmergencyView(dataset({ section: evil }), NOW); + expect(view.innerHTML).not.toContain(""); + expect(view.innerHTML).not.toContain("do"); + }); +}); + +describe("dataset emergency loader", () => { + function deleteDb(name: string): Promise { + return new Promise((resolve, reject) => { + const request = (g.indexedDB as IDBFactory).deleteDatabase(name); + request.onsuccess = () => { + resolve(); + }; + request.onerror = () => { + reject(request.error ?? new Error("delete failed")); + }; + request.onblocked = () => { + resolve(); + }; + }); + } + + async function fixturePackage(version: number) { + const keyPair = generateTestKeyPair(); + const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair }); + if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed"); + const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest)); + const files = new Map(); + for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes); + for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent); + const result = await verifyPackage( + { + manifestBytes, + signature: built.pkg.signature, + files: { getFile: (name) => Promise.resolve(files.get(name)) }, + emergencyFloor: built.pkg.emergencyFloor, + }, + { + trustedKeys: new Map([ + [keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)], + ]), + appVersion: "1.0.0", + supportedSchemaRange: [1], + }, + ); + if (!result.ok) throw new Error(`verify failed: ${result.reason}`); + return result; + } + + beforeEach(async () => { + g.indexedDB = new FDBFactory() as unknown; + g.IDBKeyRange = FDBKeyRange as unknown; + await deleteDb(DB.SYSTEM); + await deleteDb(DB.SLOT_A); + await deleteDb(DB.SLOT_B); + }); + + afterEach(() => { + // Leave no polyfill behind for the zero-storage test above. + delete g.indexedDB; + }); + + it("fresh install → absent", async () => { + expect(await loadDatasetEmergency()).toEqual({ status: "absent" }); + }); + + it("staged + activated fixture → ready with version detail", async () => { + const pkg = await fixturePackage(2); + const staged = await stageVerifiedPackage(pkg); + const activated = await activateStagedPackage(pkg, staged, "1.0.0"); + expect(activated.ok).toBe(true); + const loaded = await loadDatasetEmergency(); + expect(loaded.status).toBe("ready"); + if (loaded.status !== "ready") throw new Error("unreachable"); + expect(loaded.data.packageVersion).toBe(2); + expect(loaded.data.section.services.emergencyNumber).toBeTruthy(); + expect(loaded.data.generatedAt).toBe(pkg.manifest.generatedAt); + }); + + it("malformed stored section → unusable malformed-section", async () => { + const system = await openSystemDB(); + const { writeSystemMeta } = await import("../../src/data/system-meta/store.js"); + const { INITIAL_SYSTEM_META } = await import("../../src/data/system-meta/types.js"); + await writeSystemMeta(system, { + ...INITIAL_SYSTEM_META, + activeSlot: "A", + activeEdition: "test-edition", + activePackageVersion: 1, + }); + system.close(); + const slot = await openSlotDB("A"); + await writeSlotFile(slot, "emergency", { + bytes: 32, + sha256: "0".repeat(64), + json: { section: "emergency", services: {} }, + }); + slot.close(); + expect(await loadDatasetEmergency()).toEqual({ + status: "unusable", + reason: "malformed-section", + }); + }); + + it("boot renders Tier 2 through the real path after staging", async () => { + const pkg = await fixturePackage(1); + const staged = await stageVerifiedPackage(pkg); + expect((await activateStagedPackage(pkg, staged, "1.0.0")).ok).toBe(true); + history.replaceState(null, "", "/emergency"); + document.body.innerHTML = '
'; + boot(); + await vi.waitFor(() => { + expect(document.querySelector(".emergency-view")?.textContent).toContain("FESTIVAL DATA v1"); + }); + document.body.innerHTML = ""; + }); +}); diff --git a/tests/unit/readiness.test.ts b/tests/unit/readiness.test.ts new file mode 100644 index 0000000..af98f3e --- /dev/null +++ b/tests/unit/readiness.test.ts @@ -0,0 +1,627 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/** + * Stage 9 — Offline Ready predicate C1–C8, six-state taxonomy, boot evaluation. + * Pure truth table (every state reachable) + boot integration against + * fake-indexeddb using the Stage 8 staging/activation path. + * Trace: SPIKE-05 §2–§5, IMPLEMENTATION-CONTRACT.md §20. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBFactory from "fake-indexeddb/lib/FDBFactory"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange"; +import { readFileSync } from "node:fs"; + +const g = globalThis as unknown as Record; +g.indexedDB = new FDBFactory() as unknown; +g.IDBKeyRange = FDBKeyRange as unknown; + +import { buildPackage } from "../../pipeline/package.js"; +import { generateTestKeyPair } from "../../pipeline/sign.js"; +import { makeValidInput } from "../../pipeline/fixtures.js"; +import { canonicalJson } from "../../pipeline/canonical-json.js"; +import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js"; +import { verifyPackage } from "../../src/sync/verifier/package.js"; +import type { VerifiedPackage } from "../../src/sync/verifier/types.js"; +import { + activateStagedPackage, + restorePreviousSlot, + stageVerifiedPackage, +} from "../../src/sync/activation.js"; +import { openSystemDB, readSystemMeta, writeSystemMeta } from "../../src/data/system-meta/store.js"; +import { INITIAL_SYSTEM_META } from "../../src/data/system-meta/types.js"; +import { + initializeStaging, + markStagingComplete, + openSlotDB, + readSlotFileMeta, + writeSlotFile, + writeSlotFileWithProgress, + writeSlotManifest, +} from "../../src/data/slot/store.js"; +import type { FestivalManifest } from "../../src/data/festival-package/types.js"; +import { withTx } from "../../src/platform/idb/wrapper.js"; +import { DB, SLOT_FILES } from "../../src/platform/idb/names.js"; +import { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from "../../src/domain/readiness/config.js"; +import { + chipForState, + evaluateReadiness, + preparationLevel, +} from "../../src/domain/readiness/logic.js"; +import type { ReadinessEvidence } from "../../src/domain/readiness/types.js"; +import { checkShellCache } from "../../src/platform/cache/shell-cache.js"; +import { defaultBootDeps, evaluateBootReadiness } from "../../src/app/readiness.js"; +import type { BootReadinessDeps } from "../../src/app/readiness.js"; + +const NOW = 1_750_000_000_000; + +function okEvidence(patch: Partial = {}): ReadinessEvidence { + return { + shellValid: true, + datasetPresent: true, + authenticity: "verified", + integrity: "ok", + schemaCompatible: true, + requiredSectionsOk: true, + requiredAssetsOk: true, + emergencyFloorOk: true, + missing: [], + hadVerifiedDataset: true, + manifestOk: true, + incompatible: false, + failure: null, + presentSections: ["emergency", "schedule", "info", "map", "assets"] as readonly string[], + stagedPending: null, + ...patch, + }; +} + +describe("readiness predicate — C1–C8 truth table (SPIKE-05 §5)", () => { + it("all true → READY, level L2, green chip", () => { + const result = evaluateReadiness(okEvidence()); + expect(result.state).toBe("READY"); + expect(result.missing).toEqual([]); + expect(result.level).toBe("L2"); + const chip = chipForState("READY"); + expect(chip.text).toBe("OFFLINE READY ✓"); + expect(chip.tone).toBe("ready"); + }); + + it("no dataset + never verified → NOT_READY; + previously verified → RECOVERY(missing)", () => { + expect( + evaluateReadiness(okEvidence({ datasetPresent: false, hadVerifiedDataset: false })).state, + ).toBe("NOT_READY"); + const recovery = evaluateReadiness( + okEvidence({ datasetPresent: false, hadVerifiedDataset: true }), + ); + expect(recovery.state).toBe("RECOVERY"); + expect(recovery.reason).toBe("missing"); + }); + + it("record present but files gone → RECOVERY(missing), not corrupt", () => { + const result = evaluateReadiness( + okEvidence({ authenticity: "mismatch", integrity: "files-missing", missing: ["schedule"] }), + ); + expect(result.state).toBe("RECOVERY"); + expect(result.reason).toBe("missing"); + }); + + it("size tampering → RECOVERY(corrupt); record mismatch alone → RECOVERY(corrupt)", () => { + expect(evaluateReadiness(okEvidence({ integrity: "corrupt" }))).toMatchObject({ + state: "RECOVERY", + reason: "corrupt", + }); + expect(evaluateReadiness(okEvidence({ authenticity: "mismatch" }))).toMatchObject({ + state: "RECOVERY", + reason: "corrupt", + }); + }); + + it("incompatible schema demotes to RECOVERY(incompatible), ahead of PARTIAL", () => { + const result = evaluateReadiness( + okEvidence({ + schemaCompatible: false, + incompatible: true, + requiredSectionsOk: false, + missing: ["map"], + }), + ); + expect(result.state).toBe("RECOVERY"); + expect(result.reason).toBe("incompatible"); + }); + + it("files present but record missing → PARTIAL(verification), full re-verify path", () => { + const result = evaluateReadiness(okEvidence({ authenticity: "record-missing" })); + expect(result.state).toBe("PARTIAL"); + expect(result.missing).toContain("verification"); + }); + + it("staged manifest missing → PARTIAL(manifest)", () => { + const result = evaluateReadiness( + okEvidence({ manifestOk: false, requiredSectionsOk: false, missing: ["manifest"] }), + ); + expect(result.state).toBe("PARTIAL"); + expect(result.missing).toContain("manifest"); + }); + + it("missing required sections/assets → PARTIAL(list); optional never gates", () => { + const partial = evaluateReadiness( + okEvidence({ requiredAssetsOk: false, missing: ["asset:map-base-overview"] }), + ); + expect(partial.state).toBe("PARTIAL"); + expect(partial.missing).toEqual(["asset:map-base-overview"]); + // Optional sections are not evidence at all — absence changes nothing. + expect(evaluateReadiness(okEvidence()).state).toBe("READY"); + }); + + it("session failure → FAILED ahead of everything, even READY evidence", () => { + const result = evaluateReadiness(okEvidence({ failure: { op: "activation" } })); + expect(result.state).toBe("FAILED"); + expect(result.failedOp).toBe("activation"); + expect(chipForState("FAILED", "activation").tone).toBe("failed"); + }); + + it("broken floor build → FAILED(emergency-floor)", () => { + const result = evaluateReadiness(okEvidence({ emergencyFloorOk: false })); + expect(result.state).toBe("FAILED"); + expect(result.failedOp).toBe("emergency-floor"); + }); + + it("shell cache missing + dataset → PARTIAL(shell-cache); without dataset → NOT_READY", () => { + expect(evaluateReadiness(okEvidence({ shellValid: false })).state).toBe("PARTIAL"); + expect(evaluateReadiness(okEvidence({ shellValid: false })).missing).toContain("shell-cache"); + expect( + evaluateReadiness( + okEvidence({ shellValid: false, datasetPresent: false, hadVerifiedDataset: false }), + ).state, + ).toBe("NOT_READY"); + }); + + it("interrupted staging with no active pointer → PARTIAL (resumable)", () => { + const result = evaluateReadiness( + okEvidence({ + datasetPresent: false, + hadVerifiedDataset: false, + requiredSectionsOk: false, + requiredAssetsOk: false, + missing: ["info", "map", "assets"], + presentSections: ["emergency", "schedule"], + stagedPending: { missing: ["info", "map", "assets"] }, + }), + ); + expect(result.state).toBe("PARTIAL"); + expect(result.level).toBe("L1"); + }); + + it("preparation levels L0/L1/L2 follow staged sections, not trust", () => { + expect(preparationLevel([])).toBe("L0"); + expect(preparationLevel(["emergency"])).toBe("L0"); + expect(preparationLevel(["emergency", "schedule"])).toBe("L1"); + expect(preparationLevel(["emergency", "schedule", "info", "map", "assets"])).toBe("L2"); + }); + + it("chip mapping covers every state", () => { + expect(chipForState("PARTIAL", "map").tone).toBe("partial"); + expect(chipForState("NOT_READY").text).toBe("Get festival data"); + expect(chipForState("RECOVERY", "missing").text).toContain("missing"); + expect(chipForState("BASELINE_ONLY").tone).toBe("baseline"); + }); + + it("verdicts do not consult clocks", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2031-05-04T12:00:00Z")); + expect(evaluateReadiness(okEvidence()).state).toBe("READY"); + vi.setSystemTime(new Date("1999-01-01T00:00:00Z")); + expect(evaluateReadiness(okEvidence()).state).toBe("READY"); + } finally { + vi.useRealTimers(); + } + }); + + it("APP_VERSION matches package.json; schema range covers v1", () => { + const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { version: string }; + expect(APP_VERSION).toBe(pkg.version); + expect(SUPPORTED_SCHEMA_RANGE).toContain(1); + }); +}); + +describe("shell cache check (C1)", () => { + it("reports unavailable where Cache Storage is absent (Node)", async () => { + expect(await checkShellCache()).toMatchObject({ ok: false, cacheName: null }); + }); +}); + +// ——— Boot integration ——— + +function deleteDb(name: string): Promise { + return new Promise((resolve, reject) => { + const request = (g.indexedDB as IDBFactory).deleteDatabase(name); + request.onsuccess = () => { + resolve(); + }; + request.onerror = () => { + reject(request.error ?? new Error("delete database failed")); + }; + request.onblocked = () => { + resolve(); + }; + }); +} + +async function fixture(version = 1): Promise { + const keyPair = generateTestKeyPair(); + const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair }); + if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed"); + const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest)); + const files = new Map(); + for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes); + for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent); + const result = await verifyPackage( + { + manifestBytes, + signature: built.pkg.signature, + files: { getFile: (name) => Promise.resolve(files.get(name)) }, + emergencyFloor: built.pkg.emergencyFloor, + }, + { + trustedKeys: new Map([ + [keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)], + ]), + appVersion: "1.0.0", + supportedSchemaRange: [1], + }, + ); + if (!result.ok) throw new Error(`fixture verify failed: ${result.reason}`); + return result; +} + +function testDeps(patch: Partial = {}): BootReadinessDeps { + return { + ...defaultBootDeps, + checkShell: () => Promise.resolve({ ok: true, cacheName: "lumen-shell-test" }), + estimate: () => Promise.resolve(null), + // Pipeline fixtures declare minAppVersion 1.0.0 / schema 1 (activation.test + // does the same) — the dev shell version in package.json is younger. + appVersion: "1.0.0", + supportedSchemaRange: [1], + now: () => NOW, + ...patch, + }; +} + +async function activateFixture(version: number): Promise { + const pkg = await fixture(version); + const staged = await stageVerifiedPackage(pkg); + const activated = await activateStagedPackage(pkg, staged, APP_VERSION, () => NOW); + expect(activated.ok).toBe(true); + return pkg; +} + +beforeEach(async () => { + await deleteDb(DB.SYSTEM); + await deleteDb(DB.SLOT_A); + await deleteDb(DB.SLOT_B); + await deleteDb(DB.USER); +}); + +describe("boot evaluation", () => { + it("fresh install → NOT_READY, L0, floor present", async () => { + const report = await evaluateBootReadiness(testDeps()); + expect(report.state).toBe("NOT_READY"); + expect(report.level).toBe("L0"); + expect(report.floorVersion).toBeTruthy(); + expect(report.canRestore).toBe(false); + }); + + it("staged + activated fixture → READY with version detail, L2", async () => { + const pkg = await activateFixture(1); + const report = await evaluateBootReadiness(testDeps()); + expect(report.state).toBe("READY"); + expect(report.edition).toBe(pkg.manifest.edition); + expect(report.packageVersion).toBe(1); + expect(report.generatedAt).toBe(pkg.manifest.generatedAt); + // Journal progress timestamps come from the real clock at staging time — + // display only, never a verdict input. + expect(report.fetchedAt).toBeGreaterThan(0); + expect(report.sections).toHaveLength(5); + expect(report.sections.every((s) => s.present && s.parseable)).toBe(true); + expect(report.assets.total).toBeGreaterThan(0); + expect(report.assets.present).toBe(report.assets.total); + expect(report.level).toBe("L2"); + expect(report.shellCache).toBe("lumen-shell-test"); + }); + + it("second update retains rollback slot → canRestore; restore returns to v1 READY", async () => { + await activateFixture(1); + await activateFixture(2); + const updated = await evaluateBootReadiness(testDeps()); + expect(updated.state).toBe("READY"); + expect(updated.packageVersion).toBe(2); + expect(updated.canRestore).toBe(true); + expect(await restorePreviousSlot()).toBe(true); + const restored = await evaluateBootReadiness(testDeps()); + expect(restored.state).toBe("READY"); + expect(restored.packageVersion).toBe(1); + }); + + it("slot loss with surviving record → RECOVERY(missing)", async () => { + await activateFixture(1); + await deleteDb(DB.SLOT_A); + await deleteDb(DB.SLOT_B); + const report = await evaluateBootReadiness(testDeps()); + expect(report.state).toBe("RECOVERY"); + expect(report.reason).toBe("missing"); + }); + + it("size tampering post-activation → RECOVERY(corrupt)", async () => { + await activateFixture(1); + const system = await openSystemDB(); + const meta = await readSystemMeta(system); + system.close(); + const slot = await openSlotDB(meta.activeSlot ?? "A"); + const current = await readSlotFileMeta(slot, "schedule"); + await writeSlotFile(slot, "schedule", { + bytes: (current?.bytes ?? 100) + 7, + sha256: current?.sha256 ?? "0".repeat(64), + json: { section: "schedule" }, + }); + slot.close(); + const report = await evaluateBootReadiness(testDeps()); + expect(report.state).toBe("RECOVERY"); + expect(report.reason).toBe("corrupt"); + }); + + it("staged manifest deleted → PARTIAL(manifest), re-verify path", async () => { + await activateFixture(1); + const system = await openSystemDB(); + const meta = await readSystemMeta(system); + system.close(); + const slot = await openSlotDB(meta.activeSlot ?? "A"); + await withTx(slot, SLOT_FILES, "readwrite", async (tx) => { + await new Promise((resolve, reject) => { + const req = tx.objectStore(SLOT_FILES).delete("manifest"); + req.onsuccess = () => { + resolve(); + }; + req.onerror = () => { + reject(req.error ?? new Error("delete failed")); + }; + }); + }); + slot.close(); + const report = await evaluateBootReadiness(testDeps()); + expect(report.state).toBe("PARTIAL"); + expect(report.missing).toContain("manifest"); + }); + + it("verification record wiped but files present → PARTIAL(verification)", async () => { + await activateFixture(1); + const system = await openSystemDB(); + const meta = await readSystemMeta(system); + await writeSystemMeta(system, { ...meta, verification: null }); + system.close(); + const report = await evaluateBootReadiness(testDeps()); + expect(report.state).toBe("PARTIAL"); + expect(report.missing).toContain("verification"); + }); + + it("interrupted staging (emergency+schedule) with no active → PARTIAL, L1 core-ready", async () => { + const pkg = await fixture(1); + const slot = await openSlotDB("A"); + let journal = await initializeStaging(slot, { + edition: pkg.manifest.edition, + packageVersion: 1, + manifestSha256: pkg.manifestSha256, + startedAt: NOW, + lastProgressAt: NOW, + }); + for (const id of ["emergency", "schedule"] as const) { + const entry = pkg.manifest.sections[id]; + const bytes = pkg.files.get(entry.file); + if (!bytes) throw new Error("fixture file missing"); + journal = await writeSlotFileWithProgress( + slot, + id, + { + bytes: entry.bytes, + sha256: entry.sha256, + json: JSON.parse(new TextDecoder().decode(bytes)) as unknown, + }, + journal, + ); + } + expect(journal.complete).toBe(false); + slot.close(); + const report = await evaluateBootReadiness(testDeps()); + expect(report.state).toBe("PARTIAL"); + expect(report.level).toBe("L1"); + expect(report.missing).toEqual(expect.arrayContaining(["info", "map", "assets"])); + expect(report.activationPending).toBe(false); + }); + + it("hand-built incompatible package (schema 99) → RECOVERY(incompatible)", async () => { + const manifest = { + format: "lumen.package/1", + edition: "test-edition", + packageVersion: 9, + schemaVersion: 99, + generatedAt: "2026-09-01T00:00:00.000Z", + festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 }, + appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null }, + sections: { + emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 }, + schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 }, + map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 }, + info: { file: "info.json", sha256: "d".repeat(64), bytes: 10 }, + assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 }, + }, + counts: { events: 0, pois: 0, assets: 0 }, + limits: { totalBytes: 52 }, + } as unknown as FestivalManifest; + const slot = await openSlotDB("A"); + let journal = await initializeStaging(slot, { + edition: "test-edition", + packageVersion: 9, + manifestSha256: "f".repeat(64), + startedAt: NOW, + lastProgressAt: NOW, + }); + for (const id of ["emergency", "schedule", "map", "info"] as const) { + journal = await writeSlotFileWithProgress( + slot, + id, + { bytes: 10, sha256: `${id}-sha`, json: { section: id } }, + journal, + ); + } + journal = await writeSlotFileWithProgress( + slot, + "assets", + { bytes: 2, sha256: "e".repeat(64), json: { assets: [] } }, + journal, + ); + await writeSlotManifest(slot, manifest); + journal = await markStagingComplete(slot); + expect(journal.complete).toBe(true); + slot.close(); + const system = await openSystemDB(); + await writeSystemMeta(system, { + ...INITIAL_SYSTEM_META, + activeSlot: "A", + activeEdition: "test-edition", + activePackageVersion: 9, + verification: { + packageVersion: 9, + edition: "test-edition", + manifestSha256: "f".repeat(64), + publicKeyFingerprint: "test-key", + verifiedAt: NOW, + appVersionAtActivation: APP_VERSION, + }, + appVersionAtActivation: APP_VERSION, + }); + system.close(); + const report = await evaluateBootReadiness(testDeps()); + expect(report.state).toBe("RECOVERY"); + expect(report.reason).toBe("incompatible"); + }); + + it("optional section (required:false) absent → still READY", async () => { + const manifest = { + format: "lumen.package/1", + edition: "test-edition", + packageVersion: 3, + schemaVersion: 1, + generatedAt: "2026-09-01T00:00:00.000Z", + festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 }, + appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null }, + sections: { + emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 }, + schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 }, + map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 }, + info: { file: "info.json", sha256: "d".repeat(64), bytes: 10, required: false }, + assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 }, + }, + counts: { events: 0, pois: 0, assets: 0 }, + limits: { totalBytes: 42 }, + } as unknown as FestivalManifest; + const slot = await openSlotDB("A"); + let journal = await initializeStaging(slot, { + edition: "test-edition", + packageVersion: 3, + manifestSha256: "f".repeat(64), + startedAt: NOW, + lastProgressAt: NOW, + }); + for (const id of ["emergency", "schedule", "map"] as const) { + journal = await writeSlotFileWithProgress( + slot, + id, + { bytes: 10, sha256: `${id}-sha`, json: { section: id } }, + journal, + ); + } + journal = await writeSlotFileWithProgress( + slot, + "assets", + { bytes: 2, sha256: "e".repeat(64), json: { assets: [] } }, + journal, + ); + await writeSlotManifest(slot, manifest); + await markStagingComplete(slot); + slot.close(); + const system = await openSystemDB(); + await writeSystemMeta(system, { + ...INITIAL_SYSTEM_META, + activeSlot: "A", + activeEdition: "test-edition", + activePackageVersion: 3, + verification: { + packageVersion: 3, + edition: "test-edition", + manifestSha256: "f".repeat(64), + publicKeyFingerprint: "test-key", + verifiedAt: NOW, + appVersionAtActivation: APP_VERSION, + }, + appVersionAtActivation: APP_VERSION, + }); + system.close(); + const report = await evaluateBootReadiness(testDeps()); + expect(report.state).toBe("READY"); + expect(report.sections.find((s) => s.id === "info")).toMatchObject({ + required: false, + present: false, + }); + }); + + it("readbackPending left by a kill resolves at next boot → READY", async () => { + await activateFixture(1); + const system = await openSystemDB(); + const meta = await readSystemMeta(system); + expect(meta.readbackPending).toBe(false); + await writeSystemMeta(system, { ...meta, readbackPending: true }); + system.close(); + const report = await evaluateBootReadiness(testDeps()); + expect(report.state).toBe("READY"); + const reopened = await openSystemDB(); + expect((await readSystemMeta(reopened)).readbackPending).toBe(false); + reopened.close(); + }); + + it("unavailable storage → BASELINE_ONLY; slot IDB error → FAILED", async () => { + const baseline = await evaluateBootReadiness( + testDeps({ + openSystem: () => Promise.reject(new Error("denied")), + }), + ); + expect(baseline.state).toBe("BASELINE_ONLY"); + expect(baseline.floorVersion).toBeTruthy(); + + await activateFixture(1); + const failed = await evaluateBootReadiness( + testDeps({ + openSlot: () => Promise.reject(new Error("IDB exploded")), + }), + ); + expect(failed.state).toBe("FAILED"); + expect(failed.failedOp).toBe("boot-check"); + }); + + it("stale staging (>7d) is not resumable → NOT_READY", async () => { + const pkg = await fixture(1); + const slot = await openSlotDB("A"); + const journal = await initializeStaging(slot, { + edition: pkg.manifest.edition, + packageVersion: 1, + manifestSha256: pkg.manifestSha256, + startedAt: NOW - 8 * 24 * 60 * 60 * 1000, + lastProgressAt: NOW - 8 * 24 * 60 * 60 * 1000, + }); + expect(journal.complete).toBe(false); + slot.close(); + const report = await evaluateBootReadiness(testDeps()); + expect(report.state).toBe("NOT_READY"); + }); +}); diff --git a/tests/unit/schedule.test.ts b/tests/unit/schedule.test.ts new file mode 100644 index 0000000..3b05ad0 --- /dev/null +++ b/tests/unit/schedule.test.ts @@ -0,0 +1,528 @@ +/** + * @vitest-environment jsdom + * Stage 11 — Schedule queries, favorites, ClockService state, view, wiring. + * Trace: IMPLEMENTATION-CONTRACT.md §21/§24, SPIKE-08, ADR-009. + */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBFactory from "fake-indexeddb/lib/FDBFactory"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange"; +import { mount as boot } from "../../src/app/main.js"; +import { + allTags, + artistNames, + detectOverlaps, + filterEvents, + groupByDay, + nowEvents, + stageName, + upNext, + upNextByStage, +} from "../../src/domain/schedule/logic.js"; +import { buildMySchedule, myScheduleConflicts } from "../../src/domain/favorites/logic.js"; +import { + dayKeyFor, + deviceTimeZone, + formatClockTime, + formatDayLabel, + formatEventTime, +} from "../../src/domain/clock/logic.js"; +import { captureSkew, resolveClock } from "../../src/domain/clock/service.js"; +import { shouldWarnOutsideWindow } from "../../src/domain/clock/logic.js"; +import { narrowScheduleSection } from "../../src/app/schedule.js"; +import { loadScheduleData } from "../../src/app/schedule.js"; +import { loadPersistedSkew, persistSkew, resetClockSession } from "../../src/app/clock.js"; +import { createScheduleView } from "../../src/ui/views/schedule/schedule.js"; +import type { ScheduleViewInput } from "../../src/ui/views/schedule/schedule.js"; +import type { ScheduleSection, ScheduleEvent } from "../../src/data/festival-package/types.js"; +import { buildPackage } from "../../pipeline/package.js"; +import { generateTestKeyPair } from "../../pipeline/sign.js"; +import { makeValidInput } from "../../pipeline/fixtures.js"; +import { canonicalJson } from "../../pipeline/canonical-json.js"; +import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js"; +import { verifyPackage } from "../../src/sync/verifier/package.js"; +import { activateStagedPackage, stageVerifiedPackage } from "../../src/sync/activation.js"; +import { openUserDB, listFavorites } from "../../src/data/user/store.js"; +import { DB } from "../../src/platform/idb/names.js"; + +const g = globalThis as unknown as Record; +const CHI = "America/Chicago"; +// 2026-09-10 12:00 UTC = 07:00 CDT. +const T0 = Date.UTC(2026, 8, 10, 12, 0, 0); +const HOUR = 3600_000; + +function event(patch: Partial & { id: string }): ScheduleEvent { + return { + title: `Title ${patch.id}`, + stageId: "stage-a", + artistIds: ["art-1"], + startUtc: T0, + endUtc: T0 + HOUR, + dayKey: dayKeyFor(T0, CHI), + tags: [], + status: "scheduled", + ...patch, + }; +} + +function section(): ScheduleSection { + return { + section: "schedule", + stages: [ + { id: "stage-a", name: "Main Stage" }, + { id: "stage-b", name: "Second Stage" }, + ], + artists: [ + { id: "art-1", name: "The Luminants" }, + { id: "art-2", name: "Solar Echo" }, + ], + events: [ + event({ id: "e1", startUtc: T0, endUtc: T0 + HOUR, tags: ["music"] }), + event({ id: "e2", startUtc: T0 + 30 * 60_000, endUtc: T0 + 90 * 60_000, tags: ["music"] }), + event({ + id: "e3", + stageId: "stage-b", + artistIds: ["art-2"], + startUtc: T0 + 3 * HOUR, + endUtc: T0 + 4 * HOUR, + tags: ["talk"], + status: "moved", + originalStartUtc: T0 + 2 * HOUR, + }), + event({ + id: "e4", + stageId: "stage-b", + startUtc: T0 + 6 * HOUR, + endUtc: T0 + 7 * HOUR, + tags: ["talk"], + status: "cancelled", + }), + event({ + id: "e5", + startUtc: Date.UTC(2026, 8, 11, 12, 0, 0), + endUtc: Date.UTC(2026, 8, 11, 13, 0, 0), + dayKey: "2026-09-11", + tags: ["music"], + }), + ], + }; +} + +function viewInput(patch: Partial = {}): ScheduleViewInput { + return { + section: section(), + favorites: new Set(), + clock: { + now: T0 + 15 * 60_000, + zone: CHI, + festivalZone: CHI, + useDeviceZone: false, + outsideWarn: false, + driftWarn: false, + }, + ...patch, + }; +} + +function viewActions() { + return { + onToggleFavorite: vi.fn(() => true), + onZoneToggle: vi.fn(), + }; +} + +describe("schedule queries", () => { + it("groups by publish-time dayKey in order", () => { + const groups = groupByDay(section()); + expect(groups.map((d) => d.dayKey)).toEqual(["2026-09-10", "2026-09-11"]); + expect(groups[0]?.events.map((e) => e.id)).toEqual(["e1", "e2", "e3", "e4"]); + expect(groups[0]?.anchorUtc).toBe(T0); + }); + + it("now/next: overlaps all show; upcoming sorted", () => { + const data = section(); + expect(nowEvents(data.events, T0 + 45 * 60_000).map((e) => e.id)).toEqual(["e1", "e2"]); + expect(nowEvents(data.events, T0 - 1).map((e) => e.id)).toEqual([]); + expect(upNext(data.events, T0 + 45 * 60_000, 2).map((e) => e.id)).toEqual(["e3", "e4"]); + expect( + upNextByStage(data, T0 + 15 * 60_000, 1) + .get("stage-b") + ?.map((e) => e.id), + ).toEqual(["e3"]); + }); + + it("filters combine: day, stage, tag, status, favorites, query", () => { + const data = section(); + expect(filterEvents(data, { dayKey: "2026-09-11" }).map((e) => e.id)).toEqual(["e5"]); + expect(filterEvents(data, { stageId: "stage-b" }).map((e) => e.id)).toEqual(["e3", "e4"]); + expect(filterEvents(data, { tag: "talk" }).map((e) => e.id)).toEqual(["e3", "e4"]); + expect(filterEvents(data, { statuses: ["scheduled"] }).map((e) => e.id)).toEqual([ + "e1", + "e2", + "e5", + ]); + expect( + filterEvents(data, { favoritesOnly: true, favoriteIds: new Set(["e3"]) }).map((e) => e.id), + ).toEqual(["e3"]); + expect(filterEvents(data, { query: "luminants" }).map((e) => e.id)).toEqual([ + "e1", + "e2", + "e4", + "e5", + ]); + expect(filterEvents(data, { query: "SECOND" }).map((e) => e.id)).toEqual(["e3", "e4"]); + expect(filterEvents(data, { dayKey: "2026-09-11", tag: "music" }).map((e) => e.id)).toEqual([ + "e5", + ]); + }); + + it("conflicts: overlaps pair up, adjacency and cancelled excluded", () => { + const data = section(); + const pairs = detectOverlaps( + [data.events[0], data.events[1], data.events[3]].filter((e): e is ScheduleEvent => + Boolean(e), + ), + ); + expect(pairs).toHaveLength(1); + expect(pairs[0]?.map((e) => e.id)).toEqual(["e1", "e2"]); + const adjacent = [ + event({ id: "a", startUtc: T0, endUtc: T0 + HOUR }), + event({ id: "b", startUtc: T0 + HOUR, endUtc: T0 + 2 * HOUR }), + ]; + expect(detectOverlaps(adjacent)).toEqual([]); + }); + + it("name lookups fall back to ids; tags sorted", () => { + const data = section(); + expect(stageName(data, "stage-a")).toBe("Main Stage"); + expect(stageName(data, "nope")).toBe("nope"); + expect(artistNames(data, ["art-2", "ghost"])).toBe("Solar Echo, ghost"); + expect(allTags(data)).toEqual(["music", "talk"]); + }); + + it("tolerates missing per-event arrays", () => { + const sparse = section(); + const target = { + ...sparse.events[0], + tags: undefined, + artistIds: undefined, + } as unknown as ScheduleEvent; + expect(filterEvents({ ...sparse, events: [target] }, { tag: "music" })).toEqual([]); + expect( + filterEvents({ ...sparse, events: [target] }, { query: "title" }).map((e) => e.id), + ).toEqual([target.id]); + }); +}); + +describe("favorites domain", () => { + it("builds My Schedule sorted, orphans last and kept", () => { + const items = buildMySchedule(section(), [ + { eventId: "ghost", addedAt: 2 }, + { eventId: "e3", addedAt: 1 }, + ]); + expect(items.map((i) => i.status)).toEqual(["moved", "orphan"]); + expect(items[1]?.event).toBeUndefined(); + }); + + it("moved/cancelled mark; conflicts surface overlaps", () => { + const items = buildMySchedule(section(), [ + { eventId: "e1", addedAt: 1 }, + { eventId: "e2", addedAt: 2 }, + { eventId: "e4", addedAt: 3 }, + ]); + expect(items.map((i) => i.status)).toEqual(["scheduled", "scheduled", "cancelled"]); + const conflicts = myScheduleConflicts(items); + expect(conflicts).toHaveLength(1); + expect(conflicts[0]?.map((e) => e.id)).toEqual(["e1", "e2"]); + }); +}); + +describe("ClockService state", () => { + it("captures skew as server minus device", () => { + const skew = captureSkew(1000, { deviceNow: 700, monoNow: 50 }, "Date header"); + expect(skew).toMatchObject({ skewMs: 300, capturedAtDevice: 700, capturedAtMono: 50 }); + }); + + it("resolves now with skew; drift re-derives base and warns once", () => { + // Threshold is 120s — jumps below it are clock noise, not drift. + const first = resolveClock({ deviceNow: 1_000_000, monoNow: 0 }, null, null); + expect(first.now).toBe(1_000_000); + expect(first.driftWarn).toBe(false); + const steady = resolveClock({ deviceNow: 1_000_010, monoNow: 10 }, null, first.session); + expect(steady.driftWarn).toBe(false); + const jumped = resolveClock({ deviceNow: 1_300_000, monoNow: 20 }, null, first.session); + expect(jumped.driftWarn).toBe(true); + const settled = resolveClock({ deviceNow: 1_300_010, monoNow: 30 }, null, jumped.session); + expect(settled.driftWarn).toBe(false); + }); + + it("keeps server skew across a mid-session jump", () => { + const skew = { skewMs: 500, capturedAtDevice: 0, capturedAtMono: 0, source: "t" }; + const base = resolveClock({ deviceNow: 1_000_000, monoNow: 0 }, skew, null); + const jumped = resolveClock({ deviceNow: 1_300_000, monoNow: 10 }, skew, base.session); + expect(jumped.now).toBe(1_300_500); + expect(jumped.driftWarn).toBe(true); + }); + + it("F-3: early prep quiet; near-festival or captured skew warns", () => { + const win = { startUtc: T0, endUtc: T0 + 4 * 24 * HOUR, timezone: CHI }; + const july = Date.UTC(2026, 6, 1); + expect(shouldWarnOutsideWindow(july, win, null)).toBe(false); + expect( + shouldWarnOutsideWindow(july, win, { + skewMs: 1, + capturedAtDevice: 0, + capturedAtMono: 0, + source: "t", + }), + ).toBe(true); + expect(shouldWarnOutsideWindow(T0 + 60 * 24 * HOUR, win, null)).toBe(true); + expect(shouldWarnOutsideWindow(T0 + HOUR, win, null)).toBe(false); + }); + + it("formats in explicit zones; device zone resolves", () => { + expect(formatEventTime(Date.UTC(2026, 6, 15, 18, 0, 0), CHI)).toContain("1:00 PM"); + expect(formatDayLabel(Date.UTC(2026, 8, 10, 12, 0, 0), CHI)).toContain("Thursday"); + expect(formatClockTime(Date.UTC(2026, 8, 10, 12, 0, 0), CHI)).toContain("7:00 AM"); + expect(deviceTimeZone()).toBeTruthy(); + }); +}); + +describe("schedule narrowing", () => { + it("accepts valid sections, rejects the rest", () => { + expect(narrowScheduleSection(section())).toBeTruthy(); + for (const bad of [ + null, + {}, + { section: "schedule" }, + { section: "map", stages: [], artists: [], events: [] }, + ]) { + expect(narrowScheduleSection(bad)).toBeNull(); + } + }); +}); + +describe("schedule view", () => { + it("null input renders the honest not-downloaded card", () => { + const view = createScheduleView(null, viewActions()); + expect(view.querySelector("h1")?.textContent).toBe("Schedule"); + expect(view.textContent).toContain("isn't on this device yet"); + }); + + it("renders days, now/next, and events with statuses", () => { + const view = createScheduleView(viewInput(), viewActions()); + expect(view.querySelectorAll(".schedule-day")).toHaveLength(2); + expect(view.textContent).toContain("On now"); + expect(view.textContent).toContain("Up next"); + expect(view.textContent).toContain("Title e1"); + expect(view.textContent).toContain("Moved (was"); + expect(view.textContent).toContain("Cancelled"); + expect(view.querySelector(".schedule-badge--cancelled")).toBeTruthy(); + }); + + it("day tabs and stage filter narrow the list", () => { + const view = createScheduleView(viewInput(), viewActions()); + const tabs = [...view.querySelectorAll(".schedule-day")]; + tabs[1]?.click(); + expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e5"); + expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e1"); + const select = view.querySelector("select"); + if (!select) throw new Error("stage select missing"); + select.value = "stage-b"; + select.dispatchEvent(new Event("change", { bubbles: true })); + tabs[0]?.click(); + expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e3"); + expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e1"); + }); + + it("search narrows; empty result explains itself", () => { + const view = createScheduleView(viewInput(), viewActions()); + const search = view.querySelector('input[type="search"]'); + if (!search) throw new Error("search missing"); + search.value = "Solar Echo"; + search.dispatchEvent(new Event("input", { bubbles: true })); + expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e3"); + expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e1"); + search.value = "zzz-no-match"; + search.dispatchEvent(new Event("input", { bubbles: true })); + expect(view.querySelector(".schedule-list")?.textContent).toContain("No events match"); + }); + + it("favorite toggle flips, persists via callback, and Mine filters", () => { + const actions = viewActions(); + const view = createScheduleView(viewInput(), actions); + const card = view.querySelector('[data-event-id="e1"]'); + const star = card?.querySelector(".schedule-fav"); + if (!star) throw new Error("fav button missing"); + expect(star.getAttribute("aria-pressed")).toBe("false"); + star.click(); + expect(star.getAttribute("aria-pressed")).toBe("true"); + expect(actions.onToggleFavorite).toHaveBeenCalledWith("e1", true); + const mine = view.querySelector(".schedule-mine"); + mine?.click(); + expect(view.querySelector(".schedule-list")?.textContent).toContain("Title e1"); + expect(view.querySelector(".schedule-list")?.textContent).not.toContain("Title e3"); + }); + + it("overlap among favorites surfaces a conflict note", () => { + const view = createScheduleView(viewInput({ favorites: new Set(["e1", "e2"]) }), viewActions()); + const mine = view.querySelector(".schedule-mine"); + mine?.click(); + expect(view.querySelector(".schedule-conflicts")?.textContent).toContain( + "Overlap in My Schedule", + ); + }); + + it("clock warning shows; zone toggle reports the flip", () => { + const actions = viewActions(); + const warned = createScheduleView( + viewInput({ clock: { ...viewInput().clock, outsideWarn: true } }), + actions, + ); + expect(warned.querySelector(".schedule-warning")?.textContent).toContain("clock looks off"); + const zone = warned.querySelector(".schedule-zone"); + expect(zone?.textContent).toBe("Showing festival time"); + zone?.click(); + expect(actions.onZoneToggle).toHaveBeenCalledWith(true); + }); + + it("never creates elements from content (inert text only)", () => { + const evil = section(); + const target = evil.events[0] as unknown as Record; + target.title = ""; + const view = createScheduleView(viewInput({ section: evil }), viewActions()); + // No element is ever parsed out of content — textContent only (B-7). + expect(view.querySelector("img")).toBeNull(); + expect(view.querySelector('[data-event-id="e1"] h2')?.textContent).toContain(" { + function deleteDb(name: string): Promise { + return new Promise((resolve, reject) => { + const request = (g.indexedDB as IDBFactory).deleteDatabase(name); + request.onsuccess = () => { + resolve(); + }; + request.onerror = () => { + reject(request.error ?? new Error("delete failed")); + }; + request.onblocked = () => { + resolve(); + }; + }); + } + + async function fixturePackage(version: number) { + const keyPair = generateTestKeyPair(); + const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair }); + if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed"); + const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest)); + const files = new Map(); + for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes); + for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent); + const result = await verifyPackage( + { + manifestBytes, + signature: built.pkg.signature, + files: { getFile: (name) => Promise.resolve(files.get(name)) }, + emergencyFloor: built.pkg.emergencyFloor, + }, + { + trustedKeys: new Map([ + [keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)], + ]), + appVersion: "1.0.0", + supportedSchemaRange: [1], + }, + ); + if (!result.ok) throw new Error(`verify failed: ${result.reason}`); + return result; + } + + beforeEach(async () => { + g.indexedDB = new FDBFactory() as unknown; + g.IDBKeyRange = FDBKeyRange as unknown; + resetClockSession(); + await deleteDb(DB.SYSTEM); + await deleteDb(DB.SLOT_A); + await deleteDb(DB.SLOT_B); + await deleteDb(DB.USER); + }); + + afterEach(() => { + document.body.innerHTML = ""; + delete g.indexedDB; + }); + + it("fresh install loads nothing", async () => { + expect(await loadScheduleData()).toBeNull(); + }); + + it("staged fixture loads section, window, and empty favorites", async () => { + const pkg = await fixturePackage(1); + const staged = await stageVerifiedPackage(pkg); + expect((await activateStagedPackage(pkg, staged, "1.0.0")).ok).toBe(true); + const loaded = await loadScheduleData(); + expect(loaded?.section.events.length).toBeGreaterThan(0); + expect(loaded?.window.timezone).toBe("America/Chicago"); + expect(loaded?.favorites).toEqual([]); + expect(loaded?.skew).toBeNull(); + }); + + it("skew persists across loads", async () => { + await persistSkew({ skewMs: 500, capturedAtDevice: 1000, capturedAtMono: 10, source: "test" }); + expect(await loadPersistedSkew()).toMatchObject({ skewMs: 500 }); + const loaded = await loadScheduleData(); + // No dataset staged — loader is null, but skew survives independently. + expect(loaded).toBeNull(); + expect(await loadPersistedSkew()).toMatchObject({ skewMs: 500 }); + }); + + it("favorite survives a dataset update and still joins (X3 at schedule level)", async () => { + const first = await fixturePackage(1); + const stagedFirst = await stageVerifiedPackage(first); + expect((await activateStagedPackage(first, stagedFirst, "1.0.0")).ok).toBe(true); + const user = await openUserDB(); + const { addFavorite } = await import("../../src/data/user/store.js"); + await addFavorite(user, { eventId: "evt-0001", addedAt: 7 }); + user.close(); + const second = await fixturePackage(2); + const stagedSecond = await stageVerifiedPackage(second); + expect((await activateStagedPackage(second, stagedSecond, "1.0.0")).ok).toBe(true); + const loaded = await loadScheduleData(); + expect(loaded?.favorites).toEqual([{ eventId: "evt-0001", addedAt: 7 }]); + expect(loaded?.section.events.some((e) => e.id === "evt-0001")).toBe(true); + }); + + it("boot at /schedule renders dataset events; star toggle persists", async () => { + const pkg = await fixturePackage(1); + const staged = await stageVerifiedPackage(pkg); + expect((await activateStagedPackage(pkg, staged, "1.0.0")).ok).toBe(true); + history.replaceState(null, "", "/schedule"); + document.body.innerHTML = '
'; + boot(); + await vi.waitFor(() => { + expect(document.querySelector(".schedule-view")?.textContent).toContain("Opening Ceremony"); + }); + const star = document.querySelector( + '[data-event-id="evt-0001"] .schedule-fav', + ); + if (!star) throw new Error("fav button missing in wired view"); + star.click(); + await vi.waitFor(async () => { + const db = await openUserDB(); + try { + const favs = await listFavorites(db); + expect(favs).toHaveLength(1); + expect(favs[0]?.eventId).toBe("evt-0001"); + expect(typeof favs[0]?.addedAt).toBe("number"); + } finally { + db.close(); + } + }); + document.body.innerHTML = ""; + }); +}); diff --git a/tests/unit/status.test.ts b/tests/unit/status.test.ts new file mode 100644 index 0000000..8e39a54 --- /dev/null +++ b/tests/unit/status.test.ts @@ -0,0 +1,177 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/** + * @vitest-environment jsdom + * Stage 9 — Status screen rendering and header-chip boot wiring. + * Trace: IMPLEMENTATION-CONTRACT.md §20.5, SPIKE-05 §3. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBFactory from "fake-indexeddb/lib/FDBFactory"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange"; +import { mount as boot } from "../../src/app/main.js"; +import { createPrepGuidanceView, createStatusView } from "../../src/ui/views/status/status.js"; +import type { BootReadinessReport } from "../../src/domain/readiness/types.js"; + +// jsdom implements but not showModal — stub it like a real browser. +function stubDialog(): void { + const proto = window.HTMLDialogElement.prototype as unknown as Record; + if ( + typeof proto.showModal !== "function" || + proto.showModal.toString().includes("not implemented") + ) { + proto.showModal = function (this: Element): void { + this.setAttribute("open", ""); + }; + } +} + +function report(patch: Partial = {}): BootReadinessReport { + return { + state: "READY", + reason: null, + missing: [], + failedOp: null, + level: "L2", + edition: "lumen-2026", + packageVersion: 1, + generatedAt: "2026-09-01T00:00:00.000Z", + fetchedAt: 1_750_000_000_000, + sections: [ + { id: "emergency", required: true, present: true, parseable: true }, + { id: "schedule", required: true, present: true, parseable: true }, + ], + assets: { total: 2, present: 2, missing: [] }, + floorVersion: "1.0.0+1", + storage: { usage: 1024, quota: 2048 }, + shellCache: "lumen-shell-test", + canRestore: false, + activationPending: false, + ...patch, + }; +} + +function actions() { + return { + onCheckData: vi.fn(), + onGetData: vi.fn(), + onRestore: null as (() => void) | null, + onClose: vi.fn(), + }; +} + +describe("Status view", () => { + it("READY shows checklist, versions, storage, floor — no recovery actions", () => { + const view = createStatusView(report(), actions()); + expect(view.querySelector("h1")?.textContent).toBe("Offline status"); + expect(view.textContent).toContain("State: READY"); + expect(view.textContent).toContain("v1"); + expect(view.textContent).toContain("1.0 KB used of 2.0 KB"); + expect(view.textContent).toContain("Emergency floor"); + expect(view.textContent).not.toContain("Restore previous version"); + expect(view.querySelectorAll(".status-check--ok")).toHaveLength(2); + }); + + it("PARTIAL enumerates missing and offers Continue setup", () => { + const a = actions(); + const view = createStatusView( + report({ state: "PARTIAL", level: "L1", missing: ["map", "assets"] }), + a, + ); + expect(view.textContent).toContain("Missing: map, assets"); + const buttons = [...view.querySelectorAll("button")].map((b) => b.textContent); + expect(buttons).toContain("Continue setup"); + view.querySelectorAll("button")[0]?.click(); + expect(a.onGetData).toHaveBeenCalledTimes(1); + }); + + it("RECOVERY with retained slot offers both restore paths", () => { + const onRestore = vi.fn(); + const view = createStatusView(report({ state: "RECOVERY", reason: "missing", level: "L2" }), { + ...actions(), + onRestore, + }); + const labels = [...view.querySelectorAll("button")].map((b) => b.textContent); + expect(labels).toContain("Restore festival data"); + expect(labels).toContain("Restore previous version"); + [...view.querySelectorAll("button")] + .find((b) => b.textContent === "Restore previous version") + ?.click(); + expect(onRestore).toHaveBeenCalledTimes(1); + }); + + it("FAILED offers Retry and keeps diagnostics on-device", () => { + const a = actions(); + const view = createStatusView(report({ state: "FAILED", failedOp: "activation" }), a); + expect(view.textContent).toContain("Failed operation: activation"); + [...view.querySelectorAll("button").values()].find((b) => b.textContent === "Retry")?.click(); + expect(a.onCheckData).toHaveBeenCalledTimes(1); + }); + + it("BASELINE_ONLY explains itself with install guidance, no dead actions", () => { + const view = createStatusView(report({ state: "BASELINE_ONLY", level: "L0" }), actions()); + expect(view.textContent).toContain("cannot save offline data"); + expect(view.querySelector("button")?.textContent).toBe("Check my data"); + }); + + it("renders no raw HTML — all content is text", () => { + const view = createStatusView( + report({ edition: "", missing: ["map"] }), + actions(), + ); + expect(view.innerHTML).not.toContain(""); + expect(view.innerHTML).not.toContain("map"); + }); + + it("prep guidance has Back and Close", () => { + const onBack = vi.fn(); + const onClose = vi.fn(); + const view = createPrepGuidanceView(onBack, onClose); + expect(view.querySelector("h1")?.textContent).toBe("Get festival data"); + [...view.querySelectorAll("button").values()] + .find((b) => b.textContent === "Back to status") + ?.click(); + [...view.querySelectorAll("button").values()].find((b) => b.textContent === "Close")?.click(); + expect(onBack).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); + +describe("header chip boot wiring", () => { + beforeEach(() => { + stubDialog(); + history.replaceState(null, "", "/"); + document.body.innerHTML = '
'; + }); + afterEach(() => { + document.body.innerHTML = ""; + // Remove the polyfill for the no-IDB case. + delete (globalThis as Record).indexedDB; + }); + + it("without storage the chip reports BASELINE_ONLY, never blank", async () => { + boot(); + const chip = document.querySelector(".app-header__status"); + await vi.waitFor(() => { + expect(chip?.textContent).toBe("SAVING UNAVAILABLE"); + }); + expect(chip?.getAttribute("aria-label")).toContain("cannot save offline data"); + }); + + it("clicking the chip re-evaluates and opens the Status dialog, not a stale report", async () => { + const g = globalThis as unknown as Record; + g.indexedDB = new FDBFactory() as unknown; + g.IDBKeyRange = FDBKeyRange as unknown; + boot(); + const chip = document.querySelector(".app-header__status"); + await vi.waitFor(() => { + expect(chip?.textContent).toBe("Get festival data"); + }); + // Fresh boot: NOT_READY. Clicking must re-evaluate (not reuse the + // boot-time report) — the dialog proves which report was used. + chip?.click(); + await vi.waitFor(() => { + expect(document.querySelector("dialog[open]")?.textContent).toContain("State: NOT_READY"); + }); + }); +});