Checkpoint: current working state
Some checks failed
ci / check (push) Has been cancelled

This commit is contained in:
Lumen Stage1 2026-09-29 21:33:33 -05:00
commit e83946c207
57 changed files with 6257 additions and 775 deletions

View file

@ -1,6 +1,10 @@
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<style>/* Lumen — shell styles
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
/* Lumen — shell styles
Phone-first, high contrast, large touch targets, one-handed, outdoor, night, reduced-motion
Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:783
@ -16,7 +20,7 @@
- Emergency stays functional and "red": rose #d3224b light / #fc88ab dark
*/
:root {
:root {
/* SolLunar deck palette — light */
--deck-bg: #f6f9f7;
--deck-fg: #172621;
@ -61,18 +65,16 @@
--nav-h: 64px;
--header-h: 56px;
--content-max: 48rem;
}
}
* {
* {
box-sizing: border-box;
}
html {
}
html {
color-scheme: light dark;
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
@ -82,8 +84,8 @@ html {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
body {
}
body {
margin: 0;
font-family:
ui-sans-serif,
@ -99,29 +101,30 @@ body {
-webkit-tap-highlight-color: transparent;
background:
radial-gradient(120% 80% at 80% -10%, rgba(188, 230, 217, 0.7), transparent 60%),
radial-gradient(90% 70% at -10% 110%, rgba(249, 219, 184, 0.6), transparent 55%), var(--bg);
radial-gradient(90% 70% at -10% 110%, rgba(249, 219, 184, 0.6), transparent 55%),
var(--bg);
background-attachment: fixed;
}
}
a {
a {
color: inherit;
}
:focus-visible {
}
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
}
/* Type — SolLunar serif display + tracked eyebrows */
h1,
h2,
h3 {
/* Type — SolLunar serif display + tracked eyebrows */
h1,
h2,
h3 {
font-family: var(--font-display);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.01em;
text-wrap: balance;
}
.eyebrow {
}
.eyebrow {
display: block;
text-transform: uppercase;
letter-spacing: 0.25em;
@ -129,30 +132,30 @@ h3 {
font-weight: 600;
color: var(--muted);
margin-bottom: 0.5rem;
}
}
.summit-card {
.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 {
}
.summit-card a {
color: var(--accent);
font-weight: 700;
}
.summit-card img {
}
.summit-card img {
display: block;
width: 100%;
height: auto;
margin-bottom: 1.25rem;
border-radius: var(--radius-sm);
}
.summit-card img[role="button"] {
}
.summit-card img[role="button"] {
cursor: zoom-in;
}
.map-viewer {
}
.map-viewer {
width: min(96vw, 1100px);
height: min(96vh, 900px);
padding: 0.75rem;
@ -160,11 +163,11 @@ h3 {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.map-viewer::backdrop {
}
.map-viewer::backdrop {
background: rgba(0, 0, 0, 0.8);
}
.map-viewer__close {
}
.map-viewer__close {
display: block;
min-height: 48px;
margin-left: auto;
@ -175,8 +178,8 @@ h3 {
border-radius: var(--radius-sm);
font: inherit;
font-weight: 700;
}
.map-viewer img {
}
.map-viewer img {
display: block;
width: 100%;
height: calc(100% - 60px);
@ -184,30 +187,30 @@ h3 {
object-fit: contain;
overflow: auto;
touch-action: pinch-zoom;
}
.schedule-item + .schedule-item {
}
.schedule-item + .schedule-item {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.schedule-item h2 {
}
.schedule-item h2 {
margin: 0;
font-size: 1.1rem;
}
.schedule-item p {
}
.schedule-item p {
margin-bottom: 0;
}
}
/* Skip link */
.skip-link {
/* Skip link */
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus {
}
.skip-link:focus {
left: 1rem;
top: 1rem;
width: auto;
@ -216,15 +219,15 @@ h3 {
padding: 0.5rem 0.75rem;
border: 2px solid var(--focus);
z-index: 100;
}
}
/* App shell */
#app {
/* App shell */
#app {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
.app-header {
}
.app-header {
position: sticky;
top: 0;
z-index: 10;
@ -237,8 +240,8 @@ h3 {
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.app-header__brand {
}
.app-header__brand {
display: inline-flex;
align-items: center;
gap: 0.5rem;
@ -257,23 +260,23 @@ h3 {
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
}
.app-header__brand:hover {
}
.app-header__brand:hover {
background: var(--deck-surface-2);
border-color: var(--deck-line-2);
}
/* On destination pages the brand becomes an explicit back/home affordance. */
.app-header__brand--back {
}
/* On destination pages the brand becomes an explicit back/home affordance. */
.app-header__brand--back {
color: var(--accent);
border-color: var(--accent);
}
.app-header__brand--back::before {
}
.app-header__brand--back::before {
content: "←";
font-weight: 800;
font-size: 1.05em;
line-height: 1;
}
.app-header__status {
}
.app-header__status {
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.08em;
@ -282,21 +285,22 @@ h3 {
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
}
.app-main {
}
.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 h1 {
padding: 1.5rem max(1rem, env(safe-area-inset-right)) 1.5rem
max(1rem, env(safe-area-inset-left));
}
.app-main h1 {
font-size: 2rem;
margin: 0 0 0.875rem;
}
.app-main p {
}
.app-main p {
max-width: 36rem;
}
}
.side-nav__link {
.side-nav__link {
flex: 1;
display: flex;
align-items: center;
@ -317,9 +321,9 @@ h3 {
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
}
}
@media (max-width: 700px) {
@media (max-width: 700px) {
.app-main {
max-width: var(--content-max);
width: 100%;
@ -327,47 +331,47 @@ h3 {
margin-bottom: var(--nav-h);
padding-bottom: calc(var(--nav-h) + 1.5rem);
}
}
}
/* Increase tap size on coarse pointers */
@media (pointer: coarse) {
}
/* Increase tap size on coarse pointers */
@media (pointer: coarse) {
}
/* Emergency Ring-0 presentation */
.emergency-view section {
/* Emergency Ring-0 presentation */
.emergency-view section {
margin-block: 1.25rem;
}
.emergency-view h2 {
}
.emergency-view h2 {
font-size: 1.375rem;
margin: 0 0 0.625rem;
}
.emergency-view h3 {
}
.emergency-view h3 {
font-size: 1.0625rem;
margin: 0.75rem 0 0.25rem;
}
.emergency-view ul {
}
.emergency-view ul {
margin: 0.25rem 0 0;
padding-inline-start: 1.25rem;
}
.emergency-provenance {
}
.emergency-provenance {
color: var(--muted);
font-size: 0.875rem;
font-weight: 600;
}
.emergency-alert {
}
.emergency-alert {
background: var(--emergency);
border: 1px solid var(--emergency);
border-radius: var(--radius);
color: var(--emergency-contrast);
padding: 1rem;
}
.emergency-alert h2 {
}
.emergency-alert h2 {
margin-top: 0;
}
.emergency-service p {
}
.emergency-service p {
margin: 0;
}
.emergency-call {
}
.emergency-call {
align-items: center;
background: var(--emergency-contrast);
border-radius: 999px;
@ -380,36 +384,36 @@ h3 {
min-height: 48px;
padding: 0.625rem 1.25rem;
text-decoration: none;
}
.emergency-view > section {
}
.emergency-view > section {
border-bottom: 1px solid var(--border);
padding-bottom: 1rem;
}
}
/* Home launcher — 2×2 grid of centered destination tiles */
.home-view {
/* Home launcher — 2×2 grid of centered destination tiles */
.home-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60dvh;
text-align: center;
}
.home-view h1 {
}
.home-view h1 {
font-size: 2.5rem;
margin: 0 0 0.25rem;
}
.home-view__subtitle {
}
.home-view__subtitle {
color: var(--muted);
margin: 0 0 2rem;
}
.home-grid {
}
.home-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
width: min(100%, 28rem);
}
.home-tile {
}
.home-tile {
display: flex;
align-items: center;
justify-content: center;
@ -429,50 +433,50 @@ h3 {
border-color 0.2s ease,
transform 0.2s ease,
color 0.2s ease;
}
.home-tile:hover {
}
.home-tile:hover {
background: var(--deck-surface-2);
border-color: var(--deck-line-2);
transform: translateY(-2px);
}
.home-tile:focus-visible {
}
.home-tile:focus-visible {
outline-offset: 3px;
}
.home-tile--emergency {
}
.home-tile--emergency {
background: var(--emergency);
border-color: var(--emergency);
color: var(--emergency-contrast);
}
.home-tile--emergency:hover {
}
.home-tile--emergency:hover {
background: var(--emergency);
border-color: var(--emergency);
}
@media (max-width: 700px) {
}
@media (max-width: 700px) {
.home-tile {
min-height: 96px;
}
}
}
/* Cards — translucent surface, hairline border, 1rem radius (SolLunar signature) */
.view-placeholder {
/* Cards — translucent surface, hairline border, 1rem radius (SolLunar signature) */
.view-placeholder {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-block: 1rem;
}
}
/* Amber status callout card */
.status-card {
/* Amber status callout card */
.status-card {
background: var(--highlight-bg);
border: 1px solid var(--highlight-border);
border-radius: var(--radius);
padding: 0.875rem 1rem;
margin-block: 1rem;
}
}
/* Status chips — tinted pills */
.chip {
/* Status chips — tinted pills */
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
@ -481,47 +485,51 @@ h3 {
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.chip--pink {
}
.chip--pink {
background: var(--chip-pink-bg);
color: var(--chip-pink-fg);
}
.chip--green {
}
.chip--green {
background: var(--chip-green-bg);
color: var(--chip-green-fg);
}
.chip--blue {
}
.chip--blue {
background: var(--chip-green-bg);
color: var(--chip-green-fg);
}
.chip--indigo {
}
.chip--indigo {
background: var(--chip-indigo-bg);
color: var(--chip-indigo-fg);
}
.chip--amber {
}
.chip--amber {
background: var(--chip-amber-bg);
color: var(--chip-amber-fg);
}
</style></head>
<body>
<div id="app"><div id="lumen-shell">
<a href="#main-content" class="skip-link">Skip to content</a>
<header class="app-header" role="banner">
<a href="/" class="app-header__brand" aria-label="Lumen — home">LUMEN</a>
<div class="app-header__status" role="status">Shell</div>
</header>
<main id="main-content" class="app-main" tabindex="-1">
<section class="view" aria-labelledby="home-heading">
<h1 id="home-heading">Home</h1>
<p class="view-placeholder">Offline-first festival companion — shell is live.</p>
</section>
</main>
<nav class="side-nav" aria-label="Primary navigation">
<a class="side-nav__link" aria-current="page" href="/">Home</a>
<a class="side-nav__link side-nav__link--emergency" href="/emergency">Emergency</a>
<a class="side-nav__link" href="/schedule">Schedule</a>
<a class="side-nav__link" href="/map">Map</a>
<a class="side-nav__link" href="/festival">Information</a>
</nav>
</div></div>
</body></html>
}
</style>
</head>
<body>
<div id="app">
<div id="lumen-shell">
<a href="#main-content" class="skip-link">Skip to content</a>
<header class="app-header" role="banner">
<a href="/" class="app-header__brand" aria-label="Lumen — home">LUMEN</a>
<div class="app-header__status" role="status">Shell</div>
</header>
<main id="main-content" class="app-main" tabindex="-1">
<section class="view" aria-labelledby="home-heading">
<h1 id="home-heading">Home</h1>
<p class="view-placeholder">Offline-first festival companion — shell is live.</p>
</section>
</main>
<nav class="side-nav" aria-label="Primary navigation">
<a class="side-nav__link" aria-current="page" href="/">Home</a>
<a class="side-nav__link side-nav__link--emergency" href="/emergency">Emergency</a>
<a class="side-nav__link" href="/schedule">Schedule</a>
<a class="side-nav__link" href="/map">Map</a>
<a class="side-nav__link" href="/festival">Information</a>
</nav>
</div>
</div>
</body>
</html>

View file

@ -1,18 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="stylesheet" href="/.shots/styles-light.css" />
</head>
<body>
<div id="app">
<a class="skip-link" href="#main-content">Skip to content</a>
<header class="app-header" role="banner">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="stylesheet" href="/.shots/styles-light.css" />
</head>
<body>
<div id="app">
<a class="skip-link" href="#main-content">Skip to content</a>
<header class="app-header" role="banner">
<a class="app-header__brand" href="/">LUMEN</a>
<div class="app-header__status" role="status">offline</div>
</header>
<main id="main-content" class="app-main home-view">
</header>
<main id="main-content" class="app-main home-view">
<span class="eyebrow">Festival companion</span>
<h1>Lumen</h1>
<p class="home-view__subtitle">Offline-first guide to the festival grounds</p>
@ -29,14 +29,14 @@
<span class="chip chip--green">Synced</span>
<span class="chip chip--amber">12 updates</span>
</section>
</main>
<nav class="side-nav" role="navigation" aria-label="Primary navigation">
</main>
<nav class="side-nav" role="navigation" aria-label="Primary navigation">
<a class="side-nav__link" href="/" aria-current="page">Home</a>
<a class="side-nav__link" href="/schedule">Schedule</a>
<a class="side-nav__link" href="/map">Map</a>
<a class="side-nav__link" href="/festival">Festival</a>
<a class="side-nav__link side-nav__link--emergency" href="/emergency">Emergency</a>
</nav>
</div>
</body>
</nav>
</div>
</body>
</html>

View file

@ -1,4 +1,12 @@
<!doctype html><html><body style="margin:0"><div id="d" style="width:400px;height:300px;background:#fff"></div>
<script>
document.getElementById('d').style.background = matchMedia('(prefers-color-scheme: dark)').matches ? '#0b1411' : '#f6f9f7';
</script></body></html>
<!doctype html>
<html>
<body style="margin: 0">
<div id="d" style="width: 400px; height: 300px; background: #fff"></div>
<script>
document.getElementById("d").style.background = matchMedia("(prefers-color-scheme: dark)")
.matches
? "#0b1411"
: "#f6f9f7";
</script>
</body>
</html>

View file

@ -61,8 +61,6 @@
--content-max: 48rem;
}
* {
box-sizing: border-box;
}

10
CHECKPOINT-encryption.md Normal file
View file

@ -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.

View file

@ -6,17 +6,17 @@
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f6f9f7" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0b1411" />
<meta name="description" content="Lumen — offline-first festival companion" />
<meta name="description" content="Solarpunk — offline-first festival companion" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/sol_lunar_icon.svg" type="image/svg+xml" />
<title>Lumen</title>
<title>Solarpunk</title>
</head>
<body>
<div id="app" aria-busy="true">Loading…</div>
<script type="module" src="/src/app/main.ts"></script>
<noscript>
<p role="alert">
Lumen requires JavaScript to run the app shell. Emergency information is available on the
Solarpunk requires JavaScript to run the app shell. Emergency information is available on the
printed festival materials.
</p>
</noscript>

BIN
lumen-share.zip Normal file

Binary file not shown.

5
share/README.md Normal file
View file

@ -0,0 +1,5 @@
# public
Static shell entry (index.html no-cache, manifest.webmanifest, fallback.html Ring-0, sw.js precache). ARCH §24.
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.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

33
share/fallback.html Normal file
View file

@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lumen — offline</title>
<meta name="color-scheme" content="light dark" />
<style>
body {
font-family: system-ui, sans-serif;
max-width: 40rem;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.5;
}
h1 {
font-size: 1.5rem;
}
.note {
border: 2px solid currentColor;
padding: 1rem;
}
</style>
</head>
<body>
<h1>Lumen</h1>
<p class="note" role="alert">
You are offline and the app shell is not cached. Emergency information is available from
festival staff and printed materials.
</p>
<p>When you are back online, open <a href="/">Lumen</a> to reload the app shell.</p>
</body>
</html>

25
share/index.html Normal file
View file

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f6f9f7" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0b1411" />
<meta name="description" content="Lumen — offline-first festival companion" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/sol_lunar_icon.svg" type="image/svg+xml" />
<title>Lumen</title>
<script type="module" crossorigin src="/assets/index-CaoEjGwW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/style-D9rb7TfV.css">
</head>
<body>
<div id="app" aria-busy="true">Loading…</div>
<noscript>
<p role="alert">
Lumen requires JavaScript to run the app shell. Emergency information is available on the
printed festival materials.
</p>
</noscript>
</body>
</html>

View file

@ -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"
}
]
}

View file

@ -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"
]
}

4
share/sol_lunar_icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

3
share/start.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash
python3 -m http.server 8080 --directory .
echo "Open http://localhost:8080 in your browser"

140
share/sw.js Normal file
View file

@ -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();
}
});

55
src/app/clock.ts Normal file
View file

@ -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<ClockSkew | null> {
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<void> {
const db = await openSystemDB();
try {
const meta = await readSystemMeta(db);
await writeSystemMeta(db, { ...meta, clockSkew: skew });
} finally {
db.close();
}
}

59
src/app/emergency.ts Normal file
View file

@ -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<DatasetEmergencyLoad> {
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" };
}
}

View file

@ -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 {

View file

@ -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<HTMLDivElement>("#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<string>();
let useDeviceZone = false;
let statusDialog: HTMLDialogElement | null = null;
/** Resolve Tier 2 from the active slot — floor covers every failure. */
async function resolveDatasetEmergency(): Promise<EmergencyViewModel> {
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<void> {
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<boolean> {
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<void> {
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();
// Raw browser globals stay behind the platform seam (B-1).
void unregisterAllServiceWorkers().then((shouldReload) => {
if (shouldReload) 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))),
);
}
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,

135
src/app/map.ts Normal file
View file

@ -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<MapHandle> {
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<Blob | null>,
assetId: string,
nightAssetId: string | undefined,
width: number,
height: number,
id: string,
): Promise<MapLevelView | null> {
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<MapViewData | null> {
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<Blob | null> => {
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();
}
}

593
src/app/readiness.ts Normal file
View file

@ -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<IDBDatabase>;
readonly openSlot: (slot: SlotId) => Promise<IDBDatabase>;
readonly checkShell: () => Promise<ShellEvidence>;
readonly loadFloor: () => FloorEvidence;
readonly estimate: () => Promise<StorageEstimate | null>;
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<boolean> {
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<BootReadinessReport> {
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<BootReadinessReport> {
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<BootReadinessReport> {
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>,
): 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<ReturnType<typeof readSystemMeta>>,
slot: IDBDatabase,
activeSlot: SlotId,
): Promise<BootReadinessReport> {
const fail = (op: string): BootReadinessReport => ({
...failedReport(op, floor, storage),
shellCache: shell.cacheName,
});
let journal: Awaited<ReturnType<typeof readStagingProgress>>;
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<Record<string, SectionEntry>>;
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,
};
}

89
src/app/schedule.ts Normal file
View file

@ -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<string, unknown> | null {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : 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<ScheduleData | null> {
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;
}
}

View file

@ -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 {
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);

View file

@ -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<void> {
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<void> {
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<FestivalManifest | undefined> {
const rec = await idbGet<FestivalManifest>(db, SLOT_FILES, SLOT_MANIFEST_KEY);
return rec ?? undefined;
}
// ——— Assets (Blobs) ———
export async function writeSlotAsset(

View file

@ -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<T extends SchedEventRef>(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", {

View file

@ -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 };
}

View file

@ -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<string, unknown> | null {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : 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<EmergencySection>;
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)
/**
* 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: "baseline",
provenance: `BASELINE v${floor.floorVersion}`,
floor,
section: null,
};
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;
}

View file

@ -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;
}

View file

@ -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.

View file

@ -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] : [])));
}

View file

@ -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;
}

156
src/domain/map/logic.ts Normal file
View file

@ -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<PoiCategory>;
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<PoiCategory, MapPoi[]>();
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<string, unknown> | null {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : 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;
}

52
src/domain/map/types.ts Normal file
View file

@ -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 };

View file

@ -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.

View file

@ -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];

View file

@ -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.`,
};
}
}

View file

@ -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;
}

View file

@ -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.

View file

@ -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<string, ScheduleEvent[]>();
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<string, readonly ScheduleEvent[]> {
const byStage = new Map<string, ScheduleEvent[]>();
for (const event of section.events) {
const list = byStage.get(event.stageId) ?? [];
list.push(event);
byStage.set(event.stageId, list);
}
const out = new Map<string, readonly ScheduleEvent[]>();
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<string>();
for (const event of section.events) for (const tag of event.tags ?? []) tags.add(tag);
return [...tags].sort((a, b) => a.localeCompare(b));
}

View file

@ -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<string>;
/** 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[];
}

57
src/platform/cache/shell-cache.ts vendored Normal file
View file

@ -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<void> {
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<ShellCacheCheck> {
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 };
}

View file

@ -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<boolean> {
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);
}

View file

@ -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<Staged
);
}
// Persist the manifest itself: boot C4 (sizes), C5 (compat) and C6
// (required flags) are evidenced from it. One short txn, P1-style.
await writeSlotManifest(db, pkg.manifest);
if (!journal.complete) journal = await markStagingComplete(db);
system.close();
db.close();

View file

@ -21,13 +21,7 @@ export const ROUTES: readonly Route[] = [
{ 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: "http://10.144.0.220:8080/home.html",
label: "Food",
title: "Food",
external: true,
},
{ id: "festival", path: "/festival", label: "Information", title: "Information" },
] as const;
const PATH_TO_ID = new Map<string, RouteId>([HOME_ROUTE, ...ROUTES].map((r) => [r.path, r.id]));

View file

@ -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);
}
section.append(procedures);
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(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);
}

View file

@ -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 === " ") {
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<PoiCategory>(),
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<string, HTMLButtonElement>();
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();
viewer.showModal();
}
});
section.append(viewer);
// Drag pan + two-pointer pinch, all pointer types.
const pointers = new Map<number, { x: number; y: number }>();
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"));
});
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;
}

View file

@ -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<string>;
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<boolean>;
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);
if (!input) {
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);
}
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;
}
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<string, HTMLButtonElement>();
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<string, HTMLButtonElement>();
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;
}

View file

@ -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`.

View file

@ -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;
}

View file

@ -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");
expect(
computeReadiness({
const { evaluateReadiness } = await import("../../src/domain/readiness/logic.js");
const allOk = {
shellValid: true,
datasetPresent: true,
authenticityOk: true,
integrityOk: true,
authenticity: "verified" as const,
integrity: "ok" as const,
schemaCompatible: true,
requiredSectionsOk: true,
requiredAssetsOk: true,
emergencyFloorOk: true,
}),
).toBe("READY");
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,
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");
});
});

View file

@ -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<string, unknown>;
const NOW = 1_750_000_000_000;
function section(patch: Record<string, unknown> = {}): 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> = {}): 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<string, unknown>).caches;
// Ring-0 proof — storage must not exist here.
delete g.indexedDB;
delete (globalThis as Record<string, unknown>).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<string, unknown>).caches = cachesGlobal;
}
}
});
it("never renders raw HTML from content", () => {
const evil = section({ procedures: [{ id: "x", title: "<img src=x>", steps: ["<b>do</b>"] }] });
const view = createEmergencyView(dataset({ section: evil }), NOW);
expect(view.innerHTML).not.toContain("<img src=x>");
expect(view.innerHTML).not.toContain("<b>do</b>");
});
});
describe("dataset emergency loader", () => {
function deleteDb(name: string): Promise<void> {
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<string, Uint8Array>();
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 = '<div id="app" aria-busy="true"></div>';
boot();
await vi.waitFor(() => {
expect(document.querySelector(".emergency-view")?.textContent).toContain("FESTIVAL DATA v1");
});
document.body.innerHTML = "";
});
});

View file

@ -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<string, unknown>;
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> = {}): 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<void> {
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<VerifiedPackage> {
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<string, Uint8Array>();
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> = {}): 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<VerifiedPackage> {
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<void>((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");
});
});

528
tests/unit/schedule.test.ts Normal file
View file

@ -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<string, unknown>;
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<ScheduleEvent> & { 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> = {}): ScheduleViewInput {
return {
section: section(),
favorites: new Set<string>(),
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<HTMLButtonElement>(".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<HTMLSelectElement>("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<HTMLInputElement>('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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<string, unknown>;
target.title = "<img src=x onerror=alert(1)>";
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("<img src=x");
});
});
describe("schedule loader + clock persistence + wiring", () => {
function deleteDb(name: string): Promise<void> {
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<string, Uint8Array>();
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 = '<div id="app" aria-busy="true"></div>';
boot();
await vi.waitFor(() => {
expect(document.querySelector(".schedule-view")?.textContent).toContain("Opening Ceremony");
});
const star = document.querySelector<HTMLButtonElement>(
'[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 = "";
});
});

177
tests/unit/status.test.ts Normal file
View file

@ -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 <dialog> but not showModal — stub it like a real browser.
function stubDialog(): void {
const proto = window.HTMLDialogElement.prototype as unknown as Record<string, unknown>;
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> = {}): 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: "<img src=x>", missing: ["<b>map</b>"] }),
actions(),
);
expect(view.innerHTML).not.toContain("<img src=x>");
expect(view.innerHTML).not.toContain("<b>map</b>");
});
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 = '<div id="app" aria-busy="true"></div>';
});
afterEach(() => {
document.body.innerHTML = "";
// Remove the polyfill for the no-IDB case.
delete (globalThis as Record<string, unknown>).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<string, unknown>;
g.indexedDB = new FDBFactory() as unknown;
g.IDBKeyRange = FDBKeyRange as unknown;
boot();
const chip = document.querySelector<HTMLElement>(".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");
});
});
});