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
+
+ You are offline and the app shell is not cached. Emergency information is available from
+ festival staff and printed materials.
+
+ 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]",
+ },
+ },
+ },
+});