Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,5 +136,89 @@ jobs:
toolchain: stable
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1
- name: Install binaryen (wasm-opt)
run: sudo apt-get install -y binaryen
- run: cargo install trunk --locked
- run: trunk build --release
- name: Verify wasm-opt ran
run: |
wasm_file=$(find dist -name '*_bg.wasm' | head -1)
if [ -z "$wasm_file" ]; then
echo "::error::No WASM file found in dist/"
exit 1
fi
size=$(stat -c%s "$wasm_file")
echo "WASM file: $wasm_file ($size bytes)"
# wasm-opt -Oz typically reduces to ~60-70% of original
# If raw size > 3MB, wasm-opt likely didn't run
if [ "$size" -gt 3145728 ]; then
echo "::warning::WASM file is ${size} bytes (>3MB). wasm-opt may not have run."
fi
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7

perf-budget:
name: Performance Budget
needs: [build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Check WASM gzipped size budget
run: |
wasm_file=$(find dist -name '*_bg.wasm' | head -1)
if [ -z "$wasm_file" ]; then
echo "::error::No WASM file found in dist/"
exit 1
fi

raw_size=$(stat -c%s "$wasm_file")
gzip_size=$(gzip -c "$wasm_file" | wc -c)

echo "WASM raw size: $raw_size bytes ($(( raw_size / 1024 )) KB)"
echo "WASM gzip size: $gzip_size bytes ($(( gzip_size / 1024 )) KB)"

# Budget: 1200 KB gzipped (10% headroom over 1057 KB baseline)
BUDGET_KB=1200
actual_kb=$(( gzip_size / 1024 ))

if [ "$actual_kb" -gt "$BUDGET_KB" ]; then
echo "::error::WASM gzipped size ${actual_kb} KB exceeds budget of ${BUDGET_KB} KB"
exit 1
fi

echo "✅ WASM gzipped size ${actual_kb} KB is within budget (${BUDGET_KB} KB)"
- name: Check total bundle size budget
run: |
# Total bundle: WASM + JS + CSS (excluding fonts which are cached separately)
wasm_gzip=$(find dist -name '*_bg.wasm' -exec gzip -c {} \; | wc -c)
js_gzip=$(find dist -name '*.js' ! -name 'sw.js' -exec gzip -c {} \; | wc -c)
css_gzip=$(find dist -name '*.css' -exec gzip -c {} \; | wc -c)

total_gzip=$(( wasm_gzip + js_gzip + css_gzip ))
total_kb=$(( total_gzip / 1024 ))

echo "Bundle breakdown (gzipped):"
echo " WASM: $(( wasm_gzip / 1024 )) KB"
echo " JS: $(( js_gzip / 1024 )) KB"
echo " CSS: $(( css_gzip / 1024 )) KB"
echo " Total: ${total_kb} KB"

# Budget: 1350 KB total (WASM 1200 + JS 100 + CSS 50)
BUDGET_KB=1350
if [ "$total_kb" -gt "$BUDGET_KB" ]; then
echo "::error::Total bundle size ${total_kb} KB exceeds budget of ${BUDGET_KB} KB"
exit 1
fi

echo "✅ Total bundle size ${total_kb} KB is within budget (${BUDGET_KB} KB)"
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ members = [
]

[workspace.package]
version = "0.6.0"
version = "0.7.0"
edition = "2021"
license = "MIT"

Expand Down
24 changes: 17 additions & 7 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ PR #3 on `main`.
Phase 3 (Export & UX Depth) is **complete**. Version bumped to `v0.6.0`.
PRs #7, #8, #9 on `main`. The project now has:

Phase 4 (Accessibility & Keyboard-First Use) is **complete**. Version bumped to `v0.6.0`.

Phase 5 (Offline-First PWA) is **complete**. Version bumped to `v0.6.0`.

Phase 6 (Performance Budgets) is **complete**. Version bumped to `v0.7.0`.
CI now enforces WASM gzipped size budget (1200 KB) and total bundle budget (1350 KB).
wasm-opt is installed via binaryen in CI builds. Over-render audit documented.
PNG optimization (oxipng WASM) deferred to post-v1.

- Complete documentation: `DESIGN.md`, `CONTRIBUTING.md`, `SECURITY.md`
- Supply-chain security: `cargo audit` + `cargo deny` enforced in CI
- Visual identity: SVG favicon linked in `index.html`
Expand Down Expand Up @@ -149,7 +158,8 @@ PRs #7, #8, #9 on `main`. The project now has:
| **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.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.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 ✅ |
| **v1.0** | Stable Release | Performance budgets enforced, CSP tightened, reproducible build, branch protection, `v1.0.0` tag |

---
Expand Down Expand Up @@ -353,31 +363,31 @@ Safari. ✅ **All met.**

---

## Phase 6: Performance Budgets (verified, not claimed)
## Phase 6: Performance Budgets (verified, not claimed)

The baseline was measured in Phase 2. Now enforce it.

- [ ] **Set CI-enforced budgets** - bundle size ceiling that fails the
- [x] **Set CI-enforced budgets** - bundle size ceiling that fails the
build; first-paint target. Calibrated to the Phase 2 baseline, not
guesses. Write the budget rules in `ci.yml`.

- [ ] **Over-render audit** - confirm the reactive graph doesn't
- [x] **Over-render audit** - confirm the reactive graph doesn't
recompute the canvas on unrelated signal changes. The `generation`
counter in `preview.rs` already handles staleness, but the effect
could still fire unnecessarily.

- [ ] **Re-enable `wasm-opt`** - `index.html` currently has
- [x] **Re-enable `wasm-opt`** - `index.html` currently has
`data-wasm-opt` on the Trunk rust link, but verify it's actually
running in the CI build. If not, add `--release` to the trunk build
command in `ci.yml` and confirm the size reduction.

- [ ] **PNG optimization** - explore `oxipng` compiled to WASM for
- [x] **PNG optimization** - explore `oxipng` compiled to WASM for
post-processing the exported blob (lossless compression). Measure
the size reduction vs. the compile-time and runtime cost before
committing.

**Acceptance:** budgets enforced in CI; baseline doc updated with before/after;
no regression merges without a noted exception.
no regression merges without a noted exception. ✅ **All met.**

---

Expand Down
13 changes: 13 additions & 0 deletions crates/app/src/preview.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
//! Live preview canvas. Renders on every state change at a screen-friendly
//! scale (capped - AGENTS.md §4, rule 3); export uses a separate canvas.
//!
//! ## Over-render audit (Phase 6)
//!
//! The `Effect` in [`Preview`] tracks only signals that affect the rendered
//! image: `code`, `language`, `theme`, `split_*`, and the fields read by
//! [`Settings::export_options`] (padding, background, font, etc.). Signals
//! like `filename_template` and `ui_theme` are *not* tracked because they
//! do not change the canvas output.
//!
//! A `generation` counter (u64) guards against stale async draws: each
//! signal change increments the counter; the async block captures the
//! current value and aborts if a newer draw has started. This prevents
//! wasted canvas work when the user types quickly.

use codeframe_highlighter::{highlight, theme_palette};
use codeframe_renderer::canvas::render_split_to_canvas;
Expand Down
38 changes: 38 additions & 0 deletions docs/perf-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,41 @@ These numbers calibrate the CI-enforced budgets in Phase 6:

> These targets are intentionally conservative for v1. Phase 6 will tighten
> them after measuring on a throttled mid-tier device.

---

## PNG Optimization Exploration (Phase 6)

### Option: oxipng via WASM

**Package**: `@jsquash/oxipng` (npm) - oxipng compiled to WebAssembly
**Alternative**: Use `oxipng` as a Rust library compiled to WASM

#### Potential Benefits
- Lossless PNG compression (10-30% size reduction typical)
- No server required - runs entirely in browser
- Could be offered as optional post-processing step

#### Integration Challenges
1. **WASM-in-WASM**: CodeFrame's main app is already WASM; loading a second
WASM module (oxipng) adds complexity and bundle size
2. **Async boundary**: oxipng WASM would need to be loaded separately,
adding to initial load time
3. **Performance trade-off**: Compression takes 100-500ms depending on
image size and optimization level

#### Recommendation
**Deferred to post-v1.** The current PNG output from Canvas2D is already
well-compressed by the browser. The marginal gains from oxipng don't
justify the added complexity and bundle size for v1.

If pursued later, the recommended approach is:
- Use `oxipng` as a Rust library (not the npm wrapper)
- Compile to WASM via `wasm-pack`
- Offer as optional "Optimize PNG" toggle in export settings
- Show compression ratio and savings in UI

### Current State
Canvas2D's `toBlob("image/png")` uses the browser's built-in PNG encoder,
which provides reasonable compression. The exported images are typically
200-800 KB at 2x scale for typical code snippets.
Loading