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
39 changes: 39 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,42 @@ jobs:

- name: Build with Trunk
run: trunk build --release

- name: Asset size budget
# Fails the build if any critical asset exceeds its gzipped budget.
# Budgets carry roughly +25% headroom over the current sizes so
# routine changes don't trip them, but a careless dependency
# bump or stylesheet bloat will. Update the budget alongside the
# change that exceeds it, with a clear justification in the PR.
run: |
set -euo pipefail
fail=0
check() {
local pattern="$1"
local label="$2"
local budget_kb="$3"
local file
file=$(ls dist/$pattern 2>/dev/null | head -n1 || true)
if [ -z "$file" ]; then
echo "::error::Budget check: no file matching dist/$pattern"
fail=1
return
fi
local size_b
size_b=$(gzip -c -9 "$file" | wc -c)
local size_kb=$(( (size_b + 1023) / 1024 ))
if [ "$size_kb" -gt "$budget_kb" ]; then
echo "::error::$label is ${size_kb} KB gz, budget is ${budget_kb} KB ($file)"
fail=1
else
echo "OK: $label = ${size_kb} KB gz (budget ${budget_kb} KB) -- $file"
fi
}
check 'odp-*_bg.wasm' 'wasm' 230
check 'tailwind-*.css' 'tailwind CSS' 10
check 'repo_graph.css' 'repo_graph CSS' 2
check 'odp-*.js' 'wasm-bindgen JS' 20
if [ "$fail" -ne 0 ]; then
echo "::error::One or more assets exceeded their size budget."
exit 1
fi
11 changes: 10 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ leptos = { version = "0.8.2", features = ["csr"] }
leptos_meta = "0.8.2"
leptos_router = "0.8.2"
wasm-bindgen = "0.2.100"
web-sys = { version = "0.3.77", features = ["Document", "Window", "console"] }
# All the web-sys features we use directly (Window, Document, Console)
# are already enabled transitively by leptos's `csr` feature, so we
# don't need to opt into them again here. We still depend on web-sys
# directly for the `web_sys::js_sys` re-export and `web_sys::window()`
# in `repo_view.rs`.
web-sys = "0.3.77"

[dev-dependencies]
js-sys = "0.3"
Expand All @@ -21,4 +26,8 @@ wasm-bindgen-test = "0.3"
assets-dir="public"

[profile.release]
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
6 changes: 4 additions & 2 deletions Trunk.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ inject_scripts = true
# frozen = false
# Require Cargo.lock is up to date
# locked = false
# Control minification
# minify = "never" # can be one of: never, on_release, always
# Control minification: "always" minifies CSS (incl. tailwind) and HTML
# in both dev and release builds. The wasm artifact is governed by the
# cargo release profile (Cargo.toml) -- this only affects asset minify.
minify = "always"
# Allow disabling sub-resource integrity (SRI)
# no_sri = false
# An optional cargo profile to use
Expand Down
17 changes: 10 additions & 7 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<link data-trunk rel="tailwind-css" href="/style/tailwind.css" />
<link data-trunk rel="css" href="/style/repo_graph.css" />
<link data-trunk rel="copy-file" href="/style/repo_graph.css" />
<link data-trunk rel="copy-dir" href="/public/images"/>
<link data-trunk rel="copy-dir" href="/public/fonts"/>
<link data-trunk rel="copy-file" href="/public/repo_graph.js"/>
<link data-trunk rel="copy-file" href="/public/_headers"/>

<!-- Repository graph dependencies. Loaded once per page so the
RepositoryGraph component can call window.__odpRenderGraph()
immediately on mount without racing against script downloads. -->
<script defer src="https://d3js.org/d3.v7.min.js"></script>
<script defer src="/repo_graph.js"></script>
<!--
Repository graph dependencies (D3 + repo_graph.js) are NOT loaded
here. They are injected on demand by `RepositoryGraph` (see
src/components/repo_view.rs), which keeps ~90 KB gz off the
initial bundle on the landing page, /projects, /teams, etc.
-->

<!--
`data-wasm-opt-params` forwards extra flags to wasm-opt. The bundled
Expand All @@ -30,7 +33,7 @@
data-weak-refs
/>
<link rel="icon" href="images/odpicon.ico" type="image/x-icon">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;600&display=swap" rel="stylesheet">
<link rel="preload" href="/fonts/geist-latin.woff2" as="font" type="font/woff2" crossorigin>
</head>
<body></body>
</html>
55 changes: 55 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Cloudflare Pages reads this file at the root of the published
# directory and applies the listed headers to matching requests.
# See https://developers.cloudflare.com/pages/configuration/headers/

# Default headers for every response. Light security hardening; no
# CSP yet because the d3 CDN script is loaded dynamically and would
# need to be allow-listed.
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: interest-cohort=()

# Trunk fingerprints these with a content hash in the filename, so
# they are safe to cache forever. A new build emits a new hash and
# index.html points to it; the old hash becomes orphan and aged out.
/*.wasm
Cache-Control: public, max-age=31536000, immutable

/*.css
Cache-Control: public, max-age=31536000, immutable

# Matches the wasm-bindgen JS shim (odp-<hash>.js) which is also
# fingerprinted. Plain repo_graph.js is unhashed and falls through
# to the default cache (which is fine -- it's tiny).
/odp-*.js
Cache-Control: public, max-age=31536000, immutable

# Self-hosted fonts: Trunk copies these as-is (no fingerprint).
# Cache for a week and revalidate. If the file ever changes, busting
# the cache means visitors see a one-week-old font for at most a
# week; trade-off is acceptable for the perf win.
/fonts/*.woff2
Cache-Control: public, max-age=604800, must-revalidate

# Images are not fingerprinted but rarely change. Same trade-off.
/images/*
Cache-Control: public, max-age=604800, must-revalidate

# index.html and the data/* JSON must always be revalidated so a
# fresh deploy is picked up immediately.
/
Cache-Control: public, max-age=0, must-revalidate

/index.html
Cache-Control: public, max-age=0, must-revalidate

# repo_graph.{js,css} are NOT fingerprinted (they're injected at
# runtime by RepositoryGraph and ship to dist root via copy-file).
# Cache for a week and revalidate -- a stale window of one week is
# acceptable for the project graph code.
/repo_graph.js
Cache-Control: public, max-age=604800, must-revalidate

/repo_graph.css
Cache-Control: public, max-age=604800, must-revalidate
Binary file added public/fonts/geist-latin-ext.woff2
Binary file not shown.
Binary file added public/fonts/geist-latin.woff2
Binary file not shown.
Binary file removed public/images/ECBackground.png
Binary file not shown.
Binary file added public/images/ECBackground.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/images/ECServicesBackground.png
Binary file not shown.
Binary file added public/images/ECServicesBackground.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/images/PatinaBackground.png
Binary file not shown.
Binary file added public/images/PatinaBackground.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
75 changes: 0 additions & 75 deletions public/images/dark/ProjectIcon_EC_Patina_DarkMode.svg

This file was deleted.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
75 changes: 0 additions & 75 deletions public/images/dark/ProjectIcon_ES_Patina_DarkMode.svg

This file was deleted.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
73 changes: 0 additions & 73 deletions public/images/dark/ProjectIcon_P_Patina_DarkMode.svg

This file was deleted.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/images/ec.png
Binary file not shown.
Binary file added public/images/ec.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/images/ec_services.png
Binary file not shown.
Binary file added public/images/ec_services.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 0 additions & 68 deletions public/images/light/ProjectIcon_EC_Patina_LightMode.svg

This file was deleted.

68 changes: 0 additions & 68 deletions public/images/light/ProjectIcon_ES_Patina_LightMode.svg

This file was deleted.

66 changes: 0 additions & 66 deletions public/images/light/ProjectIcon_P_Patina_LightMode.svg

This file was deleted.

Binary file removed public/images/patina.png
Binary file not shown.
Binary file added public/images/patina.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 0 additions & 35 deletions public/images/patina_header.svg

This file was deleted.

44 changes: 36 additions & 8 deletions public/repo_graph.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
// Repository graph rendered with D3 v7. The Rust component
// `RepositoryGraph` (src/components/repo_view.rs) sets
// `window.__odpGraphData = { nodes, links }` and then loads this file
// once per session. Each call to `window.__odpRenderGraph()` clears
// the existing <svg> and (re-)renders the graph with whatever is in
// __odpGraphData, so that route changes between the three project
// pages get a fresh graph without re-injecting any <script>/<style>.
// on demand on the first mount of a project page. Each call to
// `window.__odpRenderGraph()` clears the existing <svg> and
// (re-)renders the graph with whatever is in __odpGraphData, so that
// route changes between the three project pages get a fresh graph
// without re-injecting any <script>/<style>.
//
// D3 itself is also loaded on demand by this file (see `ensureD3`),
// so the ~280 KB d3 bundle never enters the critical path for users
// who don't visit a project page.

(function () {
"use strict";
Expand All @@ -15,12 +20,29 @@
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 3;

const D3_URL = "https://d3js.org/d3.v7.min.js";
let d3LoadingPromise = null;

function ensureD3() {
if (typeof d3 !== "undefined") return Promise.resolve();
if (d3LoadingPromise) return d3LoadingPromise;
d3LoadingPromise = new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = D3_URL;
s.async = true;
s.onload = () => resolve();
s.onerror = () => {
d3LoadingPromise = null;
reject(new Error("Failed to load D3"));
};
document.head.appendChild(s);
});
return d3LoadingPromise;
}

function render() {
if (typeof d3 === "undefined") {
// d3 is loaded as a sibling <script defer> in index.html;
// on a very first paint it may not be parsed yet. Try
// again on the next animation frame.
requestAnimationFrame(render);
ensureD3().then(render).catch(() => {});
return;
}
const data = window.__odpGraphData;
Expand Down Expand Up @@ -223,4 +245,10 @@
}

window.__odpRenderGraph = render;

// If the Rust component already published data and called
// __odpRenderGraph() before this script finished loading, that
// earlier call was a no-op (function did not exist yet). Render
// now so the graph appears on first navigation to a project page.
if (window.__odpGraphData) render();
})();
6 changes: 3 additions & 3 deletions src/components/landing/projects_section.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,17 @@ pub fn ProjectsSection() -> impl IntoView {
<div class="flex flex-col md:flex-row gap-16 justify-start">
<ImageButton
href="/boot-firmware"
img_src="/images/patina.png"
img_src="/images/patina.webp"
alt="Boot Firmware"
/>
<ImageButton
href="/embedded-controller"
img_src="/images/ec.png"
img_src="/images/ec.webp"
alt="Embedded Controller"
/>
<ImageButton
href="/windows-ec-services"
img_src="/images/ec_services.png"
img_src="/images/ec_services.webp"
alt="EC Services"
/>
</div>
Expand Down
6 changes: 3 additions & 3 deletions src/components/projects_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub fn ProjectsComponent() -> impl IntoView {

<ProjectRow
href="/boot-firmware"
img_src="/images/patina.png"
img_src="/images/patina.webp"
alt="Boot Firmware"
title="Patina (Boot Firmware)"
tagline="Rethink your boot firmware"
Expand All @@ -53,7 +53,7 @@ pub fn ProjectsComponent() -> impl IntoView {
/>
<ProjectRow
href="/embedded-controller"
img_src="/images/ec.png"
img_src="/images/ec.webp"
alt="Embedded Controller"
title="Secure Embedded Controller"
tagline="A Secure end-to-end Rust-based EC implementation"
Expand All @@ -65,7 +65,7 @@ pub fn ProjectsComponent() -> impl IntoView {
/>
<ProjectRow
href="/windows-ec-services"
img_src="/images/ec_services.png"
img_src="/images/ec_services.webp"
alt="EC Services"
title="Unified Embedded Controller Services"
tagline="A standard and secure cross-architecture EC services implementation"
Expand Down
85 changes: 66 additions & 19 deletions src/components/repo_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,48 @@
//!
//! All heavy lifting (force simulation, zoom controls, drag handlers,
//! styles) lives in two static assets that Trunk copies next to the
//! Wasm bundle and `index.html` loads as deferred scripts:
//! Wasm bundle:
//!
//! * `public/repo_graph.js` -- defines `window.__odpRenderGraph()`.
//! * `public/repo_graph.js` -- defines `window.__odpRenderGraph()`
//! and self-loads D3 on demand.
//! * `style/repo_graph.css` -- the graph styles.
//!
//! Because both scripts are `<script defer>` they finish executing
//! before the Wasm bundle boots, so on every component mount we can
//! just publish the per-page payload as `window.__odpGraphData` and
//! call `__odpRenderGraph()`. `repo_graph.js` itself handles the rare
//! race where its `<svg>` host hasn't mounted yet by retrying on the
//! next animation frame.
//! Both assets are loaded **lazily**, only when a `RepositoryGraph`
//! component first mounts (i.e. when the user navigates to a project
//! page). This keeps the ~280 KB D3 bundle and the graph stylesheet
//! off the critical path for every other page.
//!
//! ## Why no `<script>` injection from Rust?
//! ## Load order on first mount
//!
//! Earlier versions appended d3 + `repo_graph.js` from this Effect on
//! the first mount. That introduced a load-order race: on the first
//! navigation to a project page the scripts had not finished
//! downloading by the time the data was published, so the graph
//! never appeared until the user reloaded the page (and the scripts
//! were served from cache).
//! 1. The Effect publishes per-page payload as `window.__odpGraphData`.
//! 2. The Effect calls `request_render()`. If `__odpRenderGraph` is
//! already defined (subsequent mounts), it runs immediately.
//! 3. The Effect calls `ensure_graph_assets()`, which injects
//! `<link rel="stylesheet" href="/repo_graph.css">` and
//! `<script src="/repo_graph.js">` exactly once. When the script
//! finishes loading, it self-executes `render()` because
//! `__odpGraphData` is already set (see `public/repo_graph.js`).
//! 4. `repo_graph.js` itself injects `<script src=".../d3.v7.min.js">`
//! on first render, then resolves once D3 is ready.
//!
//! Subsequent route changes just publish fresh data + call render
//! synchronously -- no more script injection or downloads.

use leptos::prelude::*;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::js_sys;

const REPO_GRAPH_SCRIPT_ID: &str = "odp-repo-graph-script";
const REPO_GRAPH_SCRIPT_SRC: &str = "/repo_graph.js";
const REPO_GRAPH_STYLE_ID: &str = "odp-repo-graph-style";
const REPO_GRAPH_STYLE_HREF: &str = "/repo_graph.css";

#[component]
pub fn RepositoryGraph(#[prop(into)] nodes: String, #[prop(into)] links: String) -> impl IntoView {
Effect::new(move |_| {
publish_graph_data(&nodes, &links);
request_render();
ensure_graph_assets();
});

view! {
Expand Down Expand Up @@ -63,10 +75,11 @@ fn publish_graph_data(nodes_json: &str, links_json: &str) {
let _ = js_sys::Reflect::set(&window, &JsValue::from_str("__odpGraphData"), &payload);
}

/// Calls `window.__odpRenderGraph()` if it is defined. The script
/// that defines it is injected via `<script defer>` in `index.html`,
/// so by the time the Wasm bundle mounts a `RepositoryGraph` it has
/// already executed.
/// Calls `window.__odpRenderGraph()` if it is defined. On the very
/// first mount the script that defines it has not been injected yet,
/// in which case this is a no-op and `repo_graph.js` will self-render
/// once it loads (it checks for `__odpGraphData` at the end of its
/// IIFE).
fn request_render() {
let Some(window) = web_sys::window() else {
return;
Expand All @@ -78,3 +91,37 @@ fn request_render() {
let _ = func.call0(&JsValue::UNDEFINED);
}
}

/// Injects `<link rel="stylesheet">` and `<script src="/repo_graph.js">`
/// into `<head>` exactly once per session. Subsequent calls are cheap
/// no-ops. Both assets are kept off the critical path so the landing
/// page (and every non-project route) never pays for them.
fn ensure_graph_assets() {
let Some(window) = web_sys::window() else {
return;
};
let Some(document) = window.document() else {
return;
};
let Some(head) = document.head() else {
return;
};

if document.get_element_by_id(REPO_GRAPH_STYLE_ID).is_none() {
if let Ok(link) = document.create_element("link") {
let _ = link.set_attribute("id", REPO_GRAPH_STYLE_ID);
let _ = link.set_attribute("rel", "stylesheet");
let _ = link.set_attribute("href", REPO_GRAPH_STYLE_HREF);
let _ = head.append_child(&link);
}
}

if document.get_element_by_id(REPO_GRAPH_SCRIPT_ID).is_none() {
if let Ok(script) = document.create_element("script") {
let _ = script.set_attribute("id", REPO_GRAPH_SCRIPT_ID);
let _ = script.set_attribute("src", REPO_GRAPH_SCRIPT_SRC);
let _ = script.set_attribute("defer", "");
let _ = head.append_child(&script);
}
}
}
Loading
Loading