From c9b35992a9036c0800970fcc15c997646e6d8059 Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Fri, 7 Aug 2026 15:33:57 +0700 Subject: [PATCH 01/11] fix(renderer): use hanging baseline for SVG line numbers --- crates/renderer/src/svg.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/renderer/src/svg.rs b/crates/renderer/src/svg.rs index 61d8399..538627e 100644 --- a/crates/renderer/src/svg.rs +++ b/crates/renderer/src/svg.rs @@ -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; @@ -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; } @@ -570,6 +570,24 @@ mod tests { assert!(svg.contains("1")); } + #[test] + fn line_numbers_use_hanging_baseline() { + let tokens = sample_tokens(); + let palette = ThemePalette { + background: RgbColor::new(0x28, 0x2a, 0x36), + foreground: RgbColor::new(0xf8, 0xf8, 0xf2), + }; + let options = ExportOptions { + line_numbers: true, + ..Default::default() + }; + let (svg, _) = render_svg(&tokens, &palette, &options); + // Line numbers must use the same baseline as the token text so the SVG + // renders pixel-aligned with the canvas output. + assert!(svg.contains("dominant-baseline=\"hanging\"")); + assert!(!svg.contains("dominant-baseline=\"hopping\"")); + } + #[test] fn no_line_numbers_when_disabled() { let tokens = sample_tokens(); From d0dd737ef6d892f4e276871e1612f26be7f33261 Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Fri, 7 Aug 2026 15:35:21 +0700 Subject: [PATCH 02/11] fix(renderer): advance SVG x-cursor by char count, not byte count --- crates/app/src/export.rs | 6 ++--- crates/renderer/src/svg.rs | 47 ++++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/crates/app/src/export.rs b/crates/app/src/export.rs index 24f7411..028dbcb 100644 --- a/crates/app/src/export.rs +++ b/crates/app/src/export.rs @@ -213,7 +213,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 +239,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 +251,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/renderer/src/svg.rs b/crates/renderer/src/svg.rs index 538627e..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; } @@ -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; } @@ -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(" Date: Fri, 7 Aug 2026 15:35:45 +0700 Subject: [PATCH 03/11] fix(app): defer object URL revocation so Firefox downloads finish --- crates/app/src/export.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/app/src/export.rs b/crates/app/src/export.rs index 028dbcb..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(()) } From 2099ca238364637d034c8d732148d56a80c92977 Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Fri, 7 Aug 2026 15:36:07 +0700 Subject: [PATCH 04/11] fix(app): keep fractional scales in export filenames --- crates/app/src/state.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) 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"); + } +} From 767c5337e7872682a49ff3009056da674937c16f Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Fri, 7 Aug 2026 15:36:31 +0700 Subject: [PATCH 05/11] fix(pwa): bound service-worker cache growth across redeploys --- static/pwa/sw.js | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/static/pwa/sw.js b/static/pwa/sw.js index d23e667..b02552a 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 = keys.length - MAX_CACHE_ENTRIES; + 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; }) From ad688e982751800be7e75a957f585368c852a7b2 Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Fri, 7 Aug 2026 15:36:39 +0700 Subject: [PATCH 06/11] fix(deploy): run wasm-opt in Vercel builds (wasm-opt was CI-only) --- vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vercel.json b/vercel.json index ddd1f44..190cc01 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": "apt-get install -y binaryen && cargo install trunk --locked && rustup target add wasm32-unknown-unknown && trunk build --release", "outputDirectory": "dist", "framework": null, "rewrites": [ From f8d5abb810b989057533803f70d38b9ad174fbad Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Fri, 7 Aug 2026 15:36:43 +0700 Subject: [PATCH 07/11] chore: sync Cargo.lock with 0.7.0 workspace version --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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", From 1e3a27b8b99dc511cca763a8af862b8d9f509c4e Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Fri, 7 Aug 2026 15:38:39 +0700 Subject: [PATCH 08/11] docs(roadmap): reconcile ROADMAP with actual repo state --- ROADMAP.md | 89 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 49 insertions(+), 40 deletions(-) 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. From a5f39e49646eeaf81eec51259c23565c16c734e0 Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Fri, 7 Aug 2026 15:39:10 +0700 Subject: [PATCH 09/11] docs: fix stale claims in README and CONTRIBUTING --- CONTRIBUTING.md | 19 +++++++++++-------- README.md | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) 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/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 From 489dd4c623cfd8512a3696e4cd3150f73548882a Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Fri, 7 Aug 2026 15:40:54 +0700 Subject: [PATCH 10/11] fix(pwa): clamp cache prune count to available entries --- static/pwa/sw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/pwa/sw.js b/static/pwa/sw.js index b02552a..9c6623f 100644 --- a/static/pwa/sw.js +++ b/static/pwa/sw.js @@ -23,7 +23,7 @@ async function pruneCache() { ); // keys() returns insertion order, so filtering keeps the newest extras. const extras = keys.filter((request) => !keep.has(request.url)); - const excess = keys.length - MAX_CACHE_ENTRIES; + const excess = Math.min(keys.length - MAX_CACHE_ENTRIES, extras.length); for (let i = 0; i < excess; i++) { await cache.delete(extras[i]); } From 18f0925f4aeb82a9fbf6dd7382fc6a097dc787a7 Mon Sep 17 00:00:00 2001 From: Suradet Pratomsak Date: Fri, 7 Aug 2026 15:50:34 +0700 Subject: [PATCH 11/11] fix(deploy): install wasm-opt on Vercel without apt-get Vercel's container-optimized build image has no apt-get (build failed with 'apt-get: command not found'). Use binaryen's official Node.js build - a drop-in wasm-opt replacement (~2 MB download vs ~100 MB native tarball) - installed by scripts/install-wasm-opt.sh, verified locally: 4578 KB raw wasm -> 2599 KB after -Oz via the Node drop-in. --- scripts/install-wasm-opt.sh | 44 +++++++++++++++++++++++++++++++++++++ vercel.json | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 scripts/install-wasm-opt.sh diff --git a/scripts/install-wasm-opt.sh b/scripts/install-wasm-opt.sh new file mode 100644 index 0000000..6584798 --- /dev/null +++ b/scripts/install-wasm-opt.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Install a wasm-opt CLI on hosts without a package manager. +# +# Vercel's container-optimized build image has no apt-get (build fails with +# "apt-get: command not found"), so instead of native packages we install +# binaryen's official Node.js build - a drop-in wasm-opt replacement that +# runs on any Node 18+ environment. ~2 MB download vs ~100 MB for the +# native x86_64-linux tarball. +# +# If a native wasm-opt is already on PATH (e.g. GitHub Actions installs +# binaryen via apt), it is used as-is. +# +# Usage: bash scripts/install-wasm-opt.sh +# After running, add "$HOME/.codeframe-bin" to PATH. +set -eu + +BINARYEN_VERSION=130 + +if command -v wasm-opt >/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/vercel.json b/vercel.json index 190cc01..9fcb422 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,5 @@ { - "buildCommand": "apt-get install -y binaryen && 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": [