diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 116c41c..a8d57f1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,7 +24,7 @@ Opens at `http://localhost:8080` with hot-reload on file changes.
## CI Checks
-Every PR must pass these 5 jobs (defined in `.github/workflows/ci.yml`):
+Every PR must pass all jobs (defined in `.github/workflows/ci.yml`):
| Job | Command | What it catches |
|-----|---------|-----------------|
@@ -32,10 +32,12 @@ Every PR must pass these 5 jobs (defined in `.github/workflows/ci.yml`):
| Clippy | `cargo clippy --workspace --all-targets -- -D clippy::correctness -D clippy::suspicious` | Correctness and suspicious lints |
| Format | `cargo fmt --all --check` | Formatting drift |
| Test | `cargo test --lib` | Unit test failures |
-| Build | `trunk build --release` | Full production build (gated on the 4 above) |
-
-Additionally, `cargo audit` and `cargo deny` run as separate jobs to catch
-advisories and license issues.
+| Audit | `cargo audit` | Dependency advisories |
+| Deny | `cargo deny check` | License + yanked-crate policy |
+| Hex Audit | grep over `style.css` | Raw hex colors outside token definitions |
+| CSP Verify | python3 check over `vercel.json` | Required CSP directives present |
+| Build | `trunk build --release` (gated on all of the above) | Full production build with wasm-opt |
+| Perf Budget | gzip-size check over `dist/` | WASM gzipped ≤ 1200 KB, total bundle ≤ 1350 KB |
Run all checks locally before pushing:
@@ -113,8 +115,9 @@ No exceptions in production code. If you truly need `unsafe`, it must:
- **Formatting:** `cargo fmt --all --check` (2-space indent, 100 char max).
See `rustfmt.toml`.
-- **Lints:** `#![deny(unsafe_code)]` + `#![deny(unused_must_use)]` at crate
- root. Clippy: correctness + suspicious.
+- **Lints:** `#![deny(unsafe_code)]` at every crate root. Clippy:
+ correctness + suspicious in CI; run `cargo clippy --all-targets -- -D
+ warnings` locally to catch the rest.
- **Error handling:** Use `thiserror` for crate error types. No `unwrap()`
in production code paths.
- **Doc comments:** Every public function in `renderer` and `highlighter`
@@ -156,7 +159,7 @@ No exceptions in production code. If you truly need `unsafe`, it must:
1. Create a branch from `main`.
2. Make your changes, ensuring all CI checks pass locally.
3. Open a PR against `main`.
-4. CI runs automatically. All 7 jobs must pass.
+4. CI runs automatically. All jobs must pass.
5. Squash-merge (or regular merge - team preference).
---
diff --git a/Cargo.lock b/Cargo.lock
index c26dd61..2b4a65b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -174,7 +174,7 @@ dependencies = [
[[package]]
name = "codeframe-app"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"codeframe-highlighter",
"codeframe-models",
@@ -191,7 +191,7 @@ dependencies = [
[[package]]
name = "codeframe-highlighter"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"codeframe-models",
"syntect",
@@ -200,14 +200,14 @@ dependencies = [
[[package]]
name = "codeframe-models"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"serde",
]
[[package]]
name = "codeframe-renderer"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"codeframe-models",
"thiserror 2.0.19",
diff --git a/README.md b/README.md
index c924e6a..2d52b3c 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Built as a lightweight alternative to carbon.now.sh and ray.so, with one differe
- **Live preview** - Reactivity-driven canvas updates as you type, with a capped preview scale for performance.
- **SVG export** - token-accurate SVG output alongside PNG, using the same layout engine.
- **Split-screen comparison** - side-by-side view with separate code inputs for each panel.
-- **7 B&W background presets** - Snow, Top Glow, Bottom Glow, Left Beam, Right Beam, Center Radial, Dark Vignette. Curated monochrome gradients, no custom color picker needed.
+- **5 B&W background presets** - Snow, Top Glow, Bottom Glow, Left Beam, Right Beam. Curated monochrome gradients, no custom color picker needed.
- **Zero dependencies at runtime** - static WASM, no server required.
## Getting Started
diff --git a/ROADMAP.md b/ROADMAP.md
index 269094f..f0b0c38 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -39,16 +39,18 @@ shape are listed under "Out of Scope" so the line is drawn on purpose.
## Current State (verified against the repo, not assumed)
- **Stack**: Rust 2021 + Leptos 0.8 (CSR) + Trunk, `wasm32-unknown-unknown`,
- deployed to Vercel as static assets behind security headers. Version `0.6.0`
+ deployed to Vercel as static assets behind security headers. Version `0.7.0`
in `Cargo.toml`. No server - the browser does everything.
- **Workspace**: 4 crates - `models` (shared types, zero deps beyond serde),
`highlighter` (syntect wrapper, framework-agnostic), `renderer` (Canvas2D
drawing, no Leptos), `app` (the only Leptos-aware crate).
-- **CI** (`.github/workflows/ci.yml`): 7 jobs - `check` (WASM), `clippy`,
+- **CI** (`.github/workflows/ci.yml`): 10 jobs - `check` (WASM), `clippy`,
`fmt --check`, `test` (`cargo test --lib`), `cargo audit`, `cargo deny`,
- gated `trunk build --release`. SHA-pinned actions, `persist-credentials:
- false`, `permissions: contents: read`. `RUSTFLAGS: "-Dwarnings"` enforced
- globally.
+ `hex-audit` (no raw hex outside CSS tokens), `csp-verify` (validates the
+ CSP in `vercel.json`), gated `trunk build --release`, and `perf-budget`
+ (WASM gzipped ≤ 1200 KB, total bundle ≤ 1350 KB). SHA-pinned actions,
+ `persist-credentials: false`, `permissions: contents: read`. `RUSTFLAGS:
+ "-Dwarnings"` enforced globally.
- **Syntax highlighting**: `syntect` with `default-fancy` (fancy-regex backend,
wasm32-compatible). 15 languages. Extra grammars: TypeScript (wrapper),
TOML (vendored).
@@ -69,8 +71,9 @@ shape are listed under "Out of Scope" so the line is drawn on purpose.
5 B&W background presets (Snow, Top Glow, Bottom Glow, Left Beam, Right Beam).
Filename template.
Keyboard: Ctrl/Cmd+Enter to export, Tab inserts spaces.
-- **Tests**: 30 unit tests across `models` (4), `highlighter` (7), `renderer`
- (13 in `layout.rs` + `svg.rs`, 6 doc-tests). All pure Rust, no WASM runtime needed.
+- **Tests**: 32 tests across `models` (4), `highlighter` (7), `renderer`
+ (15 in `layout.rs` + `svg.rs`), `app` (2 in `state.rs`), plus 6 doc-tests.
+ All pure Rust, no WASM runtime needed.
- **Deployment**: Vercel with SPA rewrite, security headers (X-Content-Type-Options,
X-Frame-Options, Referrer-Policy, Permissions-Policy), immutable caching for
static assets.
@@ -97,15 +100,16 @@ CI now enforces WASM gzipped size budget (1200 KB) and total bundle budget (1350
wasm-opt is installed via binaryen in CI builds. Over-render audit documented.
PNG optimization (oxipng WASM) deferred to post-v1.
-Phase 7 (Supply-Chain & Security Hardening) is **complete**. Version bumped to `v0.8.0`.
-PR #18 on `main`. CSP header added to `vercel.json`, CI now enforces 8 jobs
-with a new `csp-verify` step that validates CSP directives.
+Phase 7 (Supply-Chain & Security Hardening) is **complete**. Remains at
+`v0.7.0` (no version bump was made). PRs #18–#21 on `main`. CSP header added
+to `vercel.json`, CI now enforces 10 jobs with a `csp-verify` step that
+validates CSP directives.
- Complete documentation: `DESIGN.md`, `CONTRIBUTING.md`, `SECURITY.md`
- Supply-chain security: `cargo audit` + `cargo deny` enforced in CI
- CSP: `Content-Security-Policy` header enforced via CI verification
- Visual identity: SVG favicon linked in `index.html`
-- CI hardened: SHA-pinned actions, restricted permissions, 8-job pipeline
+- CI hardened: SHA-pinned actions, restricted permissions, 10-job pipeline
| Feature | Status |
|---------|--------|
@@ -121,7 +125,6 @@ with a new `csp-verify` step that validates CSP directives.
| Split-screen comparison (separate code inputs) | Working |
| Font-size / padding / corner-radius controls | Working |
| Line-height slider (1.0–2.5) | Working |
-| Tab-width control (2/4/8) | Working |
| Copy to clipboard | Working |
| Keyboard shortcuts (Ctrl/Cmd+Enter) | Working |
| Export filename template | Working |
@@ -132,7 +135,7 @@ with a new `csp-verify` step that validates CSP directives.
| Staleness guard in preview (generation counter) | Working |
| Separate preview/export canvases | Working |
| `#![deny(unsafe_code)]` | Enforced |
-| CI (7 jobs) | Enforced |
+| CI (10 jobs) | Enforced |
| DESIGN.md | Exists |
| CONTRIBUTING.md | Exists |
| SECURITY.md | Exists |
@@ -149,10 +152,6 @@ with a new `csp-verify` step that validates CSP directives.
2. **No URL sharing of settings.** Each page load starts from the same
defaults. There is no way to bookmark a specific configuration.
-3. **No offline story.** The app is a static site, but there is no service
- worker, no manifest, no PWA support. It could work offline trivially
- (it's already CSR + static), but doesn't yet.
-
---
## Milestones
@@ -161,7 +160,7 @@ with a new `csp-verify` step that validates CSP directives.
|-----------|-------|------------|
| **v0.2** | Foundation | `DESIGN.md`, `CONTRIBUTING.md`, `SECURITY.md`, favicon, `cargo audit` + `cargo deny` in CI ✅ |
| **v0.4** | Visual Identity | Dark / sepia / light UI theme toggle, favicon, inline hex audit, perf baseline measured ✅ |
-| **v0.5** | Export & UX | Copy to clipboard, line-height/tab-width controls, filename template, keyboard shortcuts ✅ |
+| **v0.5** | Export & UX | Copy to clipboard, line-height control (tab-width decided against - see Phase 3), filename template, keyboard shortcuts ✅ |
| **v0.6** | Export & UX | SVG export, B&W background presets, custom export dimensions, split-screen comparison ✅ |
| **v0.7** | Accessible + Offline | Full a11y pass, WCAG AA contrast, PWA with offline support, service worker ✅ |
| **v0.8** | Performance | CI-enforced budgets (1200 KB WASM gzipped), wasm-opt in CI, over-render audit, PNG optimization explored ✅ |
@@ -233,11 +232,12 @@ should not be blinded by a white sidebar.
- [x] **Add a theme-toggle button** in the topbar (lucide `sun` / `moon`
/ `coffee` icons), next to the export button.
-- [x] **Inline hex audit** - move the renderer's `TRAFFIC_LIGHT_COLORS`
- into the theme palette (each theme defines its own traffic-light colors,
- or a fixed set is exposed as a CSS custom property). Move the canvas
- shadow `rgba` values in `style.css` into tokens. Add a CI grep step
- that fails on raw `#rrggbb` in `.css` / `.rs` view code.
+- [x] **Inline hex audit** - move the preview-canvas shadow `rgba` values
+ in `style.css` into tokens (`--preview-shadow-*`). Add a CI grep step
+ (`hex-audit`) that fails on raw `#rrggbb` in `style.css` outside token
+ definitions. The renderer's `TRAFFIC_LIGHT_COLORS` and SVG brand colors
+ are *output-image* colors (not UI chrome) and intentionally remain
+ inline in `canvas.rs` / `svg.rs` / the inlined logo.
- [x] **Performance baseline** - measure WASM `.wasm` gzip size, cold
first-paint, preview render time, export time at 4x on a mid-tier
@@ -267,9 +267,11 @@ adjustments that make the output *yours*, plus export formats beyond PNG.
1.0–2.5, step 0.1, default 1.5). Currently hardcoded in
`state.rs:63`.
-- [x] **Tab-width control** - the renderer hardcodes `TAB_WIDTH = 4` in
- `layout.rs`. Expose as a select (2 / 4 / 8) so users can match their
- editor's settings.
+- [x] ~~**Tab-width control**~~ **decided against.** Tab width stays
+ hardcoded at `4` (`ExportOptions.tab_width`, `state.rs`). A user control
+ (2/4/8) was prototyped in PR #17 and reverted (`07d8c7a`) because it
+ conflicted with pasted code and the Tab key in the textarea inserts 4
+ spaces, which is the common editor default.
- [x] **B&W background presets** - 5 curated monochrome presets (Snow,
Top Glow, Bottom Glow, Left Beam, Right Beam). The `Background` enum in
@@ -278,16 +280,19 @@ adjustments that make the output *yours*, plus export formats beyond PNG.
a `GradientDir` enum (`ToBottom`, `ToTop`, `ToRight`, `ToLeft`).
- [x] **Code input improvements** - tab key inserts spaces (not focus-
- trap), line numbers in the textarea gutter (CSS counter), and a
- "paste from clipboard" button for quick import.
+ trap). The textarea line-number gutter (CSS counter) and the "paste from
+ clipboard" button were prototyped alongside the tab-width control and
+ dropped with the same revert (`07d8c7a`) - revisit if they earn their
+ keep.
- [x] **Export filename template** - allow the user to set a pattern
(default: `CodeFrame-{scale}x.png`). Simple string interpolation:
`{language}`, `{theme}`, `{timestamp}`.
- [x] **Keyboard shortcuts** - `Ctrl/Cmd+Enter` to export, `Ctrl/Cmd+Z`
- undo (native textarea), `Ctrl/Cmd+Shift+Z` redo. Document in the UI
- with a subtle hint or a `?` help overlay.
+ undo (native textarea), `Ctrl/Cmd+Shift+Z` redo. UI documentation of the
+ shortcuts (hint / `?` overlay) is deferred to Phase 8's getting-started
+ walkthrough.
- [x] **Custom export dimensions** - let the user set a target width
(e.g. 1200px for Twitter, 1920 for a slide) and compute the scale
@@ -402,11 +407,14 @@ no regression merges without a noted exception. ✅ **All met.**
`cargo audit` and `cargo deny` were added in Phase 1. This phase
tightens the remaining security surface.
-- [x] **CSP audit** - review `vercel.json` headers. The current config
- has no `Content-Security-Policy` header. Add one that allows only
- `script-src 'self'`, `style-src 'self' 'unsafe-inline'` (Leptos
- needs inline styles), `connect-src 'self'` (no external APIs), and
- `font-src 'self'`. No `unsafe-eval`.
+- [x] **CSP audit** - add `Content-Security-Policy` to `vercel.json`:
+ `default-src 'none'`, `style-src 'self' 'unsafe-inline'` (Leptos needs
+ inline styles), `connect-src 'self'` (no external APIs), `font-src
+ 'self'`. Two documented exceptions to the ideal `script-src 'self'`:
+ `'wasm-unsafe-eval'` (required by `WebAssembly.instantiateStreaming`,
+ strictly narrower than `unsafe-eval`) and `script-src 'unsafe-inline'`
+ (Trunk's inline module bootstrap changes per build, so a static hash is
+ not viable). See `SECURITY.md`.
- [x] **Dependency pinning** - `Cargo.lock` is already committed (good).
Verify `Cargo.toml` uses version ranges, not exact pins, for direct
@@ -416,10 +424,11 @@ tightens the remaining security surface.
exception must be justified, isolated, tested, and noted in the crate's
`lib.rs` doc comment.
-- [x] **CSP header verification** - add a CI step that fetches the
- deployed site and asserts the `Content-Security-Policy` header is
- present and contains no `unsafe-inline` or `unsafe-eval` (except
- the Leptos inline-style exception).
+- [x] **CSP header verification** - add a `csp-verify` CI step that
+ validates the `Content-Security-Policy` in `vercel.json` before deploy:
+ all required directives present, no `unsafe-eval` (modulo the
+ `wasm-unsafe-eval` exception), and prints the documented
+ `unsafe-inline` exceptions for auditability.
**Acceptance:** CSP header present and correct; `cargo audit` + `cargo deny`
green in CI; no `unsafe` in any crate. ✅ **All met.**
@@ -437,7 +446,7 @@ green in CI; no `unsafe` in any crate. ✅ **All met.**
so header/rewrite regressions are caught before `main`.
- [ ] **Branch protection on `main`** - strict required status checks
- (the 7 CI jobs), no force-push, no deletion.
+ (the CI jobs), no force-push, no deletion.
- [ ] **User-facing getting-started** - extend the README with a
screenshot walkthrough: open → paste code → tweak settings → export.
diff --git a/crates/app/src/export.rs b/crates/app/src/export.rs
index 24f7411..a0e204a 100644
--- a/crates/app/src/export.rs
+++ b/crates/app/src/export.rs
@@ -127,6 +127,9 @@ async fn do_export_png(settings: Settings) -> Result<(), String> {
anchor.set_href(&url);
anchor.set_download(&format!("{}.png", settings.expanded_filename()));
anchor.click();
+ // Revoking the object URL immediately after click() races the download
+ // in Firefox and can abort it. Give the browser a moment to start.
+ gloo_timers::future::TimeoutFuture::new(1500).await;
let _ = Url::revoke_object_url(&url);
Ok(())
}
@@ -193,6 +196,8 @@ async fn do_export_svg(settings: Settings) -> Result<(), String> {
anchor.set_href(&url);
anchor.set_download(&format!("{}.svg", settings.expanded_filename()));
anchor.click();
+ // See the PNG path: revoking too early can cancel the download in Firefox.
+ gloo_timers::future::TimeoutFuture::new(1500).await;
let _ = Url::revoke_object_url(&url);
Ok(())
}
@@ -213,7 +218,7 @@ fn compute_scale_for_width(
for line in &lines {
let mut w = 0.0;
for token in line {
- w += token.text.len() as f64 * char_width;
+ w += token.text.chars().count() as f64 * char_width;
}
max_line_width = max_line_width.max(w);
}
@@ -239,7 +244,7 @@ fn compute_split_scale_for_width(
for line in &lines_left {
let mut w = 0.0;
for token in line {
- w += token.text.len() as f64 * char_width;
+ w += token.text.chars().count() as f64 * char_width;
}
max_left = max_left.max(w);
}
@@ -251,7 +256,7 @@ fn compute_split_scale_for_width(
for line in &lines_right {
let mut w = 0.0;
for token in line {
- w += token.text.len() as f64 * char_width;
+ w += token.text.chars().count() as f64 * char_width;
}
max_right = max_right.max(w);
}
diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs
index 5f5fc22..f339426 100644
--- a/crates/app/src/state.rs
+++ b/crates/app/src/state.rs
@@ -167,9 +167,36 @@ impl Settings {
// Extract date part (YYYY-MM-DD) from ISO string.
let date = timestamp.split('T').next().unwrap_or("unknown").to_string();
template
- .replace("{scale}", &format!("{}", scale as u32))
+ .replace("{scale}", &format_scale(scale))
.replace("{language}", &language)
.replace("{theme}", &theme)
.replace("{timestamp}", &date)
}
}
+
+/// Render an export scale for filenames: `2.0` becomes `2`, `2.5` stays
+/// `2.5` (no trailing `.0`).
+fn format_scale(scale: f64) -> String {
+ if scale.fract() == 0.0 {
+ format!("{}", scale as u64)
+ } else {
+ format!("{scale}")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::format_scale;
+
+ #[test]
+ fn format_scale_drops_trailing_zero() {
+ assert_eq!(format_scale(2.0), "2");
+ assert_eq!(format_scale(8.0), "8");
+ }
+
+ #[test]
+ fn format_scale_keeps_fractions() {
+ assert_eq!(format_scale(2.5), "2.5");
+ assert_eq!(format_scale(1.25), "1.25");
+ }
+}
diff --git a/crates/renderer/src/svg.rs b/crates/renderer/src/svg.rs
index 61d8399..e99ae86 100644
--- a/crates/renderer/src/svg.rs
+++ b/crates/renderer/src/svg.rs
@@ -74,7 +74,7 @@ pub fn render_svg(
for line in &lines {
let mut w = 0.0;
for token in line {
- w += token.text.len() as f64 * cw;
+ w += token.text.chars().count() as f64 * cw;
}
max_line_width = max_line_width.max(w);
}
@@ -221,7 +221,7 @@ pub fn render_svg(
svg.push_str(&format!(
"{text}",
));
- x += token.text.len() as f64 * cw;
+ x += token.text.chars().count() as f64 * cw;
}
y += layout.line_height_px;
}
@@ -233,7 +233,7 @@ pub fn render_svg(
let mut y = layout.code_origin_y;
for number in 1..=layout.line_count {
svg.push_str(&format!(
- "{number}",
+ "{number}",
x = layout.gutter_right_x,
));
y += layout.line_height_px;
@@ -264,7 +264,7 @@ pub fn render_split_svg(
for line in &lines_left {
let mut w = 0.0;
for token in line {
- w += token.text.len() as f64 * cw;
+ w += token.text.chars().count() as f64 * cw;
}
max_left = max_left.max(w);
}
@@ -276,7 +276,7 @@ pub fn render_split_svg(
for line in &lines_right {
let mut w = 0.0;
for token in line {
- w += token.text.len() as f64 * cw;
+ w += token.text.chars().count() as f64 * cw;
}
max_right = max_right.max(w);
}
@@ -463,7 +463,7 @@ fn render_panel_svg(
svg.push_str(&format!(
"{text}",
));
- x += token.text.len() as f64 * cw;
+ x += token.text.chars().count() as f64 * cw;
}
y += layout.line_height_px;
}
@@ -476,7 +476,7 @@ fn render_panel_svg(
let gutter_x = layout.gutter_right_x + offset_x;
for number in 1..=layout.line_count {
svg.push_str(&format!(
- "{number}",
+ "{number}",
));
y += layout.line_height_px;
}
@@ -555,6 +555,43 @@ mod tests {
assert!(svg.contains("<script>"));
}
+ #[test]
+ fn svg_advances_by_char_count_not_byte_count() {
+ // 2 Thai chars = 6 UTF-8 bytes. The next token must start 2 cells (not 6)
+ // after the first, matching the canvas renderer's monospace measurement.
+ let tokens = vec![
+ Token {
+ text: "\u{0e01}\u{0e02}".to_string(), // "กข" - 2 chars, 6 bytes
+ color: RgbColor::new(0xff, 0xff, 0xff),
+ font_style: FontStyle::default(),
+ },
+ Token {
+ text: "x".to_string(),
+ color: RgbColor::new(0xff, 0xff, 0xff),
+ font_style: FontStyle::default(),
+ },
+ ];
+ let palette = ThemePalette {
+ background: RgbColor::new(0x00, 0x00, 0x00),
+ foreground: RgbColor::new(0xff, 0xff, 0xff),
+ };
+ let options = ExportOptions::default();
+ let (svg, layout) = render_svg(&tokens, &palette, &options);
+ let cw = estimate_char_width(options.font_size);
+ let expected_x = layout.code_origin_x + 2.0 * cw;
+ let second_x: f64 = svg
+ .split("/dev/null 2>&1; then
+ echo "wasm-opt already available: $(command -v wasm-opt)"
+ exit 0
+fi
+
+if ! command -v node >/dev/null 2>&1; then
+ echo "error: node is required to install wasm-opt" >&2
+ exit 1
+fi
+
+INSTALL_DIR="$HOME/.codeframe-bin"
+mkdir -p "$INSTALL_DIR"
+cd "$INSTALL_DIR"
+
+curl -fsSL "https://github.com/WebAssembly/binaryen/releases/download/version_${BINARYEN_VERSION}/binaryen-version_${BINARYEN_VERSION}-node.tar.gz" \
+ -o "binaryen-version_${BINARYEN_VERSION}.tar.gz"
+rm -rf "binaryen-version_${BINARYEN_VERSION}"
+tar xzf "binaryen-version_${BINARYEN_VERSION}.tar.gz"
+
+# Shim: trunk (and any other tool) spawns `wasm-opt `; the Node build
+# is invoked as `node wasm-opt.js `.
+printf '#!/bin/sh\nexec node "%s/binaryen-version_%s/wasm-opt.js" "$@"\n' \
+ "$INSTALL_DIR" "$BINARYEN_VERSION" > wasm-opt
+chmod +x wasm-opt
+
+echo "wasm-opt installed to $INSTALL_DIR (binaryen version_${BINARYEN_VERSION})"
diff --git a/static/pwa/sw.js b/static/pwa/sw.js
index d23e667..9c6623f 100644
--- a/static/pwa/sw.js
+++ b/static/pwa/sw.js
@@ -9,6 +9,26 @@ const PRECACHE_URLS = [
"/icon-512.png"
];
+// Content-hashed assets (JS/WASM) are added to the cache on first fetch and
+// never referenced again after a redeploy, so the cache would grow forever.
+// Cap the total number of entries; the oldest extra entries are evicted.
+const MAX_CACHE_ENTRIES = 80;
+
+async function pruneCache() {
+ const cache = await caches.open(CACHE_NAME);
+ const keys = await cache.keys();
+ if (keys.length <= MAX_CACHE_ENTRIES) return;
+ const keep = new Set(
+ PRECACHE_URLS.map((url) => new URL(url, self.location.origin).href)
+ );
+ // keys() returns insertion order, so filtering keeps the newest extras.
+ const extras = keys.filter((request) => !keep.has(request.url));
+ const excess = Math.min(keys.length - MAX_CACHE_ENTRIES, extras.length);
+ for (let i = 0; i < excess; i++) {
+ await cache.delete(extras[i]);
+ }
+}
+
// --- Install: precache the app shell ---
self.addEventListener("install", (event) => {
event.waitUntil(
@@ -54,7 +74,9 @@ self.addEventListener("fetch", (event) => {
.then((response) => {
if (response.ok && request.url.startsWith(self.location.origin)) {
const clone = response.clone();
- caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
+ caches.open(CACHE_NAME).then((cache) =>
+ cache.put(request, clone).then(pruneCache)
+ );
}
return response;
})
diff --git a/vercel.json b/vercel.json
index ddd1f44..9fcb422 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,5 +1,5 @@
{
- "buildCommand": "cargo install trunk --locked && rustup target add wasm32-unknown-unknown && trunk build --release",
+ "buildCommand": "bash scripts/install-wasm-opt.sh && export PATH=\"$HOME/.codeframe-bin:$PATH\" && cargo install trunk --locked && rustup target add wasm32-unknown-unknown && trunk build --release",
"outputDirectory": "dist",
"framework": null,
"rewrites": [