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 855cda7..064818a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,6 +51,20 @@ frequency + play state) via the `screenSummaryContent` super-option, and orders
- **Root constants:** `src/DopplerEffectConstants.ts` (sim-wide); no separate nested primary constants module.
- **Domain clock:** `timeSpeedProperty` (including reverse) and simulation-state history drive the clock instead of composing fleet-standard `TimeModel` (`src/common/TimeModel.ts` is present for shared reference only).
+
+### `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
Fleet-standard Vitest layout:
@@ -78,6 +92,8 @@ npm run lint && npm run check && npm run build
npm test
```
+`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.
+
## Development notes
- Microphone node listens at an arbitrary point (can differ from observer icon). Motion trails and projector mode supported via preferences.
diff --git a/README.md b/README.md
index 8836709..8b45d18 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Doppler Effect Simulation
+[](https://github.com/OpenPhysics/DopplerEffect/actions/workflows/ci.yml)
+
An interactive simulation of the Doppler Effect built with [SceneryStack](https://scenerystack.org/). Explore
how observed sound frequency shifts when source and observer move relative to each other.
diff --git a/index.html b/index.html
index 9af08c7..a225fc5 100644
--- a/index.html
+++ b/index.html
@@ -12,6 +12,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 5b47c33..83c07d2 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 a01551d..701d500 100644
--- a/package.json
+++ b/package.json
@@ -62,8 +62,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..c8c8f73
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..730cd96
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..c342638 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 (`#2575ba`). */
+const THEME_BG = { r: 37, g: 117, b: 186, 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 ea87239..1520193 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: "doppler-effect",
name: "Doppler Effect",
// biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
short_name: "Doppler",
description: "An interactive simulation of the Doppler Effect using SceneryStack.",
+ categories: ["education", "science"],
// biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
theme_color: "#2575ba",
// 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: "Doppler Effect",
+ },
+ {
+ 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: "Doppler Effect",
+ },
+ ],
},
workbox: {
- maximumFileSizeToCacheInBytes: 12 * 1024 * 1024,
+ maximumFileSizeToCacheInBytes: WORKBOX_MAX_FILE_BYTES,
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
},
}),