diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index c67ba08..350b6e7 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -18,6 +18,11 @@ updates:
ignore:
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
+ # Forced in package.json overrides (see CLAUDE.md → package.json overrides).
+ # Dependabot PRs would fight the pin / reopen known-accepted risk.
+ - dependency-name: "lodash"
+ - dependency-name: "three"
+ - dependency-name: "brace-expansion"
groups:
development-dependencies:
dependency-type: "development"
diff --git a/CLAUDE.md b/CLAUDE.md
index b62f914..8b0d910 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -109,6 +109,20 @@ Full convention: [Baton/ACCESSIBILITY.md](https://github.com/OpenPhysics/Baton/b
flat list of exports. Four screens' worth of names is more than one flat list keeps legible; Baton
CONVENTIONS §2 permits this variation, and the file still lives at `src/` root as required.
+
+### `package.json` overrides
+
+JSON cannot carry comments, so the rationale for forced transitive pins lives here. Prefer
+**tilde (`~`) or exact** versions — caret (`^`) lets minors drift under what is meant to be a
+hard pin. Dependabot ignores these three names (see `.github/dependabot.yml`) so it does not
+open PRs that fight the overrides. Revisit when SceneryStack drops or re-pins them upstream.
+
+| Override | Pin | Why |
+|---|---|---|
+| `lodash` | `~4.18.1` | SceneryStack declares `~4.17.12`. Bump clears Dependabot/npm advisories patched in 4.18.x (e.g. GHSA-r5fr-rjxr-66jc, GHSA-f23m-r3pf-42rh). |
+| `three` | `~0.125.2` | SceneryStack declares `^0.104.0`. Floor is 0.125.0 for GHSA-fq6p-x6j3-cmmq (ReDoS). Staying on the 0.125 line avoids a larger API jump; **0.125.x still has open CVEs** (e.g. XSS GHSA-7vvq-7r29-5vg3, fixed only in ≥0.137.0). Remove this override if/when SceneryStack stops depending on `three` or pins a patched line itself. LightPropagation keeps a higher `three` pin — do not force 0.125 there. |
+| `brace-expansion` | `~5.0.9` | Transitive via `vite-plugin-pwa` / Workbox. Clears npm audit (originally GHSA-mh99-v99m-4gvg; keep ≥5.0.9 for GHSA-rgw5-rvv9-x895). |
+
## Testing
`npm test` — Vitest, `happy-dom`, `--expose-gc`. 151 tests across seven files.
@@ -135,6 +149,8 @@ npm run test:fuzz:quick
bash ../Baton/scripts/check-repo-compliance.sh SpecialRelativity # from the workspace root
```
+`npm run release` intentionally skips `npm test` in some sims — append `&& npm test` before the version bump so a release cannot ship a failing suite.
+
Query parameters: `?initialBeta=0.8`, `?showRapidity=true`, `?shadeLightCone=true`.
## PWA
diff --git a/README.md b/README.md
index 4bb5bbc..95ec9bf 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Special Relativity
+[](https://github.com/OpenPhysics/SpecialRelativity/actions/workflows/ci.yml)
+
An interactive simulation of flat-spacetime relativity — a moving light clock, a live Minkowski
diagram, the ladder-and-barn paradox, the twin paradox, and the relativistic Doppler effect — built
with
diff --git a/index.html b/index.html
index f33dc56..02df0aa 100644
--- a/index.html
+++ b/index.html
@@ -12,6 +12,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index bc40a0d..02acf9d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4701,9 +4701,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.8",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
- "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index fbff3af..42ede5e 100644
--- a/package.json
+++ b/package.json
@@ -59,8 +59,8 @@
"node": ">=24"
},
"overrides": {
- "lodash": "^4.18.0",
- "three": "^0.125.0",
- "brace-expansion": "^5.0.8"
+ "lodash": "~4.18.1",
+ "three": "~0.125.2",
+ "brace-expansion": "~5.0.9"
}
}
diff --git a/public/screenshots/narrow.png b/public/screenshots/narrow.png
new file mode 100644
index 0000000..9fa3173
Binary files /dev/null and b/public/screenshots/narrow.png differ
diff --git a/public/screenshots/wide.png b/public/screenshots/wide.png
new file mode 100644
index 0000000..582c1c2
Binary files /dev/null and b/public/screenshots/wide.png differ
diff --git a/scripts/generate-icons.ts b/scripts/generate-icons.ts
index 25cb167..eca966a 100644
--- a/scripts/generate-icons.ts
+++ b/scripts/generate-icons.ts
@@ -1,11 +1,14 @@
/**
* generate-icons.ts
*
- * Rasterizes public/icons/icon.svg into the PNG icons and favicon.ico used by the PWA.
- * Run with: npm run icons
+ * Rasterizes public/icons/icon.svg into the PNG icons, favicon.ico, and placeholder
+ * PWA install screenshots used by the manifest. Run with: npm run icons
+ *
+ * Replace public/screenshots/{wide,narrow}.png with real sim shots before shipping
+ * (e.g. Baton/scripts/generate-screenshots.sh → copy into public/screenshots/).
*/
-import { readFileSync, writeFileSync } from "node:fs";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import pngToIco from "png-to-ico";
@@ -15,6 +18,9 @@ const here = dirname(fileURLToPath(import.meta.url));
const publicDir = resolve(here, "..", "public");
const svg = readFileSync(resolve(publicDir, "icons", "icon.svg"));
+/** Theme background matching `theme_color` / icon.svg fill (`#1a1a2e`). */
+const THEME_BG = { r: 26, g: 26, b: 46, alpha: 1 };
+
const density = 512;
const pngTargets = [
@@ -31,3 +37,24 @@ const icoBuffers = await Promise.all(
[16, 32, 48, 64].map((size) => sharp(svg, { density }).resize(size, size).png().toBuffer()),
);
writeFileSync(resolve(publicDir, "favicon.ico"), await pngToIco(icoBuffers));
+
+/** Branded placeholder screenshots for the Web App Manifest `screenshots` member. */
+async function writeScreenshot(width: number, height: number, file: string): Promise {
+ const iconSize = Math.round(Math.min(width, height) * 0.4);
+ const icon = await sharp(svg, { density }).resize(iconSize, iconSize).png().toBuffer();
+ await sharp({
+ create: {
+ width,
+ height,
+ channels: 4,
+ background: THEME_BG,
+ },
+ })
+ .composite([{ input: icon, gravity: "center" }])
+ .png()
+ .toFile(resolve(publicDir, file));
+}
+
+mkdirSync(resolve(publicDir, "screenshots"), { recursive: true });
+await writeScreenshot(1280, 720, "screenshots/wide.png");
+await writeScreenshot(720, 1280, "screenshots/narrow.png");
diff --git a/vite.config.ts b/vite.config.ts
index 433ac13..39e8707 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,6 +6,7 @@ import { VitePWA } from "vite-plugin-pwa";
* Security headers required for:
* - COOP/COEP: SharedArrayBuffer support
* - CSP: restrict resource loading to same-origin + known blob/data exceptions
+ * - Referrer / Permissions: tighten default browser leakage
* - X-Content-Type-Options: prevent MIME sniffing
* - X-Frame-Options: prevent clickjacking (belt-and-suspenders alongside frame-ancestors)
*/
@@ -14,9 +15,17 @@ const securityHeaders: Record = {
"Cross-Origin-Embedder-Policy": "require-corp",
"Content-Security-Policy": [
"default-src 'self'",
+ // TODO(scenerystack): drop 'unsafe-eval' when SceneryStack no longer needs
+
+ // Function/eval for query-parameter parsing — reopen a CSP audit then.
+
// 'unsafe-eval' is required for SceneryStack query parameter parsing
"script-src 'self' 'unsafe-eval'",
"worker-src blob: 'self'",
+ // TODO(scenerystack): drop 'unsafe-inline' when SceneryStack stops setting
+
+ // element.style / cssText for theming (same CSP revisit as unsafe-eval).
+
// Inline styles are set via element.style / cssText throughout the UI layer
"style-src 'self' 'unsafe-inline'",
// data: for icons
@@ -29,10 +38,18 @@ const securityHeaders: Record = {
"base-uri 'self'",
"frame-ancestors 'none'",
].join("; "),
+ "Referrer-Policy": "strict-origin-when-cross-origin",
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
};
+/** Single-file mode: inline every imported asset as base64 (effectively unlimited). */
+const INLINE_LIMIT_BYTES = 100 * 1024 * 1024;
+
+/** Workbox precache ceiling — SceneryStack bundles exceed the default 2 MB limit. */
+const WORKBOX_MAX_FILE_BYTES = 12 * 1024 * 1024;
+
/** Escape a string for literal use inside a `RegExp`. */
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -130,7 +147,7 @@ export default defineConfig(({ mode }) => {
chunkSizeWarningLimit: 5000,
...(single && {
// Inline every imported asset as a base64 data URI instead of emitting files.
- assetsInlineLimit: 100_000_000,
+ assetsInlineLimit: INLINE_LIMIT_BYTES,
// Emit one CSS file (no per-chunk split) so there is a single tag to inline.
cssCodeSplit: false,
// Skip copying public/ (favicon, icons) — nothing external should remain.
@@ -154,16 +171,20 @@ export default defineConfig(({ mode }) => {
registerType: "autoUpdate",
includeAssets: ["favicon.ico", "icons/apple-touch-icon.png"],
manifest: {
+ id: "special-relativity",
name: "Special Relativity",
// biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
short_name: "SpecialRelativity",
description: "A SceneryStack simulation: Special Relativity",
+ categories: ["education", "science"],
// biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
theme_color: "#1a1a2e",
// biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
background_color: "#000000",
display: "standalone",
- orientation: "landscape",
+ // biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
+ display_override: ["window-controls-overlay", "standalone"],
+ // No `orientation` — leave free so portrait-friendly sims are not forced landscape.
icons: [
{
src: "icons/icon-192.png",
@@ -182,9 +203,28 @@ export default defineConfig(({ mode }) => {
purpose: "maskable",
},
],
+ // Placeholder shots from `npm run icons`; replace with real sim screenshots before shipping.
+ screenshots: [
+ {
+ src: "screenshots/wide.png",
+ sizes: "1280x720",
+ type: "image/png",
+ // biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
+ form_factor: "wide",
+ label: "Special Relativity",
+ },
+ {
+ src: "screenshots/narrow.png",
+ sizes: "720x1280",
+ type: "image/png",
+ // biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
+ form_factor: "narrow",
+ label: "Special Relativity",
+ },
+ ],
},
workbox: {
- maximumFileSizeToCacheInBytes: 12 * 1024 * 1024,
+ maximumFileSizeToCacheInBytes: WORKBOX_MAX_FILE_BYTES,
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
},
}),