#59 Review #3: harden build/serve, render fixes, Pages inbox (#77-#96)

mergedpassopened
details
branches
master ← fix/review3-hardening
issues
90121d61-86ed-48ff-8c57-b04fb8a3ce5d
updated
id
146353cc-69c7-4238-bfd3-d2adebd844f7

Patch

.forge/ci/steps/04_pages_inbox.sh+8 −0
diff --git a/.forge/ci/steps/04_pages_inbox.sh b/.forge/ci/steps/04_pages_inbox.shnew file mode 100755index 0000000..bc39c5a--- /dev/null+++ b/.forge/ci/steps/04_pages_inbox.sh@@ -0,0 +1,8 @@1+#!/bin/sh2+# Cloudflare Pages inbox parity (f0rge #79). Skips only when Node is absent.3+set -eu4+if ! command -v node >/dev/null 2>&1; then5+    echo "pages inbox: node not installed; skipping"6+    exit 07+fi8+node --test tests/pages_inbox.test.mjs
.forge/wiki/deploy+5 −0
diff --git a/.forge/wiki/deploy b/.forge/wiki/deployindex 3e94c60..1fd24c2 100644--- a/.forge/wiki/deploy+++ b/.forge/wiki/deploy@@ -69,6 +69,11 @@ What happens inside (`entrypoint.sh`):6969   `dist/_headers` carries the `/raw/**` download policy for Pages. On7070   nginx, add the equivalent `add_header Content-Disposition attachment`7171   for `/raw/` — committed HTML/SVG must never render from that path.72+- Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The73+  Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and74+  secret `INBOX_SALT` implement the same contract. See75+  `docs/cloudflare.md` for the bucket, binding, secret and WAF rate-rule76+  recipe. `dist/_routes.json` limits Functions to `/inbox/*`.7277 7378 - Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped.7479   If it ever tops 2MB stripped, the cut list is: replace `yaml_serde`
docs/cloudflare.md+95 −0
diff --git a/docs/cloudflare.md b/docs/cloudflare.mdnew file mode 100644index 0000000..dedbd9d--- /dev/null+++ b/docs/cloudflare.md@@ -0,0 +1,95 @@1+# Deploy hu13 on Cloudflare Pages (Free)2+3+The same `dist/` works on Docker/`hu13 serve`, nginx, and Pages. On4+Pages the inbox is implemented by a Pages Function backed by R2; a5+static-only upload has no working inbox.6+7+## 1. Build the site8+9+```sh10+cargo build --release11+./target/release/hu13 build . -o dist \12+  --public-origin https://hu13.example.com13+```14+15+`--public-origin` makes the clone command, `og:image`, Atom links and16+canonical URLs absolute. The build refuses output over Cloudflare's17+limits (20,000 files, 25 MiB/file) and writes `_headers` and18+`_routes.json` into `dist/`.19+20+## 2. Create the R2 bucket and Pages project21+22+```sh23+npx wrangler r2 bucket create hu13-inbox24+npx wrangler pages project create hu1325+```26+27+Bind the bucket to the Pages project as **`INBOX`** (dashboard:28+Settings → Functions → R2 bindings, or the `[[r2_buckets]]` block in29+`wrangler.toml`). Set the required secret:30+31+```sh32+npx wrangler pages secret put INBOX_SALT --project-name hu1333+```34+35+`INBOX_SALT` is a random string (for example `openssl rand -hex 32`).36+The function hashes `v1|<ip>|<yyyy-mm-dd>|<salt>` with SHA-256; no37+plaintext IP is stored. Rotating the secret unlinks old hashes.38+39+Optional variables: `INBOX_RATE_PER_HOUR` (default 2),40+`INBOX_QUOTA` (default 500), `INBOX_MAX_TITLE` (300),41+`INBOX_MAX_BODY` (20000).42+43+## 3. Deploy44+45+```sh46+npx wrangler pages deploy dist --project-name hu1347+```48+49+Wrangler uploads `functions/` alongside `dist/`. `dist/_routes.json`50+limits Functions to `/inbox/*`, so every other request is static.51+Keep `wrangler.toml` at the repo root for the R2 binding.52+53+## What the Function implements54+55+`functions/inbox/[kind].js` serves both mailboxes:56+57+- `POST /inbox/issue` (`body`) and `POST /inbox/pr-prompt` (`prompt`,58+  `body` accepted as an alias)59+- urlencoded only; 32 KiB whole-body cap; strict UTF-8; NUL → 40060+- controls stripped; title ≤300, body ≤20000, handle ≤40, contact ≤10061+- license checkbox required; filled honeypot returns a fake receipt62+  without storing63+- same-origin `Origin`/`Referer` gate; no input is echoed or logged64+- fixed ~500 ms response delay; `Cache-Control: no-store` and65+  `X-Content-Type-Options: nosniff`66+- one R2 object per accepted submission:67+  `<uuid>.json` with `type/title/body/handle/contact/license_version/68+  received_at/salted_ip_hash` (same shape as the Docker inbox)69+70+Rate limits are layered: the Function has a best-effort isolate-local71+bucket, but that is **not global**. Add a Cloudflare WAF rate-limiting72+rule for `/inbox/*` (for example 10 requests/minute per IP) as the73+durable control. The global quota is checked against the R2 bucket74+before storing; R2 list consistency means a burst can briefly exceed it.75+76+## Reading submissions77+78+```sh79+npx wrangler r2 object list hu13-inbox80+npx wrangler r2 object get hu13-inbox/<uuid>.json81+```82+83+`hu13 inbox list` reads the local filesystem inbox used by Docker/`serve`;84+it does not read R2. Copy objects down if you want the terminal reader.85+86+## Limits and caveats87+88+- Pages Free: 20,000 files and 25 MiB per file per deployment. The89+  build enforces both; large assets belong outside git or in Git LFS.90+- The function bundle must be deployed with `functions/`; uploading only91+  `dist/` (drag-and-drop or a plain static host) gives a dead inbox.92+- `repo.git` is a dumb-HTTP repo: cloning works on any static host, no93+  smart server. No GitHub dependency.94+- Committed `/raw/*` bytes always download (`Content-Disposition:95+  attachment`) on both `serve` and Pages.
functions/inbox/[kind].js+349 −0
diff --git a/functions/inbox/[kind].js b/functions/inbox/[kind].jsnew file mode 100644index 0000000..d7592b8--- /dev/null+++ b/functions/inbox/[kind].js@@ -0,0 +1,349 @@1+// Cloudflare Pages Functions inbox (f0rge #79).2+//3+// One dynamic route serves both mailboxes: /inbox/issue and4+// /inbox/pr-prompt. Contract parity with `hu13 serve`:5+//   urlencoded only, 32 KiB cap, strict UTF-8, NUL rejected,6+//   controls stripped, title/body caps, license required,7+//   same-origin gate, fixed delay, ack uuid, no echo.8+// Storage is one JSON object per submission in the R2 binding INBOX.9+//10+// Bindings (Pages project settings / wrangler.toml):11+//   INBOX       R2 bucket12+//   INBOX_SALT  secret (required; SHA-256 salt for IP hashes)13+// Optional: INBOX_RATE_PER_HOUR (default 2), INBOX_QUOTA (default 500).14+15+const MAX_BYTES = 32 * 1024;16+const MAX_TITLE = 300;17+const MAX_BODY = 20000;18+const MAX_HANDLE = 40;19+const MAX_CONTACT = 100;20+const LICENSE_VERSION = "v1";21+const ACK_DELAY_MS = 500;22+23+const encoder = new TextEncoder();24+25+function esc(s) {26+  return String(s).replace(27+    /[&<>"]/g,28+    (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],29+  );30+}31+32+function page(title, body) {33+  return `<!DOCTYPE html>34+<html lang="en"><head><meta charset="utf-8">35+<meta name="viewport" content="width=device-width, initial-scale=1">36+<meta name="robots" content="noindex">37+<title>${esc(title)}</title></head>38+<body><main><h1>${esc(title)}</h1>39+${body}40+<p><a href="/">hu13</a></p></main></body></html>41+`;42+}43+44+function answer(status, body, extra = {}, delay = true) {45+  const headers = {46+    "content-type": "text/html; charset=utf-8",47+    "x-content-type-options": "nosniff",48+    "cache-control": "no-store",49+    ...extra,50+  };51+  const response = () => new Response(body, { status, headers });52+  if (!delay) return response();53+  return new Promise((resolve) =>54+    setTimeout(() => resolve(response()), ACK_DELAY_MS),55+  );56+}57+58+function errorPage(status, detail, extra = {}) {59+  const title = status === 405 ? "method not allowed" : status === 42960+    ? "too many requests"61+    : status === 50362+    ? "unavailable"63+    : status === 41364+    ? "too large"65+    : "bad request";66+  return answer(67+    status,68+    page(title, `<p>${esc(detail)}</p>`),69+    extra,70+  );71+}72+73+function ackPage(uuid) {74+  return page(75+    "received",76+    `<p>ack: <code>${esc(uuid)}</code></p>77+<p>Thank you. This identifier was shown once and is not a lookup URL.</p>`,78+  );79+}80+81+function sleep(ms) {82+  return new Promise((resolve) => setTimeout(resolve, ms));83+}84+85+// Same-origin gate: browsers send Origin/Referer; agents (curl) send86+// neither and pass. `null`/unparsable/mismatched hosts fail closed.87+function originAllowed(request) {88+  let host;89+  try {90+    host = new URL(request.url).host.toLowerCase();91+  } catch {92+    return false;93+  }94+  for (const name of ["origin", "referer"]) {95+    const value = request.headers.get(name);96+    if (value === null) continue;97+    if (!value || value.trim().toLowerCase() === "null") return false;98+    try {99+      if (new URL(value).host.toLowerCase() !== host) return false;100+    } catch {101+      return false;102+    }103+  }104+  return true;105+}106+107+function hexByte(b) {108+  if (b >= 0x30 && b <= 0x39) return b - 0x30;109+  if (b >= 0x61 && b <= 0x66) return b - 0x61 + 10;110+  if (b >= 0x41 && b <= 0x46) return b - 0x41 + 10;111+  return -1;112+}113+114+// Strict percent-decoder matching hu13's `parse_form`: malformed `%`,115+// non-UTF-8, or a bad escape returns null (400) instead of guessing.116+function decodeComponent(input) {117+  const raw = encoder.encode(input);118+  const out = [];119+  let i = 0;120+  while (i < raw.length) {121+    let b = raw[i++];122+    if (b === 0x25) {123+      if (i + 1 >= raw.length) return null;124+      const hi = hexByte(raw[i++]);125+      const lo = hexByte(raw[i++]);126+      if (hi < 0 || lo < 0) return null;127+      b = (hi << 4) | lo;128+    } else if (b === 0x2b) {129+      b = 0x20;130+    }131+    out.push(b);132+  }133+  try {134+    return new TextDecoder("utf-8", { fatal: true }).decode(135+      Uint8Array.from(out),136+    );137+  } catch {138+    return null;139+  }140+}141+142+function parseForm(body) {143+  if (body === "") return [];144+  const pairs = [];145+  for (const part of body.split("&")) {146+    const eq = part.indexOf("=");147+    const key = eq < 0 ? part : part.slice(0, eq);148+    const value = eq < 0 ? "" : part.slice(eq + 1);149+    const k = decodeComponent(key);150+    const v = decodeComponent(value);151+    if (k === null || v === null) return null;152+    pairs.push([k, v]);153+  }154+  return pairs;155+}156+157+function field(pairs, name) {158+  const hit = pairs.find(([k]) => k === name);159+  return hit ? hit[1] : undefined;160+}161+162+function stripControls(s) {163+  return [...s]164+    .filter((c) => c === "\n" || c === "\t" || !/\p{Cc}/u.test(c))165+    .join("");166+}167+168+function licenseChecked(value) {169+  if (value === undefined) return false;170+  return ["on", "agree", "yes", "true", "1", "checked", "accept"].includes(171+    value.trim().toLowerCase(),172+  );173+}174+175+function validate(kind, pairs, maxTitle = MAX_TITLE, maxBody = MAX_BODY) {176+  if (177+    (field(pairs, "website") || "") !== "" ||178+    (field(pairs, "honeypot") || "") !== ""179+  ) {180+    return { kind: "honeypot" };181+  }182+  for (const [k, v] of pairs) {183+    if (k.includes("\0") || v.includes("\0")) return { kind: "reject" };184+  }185+  const rawTitle = field(pairs, "title") || "";186+  const bodyName = kind === "pr-prompt" ? "prompt" : "body";187+  let rawBody = field(pairs, bodyName);188+  if (rawBody === undefined && kind === "pr-prompt") {189+    rawBody = field(pairs, "body");190+  }191+  rawBody = rawBody || "";192+  const title = stripControls(rawTitle);193+  const body = stripControls(rawBody);194+  const handle = stripControls(field(pairs, "handle") || "");195+  const contact = stripControls(field(pairs, "contact") || "");196+  if (title.trim() === "" || body.trim() === "") return { kind: "reject" };197+  if (198+    [...title].length > maxTitle ||199+    [...body].length > maxBody ||200+    [...handle].length > MAX_HANDLE ||201+    [...contact].length > MAX_CONTACT202+  ) {203+    return { kind: "reject" };204+  }205+  if (!licenseChecked(field(pairs, "license"))) return { kind: "reject" };206+  return { kind: "accept", title, body, handle, contact };207+}208+209+async function readCapped(request) {210+  const declared = request.headers.get("content-length");211+  if (declared !== null) {212+    if (!/^\d+$/.test(declared.trim())) return { error: 400 };213+    if (Number(declared) > MAX_BYTES) return { error: 413 };214+  }215+  if (!request.body) return { text: "" };216+  const reader = request.body.getReader();217+  const chunks = [];218+  let total = 0;219+  for (;;) {220+    const { done, value } = await reader.read();221+    if (done) break;222+    total += value.byteLength;223+    if (total > MAX_BYTES) {224+      await reader.cancel();225+      return { error: 413 };226+    }227+    chunks.push(value);228+  }229+  const merged = new Uint8Array(total);230+  let at = 0;231+  for (const chunk of chunks) {232+    merged.set(chunk, at);233+    at += chunk.byteLength;234+  }235+  try {236+    return { text: new TextDecoder("utf-8", { fatal: true }).decode(merged) };237+  } catch {238+    return { error: 400 };239+  }240+}241+242+async function saltedIpHash(ip, salt) {243+  const date = new Date().toISOString().slice(0, 10);244+  const bytes = encoder.encode(`v1|${ip}|${date}|${salt}`);245+  const digest = await crypto.subtle.digest("SHA-256", bytes);246+  return [...new Uint8Array(digest)]247+    .map((b) => b.toString(16).padStart(2, "0"))248+    .join("");249+}250+251+// Best-effort isolate-local limiter. Cloudflare's edge rate-limiting252+// rule for /inbox/* is the durable control; this still stops a single253+// hot isolate from storing unlimited submissions.254+const buckets = new Map();255+function rateAllowed(key, limit) {256+  const now = Date.now();257+  if (buckets.size > 10000) {258+    for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k);259+  }260+  let b = buckets.get(key);261+  if (!b || b.reset <= now) {262+    b = { count: 0, reset: now + 3600_000 };263+    buckets.set(key, b);264+  }265+  if (b.count >= limit) return false;266+  b.count += 1;267+  return true;268+}269+270+async function overQuota(bucket, limit) {271+  const listed = await bucket.list({ limit: Math.min(limit, 1000) });272+  return listed.objects.length >= limit;273+}274+275+function nowRfc3339() {276+  return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");277+}278+279+async function handlePost(context) {280+  const request = context.request;281+  const rawKind = context.params ? context.params.kind : "";282+  const kind = Array.isArray(rawKind) ? rawKind[0] : rawKind;283+  if (kind !== "issue" && kind !== "pr-prompt") {284+    return errorPage(404, "Nothing here.", {}, false);285+  }286+  if (!originAllowed(request)) {287+    return errorPage(403, "Bad origin.");288+  }289+  const ctype = (request.headers.get("content-type") || "").toLowerCase();290+  if (!ctype.startsWith("application/x-www-form-urlencoded")) {291+    return errorPage(400, "This endpoint accepts urlencoded forms only.");292+  }293+  const read = await readCapped(request);294+  if (read.error) {295+    return errorPage(read.error, read.error === 413 ? "Submission too large." : "Bad input.");296+  }297+  if (read.text.includes("\0")) return errorPage(400, "Bad input.");298+  const pairs = parseForm(read.text);299+  if (pairs === null) return errorPage(400, "Bad input.");300+  const checked = validate(kind, pairs,301+    context.env.INBOX_MAX_TITLE ? Number(context.env.INBOX_MAX_TITLE) : MAX_TITLE,302+    context.env.INBOX_MAX_BODY ? Number(context.env.INBOX_MAX_BODY) : MAX_BODY);303+  if (checked.kind === "honeypot") {304+    return answer(200, ackPage(crypto.randomUUID()));305+  }306+  if (checked.kind === "reject") return errorPage(400, "Bad input.");307+308+  const env = context.env || {};309+  if (!env.INBOX) return errorPage(503, "Could not store that. Try later.");310+  if (!env.INBOX_SALT) return errorPage(503, "Could not store that. Try later.");311+  const ip = request.headers.get("cf-connecting-ip") || "unknown";312+  const ipHash = await saltedIpHash(ip, env.INBOX_SALT);313+  const rate = env.INBOX_RATE_PER_HOUR ? Number(env.INBOX_RATE_PER_HOUR) : 2;314+  if (!rateAllowed(ipHash, rate)) return errorPage(429, "Slow down.");315+  const quota = env.INBOX_QUOTA ? Number(env.INBOX_QUOTA) : 500;316+  try {317+    if (await overQuota(env.INBOX, quota)) {318+      return errorPage(503, "Inbox full. Try later.");319+    }320+    const uuid = crypto.randomUUID();321+    const doc = {322+      type: kind,323+      title: checked.title,324+      body: checked.body,325+      handle: checked.handle,326+      contact: checked.contact,327+      license_version: LICENSE_VERSION,328+      received_at: nowRfc3339(),329+      salted_ip_hash: ipHash,330+    };331+    await env.INBOX.put(`${uuid}.json`, JSON.stringify(doc) + "\n");332+    return answer(200, ackPage(uuid));333+  } catch {334+    return errorPage(503, "Could not store that. Try later.");335+  }336+}337+338+export async function onRequestGet() {339+  return errorPage(405, "This endpoint accepts POST only.", { allow: "POST" }, false);340+}341+342+export async function onRequestPost(context) {343+  try {344+    return await handlePost(context);345+  } catch {346+    // Never echo or log submission text.347+    return errorPage(503, "Could not store that. Try later.");348+  }349+}
src/build.rs+285 −19
diff --git a/src/build.rs b/src/build.rsindex d6e3420..532802d 100644--- a/src/build.rs+++ b/src/build.rs@@ -353,6 +353,12 @@ pub const MAX_INLINE_BLOB: u64 = 8 * 1024 * 1024;353353 /// tried). Prevents a huge README from bloating every page's preview.354354 pub const README_MAX_BYTES: u64 = 1024 * 1024;355355 356+/// Cloudflare Pages Free deployment limits (f0rge #81). `raw/` mirrors357+/// every blob, so an ordinary asset-heavy repo can exceed these even358+/// when the build itself succeeds.359+pub const PAGES_MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;360+pub const PAGES_MAX_FILES: usize = 20_000;361+356362 /// Bytes sniffed for the language-math binary check (first 8KiB).357363 pub const BINARY_SNIFF_BYTES: usize = 8 * 1024;358364 @@ -404,8 +410,25 @@ pub fn is_markdown(path: &str) -> bool {404410     lower.ends_with(".md") || lower.ends_with(".markdown")405411 }406412 413+/// Percent-encode a repo path for a URL, then HTML-escape it. Git414+/// allows `#`, `?`, `%`, spaces and quotes in names; without this the415+/// browser truncates a link at `#`/`?` (f0rge #89). `/` stays literal416+/// so file paths remain hierarchical; everything outside the unreserved417+/// set becomes `%XX` of its UTF-8 bytes.418+fn encode_path(path: &str) -> String {419+    let mut out = String::with_capacity(path.len());420+    for &b in path.as_bytes() {421+        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {422+            out.push(char::from(b));423+        } else {424+            out.push_str(&format!("%{b:02X}"));425+        }426+    }427+    out428+}429+407430 fn href_esc(path: &str) -> String {408-    esc(path)431+    esc(&encode_path(path))409432 }410433 411434 /// Humanized byte size (`24 B`, `2.0 KiB`, `3.0 MiB`) for meta bars@@ -534,7 +557,20 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {534557     let y: i64 = d.next()?.parse().ok()?;535558     let mo: i64 = d.next()?.parse().ok()?;536559     let da: i64 = d.next()?.parse().ok()?;537-    if !(1..=12).contains(&mo) || !(1..=31).contains(&da) {560+    // Keep the civil-date math overflow-free and refuse dates that never561+    // existed (Feb 31 used to normalize to Mar 3; huge years panicked).562+    if !(1..=9999).contains(&y) || !(1..=12).contains(&mo) {563+        return None;564+    }565+    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;566+    let max_day = match mo {567+        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,568+        4 | 6 | 9 | 11 => 30,569+        2 if leap => 29,570+        2 => 28,571+        _ => return None,572+    };573+    if !(1..=max_day).contains(&da) {538574         return None;539575     }540576     let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') {@@ -550,6 +586,9 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {550586         };551587         let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?;552588         let om: i64 = rest[rest.len() - 2..].parse().ok()?;589+        if oh > 23 || om > 59 {590+            return None;591+        }553592         (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60))554593     } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') {555594         let sign = if rest.as_bytes()[rest.len() - 5] == b'-' {@@ -559,6 +598,9 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {559598         };560599         let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?;561600         let om: i64 = rest[rest.len() - 2..].parse().ok()?;601+        if oh > 23 || om > 59 {602+            return None;603+        }562604         (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60))563605     } else {564606         (rest, 0)@@ -1019,13 +1061,18 @@ pub fn clone_block(10191061 /// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container10201062 /// so long lines never break the 360px layout.10211063 fn render_text_pre(display: &str, path: &str) -> String {1064+    // Normalize line endings and drop exactly one trailing terminator:1065+    // `split('\n')` used to turn a final newline into an empty L404 and1066+    // kept `\r` in CRLF files (f0rge #88).1067+    let normalized = display.replace("\r\n", "\n").replace('\r', "\n");1068+    let text = normalized.strip_suffix('\n').unwrap_or(&normalized);10221069     let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");1023-    if display.is_empty() {1070+    if text.is_empty() {10241071         out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>");10251072     } else {10261073         // Build-time tokens (f0rge #73): unknown/huge files stay plain.1027-        let highlighted = crate::highlight::highlight_lines(path, display);1028-        for (i, line) in display.split('\n').enumerate() {1074+        let highlighted = crate::highlight::highlight_lines(path, text);1075+        for (i, line) in text.split('\n').enumerate() {10291076             let n = i + 1;10301077             out.push_str("<span class=\"line\" id=\"L");10311078             out.push_str(&n.to_string());@@ -1076,7 +1123,7 @@ fn nav_bar(10761123         tabs.push("<a href=\"/wiki/index.html\">wiki</a>".to_string());10771124     }10781125     tabs.push("<a href=\"/agents.html\">agents</a>".to_string());1079-    tabs.join(" · ")1126+    tabs.join("<span class=\"nav-sep\" aria-hidden=\"true\">·</span>")10801127 }10811128 10821129 /// Write `out/style.css` from the embedded static file.@@ -1088,6 +1135,12 @@ fn write_style(out: &Path) -> Result<(), String> {10881135 /// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest`10891136 /// from the embedded static files (m6 favicon #16; logo-text #27 + #28).10901137 /// SVG-first, zero binary blobs: the header lockup is `logo.svg`1138+/// Pages Functions routing: only `/inbox/*` runs a Function; every1139+/// other path stays a static asset. Must live in the build output, not1140+/// the repo root (f0rge #79).1141+pub const ROUTES_FILE: &str =1142+    "{\n  \"version\": 1,\n  \"include\": [\"/inbox/*\"],\n  \"exclude\": []\n}\n";1143+10911144 /// Header rules for Cloudflare Pages (and any host that reads `_headers`).10921145 ///10931146 /// `/raw/**` holds committed, untrusted bytes: a repo can contain@@ -1120,11 +1173,12 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {11201173         .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;11211174     std::fs::write(out.join("_headers"), HEADERS_FILE)11221175         .map_err(|e| format!("hu13: write _headers: {e}"))?;1176+    std::fs::write(out.join("_routes.json"), ROUTES_FILE)1177+        .map_err(|e| format!("hu13: write _routes.json: {e}"))?;11231178     Ok(())11241179 }11251180 1126-/// Absolute lexical form of `p` (no symlink resolution beyond what the1127-/// OS already did for existing components).1181+/// Absolute form of `p` (join cwd for relative paths, no symlink work).11281182 fn absolute(p: &Path) -> std::path::PathBuf {11291183     if p.is_absolute() {11301184         p.to_path_buf()@@ -1135,15 +1189,81 @@ fn absolute(p: &Path) -> std::path::PathBuf {11351189     }11361190 }11371191 1192+/// Absolute lexical form of `p` with `.`/`..` collapsed. Does not1193+/// touch the filesystem (unlike canonicalize), so it also works for an1194+/// output path that does not exist yet.1195+fn normalize_lexical(p: &Path) -> std::path::PathBuf {1196+    use std::path::Component;1197+    let mut out = std::path::PathBuf::new();1198+    for c in p.components() {1199+        match c {1200+            Component::CurDir => {}1201+            Component::ParentDir => {1202+                out.pop();1203+            }1204+            Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {1205+                out.push(c.as_os_str());1206+            }1207+        }1208+    }1209+    out1210+}1211+1212+/// Resolve symlinks in the deepest existing ancestor, then append the1213+/// not-yet-created suffix. This catches `-o alias/..` where `alias` is a1214+/// symlink into the repo (f0rge #77).1215+fn resolve_existing(p: &Path) -> std::path::PathBuf {1216+    let abs = normalize_lexical(&absolute(p));1217+    let mut suffix: Vec<std::ffi::OsString> = Vec::new();1218+    let mut cur = abs.as_path();1219+    loop {1220+        if let Ok(mut resolved) = cur.canonicalize() {1221+            for part in suffix.iter().rev() {1222+                resolved.push(part);1223+            }1224+            return normalize_lexical(&resolved);1225+        }1226+        match cur.file_name() {1227+            Some(name) => {1228+                suffix.push(name.to_os_string());1229+                match cur.parent() {1230+                    Some(parent) => cur = parent,1231+                    None => return abs,1232+                }1233+            }1234+            None => return abs,1235+        }1236+    }1237+}1238+1239+/// `true` when git tracks at least one path under repo-relative `rel`.1240+fn has_tracked_under(repo: &Path, rel: &Path) -> bool {1241+    if rel.as_os_str().is_empty() {1242+        return true;1243+    }1244+    std::process::Command::new("git")1245+        .arg("-C")1246+        .arg(repo)1247+        .arg("ls-files")1248+        .arg("-z")1249+        .arg("--")1250+        .arg(rel)1251+        .output()1252+        .map(|o| o.status.success() && !o.stdout.is_empty())1253+        .unwrap_or(true)1254+}1255+11381256 /// Remove everything already in `out/` before a build (f0rge #67).11391257 ///11401258 /// Without this, pages for deleted issues/files keep being served from a11411259 /// reused output dir. Refuses to delete the repo itself or anything that1142-/// contains it (`-o ..`, `-o .`), and handles a symlinked output dir1143-/// without following it.1260+/// contains it (`-o ..`, `-o .`), resolves `.`/`..` and symlinked1261+/// ancestors before comparing, and refuses an in-repo output that holds1262+/// tracked files (f0rge #77).11441263 fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {1145-    let out_abs = absolute(out);1146-    let repo_abs = repo.canonicalize().unwrap_or_else(|_| absolute(repo));1264+    let out_path = normalize_lexical(&absolute(out));1265+    let out_abs = resolve_existing(&out_path);1266+    let repo_abs = resolve_existing(repo);11471267     if out_abs == repo_abs || repo_abs.starts_with(&out_abs) {11481268         return Err(format!(11491269             "hu13: refusing to clean output dir {}: it contains the repo",@@ -1153,25 +1273,114 @@ fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {11531273     if out_abs.parent().is_none() {11541274         return Err("hu13: refusing to use the filesystem root as output dir".to_string());11551275     }1156-    match std::fs::symlink_metadata(&out_abs) {1276+    if let Ok(rel) = out_abs.strip_prefix(&repo_abs) {1277+        if rel1278+            .components()1279+            .next()1280+            .is_some_and(|c| c.as_os_str() == ".git")1281+            || has_tracked_under(&repo_abs, rel)1282+        {1283+            return Err(format!(1284+                "hu13: refusing to clean in-repo output dir {}: it holds tracked files",1285+                out.display()1286+            ));1287+        }1288+    }1289+    match std::fs::symlink_metadata(&out_path) {11571290         Ok(meta) if meta.file_type().is_symlink() => {1158-            std::fs::remove_file(&out_abs)1291+            std::fs::remove_file(&out_path)11591292                 .map_err(|e| format!("hu13: remove symlinked out dir {}: {e}", out.display()))?;11601293         }11611294         Ok(meta) if meta.is_dir() => {1162-            std::fs::remove_dir_all(&out_abs)1295+            std::fs::remove_dir_all(&out_path)11631296                 .map_err(|e| format!("hu13: clean output dir {}: {e}", out.display()))?;11641297         }11651298         Ok(_) => {1166-            std::fs::remove_file(&out_abs)1299+            std::fs::remove_file(&out_path)11671300                 .map_err(|e| format!("hu13: remove output file {}: {e}", out.display()))?;11681301         }11691302         Err(_) => {}11701303     }1171-    std::fs::create_dir_all(&out_abs)1304+    std::fs::create_dir_all(&out_path)11721305         .map_err(|e| format!("hu13: create output dir {}: {e}", out.display()))11731306 }11741307 1308+/// Make `og:image` absolute on every built page when a public origin is1309+/// configured (f0rge #95). Done once over the output tree because the1310+/// origin is build config, not page content: all pages share the same1311+/// `content="/og.png"` marker.1312+fn absolutize_social_meta(out: &Path, origin: &str) -> Result<(), String> {1313+    let base = origin.trim_end_matches('/');1314+    if base.is_empty() {1315+        return Ok(());1316+    }1317+    let absolute = format!("content=\"{}/og.png\"", esc(base));1318+    let mut stack = vec![out.to_path_buf()];1319+    while let Some(dir) = stack.pop() {1320+        let rd = std::fs::read_dir(&dir)1321+            .map_err(|e| format!("hu13: social meta read {}: {e}", dir.display()))?;1322+        for ent in rd {1323+            let ent = ent.map_err(|e| format!("hu13: social meta entry: {e}"))?;1324+            let path = ent.path();1325+            if path.is_dir() {1326+                stack.push(path);1327+            } else if path.extension().is_some_and(|x| x == "html") {1328+                let html = std::fs::read_to_string(&path)1329+                    .map_err(|e| format!("hu13: social meta read {}: {e}", path.display()))?;1330+                let replaced = html.replace("content=\"/og.png\"", &absolute);1331+                if replaced != html {1332+                    std::fs::write(&path, replaced)1333+                        .map_err(|e| format!("hu13: social meta write {}: {e}", path.display()))?;1334+                }1335+            }1336+        }1337+    }1338+    Ok(())1339+}1340+1341+/// Check an output tree against deployment limits. Returns the first1342+/// offending file (or the file-count overflow) with enough context to1343+/// fix the repo. Kept separate from the walk so tests can use tiny caps.1344+pub fn check_output_budget(1345+    out: &Path,1346+    max_file_bytes: u64,1347+    max_files: usize,1348+) -> Result<(), String> {1349+    let mut stack = vec![out.to_path_buf()];1350+    let mut files = 0usize;1351+    while let Some(dir) = stack.pop() {1352+        let rd = std::fs::read_dir(&dir)1353+            .map_err(|e| format!("hu13: output budget read {}: {e}", dir.display()))?;1354+        for ent in rd {1355+            let ent = ent.map_err(|e| format!("hu13: output budget entry: {e}"))?;1356+            let path = ent.path();1357+            let meta = ent1358+                .metadata()1359+                .map_err(|e| format!("hu13: output budget stat {}: {e}", path.display()))?;1360+            if meta.is_dir() {1361+                stack.push(path);1362+            } else if meta.is_file() {1363+                files += 1;1364+                if files > max_files {1365+                    return Err(format!(1366+                        "hu13: output has more than {max_files} files (Cloudflare Pages limit);                          last: {}",1367+                        path.display()1368+                    ));1369+                }1370+                if meta.len() > max_file_bytes {1371+                    return Err(format!(1372+                        "hu13: output file {} is {} bytes, over the Cloudflare Pages limit of {} bytes;                          keep large assets out of git or use Git LFS",1373+                        path.display(),1374+                        meta.len(),1375+                        max_file_bytes1376+                    ));1377+                }1378+            }1379+        }1380+    }1381+    Ok(())1382+}1383+11751384 /// Build the full static site. Returns a summary on success.11761385 pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {11771386     let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())@@ -1308,7 +1517,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu13081517             format!(".. ({parent})")13091518         };13101519         let mut body = breadcrumb(dir);1311-        body.push_str(&format!("<h1>dir: {}</h1>\n", esc(dir),));1520+        body.push_str(&format!("<h1>{}/</h1>\n", esc(dir)));13121521         body.push_str(&file_table_html(13131522             dir,13141523             &entries,@@ -1407,7 +1616,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu14071616         &short_sha,14081617         desc_opt,14091618     )?;1410-    feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;1619+    feed::render_loaded_with_origin(1620+        repo,1621+        out,1622+        &branch,1623+        &name,1624+        &commit_log,1625+        desc_opt,1626+        public_origin.as_deref(),1627+    )?;14111628     let wiki_pages = wiki::render(repo, out, &name, &nav, &branch, &short_sha, desc_opt)?;14121629     // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.14131630     // Tasks/releases/wiki indexes always render even when empty (m6@@ -1511,6 +1728,28 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu15111728     std::fs::write(out.join("index.html"), index)15121729         .map_err(|e| format!("hu13: write index.html: {e}"))?;15131730 1731+    // Branded 404 for Pages/nginx; `serve` streams this file with status1732+    // 404 on unknown paths (f0rge #85). Same nav/footer as every page.1733+    let not_found = crate::layout::page_site_with_provenance(1734+        &format!("not found — {name}"),1735+        &nav,1736+        "<h1>404</h1>\n<p>Nothing here.</p>\n<p><a href=\"/\">Back to code</a></p>\n",1737+        &name,1738+        desc_opt,1739+        &branch,1740+        &short_sha,1741+    );1742+    std::fs::write(out.join("404.html"), not_found)1743+        .map_err(|e| format!("hu13: write 404.html: {e}"))?;1744+1745+    if let Some(origin) = public_origin.as_deref() {1746+        absolutize_social_meta(out, origin)?;1747+    }1748+1749+    // Fail loudly here instead of letting Pages reject an opaque deploy1750+    // (f0rge #81). Counts the whole emitted tree, including repo.git.1751+    check_output_budget(out, PAGES_MAX_FILE_BYTES, PAGES_MAX_FILES)?;1752+15141753     Ok(BuildSummary {15151754         branch,15161755         short_sha,@@ -2235,6 +2474,33 @@ mod tests {22352474         assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC");22362475     }22372476 2477+    #[test]2478+    fn output_budget_reports_file_size_and_count() {2479+        let dir = mktemp("budget");2480+        std::fs::write(dir.join("small.txt"), "ok").unwrap();2481+        std::fs::create_dir_all(dir.join("nested")).unwrap();2482+        std::fs::write(dir.join("nested/big.bin"), vec![0u8; 64]).unwrap();2483+        assert!(check_output_budget(&dir, 32, 10).is_err(), "size cap");2484+        assert!(check_output_budget(&dir, 1024, 1).is_err(), "count cap");2485+        assert!(check_output_budget(&dir, 1024, 2).is_ok(), "within caps");2486+        std::fs::remove_dir_all(&dir).unwrap();2487+    }2488+2489+    #[test]2490+    fn hostile_dates_are_rejected() {2491+        // Hostile `.forge` timestamps must not panic (f0rge #82).2492+        assert!(parse_ts_secs("100000000000000000-01-01T00:00:00Z").is_none());2493+        assert!(parse_ts_secs("-9223372036854775808-01-01T00:00:00Z").is_none());2494+        assert!(parse_ts_secs("0000-01-01T00:00:00Z").is_none());2495+        assert!(parse_ts_secs("10000-01-01T00:00:00Z").is_none());2496+        assert!(parse_ts_secs("2026-02-31T00:00:00Z").is_none());2497+        assert!(parse_ts_secs("2026-04-31T00:00:00Z").is_none());2498+        assert!(parse_ts_secs("2026-02-29T00:00:00Z").is_none());2499+        assert!(parse_ts_secs("2024-02-29T00:00:00Z").is_some());2500+        assert!(parse_ts_secs("2026-01-01T00:00:00+2500").is_none());2501+        assert!(parse_ts_secs("2026-01-01T00:00:00+0060").is_none());2502+    }2503+22382504     #[test]22392505     fn relative_age_fixed_clock() {22402506         // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.
src/feed.rs+49 −4
diff --git a/src/feed.rs b/src/feed.rsindex c6b99b6..24be85d 100644--- a/src/feed.rs+++ b/src/feed.rs@@ -143,12 +143,31 @@ pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result<Vec<F143143 /// when present, becomes the feed `<subtitle>` (escaped; hostile markup144144 /// renders as text). `None` omits `<subtitle>` entirely.145145 #[must_use]146+#[allow(dead_code)]146147 pub fn render_feed(147148     site_name: &str,148149     branch: &str,149150     entries: &[FeedEntry],150151     description: Option<&str>,151152 ) -> String {153+    render_feed_with_origin(site_name, branch, entries, description, None)154+}155+156+/// [`render_feed`] with absolute entry links (f0rge #96): Atom readers157+/// disagree on relative IRIs, so prefix `/...` links with the public158+/// origin when one is configured. Already-absolute links stay untouched.159+#[must_use]160+pub fn render_feed_with_origin(161+    site_name: &str,162+    branch: &str,163+    entries: &[FeedEntry],164+    description: Option<&str>,165+    public_origin: Option<&str>,166+) -> String {167+    let origin = public_origin168+        .map(str::trim)169+        .map(|s| s.trim_end_matches('/'))170+        .filter(|s| !s.is_empty());152171     let updated = entries153172         .first()154173         .map_or_else(|| "1970-01-01T00:00:00Z".to_string(), |e| esc(&e.updated));@@ -161,12 +180,22 @@ pub fn render_feed(161180     out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));162181     out.push_str(&format!("<updated>{updated}</updated>\n"));163182     out.push_str("<author><name>hu13</name></author>\n");183+    if let Some(base) = origin {184+        out.push_str(&format!(185+            "<link rel=\"self\" href=\"{}/feed.atom\"/>\n",186+            esc(base)187+        ));188+    }164189     for e in entries {165190         out.push_str("<entry>\n");166191         out.push_str(&format!("<title>{}</title>\n", esc(&e.title)));167192         out.push_str(&format!("<id>{}</id>\n", esc(&e.id)));168193         out.push_str(&format!("<updated>{}</updated>\n", esc(&e.updated)));169-        out.push_str(&format!("<link href=\"{}\"/>\n", esc(&e.link)));194+        let link = match origin {195+            Some(base) if e.link.starts_with('/') => format!("{base}{}", e.link),196+            _ => e.link.clone(),197+        };198+        out.push_str(&format!("<link href=\"{}\"/>\n", esc(&link)));170199         out.push_str(&format!("<summary>{}</summary>\n", esc(&e.summary)));171200         out.push_str("</entry>\n");172201     }@@ -185,10 +214,11 @@ pub fn render(185214     description: Option<&str>,186215 ) -> Result<usize, String> {187216     let entries = collect_entries(repo, branch)?;188-    render_entries(out, site_name, branch, &entries, description)217+    render_entries(out, site_name, branch, &entries, description, None)189218 }190219 191220 /// Build `feed.atom` from already-loaded commits (one struct pass).221+#[allow(dead_code)]192222 pub fn render_loaded(193223     repo: &Path,194224     out: &Path,@@ -196,9 +226,23 @@ pub fn render_loaded(196226     site_name: &str,197227     log: &[gitx::Commit],198228     description: Option<&str>,229+) -> Result<usize, String> {230+    render_loaded_with_origin(repo, out, branch, site_name, log, description, None)231+}232+233+/// [`render_loaded`] with absolute links for a configured public origin.234+#[allow(clippy::too_many_arguments)]235+pub fn render_loaded_with_origin(236+    repo: &Path,237+    out: &Path,238+    branch: &str,239+    site_name: &str,240+    log: &[gitx::Commit],241+    description: Option<&str>,242+    public_origin: Option<&str>,199243 ) -> Result<usize, String> {200244     let entries = collect_entries_loaded(repo, log)?;201-    render_entries(out, site_name, branch, &entries, description)245+    render_entries(out, site_name, branch, &entries, description, public_origin)202246 }203247 204248 fn render_entries(@@ -207,8 +251,9 @@ fn render_entries(207251     branch: &str,208252     entries: &[FeedEntry],209253     description: Option<&str>,254+    public_origin: Option<&str>,210255 ) -> Result<usize, String> {211-    let doc = render_feed(site_name, branch, entries, description);256+    let doc = render_feed_with_origin(site_name, branch, entries, description, public_origin);212257     std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;213258     std::fs::write(out.join("feed.atom"), doc)214259         .map_err(|e| format!("hu13: write feed.atom: {e}"))?;
src/forge.rs+46 −9
diff --git a/src/forge.rs b/src/forge.rsindex a9ada13..c9f7ce6 100644--- a/src/forge.rs+++ b/src/forge.rs@@ -765,6 +765,26 @@ fn load_milestones(root: &Path) -> Vec<Milestone> {765765     }766766 }767767 768+/// Remove builder-local absolute paths from published CI logs (f0rge769+/// #94): cargo writes `Checking hu13 (/home/alice/.../hu13)`. Replace770+/// the canonical repo root with `<repo>`, then `$HOME` with `~`.771+fn redact_paths(text: &str, root: &Path) -> String {772+    let mut out = text.to_string();773+    let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());774+    for p in [root_abs.as_path(), root] {775+        let needle = p.to_string_lossy();776+        if !needle.is_empty() && needle != "/" {777+            out = out.replace(needle.as_ref(), "<repo>");778+        }779+    }780+    if let Ok(home) = std::env::var("HOME") {781+        if !home.is_empty() && home != "/" {782+            out = out.replace(&home, "~");783+        }784+    }785+    out786+}787+768788 fn truncate_log(text: &str) -> String {769789     if text.len() <= LOG_PREVIEW_MAX {770790         return text.to_string();@@ -778,6 +798,8 @@ fn truncate_log(text: &str) -> String {778798 779799 fn load_ci(root: &Path) -> CiInfo {780800     let dir = root.join("ci");801+    // `root` is `<repo>/.forge`; logs name the repo checkout itself.802+    let repo_root = root.parent().unwrap_or(root);781803     let runner = if dir.join("ci.env").exists() {782804         match read_text_capped(&dir.join("ci.env"), "ci/ci.env") {783805             Some(text) => match yaml_serde::from_str::<CiEnvFm>(&text) {@@ -817,7 +839,8 @@ fn load_ci(root: &Path) -> CiInfo {817839             }818840             let bytes = std::fs::read(dir.join("runs").join(&ts).join("steps").join(&name))819841                 .unwrap_or_default();820-            logs.push((name, truncate_log(&String::from_utf8_lossy(&bytes))));842+            let text = String::from_utf8_lossy(&bytes);843+            logs.push((name, truncate_log(&redact_paths(&text, repo_root))));821844         }822845         Some(CiRun {823846             run: fm.run,@@ -832,7 +855,7 @@ fn load_ci(root: &Path) -> CiInfo {832855                     duration_ms: s.duration_ms,833856                 })834857                 .collect(),835-            body: body.to_string(),858+            body: redact_paths(body, repo_root),836859             logs,837860         })838861     });@@ -1142,14 +1165,19 @@ fn render_issues_index(11421165         // non-open (newest-first within). Numbers stay creation-rank.11431166         // m6/cards2 (#46 + #47): title-first column order.11441167         for issue in data.issues_in_display_order() {1168+            let state_plain = esc(&issue.state);1169+            let state_html = state_pill(&issue.state);1170+            let created_plain = time_ago(&issue.created, now);11451171             body.push_str(&format!(1146-                "<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",1172+                "<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",11471173                 n = issue.number,11481174                 id = esc(&issue.id),11491175                 title = esc(&issue.title),1150-                state = state_pill(&issue.state),1176+                state_plain = state_plain,1177+                state_html = state_html,11511178                 comments = issue.comments.len(),1152-                created = time_ago(&issue.created, now),1179+                created = esc(&created_plain),1180+                created_html = created_plain,11531181             ));11541182         }11551183         body.push_str("</tbody>\n</table></div>\n");@@ -1207,19 +1235,28 @@ fn render_pulls_index(12071235         // Numbers stay creation-rank.12081236         // m6/cards2 (#46 + #47): title-first column order.12091237         for pr in data.prs_in_display_order() {1210-            let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));1238+            let lanes = format!(1239+                "<span class=\"lane\">{}</span><span class=\"lane-arrow\" aria-hidden=\"true\">→</span><span class=\"lane\">{}</span>",1240+                esc(&pr.head),1241+                esc(&pr.base)1242+            );12111243             let lanes_plain = format!("{}→{}", pr.head, pr.base);12121244             let draft = if pr.draft { " draft" } else { "" };12131245             let full_title = format!("{}{}", pr.title, draft);1246+            let state_plain = esc(&pr.state);1247+            let state_html = state_pill(&pr.state);1248+            let created_plain = time_ago(&pr.created, now);12141249             body.push_str(&format!(1215-                "<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",1250+                "<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",12161251                 n = pr.number,12171252                 id = esc(&pr.id),12181253                 title = esc(&pr.title),12191254                 full = esc(&full_title),1220-                state = state_pill(&pr.state),1255+                state_plain = state_plain,1256+                state_html = state_html,12211257                 lanes_title = esc(&lanes_plain),1222-                created = time_ago(&pr.created, now),1258+                created = esc(&created_plain),1259+                created_html = created_plain,12231260             ));12241261         }12251262         body.push_str("</tbody>\n</table></div>\n");
src/highlight.rs+58 −23
diff --git a/src/highlight.rs b/src/highlight.rsindex 5a79051..65c885d 100644--- a/src/highlight.rs+++ b/src/highlight.rs@@ -235,48 +235,74 @@ pub fn highlight_lines(path: &str, text: &str) -> Option<Vec<String>> {235235     Some(out)236236 }237237 238+fn starts_with_at(chars: &[char], at: usize, pat: &str) -> bool {239+    for (i, c) in (at..).zip(pat.chars()) {240+        if chars.get(i) != Some(&c) {241+            return false;242+        }243+    }244+    true245+}246+247+/// Find `pat` at or after char index `from`, without building a248+/// `String` for the rest of the line (the old scanner did that once per249+/// character, which made minified files quadratic; f0rge #78).250+fn find_at(chars: &[char], from: usize, pat: &str) -> Option<usize> {251+    let len = pat.chars().count();252+    if len == 0 {253+        return Some(from);254+    }255+    let last = chars.len().checked_sub(len)?;256+    if from > last {257+        return None;258+    }259+    (from..=last).find(|&i| starts_with_at(chars, i, pat))260+}261+262+fn slice(chars: &[char], from: usize, to: usize) -> String {263+    chars[from..to].iter().collect()264+}265+238266 fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {239267     let chars: Vec<char> = line.chars().collect();240268     let mut out = String::with_capacity(line.len() + 16);241269     let mut i = 0;242270     while i < chars.len() {243-        let rest: String = chars[i..].iter().collect();244-245271         if *in_block {246272             if let Some((_, end)) = lang.block {247-                if let Some(pos) = rest.find(end) {248-                    let stop = i + rest[..pos].chars().count() + end.chars().count();249-                    let piece: String = chars[i..stop].iter().collect();250-                    push_span(&mut out, "com", &piece);273+                if let Some(end_at) = find_at(&chars, i, end) {274+                    let stop = end_at + end.chars().count();275+                    push_span(&mut out, "com", &slice(&chars, i, stop));251276                     *in_block = false;252277                     i = stop;253278                     continue;254279                 }255280             }256-            let piece: String = chars[i..].iter().collect();257-            push_span(&mut out, "com", &piece);281+            push_span(&mut out, "com", &slice(&chars, i, chars.len()));258282             return out;259283         }260284 261285         if let Some((start, end)) = lang.block {262-            if let Some(after_start) = rest.strip_prefix(start) {263-                if let Some(pos) = after_start.find(end) {264-                    let stop = i + rest[..pos + start.len()].chars().count() + end.chars().count();265-                    let piece: String = chars[i..stop].iter().collect();266-                    push_span(&mut out, "com", &piece);286+            if starts_with_at(&chars, i, start) {287+                let after_start = i + start.chars().count();288+                if let Some(end_at) = find_at(&chars, after_start, end) {289+                    let stop = end_at + end.chars().count();290+                    push_span(&mut out, "com", &slice(&chars, i, stop));267291                     i = stop;268292                     continue;269293                 }270294                 *in_block = true;271-                let piece: String = chars[i..].iter().collect();272-                push_span(&mut out, "com", &piece);295+                push_span(&mut out, "com", &slice(&chars, i, chars.len()));273296                 return out;274297             }275298         }276299 277-        if lang.line_comment.iter().any(|m| rest.starts_with(m)) {278-            let piece: String = chars[i..].iter().collect();279-            push_span(&mut out, "com", &piece);300+        if lang301+            .line_comment302+            .iter()303+            .any(|m| starts_with_at(&chars, i, m))304+        {305+            push_span(&mut out, "com", &slice(&chars, i, chars.len()));280306             return out;281307         }282308 @@ -296,8 +322,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {296322                 }297323                 j += 1;298324             }299-            let piece: String = chars[i..j].iter().collect();300-            push_span(&mut out, "str", &piece);325+            push_span(&mut out, "str", &slice(&chars, i, j));301326             i = j;302327             continue;303328         }@@ -309,8 +334,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {309334             {310335                 j += 1;311336             }312-            let piece: String = chars[i..j].iter().collect();313-            push_span(&mut out, "num", &piece);337+            push_span(&mut out, "num", &slice(&chars, i, j));314338             i = j;315339             continue;316340         }@@ -320,7 +344,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {320344             while j < chars.len() && is_ident(chars[j]) {321345                 j += 1;322346             }323-            let word: String = chars[i..j].iter().collect();347+            let word = slice(&chars, i, j);324348             let next_non_space = chars[j..].iter().find(|c| !c.is_whitespace());325349             let call = next_non_space.is_some_and(|c| *c == '(');326350             let class = if lang.keywords.contains(&word.as_str()) {@@ -395,6 +419,17 @@ mod tests {395419         assert!(lines[0].contains("&lt;script&gt;"));396420     }397421 422+    #[test]423+    fn long_single_line_is_not_quadratic() {424+        // 64 KiB on one line took ~27s with the per-char suffix clone;425+        // the linear scanner stays far below this coarse bound (f0rge #78).426+        let big = "var x=1;".repeat(8000);427+        let start = std::time::Instant::now();428+        let lines = highlight_lines("min.js", &big).expect("js");429+        assert!(start.elapsed() < std::time::Duration::from_secs(5));430+        assert!(!lines[0].is_empty());431+    }432+398433     #[test]399434     fn oversized_inputs_stay_plain() {400435         let big = "x".repeat(MAX_HIGHLIGHT_BYTES + 1);
src/layout.rs+65 −22
diff --git a/src/layout.rs b/src/layout.rsindex 20480b7..b2e29e5 100644--- a/src/layout.rs+++ b/src/layout.rs@@ -57,10 +57,13 @@ pub fn diff_html(raw: &str) -> String {5757         let path = diff_chunk_path(chunk);5858         let mut added = 0usize;5959         let mut removed = 0usize;60+        let mut counting_hunk = false;6061         for line in chunk {61-            if line.starts_with('+') && !line.starts_with("+++") {62+            if line.starts_with("@@") {63+                counting_hunk = true;64+            } else if line.starts_with('+') && (counting_hunk || !line.starts_with("+++")) {6265                 added += 1;63-            } else if line.starts_with('-') && !line.starts_with("---") {66+            } else if line.starts_with('-') && (counting_hunk || !line.starts_with("---")) {6467                 removed += 1;6568             }6669         }@@ -82,13 +85,10 @@ pub fn diff_html(raw: &str) -> String {8285 8386         let mut old_ln = 0i64;8487         let mut new_ln = 0i64;85-        let mut first = true;88+        let mut in_hunk = false;8689         for line in chunk {87-            if !first {88-                out.push('\n');89-            }90-            first = false;9190             if line.starts_with("@@") {91+                in_hunk = true;9292                 let mut parts = line.split_whitespace();9393                 let _ = parts.next();9494                 if let Some(o) = parts.next().and_then(|v| v.strip_prefix('-')) {@@ -106,10 +106,22 @@ pub fn diff_html(raw: &str) -> String {106106                         .unwrap_or(0);107107                 }108108                 out.push_str(&format!("<span class=\"diff-hunk\">{}</span>", esc(line)));109-            } else if line.starts_with('+') && !line.starts_with("+++") {109+            } else if line.starts_with("+++") || line.starts_with("---") {110+                if in_hunk {111+                    if line.starts_with('+') {112+                        out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));113+                        new_ln += 1;114+                    } else {115+                        out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));116+                        old_ln += 1;117+                    }118+                } else {119+                    out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));120+                }121+            } else if line.starts_with('+') {110122                 out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));111123                 new_ln += 1;112-            } else if line.starts_with('-') && !line.starts_with("---") {124+            } else if line.starts_with('-') {113125                 out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));114126                 old_ln += 1;115127             } else if line.starts_with(' ') {@@ -121,8 +133,6 @@ pub fn diff_html(raw: &str) -> String {121133                 ));122134                 old_ln += 1;123135                 new_ln += 1;124-            } else if line.starts_with("+++") || line.starts_with("---") {125-                out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));126136             } else {127137                 out.push_str(&format!("<span class=\"df-meta\">{}</span>", esc(line)));128138             }@@ -142,14 +152,15 @@ fn diff_numbered(old: &str, new: &str, class: &str, line: &str) -> String {142152 /// Path for one chunk: `+++ b/<p>` first, then `--- a/<p>`, then the143153 /// `diff --git` header. `/dev/null` falls through to the other side.144154 fn diff_chunk_path(lines: &[&str]) -> String {145-    for line in lines {155+    let headers = lines.iter().take_while(|l| !l.starts_with("@@"));156+    for line in headers.clone() {146157         if let Some(rest) = line.strip_prefix("+++ ") {147158             if rest != "/dev/null" {148159                 return rest.strip_prefix("b/").unwrap_or(rest).to_string();149160             }150161         }151162     }152-    for line in lines {163+    for line in headers {153164         if let Some(rest) = line.strip_prefix("--- ") {154165             if rest != "/dev/null" {155166                 return rest.strip_prefix("a/").unwrap_or(rest).to_string();@@ -168,16 +179,12 @@ fn diff_chunk_path(lines: &[&str]) -> String {168179 /// with line-level coloring (markers stay span-free, old behavior).169180 fn plain_diff_html(raw: &str) -> String {170181     let mut out = String::with_capacity(raw.len() + raw.len() / 4);171-    let mut first = true;172182     for line in raw.lines() {173-        if !first {174-            out.push('\n');175-        }176-        first = false;183+        // One block span per line; no literal newlines between spans or184+        // `white-space:pre` inserts a blank row (f0rge #86).185+        out.push_str("<span class=\"dline\">");177186         out.push_str(&diff_line_span(line));178-    }179-    if raw.ends_with('\n') {180-        out.push('\n');187+        out.push_str("</span>");181188     }182189     out183190 }@@ -322,7 +329,7 @@ pub fn provenance_footer(branch: Option<&str>, short_sha: Option<&str>) -> Strin322329         }323330     }324331     format!(325-        "<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>"332+        "<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>"326333     )327334 }328335 @@ -644,6 +651,42 @@ mod tests {644651         assert!(!html.contains("<span> line one"), "context has no span");645652     }646653 654+    #[test]655+    fn diff_rows_have_no_blank_lines_and_content_headers_count() {656+        // f0rge #86: no literal newline between block rows.657+        // f0rge #87: `++++` after a hunk is added content, not a header.658+        let raw = "diff --git a/a.txt b/a.txt\n\659+--- a/a.txt\n+++ b/a.txt\n\660+@@ -1 +1,4 @@\n context\n\661+++++ not a header\n+--- not a header\n----- removed content\n+plain\n";662+        let html = diff_html(raw);663+        assert!(664+            !html.contains("</span>\n<span class=\"df-line\""),665+            "no newline between diff rows: {html}"666+        );667+        assert!(668+            html.contains("<span class=\"df-add\">+3</span>"),669+            "three added lines, including +++ content: {html}"670+        );671+        assert!(672+            html.contains("<span class=\"df-del\">−1</span>"),673+            "one removed line, including --- content: {html}"674+        );675+        assert!(676+            html.contains("<span class=\"diff-add\">++++ not a header</span>"),677+            "added +++ content keeps its marker and is not a file header: {html}"678+        );679+        assert!(680+            html.contains("<span class=\"diff-del\">----- removed content</span>"),681+            "removed --- content is a removal: {html}"682+        );683+        assert_eq!(684+            html.matches("<span class=\"diff-file\">").count(),685+            2,686+            "only the real ---/+++ file headers"687+        );688+    }689+647690     #[test]648691     fn diff_html_escapes_before_wrapping() {649692         let html = diff_html("+evil <script>alert(1)</script>\n-old & \"quoted\"\n");
src/serve.rs+61 −26
diff --git a/src/serve.rs b/src/serve.rsindex 12718d1..85df371 100644--- a/src/serve.rs+++ b/src/serve.rs@@ -485,7 +485,9 @@ const RAW_ACTIVE_EXTS: &[&str] = &[485485     "pdf", "swf", "hta", "jar", "crx", "xpi",486486 ];487487 488-/// Text-ish extensions safe to show inline from `/raw/`.488+/// Text-ish extensions that keep a text MIME type. They still download:489+/// `_headers` sends `attachment` for every `/raw/*` path, so `serve` uses490+/// the same disposition instead of rendering text inline (f0rge #93).489491 const RAW_TEXT_EXTS: &[&str] = &[490492     "txt",491493     "text",@@ -550,14 +552,14 @@ fn extension(name: &str) -> String {550552 ///551553 /// Never `text/html`/`image/svg+xml`: a hostile repo could otherwise run552554 /// script in the site's origin (where the inbox endpoints live). Active553-/// types become an octet-stream download; text stays inline as554-/// `text/plain`; everything else downloads.555+/// types become an octet-stream download; text keeps `text/plain` but556+/// also downloads; everything downloads with `attachment` (f0rge #93).555557 fn raw_content_type(name: &str) -> (&'static str, &'static str) {556558     let ext = extension(name);557559     if RAW_ACTIVE_EXTS.contains(&ext.as_str()) {558560         ("application/octet-stream", "attachment")559561     } else if RAW_TEXT_EXTS.contains(&ext.as_str()) {560-        ("text/plain; charset=utf-8", "inline")562+        ("text/plain; charset=utf-8", "attachment")561563     } else {562564         ("application/octet-stream", "attachment")563565     }@@ -706,13 +708,17 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {706708     };707709     let dest = absolutize(&shared.www.join(&file_rel));708710     if dest != absolutize(&shared.www) && !dest.starts_with(absolutize(&shared.www)) {709-        static_error(&mut stream, 404);711+        static_not_found(&mut stream, &head, shared);710712         return;711713     }712-    let bytes = match std::fs::read(&dest) {713-        Ok(b) if dest.is_file() => b,714+    // Stat, then stream. Never read a static file into memory: a raw715+    // blob can be hundreds of MB and MAX_CONNS=64 (f0rge #80). The716+    // method check stays after existence so an unknown POST is still717+    // 404 (fail closed, no route discovery).718+    let meta = match std::fs::metadata(&dest) {719+        Ok(m) if m.is_file() => m,714720         _ => {715-            static_error(&mut stream, 404);721+            static_not_found(&mut stream, &head, shared);716722             return;717723         }718724     };@@ -732,22 +738,10 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {732738         &[]733739     };734740     if head.method == "HEAD" {735-        let mut out = format!(736-            "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",737-            bytes.len()738-        );739-        for (k, v) in extra {740-            out.push_str(k);741-            out.push_str(": ");742-            out.push_str(v);743-            out.push_str("\r\n");744-        }745-        out.push_str("\r\n");746-        let _ = stream.write_all(out.as_bytes());747-        let _ = stream.flush();741+        respond_file_head(&mut stream, 200, ctype, extra, meta.len());748742         return;749743     }750-    respond(&mut stream, 200, ctype, extra, &bytes);744+    respond_file(&mut stream, 200, ctype, extra, &dest, meta.len());751745 }752746 753747 /// Read-and-discard in-flight request bytes (bounded) so early rejects@@ -800,13 +794,12 @@ impl Drop for GitSlot {800794     }801795 }802796 803-/// Stream a file body with `Content-Length` (no full-pack buffering).804-fn respond_file(797+/// Headers for a file response, without opening the file body.798+fn respond_file_head(805799     stream: &mut TcpStream,806800     code: u16,807801     content_type: &str,808802     extra: &[(&str, &str)],809-    path: &Path,810803     len: u64,811804 ) {812805     let mut out = format!(@@ -820,9 +813,51 @@ fn respond_file(820813         out.push_str("\r\n");821814     }822815     out.push_str("\r\n");823-    if stream.write_all(out.as_bytes()).is_err() {816+    let _ = stream.write_all(out.as_bytes());817+    let _ = stream.flush();818+}819+820+/// Serve the built `404.html` (same chrome as every page) with status821+/// 404. Falls back to the tiny in-binary stub when the file is absent.822+/// POSTs keep the old non-echoing stub (`f0rge #85`).823+fn static_not_found(stream: &mut TcpStream, head: &Head, shared: &Shared) {824+    if head.method != "GET" && head.method != "HEAD" {825+        static_error(stream, 404);824826         return;825827     }828+    let dest = shared.www.join("404.html");829+    let Ok(meta) = std::fs::metadata(&dest) else {830+        static_error(stream, 404);831+        return;832+    };833+    if !meta.is_file() {834+        static_error(stream, 404);835+        return;836+    }837+    if head.method == "HEAD" {838+        respond_file_head(stream, 404, "text/html; charset=utf-8", &[], meta.len());839+    } else {840+        respond_file(841+            stream,842+            404,843+            "text/html; charset=utf-8",844+            &[],845+            &dest,846+            meta.len(),847+        );848+    }849+}850+851+/// Stream a file body with `Content-Length` (no full-pack buffering).852+fn respond_file(853+    stream: &mut TcpStream,854+    code: u16,855+    content_type: &str,856+    extra: &[(&str, &str)],857+    path: &Path,858+    len: u64,859+) {860+    respond_file_head(stream, code, content_type, extra, len);826861     if let Ok(mut f) = std::fs::File::open(path) {827862         let _ = std::io::copy(&mut f, stream);828863     }
static/style.css+24 −5
diff --git a/static/style.css b/static/style.cssindex 95af580..471097d 100644--- a/static/style.css+++ b/static/style.css@@ -17,6 +17,7 @@1717   --yellow:#e0af68;1818   --red:#f7768e;1919   --border:#292e42;20+  --field-border:#6a7590;2021   --visited:#9aa5ce;2122   --btn-fg:#16161e;2223   --state-open:#9ece6a;@@ -66,6 +67,7 @@6667     --yellow:#7d5f33;6768     --red:#8c4351;6869     --border:#d6dae2;70+    --field-border:#8a93a5;6971     --visited:#5a4a78;7072     --btn-fg:#ffffff;7173     --state-open:#33635c;@@ -77,9 +79,9 @@7779     --tok-com:#4a5273;7880     --tok-fn:#34548a;7981     --tok-type:#0f4b6e;80-    --diff-add:#33635c;82+    --diff-add:#2a534d;8183     --diff-add-bg:#c6d4ca;82-    --diff-del:#8c4351;84+    --diff-del:#7a3543;8385     --diff-del-bg:#d9c2c7;8486     --diff-hunk:#0f4b6e;8587     --diff-file:#5a4a78;@@ -141,8 +143,8 @@ td.num{width:1%;white-space:nowrap}141143 /* Forms: full-width on mobile, >=40px touch targets. */142144 form{max-width:44rem;margin:1rem 0;min-width:0}143145 label{display:block;margin:.75rem 0 .25rem;font-weight:600}144-input[type=text],textarea{display:block;width:100%;max-width:100%;background:var(--bg-alt);145-  color:var(--fg);border:1px solid var(--border);padding:.5rem .6rem;font:inherit;min-height:40px}146+input[type=text],textarea{display:block;width:100%;max-width:100%;background:var(--surface);147+  color:var(--fg);border:1px solid var(--field-border);padding:.5rem .6rem;font:inherit;min-height:40px}146148 input[type=text]:focus,textarea:focus{outline:2px solid var(--blue);outline-offset:0}147149 textarea{min-height:12rem}148150 button,input[type=submit]{display:inline-flex;align-items:center;justify-content:center;@@ -193,6 +195,7 @@ footer.site-footer .footer-provenance{margin:0;font-size:var(--fs-sm);text-align193195 .site-logo{display:block;width:auto;height:1.75rem;flex:none}194196 .hu13-word{font-weight:700;color:var(--blue)}195197 .sep{flex:none;color:var(--muted)}198+.nav-sep{flex:0 0 auto;color:var(--muted)}196199 .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}197200 .nav-scroll-row{display:flex;align-items:center;gap:.25rem 1rem;white-space:nowrap;width:max-content;min-width:100%}198201 .site-header .site-nav.nav-scroll a{flex:0 0 auto;white-space:nowrap}@@ -362,6 +365,7 @@ time{font-variant-numeric:tabular-nums}362365 .df-bar-add{display:block;height:100%;background:var(--green)}363366 .difffile .pre-scroll{margin:0}364367 pre.diff{margin:0;border:0;background:var(--surface)}368+pre.diff>span{display:block}365369 .df-line{display:block}366370 .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}367371 .dln+.dln{border-right:1px solid var(--border);margin-right:.5em}@@ -370,11 +374,26 @@ pre.diff{margin:0;border:0;background:var(--surface)}370374 .diff-file{color:var(--diff-file);font-weight:600}371375 .df-meta{color:var(--fg-dim)}372376 377+/* Tablet nav (f0rge #91): 768–1099px scrolls off the last tab with no378+   affordance. Give the nav its own wrapped row until the full one-row379+   header fits at 1100px; separators would otherwise begin a line. */380+@media (min-width:768px) and (max-width:1099px){381+  .site-header .wrap.header-bar{flex-wrap:wrap}382+  .site-header .sep,.site-header .nav-sep{display:none}383+  .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)}384+  .nav-scroll-row{flex-wrap:wrap;width:auto;min-width:0;white-space:normal;column-gap:.9rem;row-gap:.1rem}385+}386+/* Branch lanes (f0rge #92): never break inside a branch name; wrap only387+   between head, arrow and base (a very long token scrolls its cell).388+   Mobile card cells inherit word-break:break-all, so reset it here. */389+.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}390+.lanes .lane{white-space:nowrap}391+.lanes .lane-arrow{color:var(--muted)}373392 /* Responsive fixes (f0rge #75): every nav destination visible at 390px,374393    file table keeps Name + Age, meta chips never overflow. */375394 @media (max-width:767px){376395   .site-header .wrap.header-bar{flex-wrap:wrap}377-  .site-header .sep{display:none}396+  .site-header .sep,.site-header .nav-sep{display:none}378397   .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)}379398   .nav-scroll-row{flex-wrap:wrap;width:auto;min-width:0;white-space:normal;column-gap:.9rem;row-gap:.1rem}380399   /* Dense file table: drop "Last commit" below 768px instead of wrapping
tests/clean_out.rs+58 −0
diff --git a/tests/clean_out.rs b/tests/clean_out.rsindex fdc1c03..1d56d90 100644--- a/tests/clean_out.rs+++ b/tests/clean_out.rs@@ -122,3 +122,61 @@ fn refuses_to_clean_repo_or_its_parent() {122122         assert!(sender.is_file(), "repo untouched");123123     }124124 }125+126+#[test]127+fn refuses_relative_dotdot_output_and_keeps_parent() {128+    let root = fresh_dir("dotdot");129+    let repo = root.join("repo");130+    std::fs::create_dir_all(repo.join("src")).unwrap();131+    git(&repo, &["init", "-b", "main"]);132+    git(&repo, &["config", "user.name", "hu13-test"]);133+    git(&repo, &["config", "user.email", "[email protected]"]);134+    git(&repo, &["config", "commit.gpgsign", "false"]);135+    std::fs::write(repo.join("README.md"), "# keep\n").unwrap();136+    std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();137+    git(&repo, &["add", "-A"]);138+    git(&repo, &["commit", "-m", "one"]);139+    let canary = root.join("canary.txt");140+    std::fs::write(&canary, "must survive\n").unwrap();141+142+    let res = Command::new(env!("CARGO_BIN_EXE_hu13"))143+        .arg("build")144+        .arg(&repo)145+        .arg("-o")146+        .arg("..")147+        .current_dir(&repo)148+        .output()149+        .expect("spawn hu13 build");150+    assert!(!res.status.success(), "relative .. must be refused");151+    let err = String::from_utf8_lossy(&res.stderr);152+    assert!(err.contains("refusing"), "stderr: {err}");153+    assert!(canary.is_file(), "parent canary must survive");154+    assert!(repo.join("README.md").is_file(), "repo must survive");155+}156+157+#[cfg(unix)]158+#[test]159+fn refuses_symlinked_ancestor_into_repo() {160+    let root = fresh_dir("symlink");161+    let repo = root.join("repo");162+    std::fs::create_dir_all(repo.join("src")).unwrap();163+    git(&repo, &["init", "-b", "main"]);164+    git(&repo, &["config", "user.name", "hu13-test"]);165+    git(&repo, &["config", "user.email", "[email protected]"]);166+    git(&repo, &["config", "commit.gpgsign", "false"]);167+    std::fs::write(repo.join("README.md"), "# keep\n").unwrap();168+    std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();169+    git(&repo, &["add", "-A"]);170+    git(&repo, &["commit", "-m", "one"]);171+    let alias = root.join("alias");172+    std::os::unix::fs::symlink(&repo, &alias).unwrap();173+174+    let res = build(&repo, &alias.join("src"));175+    assert!(!res.status.success(), "symlinked src must be refused");176+    let err = String::from_utf8_lossy(&res.stderr);177+    assert!(err.contains("refusing"), "stderr: {err}");178+    assert!(179+        repo.join("src/main.rs").is_file(),180+        "tracked source must survive"181+    );182+}
tests/light_theme.rs+3 −3
diff --git a/tests/light_theme.rs b/tests/light_theme.rsindex 42f309b..575c1a9 100644--- a/tests/light_theme.rs+++ b/tests/light_theme.rs@@ -167,7 +167,7 @@ fn no_hardcoded_palette_hex_outside_root() {167167         "#f7768e", "#292e42", "#1f2335", "#9aa5ce", "#f6f7f9", "#343b58", "#34548a", "#0f4b6e",168168         "#8c4351", "#33635c", "#eceef2", "#e4e7ee", "#5a4a78", "#7d5f33", "#ffffff", "#233328",169169         "#38232e", "#c6d4ca", "#d9c2c7", "#d6dae2", "#4a5273", "#1f2335", "#24283b", "#bb9af7",170-        "#ff9e64", "#7f87ad", "#2ac3de", "#8c6c3e",170+        "#ff9e64", "#7f87ad", "#2ac3de", "#8c6c3e", "#2a534d", "#7a3543", "#6a7590", "#8a93a5",171171     ];172172     for hex in palette {173173         assert!(@@ -200,8 +200,8 @@ fn diff_vars_defined_dark_and_light() {200200         "--diff-add:#9ece6a",201201         "--diff-del:#f7768e",202202         "--diff-hunk:#7dcfff",203-        "--diff-add:#33635c",204-        "--diff-del:#8c4351",203+        "--diff-add:#2a534d",204+        "--diff-del:#7a3543",205205         "--diff-hunk:#0f4b6e",206206     ] {207207         assert!(css.contains(var), "diff var defined: {var}");
tests/pages_inbox.test.mjs+159 −0
diff --git a/tests/pages_inbox.test.mjs b/tests/pages_inbox.test.mjsnew file mode 100644index 0000000..f9625c8--- /dev/null+++ b/tests/pages_inbox.test.mjs@@ -0,0 +1,159 @@1+// Cloudflare Pages inbox parity tests (f0rge #79). Run:2+//   node --test tests/pages_inbox.test.mjs3+// No network, no wrangler: a fake R2 bucket exercises validation,4+// origin gate, ack, storage shape, rate and quota.5+import test from "node:test";6+import assert from "node:assert/strict";7+import { onRequestGet, onRequestPost } from "../functions/inbox/[kind].js";8+9+class FakeBucket {10+  constructor() {11+    this.objects = new Map();12+  }13+  async list({ limit = 1000 } = {}) {14+    return {15+      objects: [...this.objects.keys()].slice(0, limit).map((key) => ({ key })),16+      truncated: this.objects.size > limit,17+    };18+  }19+  async put(key, value) {20+    this.objects.set(key, String(value));21+  }22+}23+24+let seq = 0;25+function env(extra = {}) {26+  return {27+    INBOX: new FakeBucket(),28+    INBOX_SALT: "test-salt",29+    ...extra,30+  };31+}32+33+function post(kind, body, headers = {}, e = env()) {34+  const request = new Request(`https://hu13.test/inbox/${kind}`, {35+    method: "POST",36+    headers: {37+      "content-type": "application/x-www-form-urlencoded",38+      "cf-connecting-ip": `203.0.113.${++seq}`,39+      ...headers,40+    },41+    body,42+  });43+  return onRequestPost({ request, env: e, params: { kind } }).then(async (res) => ({44+    res,45+    text: await res.text(),46+    env: e,47+  }));48+}49+50+const validIssue = "title=Hello&body=World&license=agree";51+const validPrompt = "title=Prompt&prompt=Do+the+thing&license=on";52+53+test("GET is 405 with POST allowed and no-store/nosniff", async () => {54+  const res = await onRequestGet();55+  assert.equal(res.status, 405);56+  assert.equal(res.headers.get("allow"), "POST");57+  assert.equal(res.headers.get("x-content-type-options"), "nosniff");58+  assert.equal(res.headers.get("cache-control"), "no-store");59+});60+61+test("valid issue stores one JSON object and returns an ack", async () => {62+  const e = env();63+  const { res, text } = await post("issue", validIssue, {}, e);64+  assert.equal(res.status, 200);65+  assert.match(text, /ack: <code>[0-9a-f-]{36}<\/code>/);66+  assert.equal(e.INBOX.objects.size, 1);67+  const [key, value] = [...e.INBOX.objects.entries()][0];68+  assert.match(key, /^[0-9a-f-]{36}\.json$/);69+  const doc = JSON.parse(value);70+  assert.equal(doc.type, "issue");71+  assert.equal(doc.title, "Hello");72+  assert.equal(doc.body, "World");73+  assert.equal(doc.license_version, "v1");74+  assert.match(doc.received_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);75+  assert.match(doc.salted_ip_hash, /^[0-9a-f]{64}$/);76+  assert.ok(!text.includes("World"), "ack never reflects input");77+});78+79+test("valid pr-prompt uses prompt (and accepts body alias)", async () => {80+  for (const body of [validPrompt, "title=P&body=alias&license=yes"]) {81+    const e = env();82+    const { res } = await post("pr-prompt", body, {}, e);83+    assert.equal(res.status, 200);84+    const doc = JSON.parse([...e.INBOX.objects.values()][0]);85+    assert.equal(doc.type, "pr-prompt");86+    assert.equal(doc.body, body.includes("prompt") ? "Do the thing" : "alias");87+  }88+});89+90+test("missing license, empty fields and oversize are 400", async () => {91+  for (const body of [92+    "title=Hello&body=World",93+    "title=&body=World&license=agree",94+    "title=Hello&body=&license=agree",95+    `title=${"x".repeat(301)}&body=W&license=agree`,96+  ]) {97+    const { res } = await post("issue", body);98+    assert.equal(res.status, 400, body);99+  }100+});101+102+test("honeypot fakes success without storing", async () => {103+  const e = env();104+  const { res, text } = await post(105+    "issue",106+    "title=Hello&body=World&license=agree&website=bot",107+    {},108+    e,109+  );110+  assert.equal(res.status, 200);111+  assert.match(text, /ack: <code>/);112+  assert.equal(e.INBOX.objects.size, 0);113+});114+115+test("malformed escapes, non-UTF-8 and NUL are 400", async () => {116+  for (const body of [117+    "title=%ZZ&body=W&license=agree",118+    "title=%FF&body=W&license=agree",119+    "title=He%00llo&body=W&license=agree",120+  ]) {121+    const { res } = await post("issue", body);122+    assert.equal(res.status, 400, body);123+  }124+});125+126+test("cross-origin browser POST is rejected; curl (no Origin) passes", async () => {127+  const bad = await post("issue", validIssue, { origin: "https://evil.test" });128+  assert.equal(bad.res.status, 403);129+  const nullOrigin = await post("issue", validIssue, { origin: "null" });130+  assert.equal(nullOrigin.res.status, 403);131+  const same = await post("issue", validIssue, { origin: "https://hu13.test" });132+  assert.equal(same.res.status, 200);133+});134+135+test("wrong content type and oversized bodies are rejected", async () => {136+  const json = await post("issue", validIssue, {137+    "content-type": "application/json",138+  });139+  assert.equal(json.res.status, 400);140+  const big = `title=Hello&body=${"x".repeat(33 * 1024)}&license=agree`;141+  const over = await post("issue", big);142+  assert.equal(over.res.status, 413);143+});144+145+test("quota is enforced before storing", async () => {146+  const e = env({ INBOX_QUOTA: "1" });147+  const first = await post("issue", validIssue, {}, e);148+  assert.equal(first.res.status, 200);149+  const second = await post("issue", validIssue, {}, e);150+  assert.equal(second.res.status, 503);151+  assert.equal(e.INBOX.objects.size, 1);152+});153+154+test("missing R2 binding or salt fails closed", async () => {155+  const noBucket = await post("issue", validIssue, {}, { INBOX_SALT: "x" });156+  assert.equal(noBucket.res.status, 503);157+  const noSalt = await post("issue", validIssue, {}, { INBOX: new FakeBucket() });158+  assert.equal(noSalt.res.status, 503);159+});
tests/raw_safety.rs+5 −1
diff --git a/tests/raw_safety.rs b/tests/raw_safety.rsindex 349a1b5..378cf50 100644--- a/tests/raw_safety.rs+++ b/tests/raw_safety.rs@@ -147,7 +147,11 @@ fn raw_active_types_download_never_render() {147147             Some("text/plain; charset=utf-8"),148148             "{path}"149149         );150-        assert_eq!(header(&resp, "Content-Disposition"), Some("inline"));150+        assert_eq!(151+            header(&resp, "Content-Disposition"),152+            Some("attachment"),153+            "{path} downloads, matching _headers"154+        );151155     }152156 153157     let resp = get(&srv.addr, "/raw/blob.bin");
tests/review3_render.rs+264 −0
diff --git a/tests/review3_render.rs b/tests/review3_render.rsnew file mode 100644index 0000000..65b7d2e--- /dev/null+++ b/tests/review3_render.rs@@ -0,0 +1,264 @@1+//! Review #3 UI/build regressions (f0rge #83–#96): indexes, footer,2+//! 404, CRLF gutter, encoded paths, redacted CI logs, absolute social3+//! and feed URLs, dir headings.4+5+use std::io::{BufRead, BufReader, Read, Write};6+use std::net::TcpStream;7+use std::path::{Path, PathBuf};8+use std::process::{Child, Command, Stdio};9+use std::sync::atomic::{AtomicU64, Ordering};10+use std::time::Duration;11+12+static SEQ: AtomicU64 = AtomicU64::new(0);13+14+fn fresh(prefix: &str) -> PathBuf {15+    let dir = std::env::temp_dir().join(format!(16+        "hu13-review3-{prefix}-{}-{}",17+        std::process::id(),18+        std::time::SystemTime::now()19+            .duration_since(std::time::UNIX_EPOCH)20+            .map(|d| d.as_nanos())21+            .unwrap_or(0)22+            .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))23+    ));24+    std::fs::create_dir_all(&dir).unwrap();25+    dir26+}27+28+fn git(repo: &Path, args: &[&str]) {29+    let st = Command::new("git")30+        .arg("-C")31+        .arg(repo)32+        .args(args)33+        .status()34+        .expect("spawn git");35+    assert!(st.success(), "git {args:?} failed");36+}37+38+fn write(repo: &Path, rel: &str, body: &str) {39+    let dest = repo.join(rel);40+    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();41+    std::fs::write(dest, body).unwrap();42+}43+44+const ISSUE: &str = "11111111-1111-4111-8111-111111111111";45+const PR: &str = "44444444-4444-4444-8444-444444444444";46+47+fn fixture() -> PathBuf {48+    let repo = fresh("repo");49+    git(&repo, &["init", "-b", "main"]);50+    git(&repo, &["config", "user.name", "hu13-test"]);51+    git(&repo, &["config", "user.email", "[email protected]"]);52+    git(&repo, &["config", "commit.gpgsign", "false"]);53+    write(&repo, "README.md", "# review fixture\n");54+    write(&repo, "a#b.txt", "hash name\n");55+    write(&repo, "q?x.txt", "query name\n");56+    write(&repo, "src/hi.txt", "subdir\n");57+    write(&repo, "crlf.txt", "a\r\nb\r\n");58+    write(59+        &repo,60+        &format!(".forge/issues/{ISSUE}/issue.md"),61+        &format!(62+            "---\nid: {ISSUE}\ntitle: Closed issue\nstate: closed\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"63+        ),64+    );65+    write(66+        &repo,67+        &format!(".forge/prs/{PR}/pr.md"),68+        &format!(69+            "---\nid: {PR}\ntitle: Closed pull\nstate: closed\nbase: main\nhead: feature/topic\ncreated: 2026-09-10T03:30:00Z\ndraft: false\nissues: []\nci: pass\n---\nbody\n"70+        ),71+    );72+    write(73+        &repo,74+        ".forge/ci/runs/2026-09-10T00-00-00Z/summary.md",75+        "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n  - name: 00_test.sh\n    exit: 0\n    duration_ms: 3\n---\nsummary body\n",76+    );77+    write(78+        &repo,79+        ".forge/ci/runs/2026-09-10T00-00-00Z/steps/00_test.sh.log",80+        &format!("Checking hu13 at {}\nfinished\n", repo.display()),81+    );82+    git(&repo, &["add", "-A"]);83+    git(&repo, &["commit", "-m", "one"]);84+    repo85+}86+87+fn build(repo: &Path, out: &Path) -> std::process::Output {88+    Command::new(env!("CARGO_BIN_EXE_hu13"))89+        .arg("build")90+        .arg(repo)91+        .arg("-o")92+        .arg(out)93+        .arg("--public-origin")94+        .arg("https://hu13.example")95+        .output()96+        .expect("spawn hu13 build")97+}98+99+fn read(path: &Path) -> String {100+    std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))101+}102+103+#[test]104+fn review3_build_regressions() {105+    let repo = fixture();106+    let out = fresh("out");107+    let res = build(&repo, &out);108+    assert!(109+        res.status.success(),110+        "build: {}",111+        String::from_utf8_lossy(&res.stderr)112+    );113+114+    let index = read(&out.join("index.html"));115+    assert!(116+        index.contains("href=\"/files/a%23b.txt.html\""),117+        "hash filename is percent-encoded in links"118+    );119+    assert!(120+        index.contains("href=\"/files/q%3Fx.txt.html\""),121+        "query filename is percent-encoded in links"122+    );123+    assert!(!index.contains("href=\"/files/a#b.txt.html\""));124+    assert!(125+        index.contains("content=\"https://hu13.example/og.png\""),126+        "og:image is absolute with --public-origin"127+    );128+    assert!(129+        index.contains("href=\"/new-issue.html\">submit</a>"),130+        "footer submit points at the form, not POST-only /inbox/issue"131+    );132+133+    let hash_page = read(&out.join("files/a#b.txt.html"));134+    assert!(hash_page.contains("href=\"/raw/a%23b.txt\""));135+    let crlf = read(&out.join("files/crlf.txt.html"));136+    assert!(crlf.contains("id=\"L2\""), "two lines survive");137+    assert!(138+        !crlf.contains("id=\"L3\""),139+        "no phantom L404 after trailing CRLF"140+    );141+    assert!(142+        !crlf.contains("&#13;") && !crlf.contains('\r'),143+        "CR stripped"144+    );145+146+    let dir_page = read(&out.join("files/src.html"));147+    assert!(dir_page.contains("<h1>src/</h1>"));148+    assert!(!dir_page.contains("dir: src"), "debug heading removed");149+150+    let issues = read(&out.join("issues/index.html"));151+    let pulls = read(&out.join("pulls/index.html"));152+    for page in [&issues, &pulls] {153+        assert!(!page.contains("title=\"<"), "no HTML in title attributes");154+        assert!(155+            !page.contains("</span>\">"),156+            "no leaked span attribute text"157+        );158+        assert!(159+            !page.contains("</time>\">"),160+            "no leaked time attribute text"161+        );162+    }163+    assert!(issues.contains("title=\"closed\""), "plain state title");164+    assert!(pulls.contains("class=\"lane\""), "branch lanes are tokens");165+166+    let ci = read(&out.join("ci/index.html"));167+    assert!(168+        !ci.contains(&repo.display().to_string()),169+        "builder path redacted from CI logs"170+    );171+    assert!(172+        ci.contains("&lt;repo&gt;") || ci.contains("<repo>"),173+        "redaction marker"174+    );175+176+    let feed = read(&out.join("feed.atom"));177+    assert!(178+        feed.contains("<link rel=\"self\" href=\"https://hu13.example/feed.atom\"/>"),179+        "feed self link absolute"180+    );181+    assert!(182+        feed.contains("href=\"https://hu13.example/commit/"),183+        "entry links absolute"184+    );185+186+    let not_found = read(&out.join("404.html"));187+    assert!(not_found.contains("<h1>404</h1>"));188+    assert!(not_found.contains("site-footer"), "branded 404 has footer");189+    assert!(out.join("_routes.json").is_file(), "Pages routes emitted");190+    let routes = read(&out.join("_routes.json"));191+    assert!(routes.contains("\"/inbox/*\""));192+}193+194+struct Server {195+    child: Child,196+    addr: String,197+}198+199+impl Drop for Server {200+    fn drop(&mut self) {201+        self.child.kill().ok();202+        self.child.wait().ok();203+    }204+}205+206+fn start(www: &Path, inbox: &Path) -> Server {207+    let mut child = Command::new(env!("CARGO_BIN_EXE_hu13"))208+        .arg("serve")209+        .arg("--www")210+        .arg(www)211+        .arg("--inbox-dir")212+        .arg(inbox)213+        .arg("--listen")214+        .arg("127.0.0.1:0")215+        .stdout(Stdio::piped())216+        .stderr(Stdio::null())217+        .spawn()218+        .expect("spawn serve");219+    let stdout = child.stdout.take().expect("stdout");220+    let mut addr = String::new();221+    for line in BufReader::new(stdout).lines().take(50).flatten() {222+        if let Some(rest) = line.split("listening on ").nth(1) {223+            addr = rest.trim().to_string();224+            break;225+        }226+    }227+    assert!(!addr.is_empty(), "serve printed no address");228+    Server { child, addr }229+}230+231+fn get(addr: &str, path: &str) -> String {232+    let mut s = TcpStream::connect(addr).expect("connect");233+    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();234+    write!(235+        s,236+        "GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"237+    )238+    .unwrap();239+    let mut out = Vec::new();240+    s.read_to_end(&mut out).unwrap();241+    String::from_utf8_lossy(&out).into_owned()242+}243+244+#[test]245+fn serve_uses_branded_404_and_streams_encoded_paths() {246+    let repo = fixture();247+    let out = fresh("serve-out");248+    let res = build(&repo, &out);249+    assert!(res.status.success());250+    let inbox = fresh("inbox");251+    let srv = start(&out, &inbox);252+    std::thread::sleep(Duration::from_millis(100));253+254+    let missing = get(&srv.addr, "/definitely-not-here");255+    assert!(missing.starts_with("HTTP/1.1 404"), "{missing}");256+    assert!(missing.contains("<h1>404</h1>"));257+    assert!(missing.contains("class=\"site-footer\""), "same chrome");258+259+    let encoded = get(&srv.addr, "/files/a%23b.txt.html");260+    assert!(encoded.starts_with("HTTP/1.1 200"), "{encoded}");261+    let raw = get(&srv.addr, "/raw/a%23b.txt");262+    assert!(raw.starts_with("HTTP/1.1 200"), "{raw}");263+    assert!(raw.contains("Content-Disposition: attachment"));264+}
tests/theme_contrast.rs+23 −0
diff --git a/tests/theme_contrast.rs b/tests/theme_contrast.rsindex 0c0fea3..d3ae23d 100644--- a/tests/theme_contrast.rs+++ b/tests/theme_contrast.rs@@ -119,6 +119,8 @@ fn both_themes_pass_aa_for_text_colors() {119119         ("--tok-type", "--surface"),120120         ("--diff-file", "--surface"),121121         ("--diff-hunk", "--surface"),122+        ("--diff-add", "--diff-add-bg"),123+        ("--diff-del", "--diff-del-bg"),122124     ];123125     check("dark", dark, &pairs);124126     check("light", light, &pairs);@@ -157,3 +159,24 @@ fn themes_define_the_same_vars() {157159         );158160     }159161 }162+163+#[test]164+fn field_boundaries_pass_ui_contrast() {165+    // f0rge #90: a control boundary needs 3:1 against its own fill.166+    let css = css();167+    let dark = block(&css, css.find(":root{").expect("dark root"));168+    let light = block(169+        &css,170+        css.find("@media (prefers-color-scheme:light)")171+            .expect("light block"),172+    );173+    for (theme, block) in [("dark", dark), ("light", light)] {174+        let border = var(block, "--field-border");175+        let fill = var(block, "--surface");176+        let ratio = contrast(&border, &fill);177+        assert!(178+            ratio >= 3.0,179+            "{theme}: --field-border {border} on --surface {fill} = {ratio:.2}:1 (UI needs 3)"180+        );181+    }182+}
wrangler.toml+9 −1
diff --git a/wrangler.toml b/wrangler.tomlnew file mode 100644index 0000000..4ed018f--- /dev/null+++ b/wrangler.toml@@ -0,0 +1,9 @@1+# Cloudflare Pages project config (f0rge #79).2+# `npx wrangler pages deploy dist` reads this file and the functions/ dir.3+name = "hu13"4+pages_build_output_dir = "dist"5+compatibility_date = "2026-01-01"6+7+[[r2_buckets]]8+binding = "INBOX"9+bucket_name = "hu13-inbox"0-- 2.56.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls