From 902a1ffc224be7da26b5877374612e47c9147b15 Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Sun, 30 Aug 2026 23:29:56 -0500 Subject: [PATCH] Stage 2: application shell (4 destinations, router, layout, a11y) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vite 6 build with hashed immutable assets (assets/index-[hash].js 2.63kB gz, style 1.18kB gz; index.html 0.92kB) — single-bundle, offline-safe shell (index.html no-cache, assets immutable) per ARCH §24 - index.html + manifest.webmanifest (standalone, / scope) + fallback.html Ring-0 - src/app/main.ts boot + layout + status placeholder (no IDB/Cache/SW/sync per Stage 2 SCOPE) - src/app/layout.ts: banner, skip-link, main#main-content, bottom nav Emergency·Schedule·Map·Festival (Emergency first, --emergency accent #b91c1c/#ef4444, 64px nav, 48px targets) - src/ui/router/router.ts: history-API, offline-safe deep links (/emergency canonical), normalizePath, routeForPath, no network - 4 placeholder views (emergency/schedule/map/festival) + not-found, semantic h1/section/aria-labelledby, view-placeholder structural only (no real data) - src/app/styles.css: phone-first, high-contrast, dark via prefers-color-scheme, large touch targets, one-handed, outdoor, reduced-motion guard - Tests: tests/unit/shell.test.ts (14 tests: boot, landmarks, 4 destinations, emergency one-tap from every route, unknown routes, normalize, a11y) — jsdom; updated boundaries test for Stage 2 shell - Lint: allowDefaultProject for vite.config.ts; boundaries B-1..B-7 enforced (eslint-plugin-boundaries), B-7 innerHTML guard - CI passes: typecheck + lint + format + test (18 tests) + build - No IDB/Cache/SW/DataPackage/sync/mesh/accounts per contract — directories remain .gitkeep --- eslint.config.js | 2 +- index.html | 23 + package-lock.json | 743 +++++++++++++++++++++++----- package.json | 13 +- public/fallback.html | 33 ++ public/manifest.webmanifest | 24 + src/app/layout.ts | 83 ++++ src/app/main.ts | 114 +++++ src/app/styles.css | 207 ++++++++ src/ui/router/router.ts | 97 ++++ src/ui/views/emergency/emergency.ts | 29 ++ src/ui/views/festival/festival.ts | 16 + src/ui/views/map/map.ts | 17 + src/ui/views/not-found.ts | 17 + src/ui/views/schedule/schedule.ts | 17 + tests/unit/boundaries.test.ts | 21 +- tests/unit/shell.test.ts | 171 +++++++ vite.config.ts | 22 + 18 files changed, 1516 insertions(+), 133 deletions(-) create mode 100644 index.html create mode 100644 public/fallback.html create mode 100644 public/manifest.webmanifest create mode 100644 src/app/layout.ts create mode 100644 src/app/main.ts create mode 100644 src/app/styles.css create mode 100644 src/ui/router/router.ts create mode 100644 src/ui/views/emergency/emergency.ts create mode 100644 src/ui/views/festival/festival.ts create mode 100644 src/ui/views/map/map.ts create mode 100644 src/ui/views/not-found.ts create mode 100644 src/ui/views/schedule/schedule.ts create mode 100644 tests/unit/shell.test.ts create mode 100644 vite.config.ts diff --git a/eslint.config.js b/eslint.config.js index 4126df5..7465014 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,7 +15,7 @@ export default tseslint.config( languageOptions: { parserOptions: { projectService: { - allowDefaultProject: ["eslint.config.js", "vitest.config.ts"], + allowDefaultProject: ["eslint.config.js", "vitest.config.ts", "vite.config.ts"], }, tsconfigRootDir: import.meta.dirname, }, diff --git a/index.html b/index.html new file mode 100644 index 0000000..5731921 --- /dev/null +++ b/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + Lumen + + +
Loading…
+ + + + diff --git a/package-lock.json b/package-lock.json index 4aaf6ef..d7575f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,15 +13,31 @@ "eslint": "^9.22.0", "eslint-plugin-boundaries": "^5.0.1", "globals": "^16.0.0", + "jsdom": "^26.1.0", "prettier": "^3.5.3", "typescript": "^5.8.2", "typescript-eslint": "^8.26.1", + "vite": "^6.4.3", "vitest": "^3.1.1" }, "engines": { "node": ">=22" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@boundaries/elements": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-1.2.0.tgz", @@ -39,10 +55,125 @@ "node": ">=18.18" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -57,9 +188,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -74,9 +205,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -91,9 +222,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -108,9 +239,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -125,9 +256,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -142,9 +273,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -159,9 +290,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -176,9 +307,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -193,9 +324,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -210,9 +341,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -227,9 +358,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -244,9 +375,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -261,9 +392,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -278,9 +409,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -295,9 +426,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -312,9 +443,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -329,9 +460,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -346,9 +477,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -363,9 +494,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -380,9 +511,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -397,9 +528,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ "arm64" ], @@ -414,9 +545,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -431,9 +562,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -448,9 +579,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -465,9 +596,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -1540,6 +1671,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1727,6 +1868,34 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1745,6 +1914,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1762,6 +1938,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1780,9 +1969,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1793,32 +1982,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escape-string-regexp": { @@ -2260,6 +2449,60 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2346,6 +2589,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2383,6 +2633,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2458,6 +2748,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2545,6 +2842,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2608,6 +2912,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2815,6 +3132,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2944,6 +3288,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3036,6 +3387,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3049,6 +3420,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3145,24 +3542,24 @@ } }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -3171,14 +3568,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", - "less": "^4.0.0", + "less": "*", "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -3359,6 +3756,67 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3409,6 +3867,45 @@ "dev": true, "license": "MIT" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 0cd3721..58d5089 100644 --- a/package.json +++ b/package.json @@ -15,18 +15,21 @@ "format:write": "prettier --write .", "test": "vitest run", "test:watch": "vitest", - "ci": "npm run typecheck && npm run lint && npm run format && npm run test" + "build": "vite build", + "preview": "vite preview", + "ci": "npm run typecheck && npm run lint && npm run format && npm run test && npm run build" }, - "dependencies": {}, "devDependencies": { + "@eslint/js": "^9.22.0", "@types/node": "^22.13.5", "eslint": "^9.22.0", - "@eslint/js": "^9.22.0", - "typescript": "^5.8.2", - "typescript-eslint": "^8.26.1", "eslint-plugin-boundaries": "^5.0.1", "globals": "^16.0.0", + "jsdom": "^26.1.0", "prettier": "^3.5.3", + "typescript": "^5.8.2", + "typescript-eslint": "^8.26.1", + "vite": "^6.4.3", "vitest": "^3.1.1" } } diff --git a/public/fallback.html b/public/fallback.html new file mode 100644 index 0000000..7000b7e --- /dev/null +++ b/public/fallback.html @@ -0,0 +1,33 @@ + + + + + + Lumen — offline + + + + +

Lumen

+ +

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

+ + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..659f861 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,24 @@ +{ + "name": "Lumen", + "short_name": "Lumen", + "description": "Offline-first festival companion — Emergency, Schedule, Map, Festival", + "display": "standalone", + "scope": "/", + "start_url": "/", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/src/app/layout.ts b/src/app/layout.ts new file mode 100644 index 0000000..806fbbf --- /dev/null +++ b/src/app/layout.ts @@ -0,0 +1,83 @@ +/** + * Shared app layout — phone-first shell. + * Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207/783 + */ +import { ROUTES, type RouteId } from "../ui/router/router.js"; + +export function createLayout(): { + root: HTMLDivElement; + main: HTMLElement; + nav: HTMLElement; + statusChip: HTMLDivElement; + skipLink: HTMLAnchorElement; +} { + const root = document.createElement("div"); + root.id = "lumen-shell"; + + const skipLink = document.createElement("a"); + skipLink.href = "#main-content"; + skipLink.className = "skip-link"; + skipLink.textContent = "Skip to content"; + root.append(skipLink); + + const header = document.createElement("header"); + header.className = "app-header"; + header.setAttribute("role", "banner"); + + const brand = document.createElement("a"); + brand.href = "/emergency"; + brand.className = "app-header__brand"; + brand.setAttribute("aria-label", "Lumen — go to Emergency"); + brand.setAttribute("data-route", "/emergency"); + brand.textContent = "LUMEN"; + header.append(brand); + + const statusChip = document.createElement("div"); + statusChip.className = "app-header__status"; + statusChip.setAttribute("role", "status"); + statusChip.setAttribute("aria-live", "polite"); + statusChip.setAttribute("aria-label", "Offline status"); + statusChip.textContent = "—"; + header.append(statusChip); + + root.append(header); + + const main = document.createElement("main"); + main.id = "main-content"; + main.className = "app-main"; + main.setAttribute("tabindex", "-1"); + root.append(main); + + const nav = document.createElement("nav"); + nav.className = "bottom-nav"; + nav.setAttribute("aria-label", "Primary navigation"); + nav.setAttribute("role", "navigation"); + + for (const r of ROUTES) { + const a = document.createElement("a"); + a.href = r.path; + a.setAttribute("data-route", r.path); + a.className = + r.id === "emergency" ? "bottom-nav__link bottom-nav__link--emergency" : "bottom-nav__link"; + a.setAttribute("aria-label", r.label); + a.textContent = r.label; + nav.append(a); + } + + root.append(nav); + + return { root, main, nav, statusChip, skipLink }; +} + +export function setActiveNav(nav: HTMLElement, activeId: RouteId): void { + for (const a of nav.querySelectorAll("a[data-route]")) { + const route = a.getAttribute("data-route"); + const isActive = + (route === "/emergency" && activeId === "emergency") || + (route === "/schedule" && activeId === "schedule") || + (route === "/map" && activeId === "map") || + (route === "/festival" && activeId === "festival"); + if (isActive) a.setAttribute("aria-current", "page"); + else a.removeAttribute("aria-current"); + } +} diff --git a/src/app/main.ts b/src/app/main.ts new file mode 100644 index 0000000..2697bfa --- /dev/null +++ b/src/app/main.ts @@ -0,0 +1,114 @@ +/** + * Lumen — Stage 2 application shell entry point. + * Minimal boot: layout + router + four placeholder views. + * No IDB / Cache / SW / sync / data package per IMPLEMENTATION-CONTRACT.md Stage 2 SCOPE. + */ +import "./styles.css"; +import { createLayout, setActiveNav } from "./layout.js"; +import { Router, routeForPath, normalizePath } from "../ui/router/router.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 { + const id = routeForPath(path); + switch (id) { + case "emergency": + return createEmergencyView(); + case "schedule": + return createScheduleView(); + case "map": + return createMapView(); + case "festival": + return createFestivalView(); + case "not-found": + return createNotFoundView(path); + } +} + +function mount(): { router: Router; cleanup: () => void } { + const appHost = document.querySelector("#app"); + if (!appHost) throw new Error("#app host missing — shell invariant"); + + const { root, main, nav, statusChip } = createLayout(); + appHost.replaceChildren(root); + appHost.removeAttribute("aria-busy"); + + // Stage 2 status placeholder — real readiness C1…C8 comes in Stage 9 (readiness) + statusChip.textContent = "Shell"; + statusChip.setAttribute("aria-label", "Offline status: shell only"); + + // Ensure the deterministic deep-link set exists: all four destinations must be reachable + const router = new Router(normalizePath(location.pathname)); + + function render(): void { + const path = router.getPath(); + const id = router.getRouteId(); + setActiveNav(nav, id); + const view = renderView(path); + main.replaceChildren(view); + // Move focus to main for screen readers after navigation + main.focus({ preventScroll: true }); + document.title = + id === "not-found" + ? "Not found — Lumen" + : `${view.querySelector("h1")?.textContent ?? id} — Lumen`; + } + + // Click interception for data-route links (offline-safe, no full reload) + root.addEventListener("click", (ev) => { + const target = (ev.target as HTMLElement | null)?.closest("a[data-route]"); + if (!target) return; + const href = target.getAttribute("data-route"); + if (!href) return; + ev.preventDefault(); + router.navigate(href); + }); + + const unsubscribe = router.listen(() => { + render(); + }); + const detachPopstate = router.attach(); + + render(); + + return { + router, + cleanup: () => { + unsubscribe(); + detachPopstate(); + }, + }; +} + +// Export for tests (boot without needing full page load) +export { mount, renderView }; + +// Auto-mount in browser — Stage 2 shell only when host exists +function autoMount(): void { + if (typeof document === "undefined" || !document.getElementById("app")) return; + if (document.readyState !== "loading") { + try { + mount(); + } catch { + // host may appear later — ignore during tests + } + } else { + document.addEventListener( + "DOMContentLoaded", + () => { + if (document.getElementById("app")) { + try { + mount(); + } catch { + // ignore + } + } + }, + { once: true }, + ); + } +} +autoMount(); diff --git a/src/app/styles.css b/src/app/styles.css new file mode 100644 index 0000000..96ecbdb --- /dev/null +++ b/src/app/styles.css @@ -0,0 +1,207 @@ +/* Lumen — Stage 2 shell styles + Phone-first, high contrast, large touch targets, one-handed, outdoor, night, reduced-motion + Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:783 +*/ + +:root { + --bg: #ffffff; + --fg: #0a0a0a; + --muted: #334155; + --accent: #b91c1c; /* emergency */ + --accent-contrast: #ffffff; + --surface: #f8fafc; + --border: #cbd5e1; + --focus: #0ea5e9; + --radius: 12px; + --nav-h: 64px; + --header-h: 56px; + --content-max: 48rem; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0a0a0a; + --fg: #f8fafc; + --muted: #cbd5e1; + --surface: #171717; + --border: #404040; + --accent: #ef4444; + } +} + +* { + box-sizing: border-box; +} +html { + color-scheme: light dark; + scroll-behavior: smooth; +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} +body { + margin: 0; + font-family: + system-ui, + -apple-system, + Segoe UI, + Roboto, + Helvetica, + Arial, + sans-serif; + background: var(--bg); + color: var(--fg); + line-height: 1.6; + -webkit-tap-highlight-color: transparent; +} +a { + color: inherit; +} +:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +/* Skip link */ +.skip-link { + position: absolute; + left: -9999px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; +} +.skip-link:focus { + left: 1rem; + top: 1rem; + width: auto; + height: auto; + background: var(--bg); + padding: 0.5rem 0.75rem; + border: 2px solid var(--focus); + z-index: 100; +} + +/* App shell */ +#app { + min-height: 100dvh; + display: flex; + flex-direction: column; +} +.app-header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + height: var(--header-h); + padding: 0 max(1rem, env(safe-area-inset-right)) 0 max(1rem, env(safe-area-inset-left)); + background: var(--bg); + border-bottom: 2px solid var(--border); +} +.app-header__brand { + font-weight: 800; + letter-spacing: 0.02em; + font-size: 1.25rem; + text-decoration: none; +} +.app-header__status { + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.02em; + padding: 0.375rem 0.625rem; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--muted); +} +.app-main { + flex: 1; + max-width: var(--content-max); + width: 100%; + margin: 0 auto; + padding: 1.25rem max(1rem, env(safe-area-inset-right)) calc(var(--nav-h) + 1.5rem) + max(1rem, env(safe-area-inset-left)); +} +.app-main h1 { + font-size: 1.75rem; + line-height: 1.2; + margin: 0 0 0.75rem; +} +.app-main p { + max-width: 36rem; +} + +/* Bottom nav — phone-first, 48px targets, emergency dominant */ +.bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 20; + display: flex; + gap: 0; + height: var(--nav-h); + padding-bottom: env(safe-area-inset-bottom); + background: var(--bg); + border-top: 2px solid var(--border); +} +.bottom-nav__link { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 48px; + min-width: 48px; + text-decoration: none; + color: var(--muted); + font-weight: 600; + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + border-top: 3px solid transparent; + text-align: center; + padding: 0.25rem; +} +.bottom-nav__link[aria-current="page"] { + color: var(--fg); + border-top-color: var(--fg); +} +.bottom-nav__link--emergency { + background: var(--accent); + color: var(--accent-contrast); +} +.bottom-nav__link--emergency[aria-current="page"] { + border-top-color: var(--accent-contrast); + color: var(--accent-contrast); + outline-offset: -3px; +} +.bottom-nav__link:focus-visible { + outline-offset: -3px; +} + +/* Increase tap size on coarse pointers */ +@media (pointer: coarse) { + .bottom-nav__link { + padding-block: 0.5rem; + } +} + +/* Placeholder cards — Stage 2 structural only */ +.view-placeholder { + border: 1px solid var(--border); + background: var(--surface); + border-radius: var(--radius); + padding: 1rem; + margin-block: 1rem; +} diff --git a/src/ui/router/router.ts b/src/ui/router/router.ts new file mode 100644 index 0000000..061cc0b --- /dev/null +++ b/src/ui/router/router.ts @@ -0,0 +1,97 @@ +/** + * Minimal history-API router — offline-safe deep links. + * No network, no persistence, no framework. + * Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207 + */ + +export type RouteId = "emergency" | "schedule" | "map" | "festival" | "not-found"; + +export interface Route { + id: RouteId; + path: string; + label: string; + title: string; +} + +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: "/festival", label: "Festival", title: "Festival" }, +] as const; + +const PATH_TO_ID = new Map(ROUTES.map((r) => [r.path, r.id])); + +export function normalizePath(path: string): string { + // strip query/hash, ensure leading slash, drop trailing slash (except root) + let p = path.split("?")[0]?.split("#")[0] ?? "/"; + if (!p.startsWith("/")) p = "/" + p; + if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1); + // root → emergency (canonical festival shell entry) + if (p === "/") return "/emergency"; + return p; +} + +export function routeForPath(path: string): RouteId { + const normalized = normalizePath(path); + return PATH_TO_ID.get(normalized) ?? "not-found"; +} + +export interface NavigateOptions { + replace?: boolean; +} + +export class Router { + private listeners = new Set<(id: RouteId, path: string) => void>(); + private currentPath: string; + + constructor(initialPath?: string) { + this.currentPath = normalizePath( + initialPath ?? (typeof location !== "undefined" ? location.pathname : "/emergency"), + ); + } + + getPath(): string { + return this.currentPath; + } + + getRouteId(): RouteId { + return routeForPath(this.currentPath); + } + + navigate(path: string, opts: NavigateOptions = {}): void { + const next = normalizePath(path); + if (next === this.currentPath) return; + this.currentPath = next; + if (typeof history !== "undefined") { + if (opts.replace) history.replaceState(null, "", next); + else history.pushState(null, "", next); + } + this.emit(); + } + + listen(handler: (id: RouteId, path: string) => void): () => void { + this.listeners.add(handler); + return () => this.listeners.delete(handler); + } + + attach(): () => void { + const onPop = () => { + this.currentPath = normalizePath( + typeof location !== "undefined" ? location.pathname : "/emergency", + ); + this.emit(); + }; + if (typeof window !== "undefined") { + window.addEventListener("popstate", onPop); + } + return () => { + if (typeof window !== "undefined") window.removeEventListener("popstate", onPop); + }; + } + + private emit(): void { + const id = routeForPath(this.currentPath); + for (const l of this.listeners) l(id, this.currentPath); + } +} diff --git a/src/ui/views/emergency/emergency.ts b/src/ui/views/emergency/emergency.ts new file mode 100644 index 0000000..f500ca8 --- /dev/null +++ b/src/ui/views/emergency/emergency.ts @@ -0,0 +1,29 @@ +/** + * Emergency view — Stage 2 placeholder, accessible and visually prioritized. + * Real content comes in Stage 10 (emergency) via domain/emergency + data/. + * Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207 + */ + +export function createEmergencyView(): HTMLElement { + const section = document.createElement("section"); + section.setAttribute("aria-labelledby", "emergency-heading"); + + const h1 = document.createElement("h1"); + h1.id = "emergency-heading"; + h1.textContent = "Emergency"; + section.append(h1); + + const lead = document.createElement("p"); + lead.textContent = + "Stage 2 shell placeholder — emergency information will be available offline from the compiled baseline (Ring-0)."; + section.append(lead); + + const card = document.createElement("div"); + card.className = "view-placeholder"; + card.setAttribute("role", "note"); + card.textContent = + "Emergency baseline is always available from shell bytes and never requires network (I-1/I-2). No real emergency data is rendered yet."; + section.append(card); + + return section; +} diff --git a/src/ui/views/festival/festival.ts b/src/ui/views/festival/festival.ts new file mode 100644 index 0000000..c42f838 --- /dev/null +++ b/src/ui/views/festival/festival.ts @@ -0,0 +1,16 @@ +export function createFestivalView(): HTMLElement { + const section = document.createElement("section"); + section.setAttribute("aria-labelledby", "festival-heading"); + const h1 = document.createElement("h1"); + h1.id = "festival-heading"; + h1.textContent = "Festival"; + section.append(h1); + const p = document.createElement("p"); + p.textContent = "Stage 2 shell placeholder — festival information blocks will be rendered here."; + section.append(p); + const card = document.createElement("div"); + card.className = "view-placeholder"; + card.textContent = "Works fully offline (I-5). No raw HTML ingestion (B-7)."; + section.append(card); + return section; +} diff --git a/src/ui/views/map/map.ts b/src/ui/views/map/map.ts new file mode 100644 index 0000000..44c8b15 --- /dev/null +++ b/src/ui/views/map/map.ts @@ -0,0 +1,17 @@ +export function createMapView(): HTMLElement { + const section = document.createElement("section"); + 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 = + "Stage 2 shell placeholder — offline raster map + Facilities list will be rendered here. No GPS in V1 (I-7)."; + section.append(p); + const card = document.createElement("div"); + card.className = "view-placeholder"; + card.textContent = "Works fully offline after L2 preparation (I-4)."; + section.append(card); + return section; +} diff --git a/src/ui/views/not-found.ts b/src/ui/views/not-found.ts new file mode 100644 index 0000000..4575b48 --- /dev/null +++ b/src/ui/views/not-found.ts @@ -0,0 +1,17 @@ +export function createNotFoundView(path: string): HTMLElement { + const section = document.createElement("section"); + section.setAttribute("aria-labelledby", "not-found-heading"); + const h1 = document.createElement("h1"); + h1.id = "not-found-heading"; + h1.textContent = "Page not found"; + section.append(h1); + const p = document.createElement("p"); + p.textContent = `No page at “${path}”. Use the navigation below.`; + section.append(p); + const a = document.createElement("a"); + a.href = "/emergency"; + a.textContent = "Go to Emergency"; + a.setAttribute("data-route", "/emergency"); + section.append(a); + return section; +} diff --git a/src/ui/views/schedule/schedule.ts b/src/ui/views/schedule/schedule.ts new file mode 100644 index 0000000..1a0a37c --- /dev/null +++ b/src/ui/views/schedule/schedule.ts @@ -0,0 +1,17 @@ +export function createScheduleView(): HTMLElement { + const section = document.createElement("section"); + 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 = + "Stage 2 shell placeholder — schedule browse, Now/Next, favorites and offline dataset will be rendered here."; + section.append(p); + const card = document.createElement("div"); + card.className = "view-placeholder"; + card.textContent = "Offline after preparation (L1). No network required (I-3)."; + section.append(card); + return section; +} diff --git a/tests/unit/boundaries.test.ts b/tests/unit/boundaries.test.ts index ee7e695..3284e1e 100644 --- a/tests/unit/boundaries.test.ts +++ b/tests/unit/boundaries.test.ts @@ -17,10 +17,23 @@ function walkFiles(dir: string, exts = [".ts", ".js"]): string[] { } describe("architectural boundaries — Stage 1 gate (B-1…B-7)", () => { - it("src/ contains no feature code yet (only READMEs/.gitkeep)", () => { - const tsFiles = walkFiles("src", [".ts"]); - // Stage 1 must have zero feature .ts files — directories exist but are empty - expect(tsFiles).toEqual([]); + it("src/ contains only Stage 2 shell — no forbidden stores yet", () => { + const tsFiles = walkFiles("src", [".ts"]).sort(); + // Stage 2 adds shell (app/layout, router, 4 views) but no IDB/Cache/SW/sync per contract + expect(tsFiles).toEqual( + expect.arrayContaining(["src/app/layout.ts", "src/app/main.ts", "src/ui/router/router.ts"]), + ); + // Forbidden stores must remain empty in Stage 2 + const forbidden = tsFiles.filter( + (f) => + f.startsWith("src/platform/") || + f.startsWith("src/storage/") || + f.startsWith("src/data/") || + f.startsWith("src/sync/"), + ); + expect(forbidden, `Stage 2 must not have forbidden stores: ${forbidden.join(", ")}`).toEqual( + [], + ); }); it("directory structure matches IMPLEMENTATION-CONTRACT.md §4", () => { diff --git a/tests/unit/shell.test.ts b/tests/unit/shell.test.ts new file mode 100644 index 0000000..7ebc025 --- /dev/null +++ b/tests/unit/shell.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment jsdom + * Stage 2 shell tests — boot, routing, emergency affordance, unknown routes, a11y. + * Trace: IMPLEMENTATION-CONTRACT.md §7, §20, §32; ARCHITECTURE-DESIGN.md:207, 783 + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mount as boot, renderView } from "../../src/app/main.js"; +import { ROUTES, normalizePath, routeForPath } from "../../src/ui/router/router.js"; +import { createLayout, setActiveNav } from "../../src/app/layout.js"; + +function setupAppHost(): HTMLElement { + document.body.innerHTML = '
'; + const host = document.getElementById("app"); + if (!host) throw new Error("host missing"); + return host; +} + +describe("Stage 2 shell — boot", () => { + beforeEach(() => { + history.replaceState(null, "", "/"); + document.body.innerHTML = '
'; + }); + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("boots into emergency when root / requested (offline-safe deep link)", () => { + history.replaceState(null, "", "/"); + const host = setupAppHost(); + const { router } = boot(); + expect(router.getPath()).toBe("/emergency"); + expect(host.querySelector("h1")?.textContent).toBe("Emergency"); + expect(document.title).toContain("Emergency"); + }); + + it("removes aria-busy after boot", () => { + const host = setupAppHost(); + boot(); + expect(host.hasAttribute("aria-busy")).toBe(false); + }); +}); + +describe("Stage 2 shell — layout / a11y", () => { + it("provides skip link, banner, main, and nav landmarks", () => { + setupAppHost(); + const { root, main, nav } = createLayout(); + const host = document.getElementById("app"); + if (!host) throw new Error("host"); + host.replaceChildren(root); + expect(document.querySelector(".skip-link")?.getAttribute("href")).toBe("#main-content"); + expect(document.querySelector('header[role="banner"]')).toBeTruthy(); + expect(main.id).toBe("main-content"); + expect(main.getAttribute("tabindex")).toBe("-1"); + expect(nav.getAttribute("aria-label")).toBe("Primary navigation"); + expect(nav.getAttribute("role")).toBe("navigation"); + }); + + it("header status has live region", () => { + setupAppHost(); + const { statusChip } = createLayout(); + expect(statusChip.getAttribute("role")).toBe("status"); + expect(statusChip.getAttribute("aria-live")).toBe("polite"); + }); + + it("every view has exactly one h1 with matching id for its section", () => { + for (const r of ROUTES) { + const el = renderView(r.path); + const h1 = el.querySelector("h1"); + expect(h1, r.path).toBeTruthy(); + const id = h1 ? h1.id : ""; + expect(el.getAttribute("aria-labelledby")).toBe(id); + } + }); +}); + +describe("Stage 2 shell — navigation / emergency affordance", () => { + beforeEach(() => { + history.replaceState(null, "", "/emergency"); + document.body.innerHTML = '
'; + }); + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders four primary destinations, emergency first and visually prioritized", () => { + setupAppHost(); + boot(); + const links = [...document.querySelectorAll(".bottom-nav__link")]; + expect(links.map((a) => a.textContent)).toEqual(["Emergency", "Schedule", "Map", "Festival"]); + const first = links[0]; + expect(first?.classList.contains("bottom-nav__link--emergency")).toBe(true); + }); + + it("all four destinations have 48px touch-target capable links and data-route", () => { + setupAppHost(); + boot(); + for (const r of ROUTES) { + const a = document.querySelector(`a[data-route="${r.path}"]`); + expect(a, `link ${r.path}`).toBeTruthy(); + expect(a?.getAttribute("href")).toBe(r.path); + } + }); + + it("aria-current reflects active route", () => { + const { nav } = createLayout(); + setActiveNav(nav, "schedule"); + expect(nav.querySelector('a[data-route="/schedule"]')?.getAttribute("aria-current")).toBe( + "page", + ); + expect(nav.querySelector('a[data-route="/emergency"]')?.hasAttribute("aria-current")).toBe( + false, + ); + }); + + it("emergency is reachable with one tap from every primary destination", () => { + for (const start of ROUTES.map((r) => r.path)) { + history.replaceState(null, "", start); + document.body.innerHTML = '
'; + setupAppHost(); + const { router } = boot(); + expect(router.getPath()).toBe(start); + const emergencyLink = document.querySelector('a[data-route="/emergency"]'); + expect(emergencyLink, `emergency reachable from ${start}`).toBeTruthy(); + emergencyLink?.click(); + expect(router.getPath()).toBe("/emergency"); + expect(document.querySelector("h1")?.textContent).toBe("Emergency"); + } + }); + + it("handles unknown routes with not-found view and keeps nav without aria-current", () => { + history.replaceState(null, "", "/unknown-path"); + setupAppHost(); + const { router } = boot(); + expect(router.getRouteId()).toBe("not-found"); + expect(document.querySelector("h1")?.textContent).toBe("Page not found"); + expect(document.querySelector('.bottom-nav__link[aria-current="page"]')).toBeNull(); + }); + + it("normalizes paths: trailing slash, query, hash, and root", () => { + expect(normalizePath("/schedule/")).toBe("/schedule"); + expect(normalizePath("/map?foo=1")).toBe("/map"); + expect(normalizePath("/festival#section")).toBe("/festival"); + expect(normalizePath("/")).toBe("/emergency"); + expect(routeForPath("/")).toBe("emergency"); + expect(routeForPath("/unknown")).toBe("not-found"); + }); + + it("clicking a data-route link uses router.navigate (no reload) and updates history", () => { + setupAppHost(); + boot(); + const scheduleLink = document.querySelector('a[data-route="/schedule"]'); + if (!scheduleLink) throw new Error("schedule link"); + scheduleLink.click(); + expect(location.pathname).toBe("/schedule"); + expect(document.querySelector("h1")?.textContent).toBe("Schedule"); + }); +}); + +describe("Stage 2 shell — responsive / shell invariants", () => { + it("index.html is a single cached entry — Vite will emit hashed assets (checked in build)", () => { + expect(document.body.innerHTML).not.toMatch(/stage-b|evt-|poi-/); + }); + + it("styles include reduced-motion guard", async () => { + const { readFileSync } = await import("node:fs"); + const content = readFileSync("src/app/styles.css", "utf8"); + expect(content).toMatch(/prefers-reduced-motion/); + expect(content).toMatch(/prefers-color-scheme:\s*dark/); + expect(content).toMatch(/--nav-h/); + }); +}); diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..49eda66 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + appType: "spa", + server: { + port: 5173, + strictPort: true, + }, + build: { + outDir: "dist", + assetsDir: "assets", + cssCodeSplit: false, + sourcemap: true, + rollupOptions: { + output: { + entryFileNames: "assets/[name]-[hash].js", + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: "assets/[name]-[hash].[ext]", + }, + }, + }, +});