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
19 changes: 11 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,20 @@ 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 |
|-----|---------|-----------------|
| Check | `cargo check -p codeframe-app --target wasm32-unknown-unknown` | Compile errors in the WASM target |
| 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:

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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).

---
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 49 additions & 40 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Expand All @@ -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 |
|---------|--------|
Expand All @@ -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 |
Expand All @@ -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 |
Expand All @@ -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
Expand All @@ -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 ✅ |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.**
Expand All @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions crates/app/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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(())
}
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand Down
29 changes: 28 additions & 1 deletion crates/app/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Loading
Loading