diff --git a/.forge/prs/146353cc-69c7-4238-bfd3-d2adebd844f7/patch.diff b/.forge/prs/146353cc-69c7-4238-bfd3-d2adebd844f7/patch.diffnew file mode 100644index 0000000..5eef732--- /dev/null+++ b/.forge/prs/146353cc-69c7-4238-bfd3-d2adebd844f7/patch.diff@@ -0,0 +1,2322 @@1+From 8a1ebeffb2714b192636d23a3221e001aad730ea Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 17:41:42 +00004+Subject: [PATCH] review #3: harden build/serve, fix render regressions, add5+ Pages inbox (#77-#96)6+7+---8+ .forge/ci/steps/04_pages_inbox.sh | 8 +9+ .forge/wiki/deploy | 5 +10+ docs/cloudflare.md | 95 ++++++++11+ functions/inbox/[kind].js | 349 ++++++++++++++++++++++++++++++12+ src/build.rs | 304 ++++++++++++++++++++++++--13+ src/feed.rs | 53 ++++-14+ src/forge.rs | 55 ++++-15+ src/highlight.rs | 81 +++++--16+ src/layout.rs | 87 ++++++--17+ src/serve.rs | 87 +++++---18+ static/style.css | 29 ++-19+ tests/clean_out.rs | 58 +++++20+ tests/light_theme.rs | 6 +-21+ tests/pages_inbox.test.mjs | 159 ++++++++++++++22+ tests/raw_safety.rs | 6 +-23+ tests/review3_render.rs | 264 ++++++++++++++++++++++24+ tests/theme_contrast.rs | 23 ++25+ wrangler.toml | 9 +26+ 18 files changed, 1566 insertions(+), 112 deletions(-)27+ create mode 100755 .forge/ci/steps/04_pages_inbox.sh28+ create mode 100644 docs/cloudflare.md29+ create mode 100644 functions/inbox/[kind].js30+ create mode 100644 tests/pages_inbox.test.mjs31+ create mode 100644 tests/review3_render.rs32+ create mode 100644 wrangler.toml33+34+diff --git a/.forge/ci/steps/04_pages_inbox.sh b/.forge/ci/steps/04_pages_inbox.sh35+new file mode 10075536+index 0000000..bc39c5a37+--- /dev/null38++++ b/.forge/ci/steps/04_pages_inbox.sh39+@@ -0,0 +1,8 @@40++#!/bin/sh41++# Cloudflare Pages inbox parity (f0rge #79). Skips only when Node is absent.42++set -eu43++if ! command -v node >/dev/null 2>&1; then44++ echo "pages inbox: node not installed; skipping"45++ exit 046++fi47++node --test tests/pages_inbox.test.mjs48+diff --git a/.forge/wiki/deploy b/.forge/wiki/deploy49+index 3e94c60..1fd24c2 10064450+--- a/.forge/wiki/deploy51++++ b/.forge/wiki/deploy52+@@ -69,6 +69,11 @@ What happens inside (`entrypoint.sh`):53+ `dist/_headers` carries the `/raw/**` download policy for Pages. On54+ nginx, add the equivalent `add_header Content-Disposition attachment`55+ for `/raw/` — committed HTML/SVG must never render from that path.56++- Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The57++ Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and58++ secret `INBOX_SALT` implement the same contract. See59++ `docs/cloudflare.md` for the bucket, binding, secret and WAF rate-rule60++ recipe. `dist/_routes.json` limits Functions to `/inbox/*`.61+ 62+ - Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped.63+ If it ever tops 2MB stripped, the cut list is: replace `yaml_serde`64+diff --git a/docs/cloudflare.md b/docs/cloudflare.md65+new file mode 10064466+index 0000000..dedbd9d67+--- /dev/null68++++ b/docs/cloudflare.md69+@@ -0,0 +1,95 @@70++# Deploy hu13 on Cloudflare Pages (Free)71++72++The same `dist/` works on Docker/`hu13 serve`, nginx, and Pages. On73++Pages the inbox is implemented by a Pages Function backed by R2; a74++static-only upload has no working inbox.75++76++## 1. Build the site77++78++```sh79++cargo build --release80++./target/release/hu13 build . -o dist \81++ --public-origin https://hu13.example.com82++```83++84++`--public-origin` makes the clone command, `og:image`, Atom links and85++canonical URLs absolute. The build refuses output over Cloudflare's86++limits (20,000 files, 25 MiB/file) and writes `_headers` and87++`_routes.json` into `dist/`.88++89++## 2. Create the R2 bucket and Pages project90++91++```sh92++npx wrangler r2 bucket create hu13-inbox93++npx wrangler pages project create hu1394++```95++96++Bind the bucket to the Pages project as **`INBOX`** (dashboard:97++Settings → Functions → R2 bindings, or the `[[r2_buckets]]` block in98++`wrangler.toml`). Set the required secret:99++100++```sh101++npx wrangler pages secret put INBOX_SALT --project-name hu13102++```103++104++`INBOX_SALT` is a random string (for example `openssl rand -hex 32`).105++The function hashes `v1|<ip>|<yyyy-mm-dd>|<salt>` with SHA-256; no106++plaintext IP is stored. Rotating the secret unlinks old hashes.107++108++Optional variables: `INBOX_RATE_PER_HOUR` (default 2),109++`INBOX_QUOTA` (default 500), `INBOX_MAX_TITLE` (300),110++`INBOX_MAX_BODY` (20000).111++112++## 3. Deploy113++114++```sh115++npx wrangler pages deploy dist --project-name hu13116++```117++118++Wrangler uploads `functions/` alongside `dist/`. `dist/_routes.json`119++limits Functions to `/inbox/*`, so every other request is static.120++Keep `wrangler.toml` at the repo root for the R2 binding.121++122++## What the Function implements123++124++`functions/inbox/[kind].js` serves both mailboxes:125++126++- `POST /inbox/issue` (`body`) and `POST /inbox/pr-prompt` (`prompt`,127++ `body` accepted as an alias)128++- urlencoded only; 32 KiB whole-body cap; strict UTF-8; NUL → 400129++- controls stripped; title ≤300, body ≤20000, handle ≤40, contact ≤100130++- license checkbox required; filled honeypot returns a fake receipt131++ without storing132++- same-origin `Origin`/`Referer` gate; no input is echoed or logged133++- fixed ~500 ms response delay; `Cache-Control: no-store` and134++ `X-Content-Type-Options: nosniff`135++- one R2 object per accepted submission:136++ `<uuid>.json` with `type/title/body/handle/contact/license_version/137++ received_at/salted_ip_hash` (same shape as the Docker inbox)138++139++Rate limits are layered: the Function has a best-effort isolate-local140++bucket, but that is **not global**. Add a Cloudflare WAF rate-limiting141++rule for `/inbox/*` (for example 10 requests/minute per IP) as the142++durable control. The global quota is checked against the R2 bucket143++before storing; R2 list consistency means a burst can briefly exceed it.144++145++## Reading submissions146++147++```sh148++npx wrangler r2 object list hu13-inbox149++npx wrangler r2 object get hu13-inbox/<uuid>.json150++```151++152++`hu13 inbox list` reads the local filesystem inbox used by Docker/`serve`;153++it does not read R2. Copy objects down if you want the terminal reader.154++155++## Limits and caveats156++157++- Pages Free: 20,000 files and 25 MiB per file per deployment. The158++ build enforces both; large assets belong outside git or in Git LFS.159++- The function bundle must be deployed with `functions/`; uploading only160++ `dist/` (drag-and-drop or a plain static host) gives a dead inbox.161++- `repo.git` is a dumb-HTTP repo: cloning works on any static host, no162++ smart server. No GitHub dependency.163++- Committed `/raw/*` bytes always download (`Content-Disposition:164++ attachment`) on both `serve` and Pages.165+diff --git a/functions/inbox/[kind].js b/functions/inbox/[kind].js166+new file mode 100644167+index 0000000..d7592b8168+--- /dev/null169++++ b/functions/inbox/[kind].js170+@@ -0,0 +1,349 @@171++// Cloudflare Pages Functions inbox (f0rge #79).172++//173++// One dynamic route serves both mailboxes: /inbox/issue and174++// /inbox/pr-prompt. Contract parity with `hu13 serve`:175++// urlencoded only, 32 KiB cap, strict UTF-8, NUL rejected,176++// controls stripped, title/body caps, license required,177++// same-origin gate, fixed delay, ack uuid, no echo.178++// Storage is one JSON object per submission in the R2 binding INBOX.179++//180++// Bindings (Pages project settings / wrangler.toml):181++// INBOX R2 bucket182++// INBOX_SALT secret (required; SHA-256 salt for IP hashes)183++// Optional: INBOX_RATE_PER_HOUR (default 2), INBOX_QUOTA (default 500).184++185++const MAX_BYTES = 32 * 1024;186++const MAX_TITLE = 300;187++const MAX_BODY = 20000;188++const MAX_HANDLE = 40;189++const MAX_CONTACT = 100;190++const LICENSE_VERSION = "v1";191++const ACK_DELAY_MS = 500;192++193++const encoder = new TextEncoder();194++195++function esc(s) {196++ return String(s).replace(197++ /[&<>"]/g,198++ (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],199++ );200++}201++202++function page(title, body) {203++ return `<!DOCTYPE html>204++<html lang="en"><head><meta charset="utf-8">205++<meta name="viewport" content="width=device-width, initial-scale=1">206++<meta name="robots" content="noindex">207++<title>${esc(title)}</title></head>208++<body><main><h1>${esc(title)}</h1>209++${body}210++<p><a href="/">hu13</a></p></main></body></html>211++`;212++}213++214++function answer(status, body, extra = {}, delay = true) {215++ const headers = {216++ "content-type": "text/html; charset=utf-8",217++ "x-content-type-options": "nosniff",218++ "cache-control": "no-store",219++ ...extra,220++ };221++ const response = () => new Response(body, { status, headers });222++ if (!delay) return response();223++ return new Promise((resolve) =>224++ setTimeout(() => resolve(response()), ACK_DELAY_MS),225++ );226++}227++228++function errorPage(status, detail, extra = {}) {229++ const title = status === 405 ? "method not allowed" : status === 429230++ ? "too many requests"231++ : status === 503232++ ? "unavailable"233++ : status === 413234++ ? "too large"235++ : "bad request";236++ return answer(237++ status,238++ page(title, `<p>${esc(detail)}</p>`),239++ extra,240++ );241++}242++243++function ackPage(uuid) {244++ return page(245++ "received",246++ `<p>ack: <code>${esc(uuid)}</code></p>247++<p>Thank you. This identifier was shown once and is not a lookup URL.</p>`,248++ );249++}250++251++function sleep(ms) {252++ return new Promise((resolve) => setTimeout(resolve, ms));253++}254++255++// Same-origin gate: browsers send Origin/Referer; agents (curl) send256++// neither and pass. `null`/unparsable/mismatched hosts fail closed.257++function originAllowed(request) {258++ let host;259++ try {260++ host = new URL(request.url).host.toLowerCase();261++ } catch {262++ return false;263++ }264++ for (const name of ["origin", "referer"]) {265++ const value = request.headers.get(name);266++ if (value === null) continue;267++ if (!value || value.trim().toLowerCase() === "null") return false;268++ try {269++ if (new URL(value).host.toLowerCase() !== host) return false;270++ } catch {271++ return false;272++ }273++ }274++ return true;275++}276++277++function hexByte(b) {278++ if (b >= 0x30 && b <= 0x39) return b - 0x30;279++ if (b >= 0x61 && b <= 0x66) return b - 0x61 + 10;280++ if (b >= 0x41 && b <= 0x46) return b - 0x41 + 10;281++ return -1;282++}283++284++// Strict percent-decoder matching hu13's `parse_form`: malformed `%`,285++// non-UTF-8, or a bad escape returns null (400) instead of guessing.286++function decodeComponent(input) {287++ const raw = encoder.encode(input);288++ const out = [];289++ let i = 0;290++ while (i < raw.length) {291++ let b = raw[i++];292++ if (b === 0x25) {293++ if (i + 1 >= raw.length) return null;294++ const hi = hexByte(raw[i++]);295++ const lo = hexByte(raw[i++]);296++ if (hi < 0 || lo < 0) return null;297++ b = (hi << 4) | lo;298++ } else if (b === 0x2b) {299++ b = 0x20;300++ }301++ out.push(b);302++ }303++ try {304++ return new TextDecoder("utf-8", { fatal: true }).decode(305++ Uint8Array.from(out),306++ );307++ } catch {308++ return null;309++ }310++}311++312++function parseForm(body) {313++ if (body === "") return [];314++ const pairs = [];315++ for (const part of body.split("&")) {316++ const eq = part.indexOf("=");317++ const key = eq < 0 ? part : part.slice(0, eq);318++ const value = eq < 0 ? "" : part.slice(eq + 1);319++ const k = decodeComponent(key);320++ const v = decodeComponent(value);321++ if (k === null || v === null) return null;322++ pairs.push([k, v]);323++ }324++ return pairs;325++}326++327++function field(pairs, name) {328++ const hit = pairs.find(([k]) => k === name);329++ return hit ? hit[1] : undefined;330++}331++332++function stripControls(s) {333++ return [...s]334++ .filter((c) => c === "\n" || c === "\t" || !/\p{Cc}/u.test(c))335++ .join("");336++}337++338++function licenseChecked(value) {339++ if (value === undefined) return false;340++ return ["on", "agree", "yes", "true", "1", "checked", "accept"].includes(341++ value.trim().toLowerCase(),342++ );343++}344++345++function validate(kind, pairs, maxTitle = MAX_TITLE, maxBody = MAX_BODY) {346++ if (347++ (field(pairs, "website") || "") !== "" ||348++ (field(pairs, "honeypot") || "") !== ""349++ ) {350++ return { kind: "honeypot" };351++ }352++ for (const [k, v] of pairs) {353++ if (k.includes("\0") || v.includes("\0")) return { kind: "reject" };354++ }355++ const rawTitle = field(pairs, "title") || "";356++ const bodyName = kind === "pr-prompt" ? "prompt" : "body";357++ let rawBody = field(pairs, bodyName);358++ if (rawBody === undefined && kind === "pr-prompt") {359++ rawBody = field(pairs, "body");360++ }361++ rawBody = rawBody || "";362++ const title = stripControls(rawTitle);363++ const body = stripControls(rawBody);364++ const handle = stripControls(field(pairs, "handle") || "");365++ const contact = stripControls(field(pairs, "contact") || "");366++ if (title.trim() === "" || body.trim() === "") return { kind: "reject" };367++ if (368++ [...title].length > maxTitle ||369++ [...body].length > maxBody ||370++ [...handle].length > MAX_HANDLE ||371++ [...contact].length > MAX_CONTACT372++ ) {373++ return { kind: "reject" };374++ }375++ if (!licenseChecked(field(pairs, "license"))) return { kind: "reject" };376++ return { kind: "accept", title, body, handle, contact };377++}378++379++async function readCapped(request) {380++ const declared = request.headers.get("content-length");381++ if (declared !== null) {382++ if (!/^\d+$/.test(declared.trim())) return { error: 400 };383++ if (Number(declared) > MAX_BYTES) return { error: 413 };384++ }385++ if (!request.body) return { text: "" };386++ const reader = request.body.getReader();387++ const chunks = [];388++ let total = 0;389++ for (;;) {390++ const { done, value } = await reader.read();391++ if (done) break;392++ total += value.byteLength;393++ if (total > MAX_BYTES) {394++ await reader.cancel();395++ return { error: 413 };396++ }397++ chunks.push(value);398++ }399++ const merged = new Uint8Array(total);400++ let at = 0;401++ for (const chunk of chunks) {402++ merged.set(chunk, at);403++ at += chunk.byteLength;404++ }405++ try {406++ return { text: new TextDecoder("utf-8", { fatal: true }).decode(merged) };407++ } catch {408++ return { error: 400 };409++ }410++}411++412++async function saltedIpHash(ip, salt) {413++ const date = new Date().toISOString().slice(0, 10);414++ const bytes = encoder.encode(`v1|${ip}|${date}|${salt}`);415++ const digest = await crypto.subtle.digest("SHA-256", bytes);416++ return [...new Uint8Array(digest)]417++ .map((b) => b.toString(16).padStart(2, "0"))418++ .join("");419++}420++421++// Best-effort isolate-local limiter. Cloudflare's edge rate-limiting422++// rule for /inbox/* is the durable control; this still stops a single423++// hot isolate from storing unlimited submissions.424++const buckets = new Map();425++function rateAllowed(key, limit) {426++ const now = Date.now();427++ if (buckets.size > 10000) {428++ for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k);429++ }430++ let b = buckets.get(key);431++ if (!b || b.reset <= now) {432++ b = { count: 0, reset: now + 3600_000 };433++ buckets.set(key, b);434++ }435++ if (b.count >= limit) return false;436++ b.count += 1;437++ return true;438++}439++440++async function overQuota(bucket, limit) {441++ const listed = await bucket.list({ limit: Math.min(limit, 1000) });442++ return listed.objects.length >= limit;443++}444++445++function nowRfc3339() {446++ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");447++}448++449++async function handlePost(context) {450++ const request = context.request;451++ const rawKind = context.params ? context.params.kind : "";452++ const kind = Array.isArray(rawKind) ? rawKind[0] : rawKind;453++ if (kind !== "issue" && kind !== "pr-prompt") {454++ return errorPage(404, "Nothing here.", {}, false);455++ }456++ if (!originAllowed(request)) {457++ return errorPage(403, "Bad origin.");458++ }459++ const ctype = (request.headers.get("content-type") || "").toLowerCase();460++ if (!ctype.startsWith("application/x-www-form-urlencoded")) {461++ return errorPage(400, "This endpoint accepts urlencoded forms only.");462++ }463++ const read = await readCapped(request);464++ if (read.error) {465++ return errorPage(read.error, read.error === 413 ? "Submission too large." : "Bad input.");466++ }467++ if (read.text.includes("\0")) return errorPage(400, "Bad input.");468++ const pairs = parseForm(read.text);469++ if (pairs === null) return errorPage(400, "Bad input.");470++ const checked = validate(kind, pairs,471++ context.env.INBOX_MAX_TITLE ? Number(context.env.INBOX_MAX_TITLE) : MAX_TITLE,472++ context.env.INBOX_MAX_BODY ? Number(context.env.INBOX_MAX_BODY) : MAX_BODY);473++ if (checked.kind === "honeypot") {474++ return answer(200, ackPage(crypto.randomUUID()));475++ }476++ if (checked.kind === "reject") return errorPage(400, "Bad input.");477++478++ const env = context.env || {};479++ if (!env.INBOX) return errorPage(503, "Could not store that. Try later.");480++ if (!env.INBOX_SALT) return errorPage(503, "Could not store that. Try later.");481++ const ip = request.headers.get("cf-connecting-ip") || "unknown";482++ const ipHash = await saltedIpHash(ip, env.INBOX_SALT);483++ const rate = env.INBOX_RATE_PER_HOUR ? Number(env.INBOX_RATE_PER_HOUR) : 2;484++ if (!rateAllowed(ipHash, rate)) return errorPage(429, "Slow down.");485++ const quota = env.INBOX_QUOTA ? Number(env.INBOX_QUOTA) : 500;486++ try {487++ if (await overQuota(env.INBOX, quota)) {488++ return errorPage(503, "Inbox full. Try later.");489++ }490++ const uuid = crypto.randomUUID();491++ const doc = {492++ type: kind,493++ title: checked.title,494++ body: checked.body,495++ handle: checked.handle,496++ contact: checked.contact,497++ license_version: LICENSE_VERSION,498++ received_at: nowRfc3339(),499++ salted_ip_hash: ipHash,500++ };501++ await env.INBOX.put(`${uuid}.json`, JSON.stringify(doc) + "\n");502++ return answer(200, ackPage(uuid));503++ } catch {504++ return errorPage(503, "Could not store that. Try later.");505++ }506++}507++508++export async function onRequestGet() {509++ return errorPage(405, "This endpoint accepts POST only.", { allow: "POST" }, false);510++}511++512++export async function onRequestPost(context) {513++ try {514++ return await handlePost(context);515++ } catch {516++ // Never echo or log submission text.517++ return errorPage(503, "Could not store that. Try later.");518++ }519++}520+diff --git a/src/build.rs b/src/build.rs521+index d6e3420..532802d 100644522+--- a/src/build.rs523++++ b/src/build.rs524+@@ -353,6 +353,12 @@ pub const MAX_INLINE_BLOB: u64 = 8 * 1024 * 1024;525+ /// tried). Prevents a huge README from bloating every page's preview.526+ pub const README_MAX_BYTES: u64 = 1024 * 1024;527+ 528++/// Cloudflare Pages Free deployment limits (f0rge #81). `raw/` mirrors529++/// every blob, so an ordinary asset-heavy repo can exceed these even530++/// when the build itself succeeds.531++pub const PAGES_MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;532++pub const PAGES_MAX_FILES: usize = 20_000;533++534+ /// Bytes sniffed for the language-math binary check (first 8KiB).535+ pub const BINARY_SNIFF_BYTES: usize = 8 * 1024;536+ 537+@@ -404,8 +410,25 @@ pub fn is_markdown(path: &str) -> bool {538+ lower.ends_with(".md") || lower.ends_with(".markdown")539+ }540+ 541++/// Percent-encode a repo path for a URL, then HTML-escape it. Git542++/// allows `#`, `?`, `%`, spaces and quotes in names; without this the543++/// browser truncates a link at `#`/`?` (f0rge #89). `/` stays literal544++/// so file paths remain hierarchical; everything outside the unreserved545++/// set becomes `%XX` of its UTF-8 bytes.546++fn encode_path(path: &str) -> String {547++ let mut out = String::with_capacity(path.len());548++ for &b in path.as_bytes() {549++ if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {550++ out.push(char::from(b));551++ } else {552++ out.push_str(&format!("%{b:02X}"));553++ }554++ }555++ out556++}557++558+ fn href_esc(path: &str) -> String {559+- esc(path)560++ esc(&encode_path(path))561+ }562+ 563+ /// Humanized byte size (`24 B`, `2.0 KiB`, `3.0 MiB`) for meta bars564+@@ -534,7 +557,20 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {565+ let y: i64 = d.next()?.parse().ok()?;566+ let mo: i64 = d.next()?.parse().ok()?;567+ let da: i64 = d.next()?.parse().ok()?;568+- if !(1..=12).contains(&mo) || !(1..=31).contains(&da) {569++ // Keep the civil-date math overflow-free and refuse dates that never570++ // existed (Feb 31 used to normalize to Mar 3; huge years panicked).571++ if !(1..=9999).contains(&y) || !(1..=12).contains(&mo) {572++ return None;573++ }574++ let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;575++ let max_day = match mo {576++ 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,577++ 4 | 6 | 9 | 11 => 30,578++ 2 if leap => 29,579++ 2 => 28,580++ _ => return None,581++ };582++ if !(1..=max_day).contains(&da) {583+ return None;584+ }585+ let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') {586+@@ -550,6 +586,9 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {587+ };588+ let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?;589+ let om: i64 = rest[rest.len() - 2..].parse().ok()?;590++ if oh > 23 || om > 59 {591++ return None;592++ }593+ (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60))594+ } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') {595+ let sign = if rest.as_bytes()[rest.len() - 5] == b'-' {596+@@ -559,6 +598,9 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {597+ };598+ let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?;599+ let om: i64 = rest[rest.len() - 2..].parse().ok()?;600++ if oh > 23 || om > 59 {601++ return None;602++ }603+ (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60))604+ } else {605+ (rest, 0)606+@@ -1019,13 +1061,18 @@ pub fn clone_block(607+ /// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container608+ /// so long lines never break the 360px layout.609+ fn render_text_pre(display: &str, path: &str) -> String {610++ // Normalize line endings and drop exactly one trailing terminator:611++ // `split('\n')` used to turn a final newline into an empty L404 and612++ // kept `\r` in CRLF files (f0rge #88).613++ let normalized = display.replace("\r\n", "\n").replace('\r', "\n");614++ let text = normalized.strip_suffix('\n').unwrap_or(&normalized);615+ let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");616+- if display.is_empty() {617++ if text.is_empty() {618+ out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>");619+ } else {620+ // Build-time tokens (f0rge #73): unknown/huge files stay plain.621+- let highlighted = crate::highlight::highlight_lines(path, display);622+- for (i, line) in display.split('\n').enumerate() {623++ let highlighted = crate::highlight::highlight_lines(path, text);624++ for (i, line) in text.split('\n').enumerate() {625+ let n = i + 1;626+ out.push_str("<span class=\"line\" id=\"L");627+ out.push_str(&n.to_string());628+@@ -1076,7 +1123,7 @@ fn nav_bar(629+ tabs.push("<a href=\"/wiki/index.html\">wiki</a>".to_string());630+ }631+ tabs.push("<a href=\"/agents.html\">agents</a>".to_string());632+- tabs.join(" · ")633++ tabs.join("<span class=\"nav-sep\" aria-hidden=\"true\">·</span>")634+ }635+ 636+ /// Write `out/style.css` from the embedded static file.637+@@ -1088,6 +1135,12 @@ fn write_style(out: &Path) -> Result<(), String> {638+ /// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest`639+ /// from the embedded static files (m6 favicon #16; logo-text #27 + #28).640+ /// SVG-first, zero binary blobs: the header lockup is `logo.svg`641++/// Pages Functions routing: only `/inbox/*` runs a Function; every642++/// other path stays a static asset. Must live in the build output, not643++/// the repo root (f0rge #79).644++pub const ROUTES_FILE: &str =645++ "{\n \"version\": 1,\n \"include\": [\"/inbox/*\"],\n \"exclude\": []\n}\n";646++647+ /// Header rules for Cloudflare Pages (and any host that reads `_headers`).648+ ///649+ /// `/raw/**` holds committed, untrusted bytes: a repo can contain650+@@ -1120,11 +1173,12 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {651+ .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;652+ std::fs::write(out.join("_headers"), HEADERS_FILE)653+ .map_err(|e| format!("hu13: write _headers: {e}"))?;654++ std::fs::write(out.join("_routes.json"), ROUTES_FILE)655++ .map_err(|e| format!("hu13: write _routes.json: {e}"))?;656+ Ok(())657+ }658+ 659+-/// Absolute lexical form of `p` (no symlink resolution beyond what the660+-/// OS already did for existing components).661++/// Absolute form of `p` (join cwd for relative paths, no symlink work).662+ fn absolute(p: &Path) -> std::path::PathBuf {663+ if p.is_absolute() {664+ p.to_path_buf()665+@@ -1135,15 +1189,81 @@ fn absolute(p: &Path) -> std::path::PathBuf {666+ }667+ }668+ 669++/// Absolute lexical form of `p` with `.`/`..` collapsed. Does not670++/// touch the filesystem (unlike canonicalize), so it also works for an671++/// output path that does not exist yet.672++fn normalize_lexical(p: &Path) -> std::path::PathBuf {673++ use std::path::Component;674++ let mut out = std::path::PathBuf::new();675++ for c in p.components() {676++ match c {677++ Component::CurDir => {}678++ Component::ParentDir => {679++ out.pop();680++ }681++ Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {682++ out.push(c.as_os_str());683++ }684++ }685++ }686++ out687++}688++689++/// Resolve symlinks in the deepest existing ancestor, then append the690++/// not-yet-created suffix. This catches `-o alias/..` where `alias` is a691++/// symlink into the repo (f0rge #77).692++fn resolve_existing(p: &Path) -> std::path::PathBuf {693++ let abs = normalize_lexical(&absolute(p));694++ let mut suffix: Vec<std::ffi::OsString> = Vec::new();695++ let mut cur = abs.as_path();696++ loop {697++ if let Ok(mut resolved) = cur.canonicalize() {698++ for part in suffix.iter().rev() {699++ resolved.push(part);700++ }701++ return normalize_lexical(&resolved);702++ }703++ match cur.file_name() {704++ Some(name) => {705++ suffix.push(name.to_os_string());706++ match cur.parent() {707++ Some(parent) => cur = parent,708++ None => return abs,709++ }710++ }711++ None => return abs,712++ }713++ }714++}715++716++/// `true` when git tracks at least one path under repo-relative `rel`.717++fn has_tracked_under(repo: &Path, rel: &Path) -> bool {718++ if rel.as_os_str().is_empty() {719++ return true;720++ }721++ std::process::Command::new("git")722++ .arg("-C")723++ .arg(repo)724++ .arg("ls-files")725++ .arg("-z")726++ .arg("--")727++ .arg(rel)728++ .output()729++ .map(|o| o.status.success() && !o.stdout.is_empty())730++ .unwrap_or(true)731++}732++733+ /// Remove everything already in `out/` before a build (f0rge #67).734+ ///735+ /// Without this, pages for deleted issues/files keep being served from a736+ /// reused output dir. Refuses to delete the repo itself or anything that737+-/// contains it (`-o ..`, `-o .`), and handles a symlinked output dir738+-/// without following it.739++/// contains it (`-o ..`, `-o .`), resolves `.`/`..` and symlinked740++/// ancestors before comparing, and refuses an in-repo output that holds741++/// tracked files (f0rge #77).742+ fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {743+- let out_abs = absolute(out);744+- let repo_abs = repo.canonicalize().unwrap_or_else(|_| absolute(repo));745++ let out_path = normalize_lexical(&absolute(out));746++ let out_abs = resolve_existing(&out_path);747++ let repo_abs = resolve_existing(repo);748+ if out_abs == repo_abs || repo_abs.starts_with(&out_abs) {749+ return Err(format!(750+ "hu13: refusing to clean output dir {}: it contains the repo",751+@@ -1153,25 +1273,114 @@ fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {752+ if out_abs.parent().is_none() {753+ return Err("hu13: refusing to use the filesystem root as output dir".to_string());754+ }755+- match std::fs::symlink_metadata(&out_abs) {756++ if let Ok(rel) = out_abs.strip_prefix(&repo_abs) {757++ if rel758++ .components()759++ .next()760++ .is_some_and(|c| c.as_os_str() == ".git")761++ || has_tracked_under(&repo_abs, rel)762++ {763++ return Err(format!(764++ "hu13: refusing to clean in-repo output dir {}: it holds tracked files",765++ out.display()766++ ));767++ }768++ }769++ match std::fs::symlink_metadata(&out_path) {770+ Ok(meta) if meta.file_type().is_symlink() => {771+- std::fs::remove_file(&out_abs)772++ std::fs::remove_file(&out_path)773+ .map_err(|e| format!("hu13: remove symlinked out dir {}: {e}", out.display()))?;774+ }775+ Ok(meta) if meta.is_dir() => {776+- std::fs::remove_dir_all(&out_abs)777++ std::fs::remove_dir_all(&out_path)778+ .map_err(|e| format!("hu13: clean output dir {}: {e}", out.display()))?;779+ }780+ Ok(_) => {781+- std::fs::remove_file(&out_abs)782++ std::fs::remove_file(&out_path)783+ .map_err(|e| format!("hu13: remove output file {}: {e}", out.display()))?;784+ }785+ Err(_) => {}786+ }787+- std::fs::create_dir_all(&out_abs)788++ std::fs::create_dir_all(&out_path)789+ .map_err(|e| format!("hu13: create output dir {}: {e}", out.display()))790+ }791+ 792++/// Make `og:image` absolute on every built page when a public origin is793++/// configured (f0rge #95). Done once over the output tree because the794++/// origin is build config, not page content: all pages share the same795++/// `content="/og.png"` marker.796++fn absolutize_social_meta(out: &Path, origin: &str) -> Result<(), String> {797++ let base = origin.trim_end_matches('/');798++ if base.is_empty() {799++ return Ok(());800++ }801++ let absolute = format!("content=\"{}/og.png\"", esc(base));802++ let mut stack = vec![out.to_path_buf()];803++ while let Some(dir) = stack.pop() {804++ let rd = std::fs::read_dir(&dir)805++ .map_err(|e| format!("hu13: social meta read {}: {e}", dir.display()))?;806++ for ent in rd {807++ let ent = ent.map_err(|e| format!("hu13: social meta entry: {e}"))?;808++ let path = ent.path();809++ if path.is_dir() {810++ stack.push(path);811++ } else if path.extension().is_some_and(|x| x == "html") {812++ let html = std::fs::read_to_string(&path)813++ .map_err(|e| format!("hu13: social meta read {}: {e}", path.display()))?;814++ let replaced = html.replace("content=\"/og.png\"", &absolute);815++ if replaced != html {816++ std::fs::write(&path, replaced)817++ .map_err(|e| format!("hu13: social meta write {}: {e}", path.display()))?;818++ }819++ }820++ }821++ }822++ Ok(())823++}824++825++/// Check an output tree against deployment limits. Returns the first826++/// offending file (or the file-count overflow) with enough context to827++/// fix the repo. Kept separate from the walk so tests can use tiny caps.828++pub fn check_output_budget(829++ out: &Path,830++ max_file_bytes: u64,831++ max_files: usize,832++) -> Result<(), String> {833++ let mut stack = vec![out.to_path_buf()];834++ let mut files = 0usize;835++ while let Some(dir) = stack.pop() {836++ let rd = std::fs::read_dir(&dir)837++ .map_err(|e| format!("hu13: output budget read {}: {e}", dir.display()))?;838++ for ent in rd {839++ let ent = ent.map_err(|e| format!("hu13: output budget entry: {e}"))?;840++ let path = ent.path();841++ let meta = ent842++ .metadata()843++ .map_err(|e| format!("hu13: output budget stat {}: {e}", path.display()))?;844++ if meta.is_dir() {845++ stack.push(path);846++ } else if meta.is_file() {847++ files += 1;848++ if files > max_files {849++ return Err(format!(850++ "hu13: output has more than {max_files} files (Cloudflare Pages limit); last: {}",851++ path.display()852++ ));853++ }854++ if meta.len() > max_file_bytes {855++ return Err(format!(856++ "hu13: output file {} is {} bytes, over the Cloudflare Pages limit of {} bytes; keep large assets out of git or use Git LFS",857++ path.display(),858++ meta.len(),859++ max_file_bytes860++ ));861++ }862++ }863++ }864++ }865++ Ok(())866++}867++868+ /// Build the full static site. Returns a summary on success.869+ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {870+ let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())871+@@ -1308,7 +1517,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu872+ format!(".. ({parent})")873+ };874+ let mut body = breadcrumb(dir);875+- body.push_str(&format!("<h1>dir: {}</h1>\n", esc(dir),));876++ body.push_str(&format!("<h1>{}/</h1>\n", esc(dir)));877+ body.push_str(&file_table_html(878+ dir,879+ &entries,880+@@ -1407,7 +1616,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu881+ &short_sha,882+ desc_opt,883+ )?;884+- feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;885++ feed::render_loaded_with_origin(886++ repo,887++ out,888++ &branch,889++ &name,890++ &commit_log,891++ desc_opt,892++ public_origin.as_deref(),893++ )?;894+ let wiki_pages = wiki::render(repo, out, &name, &nav, &branch, &short_sha, desc_opt)?;895+ // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.896+ // Tasks/releases/wiki indexes always render even when empty (m6897+@@ -1511,6 +1728,28 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu898+ std::fs::write(out.join("index.html"), index)899+ .map_err(|e| format!("hu13: write index.html: {e}"))?;900+ 901++ // Branded 404 for Pages/nginx; `serve` streams this file with status902++ // 404 on unknown paths (f0rge #85). Same nav/footer as every page.903++ let not_found = crate::layout::page_site_with_provenance(904++ &format!("not found — {name}"),905++ &nav,906++ "<h1>404</h1>\n<p>Nothing here.</p>\n<p><a href=\"/\">Back to code</a></p>\n",907++ &name,908++ desc_opt,909++ &branch,910++ &short_sha,911++ );912++ std::fs::write(out.join("404.html"), not_found)913++ .map_err(|e| format!("hu13: write 404.html: {e}"))?;914++915++ if let Some(origin) = public_origin.as_deref() {916++ absolutize_social_meta(out, origin)?;917++ }918++919++ // Fail loudly here instead of letting Pages reject an opaque deploy920++ // (f0rge #81). Counts the whole emitted tree, including repo.git.921++ check_output_budget(out, PAGES_MAX_FILE_BYTES, PAGES_MAX_FILES)?;922++923+ Ok(BuildSummary {924+ branch,925+ short_sha,926+@@ -2235,6 +2474,33 @@ mod tests {927+ assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC");928+ }929+ 930++ #[test]931++ fn output_budget_reports_file_size_and_count() {932++ let dir = mktemp("budget");933++ std::fs::write(dir.join("small.txt"), "ok").unwrap();934++ std::fs::create_dir_all(dir.join("nested")).unwrap();935++ std::fs::write(dir.join("nested/big.bin"), vec![0u8; 64]).unwrap();936++ assert!(check_output_budget(&dir, 32, 10).is_err(), "size cap");937++ assert!(check_output_budget(&dir, 1024, 1).is_err(), "count cap");938++ assert!(check_output_budget(&dir, 1024, 2).is_ok(), "within caps");939++ std::fs::remove_dir_all(&dir).unwrap();940++ }941++942++ #[test]943++ fn hostile_dates_are_rejected() {944++ // Hostile `.forge` timestamps must not panic (f0rge #82).945++ assert!(parse_ts_secs("100000000000000000-01-01T00:00:00Z").is_none());946++ assert!(parse_ts_secs("-9223372036854775808-01-01T00:00:00Z").is_none());947++ assert!(parse_ts_secs("0000-01-01T00:00:00Z").is_none());948++ assert!(parse_ts_secs("10000-01-01T00:00:00Z").is_none());949++ assert!(parse_ts_secs("2026-02-31T00:00:00Z").is_none());950++ assert!(parse_ts_secs("2026-04-31T00:00:00Z").is_none());951++ assert!(parse_ts_secs("2026-02-29T00:00:00Z").is_none());952++ assert!(parse_ts_secs("2024-02-29T00:00:00Z").is_some());953++ assert!(parse_ts_secs("2026-01-01T00:00:00+2500").is_none());954++ assert!(parse_ts_secs("2026-01-01T00:00:00+0060").is_none());955++ }956++957+ #[test]958+ fn relative_age_fixed_clock() {959+ // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.960+diff --git a/src/feed.rs b/src/feed.rs961+index c6b99b6..24be85d 100644962+--- a/src/feed.rs963++++ b/src/feed.rs964+@@ -143,12 +143,31 @@ pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result<Vec<F965+ /// when present, becomes the feed `<subtitle>` (escaped; hostile markup966+ /// renders as text). `None` omits `<subtitle>` entirely.967+ #[must_use]968++#[allow(dead_code)]969+ pub fn render_feed(970+ site_name: &str,971+ branch: &str,972+ entries: &[FeedEntry],973+ description: Option<&str>,974+ ) -> String {975++ render_feed_with_origin(site_name, branch, entries, description, None)976++}977++978++/// [`render_feed`] with absolute entry links (f0rge #96): Atom readers979++/// disagree on relative IRIs, so prefix `/...` links with the public980++/// origin when one is configured. Already-absolute links stay untouched.981++#[must_use]982++pub fn render_feed_with_origin(983++ site_name: &str,984++ branch: &str,985++ entries: &[FeedEntry],986++ description: Option<&str>,987++ public_origin: Option<&str>,988++) -> String {989++ let origin = public_origin990++ .map(str::trim)991++ .map(|s| s.trim_end_matches('/'))992++ .filter(|s| !s.is_empty());993+ let updated = entries994+ .first()995+ .map_or_else(|| "1970-01-01T00:00:00Z".to_string(), |e| esc(&e.updated));996+@@ -161,12 +180,22 @@ pub fn render_feed(997+ out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));998+ out.push_str(&format!("<updated>{updated}</updated>\n"));999+ out.push_str("<author><name>hu13</name></author>\n");1000++ if let Some(base) = origin {1001++ out.push_str(&format!(1002++ "<link rel=\"self\" href=\"{}/feed.atom\"/>\n",1003++ esc(base)1004++ ));1005++ }1006+ for e in entries {1007+ out.push_str("<entry>\n");1008+ out.push_str(&format!("<title>{}</title>\n", esc(&e.title)));1009+ out.push_str(&format!("<id>{}</id>\n", esc(&e.id)));1010+ out.push_str(&format!("<updated>{}</updated>\n", esc(&e.updated)));1011+- out.push_str(&format!("<link href=\"{}\"/>\n", esc(&e.link)));1012++ let link = match origin {1013++ Some(base) if e.link.starts_with('/') => format!("{base}{}", e.link),1014++ _ => e.link.clone(),1015++ };1016++ out.push_str(&format!("<link href=\"{}\"/>\n", esc(&link)));1017+ out.push_str(&format!("<summary>{}</summary>\n", esc(&e.summary)));1018+ out.push_str("</entry>\n");1019+ }1020+@@ -185,10 +214,11 @@ pub fn render(1021+ description: Option<&str>,1022+ ) -> Result<usize, String> {1023+ let entries = collect_entries(repo, branch)?;1024+- render_entries(out, site_name, branch, &entries, description)1025++ render_entries(out, site_name, branch, &entries, description, None)1026+ }1027+ 1028+ /// Build `feed.atom` from already-loaded commits (one struct pass).1029++#[allow(dead_code)]1030+ pub fn render_loaded(1031+ repo: &Path,1032+ out: &Path,1033+@@ -196,9 +226,23 @@ pub fn render_loaded(1034+ site_name: &str,1035+ log: &[gitx::Commit],1036+ description: Option<&str>,1037++) -> Result<usize, String> {1038++ render_loaded_with_origin(repo, out, branch, site_name, log, description, None)1039++}1040++1041++/// [`render_loaded`] with absolute links for a configured public origin.1042++#[allow(clippy::too_many_arguments)]1043++pub fn render_loaded_with_origin(1044++ repo: &Path,1045++ out: &Path,1046++ branch: &str,1047++ site_name: &str,1048++ log: &[gitx::Commit],1049++ description: Option<&str>,1050++ public_origin: Option<&str>,1051+ ) -> Result<usize, String> {1052+ let entries = collect_entries_loaded(repo, log)?;1053+- render_entries(out, site_name, branch, &entries, description)1054++ render_entries(out, site_name, branch, &entries, description, public_origin)1055+ }1056+ 1057+ fn render_entries(1058+@@ -207,8 +251,9 @@ fn render_entries(1059+ branch: &str,1060+ entries: &[FeedEntry],1061+ description: Option<&str>,1062++ public_origin: Option<&str>,1063+ ) -> Result<usize, String> {1064+- let doc = render_feed(site_name, branch, entries, description);1065++ let doc = render_feed_with_origin(site_name, branch, entries, description, public_origin);1066+ std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;1067+ std::fs::write(out.join("feed.atom"), doc)1068+ .map_err(|e| format!("hu13: write feed.atom: {e}"))?;1069+diff --git a/src/forge.rs b/src/forge.rs1070+index a9ada13..c9f7ce6 1006441071+--- a/src/forge.rs1072++++ b/src/forge.rs1073+@@ -765,6 +765,26 @@ fn load_milestones(root: &Path) -> Vec<Milestone> {1074+ }1075+ }1076+ 1077++/// Remove builder-local absolute paths from published CI logs (f0rge1078++/// #94): cargo writes `Checking hu13 (/home/alice/.../hu13)`. Replace1079++/// the canonical repo root with `<repo>`, then `$HOME` with `~`.1080++fn redact_paths(text: &str, root: &Path) -> String {1081++ let mut out = text.to_string();1082++ let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());1083++ for p in [root_abs.as_path(), root] {1084++ let needle = p.to_string_lossy();1085++ if !needle.is_empty() && needle != "/" {1086++ out = out.replace(needle.as_ref(), "<repo>");1087++ }1088++ }1089++ if let Ok(home) = std::env::var("HOME") {1090++ if !home.is_empty() && home != "/" {1091++ out = out.replace(&home, "~");1092++ }1093++ }1094++ out1095++}1096++1097+ fn truncate_log(text: &str) -> String {1098+ if text.len() <= LOG_PREVIEW_MAX {1099+ return text.to_string();1100+@@ -778,6 +798,8 @@ fn truncate_log(text: &str) -> String {1101+ 1102+ fn load_ci(root: &Path) -> CiInfo {1103+ let dir = root.join("ci");1104++ // `root` is `<repo>/.forge`; logs name the repo checkout itself.1105++ let repo_root = root.parent().unwrap_or(root);1106+ let runner = if dir.join("ci.env").exists() {1107+ match read_text_capped(&dir.join("ci.env"), "ci/ci.env") {1108+ Some(text) => match yaml_serde::from_str::<CiEnvFm>(&text) {1109+@@ -817,7 +839,8 @@ fn load_ci(root: &Path) -> CiInfo {1110+ }1111+ let bytes = std::fs::read(dir.join("runs").join(&ts).join("steps").join(&name))1112+ .unwrap_or_default();1113+- logs.push((name, truncate_log(&String::from_utf8_lossy(&bytes))));1114++ let text = String::from_utf8_lossy(&bytes);1115++ logs.push((name, truncate_log(&redact_paths(&text, repo_root))));1116+ }1117+ Some(CiRun {1118+ run: fm.run,1119+@@ -832,7 +855,7 @@ fn load_ci(root: &Path) -> CiInfo {1120+ duration_ms: s.duration_ms,1121+ })1122+ .collect(),1123+- body: body.to_string(),1124++ body: redact_paths(body, repo_root),1125+ logs,1126+ })1127+ });1128+@@ -1142,14 +1165,19 @@ fn render_issues_index(1129+ // non-open (newest-first within). Numbers stay creation-rank.1130+ // m6/cards2 (#46 + #47): title-first column order.1131+ for issue in data.issues_in_display_order() {1132++ let state_plain = esc(&issue.state);1133++ let state_html = state_pill(&issue.state);1134++ let created_plain = time_ago(&issue.created, now);1135+ body.push_str(&format!(1136+- "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state}\">{state}</td><td class=\"trunc\" data-label=\"comments\" title=\"{comments}\">{comments}</td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created}</td></tr>\n",1137++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state_plain}\">{state_html}</td><td class=\"trunc\" data-label=\"comments\" title=\"{comments}\">{comments}</td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created_html}</td></tr>\n",1138+ n = issue.number,1139+ id = esc(&issue.id),1140+ title = esc(&issue.title),1141+- state = state_pill(&issue.state),1142++ state_plain = state_plain,1143++ state_html = state_html,1144+ comments = issue.comments.len(),1145+- created = time_ago(&issue.created, now),1146++ created = esc(&created_plain),1147++ created_html = created_plain,1148+ ));1149+ }1150+ body.push_str("</tbody>\n</table></div>\n");1151+@@ -1207,19 +1235,28 @@ fn render_pulls_index(1152+ // Numbers stay creation-rank.1153+ // m6/cards2 (#46 + #47): title-first column order.1154+ for pr in data.prs_in_display_order() {1155+- let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));1156++ let lanes = format!(1157++ "<span class=\"lane\">{}</span><span class=\"lane-arrow\" aria-hidden=\"true\">→</span><span class=\"lane\">{}</span>",1158++ esc(&pr.head),1159++ esc(&pr.base)1160++ );1161+ let lanes_plain = format!("{}→{}", pr.head, pr.base);1162+ let draft = if pr.draft { " draft" } else { "" };1163+ let full_title = format!("{}{}", pr.title, draft);1164++ let state_plain = esc(&pr.state);1165++ let state_html = state_pill(&pr.state);1166++ let created_plain = time_ago(&pr.created, now);1167+ body.push_str(&format!(1168+- "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state}\">{state}</td><td class=\"trunc\" data-label=\"head→base\" title=\"{lanes_title}\"><code>{lanes}</code></td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created}</td></tr>\n",1169++ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state_plain}\">{state_html}</td><td class=\"trunc\" data-label=\"head→base\" title=\"{lanes_title}\"><code class=\"lanes\">{lanes}</code></td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created_html}</td></tr>\n",1170+ n = pr.number,1171+ id = esc(&pr.id),1172+ title = esc(&pr.title),1173+ full = esc(&full_title),1174+- state = state_pill(&pr.state),1175++ state_plain = state_plain,1176++ state_html = state_html,1177+ lanes_title = esc(&lanes_plain),1178+- created = time_ago(&pr.created, now),1179++ created = esc(&created_plain),1180++ created_html = created_plain,1181+ ));1182+ }1183+ body.push_str("</tbody>\n</table></div>\n");1184+diff --git a/src/highlight.rs b/src/highlight.rs1185+index 5a79051..65c885d 1006441186+--- a/src/highlight.rs1187++++ b/src/highlight.rs1188+@@ -235,48 +235,74 @@ pub fn highlight_lines(path: &str, text: &str) -> Option<Vec<String>> {1189+ Some(out)1190+ }1191+ 1192++fn starts_with_at(chars: &[char], at: usize, pat: &str) -> bool {1193++ for (i, c) in (at..).zip(pat.chars()) {1194++ if chars.get(i) != Some(&c) {1195++ return false;1196++ }1197++ }1198++ true1199++}1200++1201++/// Find `pat` at or after char index `from`, without building a1202++/// `String` for the rest of the line (the old scanner did that once per1203++/// character, which made minified files quadratic; f0rge #78).1204++fn find_at(chars: &[char], from: usize, pat: &str) -> Option<usize> {1205++ let len = pat.chars().count();1206++ if len == 0 {1207++ return Some(from);1208++ }1209++ let last = chars.len().checked_sub(len)?;1210++ if from > last {1211++ return None;1212++ }1213++ (from..=last).find(|&i| starts_with_at(chars, i, pat))1214++}1215++1216++fn slice(chars: &[char], from: usize, to: usize) -> String {1217++ chars[from..to].iter().collect()1218++}1219++1220+ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {1221+ let chars: Vec<char> = line.chars().collect();1222+ let mut out = String::with_capacity(line.len() + 16);1223+ let mut i = 0;1224+ while i < chars.len() {1225+- let rest: String = chars[i..].iter().collect();1226+-1227+ if *in_block {1228+ if let Some((_, end)) = lang.block {1229+- if let Some(pos) = rest.find(end) {1230+- let stop = i + rest[..pos].chars().count() + end.chars().count();1231+- let piece: String = chars[i..stop].iter().collect();1232+- push_span(&mut out, "com", &piece);1233++ if let Some(end_at) = find_at(&chars, i, end) {1234++ let stop = end_at + end.chars().count();1235++ push_span(&mut out, "com", &slice(&chars, i, stop));1236+ *in_block = false;1237+ i = stop;1238+ continue;1239+ }1240+ }1241+- let piece: String = chars[i..].iter().collect();1242+- push_span(&mut out, "com", &piece);1243++ push_span(&mut out, "com", &slice(&chars, i, chars.len()));1244+ return out;1245+ }1246+ 1247+ if let Some((start, end)) = lang.block {1248+- if let Some(after_start) = rest.strip_prefix(start) {1249+- if let Some(pos) = after_start.find(end) {1250+- let stop = i + rest[..pos + start.len()].chars().count() + end.chars().count();1251+- let piece: String = chars[i..stop].iter().collect();1252+- push_span(&mut out, "com", &piece);1253++ if starts_with_at(&chars, i, start) {1254++ let after_start = i + start.chars().count();1255++ if let Some(end_at) = find_at(&chars, after_start, end) {1256++ let stop = end_at + end.chars().count();1257++ push_span(&mut out, "com", &slice(&chars, i, stop));1258+ i = stop;1259+ continue;1260+ }1261+ *in_block = true;1262+- let piece: String = chars[i..].iter().collect();1263+- push_span(&mut out, "com", &piece);1264++ push_span(&mut out, "com", &slice(&chars, i, chars.len()));1265+ return out;1266+ }1267+ }1268+ 1269+- if lang.line_comment.iter().any(|m| rest.starts_with(m)) {1270+- let piece: String = chars[i..].iter().collect();1271+- push_span(&mut out, "com", &piece);1272++ if lang1273++ .line_comment1274++ .iter()1275++ .any(|m| starts_with_at(&chars, i, m))1276++ {1277++ push_span(&mut out, "com", &slice(&chars, i, chars.len()));1278+ return out;1279+ }1280+ 1281+@@ -296,8 +322,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {1282+ }1283+ j += 1;1284+ }1285+- let piece: String = chars[i..j].iter().collect();1286+- push_span(&mut out, "str", &piece);1287++ push_span(&mut out, "str", &slice(&chars, i, j));1288+ i = j;1289+ continue;1290+ }1291+@@ -309,8 +334,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {1292+ {1293+ j += 1;1294+ }1295+- let piece: String = chars[i..j].iter().collect();1296+- push_span(&mut out, "num", &piece);1297++ push_span(&mut out, "num", &slice(&chars, i, j));1298+ i = j;1299+ continue;1300+ }1301+@@ -320,7 +344,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {1302+ while j < chars.len() && is_ident(chars[j]) {1303+ j += 1;1304+ }1305+- let word: String = chars[i..j].iter().collect();1306++ let word = slice(&chars, i, j);1307+ let next_non_space = chars[j..].iter().find(|c| !c.is_whitespace());1308+ let call = next_non_space.is_some_and(|c| *c == '(');1309+ let class = if lang.keywords.contains(&word.as_str()) {1310+@@ -395,6 +419,17 @@ mod tests {1311+ assert!(lines[0].contains("<script>"));1312+ }1313+ 1314++ #[test]1315++ fn long_single_line_is_not_quadratic() {1316++ // 64 KiB on one line took ~27s with the per-char suffix clone;1317++ // the linear scanner stays far below this coarse bound (f0rge #78).1318++ let big = "var x=1;".repeat(8000);1319++ let start = std::time::Instant::now();1320++ let lines = highlight_lines("min.js", &big).expect("js");1321++ assert!(start.elapsed() < std::time::Duration::from_secs(5));1322++ assert!(!lines[0].is_empty());1323++ }1324++1325+ #[test]1326+ fn oversized_inputs_stay_plain() {1327+ let big = "x".repeat(MAX_HIGHLIGHT_BYTES + 1);1328+diff --git a/src/layout.rs b/src/layout.rs1329+index 20480b7..b2e29e5 1006441330+--- a/src/layout.rs1331++++ b/src/layout.rs1332+@@ -57,10 +57,13 @@ pub fn diff_html(raw: &str) -> String {1333+ let path = diff_chunk_path(chunk);1334+ let mut added = 0usize;1335+ let mut removed = 0usize;1336++ let mut counting_hunk = false;1337+ for line in chunk {1338+- if line.starts_with('+') && !line.starts_with("+++") {1339++ if line.starts_with("@@") {1340++ counting_hunk = true;1341++ } else if line.starts_with('+') && (counting_hunk || !line.starts_with("+++")) {1342+ added += 1;1343+- } else if line.starts_with('-') && !line.starts_with("---") {1344++ } else if line.starts_with('-') && (counting_hunk || !line.starts_with("---")) {1345+ removed += 1;1346+ }1347+ }1348+@@ -82,13 +85,10 @@ pub fn diff_html(raw: &str) -> String {1349+ 1350+ let mut old_ln = 0i64;1351+ let mut new_ln = 0i64;1352+- let mut first = true;1353++ let mut in_hunk = false;1354+ for line in chunk {1355+- if !first {1356+- out.push('\n');1357+- }1358+- first = false;1359+ if line.starts_with("@@") {1360++ in_hunk = true;1361+ let mut parts = line.split_whitespace();1362+ let _ = parts.next();1363+ if let Some(o) = parts.next().and_then(|v| v.strip_prefix('-')) {1364+@@ -106,10 +106,22 @@ pub fn diff_html(raw: &str) -> String {1365+ .unwrap_or(0);1366+ }1367+ out.push_str(&format!("<span class=\"diff-hunk\">{}</span>", esc(line)));1368+- } else if line.starts_with('+') && !line.starts_with("+++") {1369++ } else if line.starts_with("+++") || line.starts_with("---") {1370++ if in_hunk {1371++ if line.starts_with('+') {1372++ out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));1373++ new_ln += 1;1374++ } else {1375++ out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));1376++ old_ln += 1;1377++ }1378++ } else {1379++ out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));1380++ }1381++ } else if line.starts_with('+') {1382+ out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));1383+ new_ln += 1;1384+- } else if line.starts_with('-') && !line.starts_with("---") {1385++ } else if line.starts_with('-') {1386+ out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));1387+ old_ln += 1;1388+ } else if line.starts_with(' ') {1389+@@ -121,8 +133,6 @@ pub fn diff_html(raw: &str) -> String {1390+ ));1391+ old_ln += 1;1392+ new_ln += 1;1393+- } else if line.starts_with("+++") || line.starts_with("---") {1394+- out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));1395+ } else {1396+ out.push_str(&format!("<span class=\"df-meta\">{}</span>", esc(line)));1397+ }1398+@@ -142,14 +152,15 @@ fn diff_numbered(old: &str, new: &str, class: &str, line: &str) -> String {1399+ /// Path for one chunk: `+++ b/<p>` first, then `--- a/<p>`, then the1400+ /// `diff --git` header. `/dev/null` falls through to the other side.1401+ fn diff_chunk_path(lines: &[&str]) -> String {1402+- for line in lines {1403++ let headers = lines.iter().take_while(|l| !l.starts_with("@@"));1404++ for line in headers.clone() {1405+ if let Some(rest) = line.strip_prefix("+++ ") {1406+ if rest != "/dev/null" {1407+ return rest.strip_prefix("b/").unwrap_or(rest).to_string();1408+ }1409+ }1410+ }1411+- for line in lines {1412++ for line in headers {1413+ if let Some(rest) = line.strip_prefix("--- ") {1414+ if rest != "/dev/null" {1415+ return rest.strip_prefix("a/").unwrap_or(rest).to_string();1416+@@ -168,16 +179,12 @@ fn diff_chunk_path(lines: &[&str]) -> String {1417+ /// with line-level coloring (markers stay span-free, old behavior).1418+ fn plain_diff_html(raw: &str) -> String {1419+ let mut out = String::with_capacity(raw.len() + raw.len() / 4);1420+- let mut first = true;1421+ for line in raw.lines() {1422+- if !first {1423+- out.push('\n');1424+- }1425+- first = false;1426++ // One block span per line; no literal newlines between spans or1427++ // `white-space:pre` inserts a blank row (f0rge #86).1428++ out.push_str("<span class=\"dline\">");1429+ out.push_str(&diff_line_span(line));1430+- }1431+- if raw.ends_with('\n') {1432+- out.push('\n');1433++ out.push_str("</span>");1434+ }1435+ out1436+ }1437+@@ -322,7 +329,7 @@ pub fn provenance_footer(branch: Option<&str>, short_sha: Option<&str>) -> Strin1438+ }1439+ }1440+ format!(1441+- "<footer class=\"site-footer\"><div class=\"wrap\"><div class=\"footer-brand\"><a class=\"gl0w-mark\" href=\"https://gl0w.world\" rel=\"noopener\">gl0w</a><span class=\"footer-tag\">hu13 — a gl0w project</span></div><p class=\"footer-links\"><a href=\"https://gl0w.world\" rel=\"noopener\">gl0w.world</a><a href=\"/agents.html\">agents</a><a href=\"/inbox/issue\">submit</a></p><p class=\"muted footer-provenance\">{line}</p></div></footer>"1442++ "<footer class=\"site-footer\"><div class=\"wrap\"><div class=\"footer-brand\"><a class=\"gl0w-mark\" href=\"https://gl0w.world\" rel=\"noopener\">gl0w</a><span class=\"footer-tag\">hu13 — a gl0w project</span></div><p class=\"footer-links\"><a href=\"https://gl0w.world\" rel=\"noopener\">gl0w.world</a><a href=\"/agents.html\">agents</a><a href=\"/new-issue.html\">submit</a></p><p class=\"muted footer-provenance\">{line}</p></div></footer>"1443+ )1444+ }1445+ 1446+@@ -644,6 +651,42 @@ mod tests {1447+ assert!(!html.contains("<span> line one"), "context has no span");1448+ }1449+ 1450++ #[test]1451++ fn diff_rows_have_no_blank_lines_and_content_headers_count() {1452++ // f0rge #86: no literal newline between block rows.1453++ // f0rge #87: `++++` after a hunk is added content, not a header.1454++ let raw = "diff --git a/a.txt b/a.txt\n\1455++--- a/a.txt\n+++ b/a.txt\n\1456++@@ -1 +1,4 @@\n context\n\1457++++++ not a header\n+--- not a header\n----- removed content\n+plain\n";1458++ let html = diff_html(raw);1459++ assert!(1460++ !html.contains("</span>\n<span class=\"df-line\""),1461++ "no newline between diff rows: {html}"1462++ );1463++ assert!(1464++ html.contains("<span class=\"df-add\">+3</span>"),1465++ "three added lines, including +++ content: {html}"1466++ );1467++ assert!(1468++ html.contains("<span class=\"df-del\">−1</span>"),1469++ "one removed line, including --- content: {html}"1470++ );1471++ assert!(1472++ html.contains("<span class=\"diff-add\">++++ not a header</span>"),1473++ "added +++ content keeps its marker and is not a file header: {html}"1474++ );1475++ assert!(1476++ html.contains("<span class=\"diff-del\">----- removed content</span>"),1477++ "removed --- content is a removal: {html}"1478++ );1479++ assert_eq!(1480++ html.matches("<span class=\"diff-file\">").count(),1481++ 2,1482++ "only the real ---/+++ file headers"1483++ );1484++ }1485++1486+ #[test]1487+ fn diff_html_escapes_before_wrapping() {1488+ let html = diff_html("+evil <script>alert(1)</script>\n-old & \"quoted\"\n");1489+diff --git a/src/serve.rs b/src/serve.rs1490+index 12718d1..85df371 1006441491+--- a/src/serve.rs1492++++ b/src/serve.rs1493+@@ -485,7 +485,9 @@ const RAW_ACTIVE_EXTS: &[&str] = &[1494+ "pdf", "swf", "hta", "jar", "crx", "xpi",1495+ ];1496+ 1497+-/// Text-ish extensions safe to show inline from `/raw/`.1498++/// Text-ish extensions that keep a text MIME type. They still download:1499++/// `_headers` sends `attachment` for every `/raw/*` path, so `serve` uses1500++/// the same disposition instead of rendering text inline (f0rge #93).1501+ const RAW_TEXT_EXTS: &[&str] = &[1502+ "txt",1503+ "text",1504+@@ -550,14 +552,14 @@ fn extension(name: &str) -> String {1505+ ///1506+ /// Never `text/html`/`image/svg+xml`: a hostile repo could otherwise run1507+ /// script in the site's origin (where the inbox endpoints live). Active1508+-/// types become an octet-stream download; text stays inline as1509+-/// `text/plain`; everything else downloads.1510++/// types become an octet-stream download; text keeps `text/plain` but1511++/// also downloads; everything downloads with `attachment` (f0rge #93).1512+ fn raw_content_type(name: &str) -> (&'static str, &'static str) {1513+ let ext = extension(name);1514+ if RAW_ACTIVE_EXTS.contains(&ext.as_str()) {1515+ ("application/octet-stream", "attachment")1516+ } else if RAW_TEXT_EXTS.contains(&ext.as_str()) {1517+- ("text/plain; charset=utf-8", "inline")1518++ ("text/plain; charset=utf-8", "attachment")1519+ } else {1520+ ("application/octet-stream", "attachment")1521+ }1522+@@ -706,13 +708,17 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {1523+ };1524+ let dest = absolutize(&shared.www.join(&file_rel));1525+ if dest != absolutize(&shared.www) && !dest.starts_with(absolutize(&shared.www)) {1526+- static_error(&mut stream, 404);1527++ static_not_found(&mut stream, &head, shared);1528+ return;1529+ }1530+- let bytes = match std::fs::read(&dest) {1531+- Ok(b) if dest.is_file() => b,1532++ // Stat, then stream. Never read a static file into memory: a raw1533++ // blob can be hundreds of MB and MAX_CONNS=64 (f0rge #80). The1534++ // method check stays after existence so an unknown POST is still1535++ // 404 (fail closed, no route discovery).1536++ let meta = match std::fs::metadata(&dest) {1537++ Ok(m) if m.is_file() => m,1538+ _ => {1539+- static_error(&mut stream, 404);1540++ static_not_found(&mut stream, &head, shared);1541+ return;1542+ }1543+ };1544+@@ -732,22 +738,10 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {1545+ &[]1546+ };1547+ if head.method == "HEAD" {1548+- let mut out = format!(1549+- "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",1550+- bytes.len()1551+- );1552+- for (k, v) in extra {1553+- out.push_str(k);1554+- out.push_str(": ");1555+- out.push_str(v);1556+- out.push_str("\r\n");1557+- }1558+- out.push_str("\r\n");1559+- let _ = stream.write_all(out.as_bytes());1560+- let _ = stream.flush();1561++ respond_file_head(&mut stream, 200, ctype, extra, meta.len());1562+ return;1563+ }1564+- respond(&mut stream, 200, ctype, extra, &bytes);1565++ respond_file(&mut stream, 200, ctype, extra, &dest, meta.len());1566+ }1567+ 1568+ /// Read-and-discard in-flight request bytes (bounded) so early rejects1569+@@ -800,13 +794,12 @@ impl Drop for GitSlot {1570+ }1571+ }1572+ 1573+-/// Stream a file body with `Content-Length` (no full-pack buffering).1574+-fn respond_file(1575++/// Headers for a file response, without opening the file body.1576++fn respond_file_head(1577+ stream: &mut TcpStream,1578+ code: u16,1579+ content_type: &str,1580+ extra: &[(&str, &str)],1581+- path: &Path,1582+ len: u64,1583+ ) {1584+ let mut out = format!(1585+@@ -820,9 +813,51 @@ fn respond_file(1586+ out.push_str("\r\n");1587+ }1588+ out.push_str("\r\n");1589+- if stream.write_all(out.as_bytes()).is_err() {1590++ let _ = stream.write_all(out.as_bytes());1591++ let _ = stream.flush();1592++}1593++1594++/// Serve the built `404.html` (same chrome as every page) with status1595++/// 404. Falls back to the tiny in-binary stub when the file is absent.1596++/// POSTs keep the old non-echoing stub (`f0rge #85`).1597++fn static_not_found(stream: &mut TcpStream, head: &Head, shared: &Shared) {1598++ if head.method != "GET" && head.method != "HEAD" {1599++ static_error(stream, 404);1600+ return;1601+ }1602++ let dest = shared.www.join("404.html");1603++ let Ok(meta) = std::fs::metadata(&dest) else {1604++ static_error(stream, 404);1605++ return;1606++ };1607++ if !meta.is_file() {1608++ static_error(stream, 404);1609++ return;1610++ }1611++ if head.method == "HEAD" {1612++ respond_file_head(stream, 404, "text/html; charset=utf-8", &[], meta.len());1613++ } else {1614++ respond_file(1615++ stream,1616++ 404,1617++ "text/html; charset=utf-8",1618++ &[],1619++ &dest,1620++ meta.len(),1621++ );1622++ }1623++}1624++1625++/// Stream a file body with `Content-Length` (no full-pack buffering).1626++fn respond_file(1627++ stream: &mut TcpStream,1628++ code: u16,1629++ content_type: &str,1630++ extra: &[(&str, &str)],1631++ path: &Path,1632++ len: u64,1633++) {1634++ respond_file_head(stream, code, content_type, extra, len);1635+ if let Ok(mut f) = std::fs::File::open(path) {1636+ let _ = std::io::copy(&mut f, stream);1637+ }1638+diff --git a/static/style.css b/static/style.css1639+index 95af580..471097d 1006441640+--- a/static/style.css1641++++ b/static/style.css1642+@@ -17,6 +17,7 @@1643+ --yellow:#e0af68;1644+ --red:#f7768e;1645+ --border:#292e42;1646++ --field-border:#6a7590;1647+ --visited:#9aa5ce;1648+ --btn-fg:#16161e;1649+ --state-open:#9ece6a;1650+@@ -66,6 +67,7 @@1651+ --yellow:#7d5f33;1652+ --red:#8c4351;1653+ --border:#d6dae2;1654++ --field-border:#8a93a5;1655+ --visited:#5a4a78;1656+ --btn-fg:#ffffff;1657+ --state-open:#33635c;1658+@@ -77,9 +79,9 @@1659+ --tok-com:#4a5273;1660+ --tok-fn:#34548a;1661+ --tok-type:#0f4b6e;1662+- --diff-add:#33635c;1663++ --diff-add:#2a534d;1664+ --diff-add-bg:#c6d4ca;1665+- --diff-del:#8c4351;1666++ --diff-del:#7a3543;1667+ --diff-del-bg:#d9c2c7;1668+ --diff-hunk:#0f4b6e;1669+ --diff-file:#5a4a78;1670+@@ -141,8 +143,8 @@ td.num{width:1%;white-space:nowrap}1671+ /* Forms: full-width on mobile, >=40px touch targets. */1672+ form{max-width:44rem;margin:1rem 0;min-width:0}1673+ label{display:block;margin:.75rem 0 .25rem;font-weight:600}1674+-input[type=text],textarea{display:block;width:100%;max-width:100%;background:var(--bg-alt);1675+- color:var(--fg);border:1px solid var(--border);padding:.5rem .6rem;font:inherit;min-height:40px}1676++input[type=text],textarea{display:block;width:100%;max-width:100%;background:var(--surface);1677++ color:var(--fg);border:1px solid var(--field-border);padding:.5rem .6rem;font:inherit;min-height:40px}1678+ input[type=text]:focus,textarea:focus{outline:2px solid var(--blue);outline-offset:0}1679+ textarea{min-height:12rem}1680+ button,input[type=submit]{display:inline-flex;align-items:center;justify-content:center;1681+@@ -193,6 +195,7 @@ footer.site-footer .footer-provenance{margin:0;font-size:var(--fs-sm);text-align1682+ .site-logo{display:block;width:auto;height:1.75rem;flex:none}1683+ .hu13-word{font-weight:700;color:var(--blue)}1684+ .sep{flex:none;color:var(--muted)}1685++.nav-sep{flex:0 0 auto;color:var(--muted)}1686+ .site-header .site-nav.nav-scroll{flex:1 1 auto;min-width:0;background:none;border-bottom:none;overflow-x:auto;scroll-snap-type:none;-webkit-overflow-scrolling:touch}1687+ .nav-scroll-row{display:flex;align-items:center;gap:.25rem 1rem;white-space:nowrap;width:max-content;min-width:100%}1688+ .site-header .site-nav.nav-scroll a{flex:0 0 auto;white-space:nowrap}1689+@@ -362,6 +365,7 @@ time{font-variant-numeric:tabular-nums}1690+ .df-bar-add{display:block;height:100%;background:var(--green)}1691+ .difffile .pre-scroll{margin:0}1692+ pre.diff{margin:0;border:0;background:var(--surface)}1693++pre.diff>span{display:block}1694+ .df-line{display:block}1695+ .dln{display:inline-block;width:2.8em;text-align:right;padding-right:.5em;color:var(--fg-dim);user-select:none;font-variant-numeric:tabular-nums}1696+ .dln+.dln{border-right:1px solid var(--border);margin-right:.5em}1697+@@ -370,11 +374,26 @@ pre.diff{margin:0;border:0;background:var(--surface)}1698+ .diff-file{color:var(--diff-file);font-weight:600}1699+ .df-meta{color:var(--fg-dim)}1700+ 1701++/* Tablet nav (f0rge #91): 768–1099px scrolls off the last tab with no1702++ affordance. Give the nav its own wrapped row until the full one-row1703++ header fits at 1100px; separators would otherwise begin a line. */1704++@media (min-width:768px) and (max-width:1099px){1705++ .site-header .wrap.header-bar{flex-wrap:wrap}1706++ .site-header .sep,.site-header .nav-sep{display:none}1707++ .site-header .site-nav.nav-scroll{flex:1 1 100%;overflow-x:visible;border-top:1px solid var(--border);margin-top:var(--s1);padding-top:var(--s1)}1708++ .nav-scroll-row{flex-wrap:wrap;width:auto;min-width:0;white-space:normal;column-gap:.9rem;row-gap:.1rem}1709++}1710++/* Branch lanes (f0rge #92): never break inside a branch name; wrap only1711++ between head, arrow and base (a very long token scrolls its cell).1712++ Mobile card cells inherit word-break:break-all, so reset it here. */1713++.lanes{display:inline-flex;flex-wrap:wrap;align-items:baseline;gap:.15rem .35rem;max-width:100%;overflow-x:auto;word-break:normal;overflow-wrap:normal}1714++.lanes .lane{white-space:nowrap}1715++.lanes .lane-arrow{color:var(--muted)}1716+ /* Responsive fixes (f0rge #75): every nav destination visible at 390px,1717+ file table keeps Name + Age, meta chips never overflow. */1718+ @media (max-width:767px){1719+ .site-header .wrap.header-bar{flex-wrap:wrap}1720+- .site-header .sep{display:none}1721++ .site-header .sep,.site-header .nav-sep{display:none}1722+ .site-header .site-nav.nav-scroll{flex:1 1 100%;overflow-x:visible;border-top:1px solid var(--border);margin-top:var(--s1);padding-top:var(--s1)}1723+ .nav-scroll-row{flex-wrap:wrap;width:auto;min-width:0;white-space:normal;column-gap:.9rem;row-gap:.1rem}1724+ /* Dense file table: drop "Last commit" below 768px instead of wrapping1725+diff --git a/tests/clean_out.rs b/tests/clean_out.rs1726+index fdc1c03..1d56d90 1006441727+--- a/tests/clean_out.rs1728++++ b/tests/clean_out.rs1729+@@ -122,3 +122,61 @@ fn refuses_to_clean_repo_or_its_parent() {1730+ assert!(sender.is_file(), "repo untouched");1731+ }1732+ }1733++1734++#[test]1735++fn refuses_relative_dotdot_output_and_keeps_parent() {1736++ let root = fresh_dir("dotdot");1737++ let repo = root.join("repo");1738++ std::fs::create_dir_all(repo.join("src")).unwrap();1739++ git(&repo, &["init", "-b", "main"]);1740++ git(&repo, &["config", "user.name", "hu13-test"]);1741++ git(&repo, &["config", "user.email", "[email protected]"]);1742++ git(&repo, &["config", "commit.gpgsign", "false"]);1743++ std::fs::write(repo.join("README.md"), "# keep\n").unwrap();1744++ std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();1745++ git(&repo, &["add", "-A"]);1746++ git(&repo, &["commit", "-m", "one"]);1747++ let canary = root.join("canary.txt");1748++ std::fs::write(&canary, "must survive\n").unwrap();1749++1750++ let res = Command::new(env!("CARGO_BIN_EXE_hu13"))1751++ .arg("build")1752++ .arg(&repo)1753++ .arg("-o")1754++ .arg("..")1755++ .current_dir(&repo)1756++ .output()1757++ .expect("spawn hu13 build");1758++ assert!(!res.status.success(), "relative .. must be refused");1759++ let err = String::from_utf8_lossy(&res.stderr);1760++ assert!(err.contains("refusing"), "stderr: {err}");1761++ assert!(canary.is_file(), "parent canary must survive");1762++ assert!(repo.join("README.md").is_file(), "repo must survive");1763++}1764++1765++#[cfg(unix)]1766++#[test]1767++fn refuses_symlinked_ancestor_into_repo() {1768++ let root = fresh_dir("symlink");1769++ let repo = root.join("repo");1770++ std::fs::create_dir_all(repo.join("src")).unwrap();1771++ git(&repo, &["init", "-b", "main"]);1772++ git(&repo, &["config", "user.name", "hu13-test"]);1773++ git(&repo, &["config", "user.email", "[email protected]"]);1774++ git(&repo, &["config", "commit.gpgsign", "false"]);1775++ std::fs::write(repo.join("README.md"), "# keep\n").unwrap();1776++ std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();1777++ git(&repo, &["add", "-A"]);1778++ git(&repo, &["commit", "-m", "one"]);1779++ let alias = root.join("alias");1780++ std::os::unix::fs::symlink(&repo, &alias).unwrap();1781++1782++ let res = build(&repo, &alias.join("src"));1783++ assert!(!res.status.success(), "symlinked src must be refused");1784++ let err = String::from_utf8_lossy(&res.stderr);1785++ assert!(err.contains("refusing"), "stderr: {err}");1786++ assert!(1787++ repo.join("src/main.rs").is_file(),1788++ "tracked source must survive"1789++ );1790++}1791+diff --git a/tests/light_theme.rs b/tests/light_theme.rs1792+index 42f309b..575c1a9 1006441793+--- a/tests/light_theme.rs1794++++ b/tests/light_theme.rs1795+@@ -167,7 +167,7 @@ fn no_hardcoded_palette_hex_outside_root() {1796+ "#f7768e", "#292e42", "#1f2335", "#9aa5ce", "#f6f7f9", "#343b58", "#34548a", "#0f4b6e",1797+ "#8c4351", "#33635c", "#eceef2", "#e4e7ee", "#5a4a78", "#7d5f33", "#ffffff", "#233328",1798+ "#38232e", "#c6d4ca", "#d9c2c7", "#d6dae2", "#4a5273", "#1f2335", "#24283b", "#bb9af7",1799+- "#ff9e64", "#7f87ad", "#2ac3de", "#8c6c3e",1800++ "#ff9e64", "#7f87ad", "#2ac3de", "#8c6c3e", "#2a534d", "#7a3543", "#6a7590", "#8a93a5",1801+ ];1802+ for hex in palette {1803+ assert!(1804+@@ -200,8 +200,8 @@ fn diff_vars_defined_dark_and_light() {1805+ "--diff-add:#9ece6a",1806+ "--diff-del:#f7768e",1807+ "--diff-hunk:#7dcfff",1808+- "--diff-add:#33635c",1809+- "--diff-del:#8c4351",1810++ "--diff-add:#2a534d",1811++ "--diff-del:#7a3543",1812+ "--diff-hunk:#0f4b6e",1813+ ] {1814+ assert!(css.contains(var), "diff var defined: {var}");1815+diff --git a/tests/pages_inbox.test.mjs b/tests/pages_inbox.test.mjs1816+new file mode 1006441817+index 0000000..f9625c81818+--- /dev/null1819++++ b/tests/pages_inbox.test.mjs1820+@@ -0,0 +1,159 @@1821++// Cloudflare Pages inbox parity tests (f0rge #79). Run:1822++// node --test tests/pages_inbox.test.mjs1823++// No network, no wrangler: a fake R2 bucket exercises validation,1824++// origin gate, ack, storage shape, rate and quota.1825++import test from "node:test";1826++import assert from "node:assert/strict";1827++import { onRequestGet, onRequestPost } from "../functions/inbox/[kind].js";1828++1829++class FakeBucket {1830++ constructor() {1831++ this.objects = new Map();1832++ }1833++ async list({ limit = 1000 } = {}) {1834++ return {1835++ objects: [...this.objects.keys()].slice(0, limit).map((key) => ({ key })),1836++ truncated: this.objects.size > limit,1837++ };1838++ }1839++ async put(key, value) {1840++ this.objects.set(key, String(value));1841++ }1842++}1843++1844++let seq = 0;1845++function env(extra = {}) {1846++ return {1847++ INBOX: new FakeBucket(),1848++ INBOX_SALT: "test-salt",1849++ ...extra,1850++ };1851++}1852++1853++function post(kind, body, headers = {}, e = env()) {1854++ const request = new Request(`https://hu13.test/inbox/${kind}`, {1855++ method: "POST",1856++ headers: {1857++ "content-type": "application/x-www-form-urlencoded",1858++ "cf-connecting-ip": `203.0.113.${++seq}`,1859++ ...headers,1860++ },1861++ body,1862++ });1863++ return onRequestPost({ request, env: e, params: { kind } }).then(async (res) => ({1864++ res,1865++ text: await res.text(),1866++ env: e,1867++ }));1868++}1869++1870++const validIssue = "title=Hello&body=World&license=agree";1871++const validPrompt = "title=Prompt&prompt=Do+the+thing&license=on";1872++1873++test("GET is 405 with POST allowed and no-store/nosniff", async () => {1874++ const res = await onRequestGet();1875++ assert.equal(res.status, 405);1876++ assert.equal(res.headers.get("allow"), "POST");1877++ assert.equal(res.headers.get("x-content-type-options"), "nosniff");1878++ assert.equal(res.headers.get("cache-control"), "no-store");1879++});1880++1881++test("valid issue stores one JSON object and returns an ack", async () => {1882++ const e = env();1883++ const { res, text } = await post("issue", validIssue, {}, e);1884++ assert.equal(res.status, 200);1885++ assert.match(text, /ack: <code>[0-9a-f-]{36}<\/code>/);1886++ assert.equal(e.INBOX.objects.size, 1);1887++ const [key, value] = [...e.INBOX.objects.entries()][0];1888++ assert.match(key, /^[0-9a-f-]{36}\.json$/);1889++ const doc = JSON.parse(value);1890++ assert.equal(doc.type, "issue");1891++ assert.equal(doc.title, "Hello");1892++ assert.equal(doc.body, "World");1893++ assert.equal(doc.license_version, "v1");1894++ assert.match(doc.received_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);1895++ assert.match(doc.salted_ip_hash, /^[0-9a-f]{64}$/);1896++ assert.ok(!text.includes("World"), "ack never reflects input");1897++});1898++1899++test("valid pr-prompt uses prompt (and accepts body alias)", async () => {1900++ for (const body of [validPrompt, "title=P&body=alias&license=yes"]) {1901++ const e = env();1902++ const { res } = await post("pr-prompt", body, {}, e);1903++ assert.equal(res.status, 200);1904++ const doc = JSON.parse([...e.INBOX.objects.values()][0]);1905++ assert.equal(doc.type, "pr-prompt");1906++ assert.equal(doc.body, body.includes("prompt") ? "Do the thing" : "alias");1907++ }1908++});1909++1910++test("missing license, empty fields and oversize are 400", async () => {1911++ for (const body of [1912++ "title=Hello&body=World",1913++ "title=&body=World&license=agree",1914++ "title=Hello&body=&license=agree",1915++ `title=${"x".repeat(301)}&body=W&license=agree`,1916++ ]) {1917++ const { res } = await post("issue", body);1918++ assert.equal(res.status, 400, body);1919++ }1920++});1921++1922++test("honeypot fakes success without storing", async () => {1923++ const e = env();1924++ const { res, text } = await post(1925++ "issue",1926++ "title=Hello&body=World&license=agree&website=bot",1927++ {},1928++ e,1929++ );1930++ assert.equal(res.status, 200);1931++ assert.match(text, /ack: <code>/);1932++ assert.equal(e.INBOX.objects.size, 0);1933++});1934++1935++test("malformed escapes, non-UTF-8 and NUL are 400", async () => {1936++ for (const body of [1937++ "title=%ZZ&body=W&license=agree",1938++ "title=%FF&body=W&license=agree",1939++ "title=He%00llo&body=W&license=agree",1940++ ]) {1941++ const { res } = await post("issue", body);1942++ assert.equal(res.status, 400, body);1943++ }1944++});1945++1946++test("cross-origin browser POST is rejected; curl (no Origin) passes", async () => {1947++ const bad = await post("issue", validIssue, { origin: "https://evil.test" });1948++ assert.equal(bad.res.status, 403);1949++ const nullOrigin = await post("issue", validIssue, { origin: "null" });1950++ assert.equal(nullOrigin.res.status, 403);1951++ const same = await post("issue", validIssue, { origin: "https://hu13.test" });1952++ assert.equal(same.res.status, 200);1953++});1954++1955++test("wrong content type and oversized bodies are rejected", async () => {1956++ const json = await post("issue", validIssue, {1957++ "content-type": "application/json",1958++ });1959++ assert.equal(json.res.status, 400);1960++ const big = `title=Hello&body=${"x".repeat(33 * 1024)}&license=agree`;1961++ const over = await post("issue", big);1962++ assert.equal(over.res.status, 413);1963++});1964++1965++test("quota is enforced before storing", async () => {1966++ const e = env({ INBOX_QUOTA: "1" });1967++ const first = await post("issue", validIssue, {}, e);1968++ assert.equal(first.res.status, 200);1969++ const second = await post("issue", validIssue, {}, e);1970++ assert.equal(second.res.status, 503);1971++ assert.equal(e.INBOX.objects.size, 1);1972++});1973++[hu13: diff truncated at 2000 lines]