diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 4d41693..b31be3f 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -1,6 +1,7 @@
name: Check site
on:
+ workflow_call:
pull_request:
branches: ["master"]
@@ -18,6 +19,7 @@ jobs:
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
+ ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml
index 1927b80..b1c97d5 100644
--- a/.github/workflows/nextjs.yml
+++ b/.github/workflows/nextjs.yml
@@ -13,7 +13,11 @@ concurrency:
cancel-in-progress: false
jobs:
+ verify:
+ uses: ./.github/workflows/checks.yml
+
build:
+ needs: verify
permissions:
contents: read
pages: read
@@ -22,6 +26,7 @@ jobs:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
+ ref: ${{ github.sha }}
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
diff --git a/README.md b/README.md
index 0d4bee1..8148ad8 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,21 @@ npm run test:e2e
## Deployment
-Pushes to `master` build and deploy the static export to [luinbytes.dev](https://luinbytes.dev/) through GitHub Pages.
+Pushes to `master` and manual runs deploy the static export through GitHub Pages.
+Before the artifact build, deployment calls `.github/workflows/checks.yml`, the
+same verification used by pull requests: dependency audit, lint, pond tests, and
+the production browser suite, which includes a production build. A failed or
+cancelled verification prevents the artifact build and deployment.
+
+Verification and artifact build both check out the event's immutable
+`github.sha`, including when a branch advances during a run. The local reusable
+workflow reference selects the checks from the caller's commit. Pull requests
+verify their synthetic merge commit; deployment verifies its own push or manual
+run commit again. This gate does not rely on branch protection or an earlier
+PR result. See [GitHub's reusable workflow rules](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows).
+
+Successful local checks do not establish a successful Actions run. Runner
+availability, billing, and the Pages environment can still prevent deployment.
## Social preview
@@ -90,16 +104,19 @@ Home, privacy, and the 404 page have separate HTML and client entries. Vite gene
their stylesheet and preload links. Run `node scripts/measure-page-assets.mjs`
after a build to inspect each page's initial uncompressed JavaScript and CSS.
-Each production page receives a Content Security Policy that allows scripts and
-connections from this site. Inline JavaScript, embedded frames, plugins, and form
-submissions are blocked. Inline styles remain allowed for React and animation
-styles. Blob images remain allowed for local pond textures.
+Each production page contains a meta Content Security Policy that allows scripts
+and connections from this site, plus the approved inline script hash. Other inline
+JavaScript, embedded frames, plugins, and form submissions are blocked. Inline
+styles remain allowed for React and animation styles. Blob images remain allowed for local pond textures.
The preview server also sends `X-Content-Type-Options`, `X-Frame-Options`, and
-`Referrer-Policy`. These server headers apply to local previews. GitHub Pages and
-Cloudflare control the public response headers. In particular, public frame
+`Referrer-Policy`. These server headers apply to local previews. GitHub Pages
+serves the static export through the public Cloudflare edge.
+The sampled public responses lack those headers. In particular, public frame
embedding restrictions require a response header at the hosting layer; an HTML
-meta policy cannot enforce `frame-ancestors`.
+meta policy cannot enforce `frame-ancestors`. See the
+[public header evidence and hosting decision](docs/security-headers.md) for the
+verified gap, the policy to preserve, and the work that needs a hosting decision.
Pull requests run dependency auditing, lint, unit tests, and the production browser
suite. Deployment actions use pinned commits, and only the deployment job receives
diff --git a/app/layout.tsx b/app/layout.tsx
index 74f8c6d..f2d8227 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<>
- Skip to content
+ document.getElementById("main")?.focus({ preventScroll: true })}>Skip to content
{children}
>
);
diff --git a/components/concepts/signal-desk/pond-environment.module.css b/components/concepts/signal-desk/pond-environment.module.css
index 1c616bb..fd12da8 100644
--- a/components/concepts/signal-desk/pond-environment.module.css
+++ b/components/concepts/signal-desk/pond-environment.module.css
@@ -25,7 +25,7 @@
.pondImage {
z-index: 0;
visibility: hidden;
- background-image: url("/images/portfolio/pixel-pond-world.webp");
+ --pond-fallback:url("/images/portfolio/pixel-pond-world.webp");
background-position: center;
background-size: cover;
transform: scale(1.015);
@@ -34,10 +34,12 @@
.pond[data-renderer="fallback"] .pondImage {
visibility: visible;
+ background-image:var(--pond-fallback);
}
:global(html[data-pond-boot="pending"]) .pond[data-renderer="fallback"] .pondImage {
visibility: hidden;
+ background-image:none;
}
.pondSkeleton[skullet-reveal] {
@@ -165,3 +167,5 @@
}
}
+
+@media(max-width:720px) { .pondImage { --pond-fallback:url("/images/portfolio/pixel-pond-world-mobile.webp"); } }
diff --git a/components/concepts/signal-desk/pond-environment.tsx b/components/concepts/signal-desk/pond-environment.tsx
index e7f5b0d..756333c 100644
--- a/components/concepts/signal-desk/pond-environment.tsx
+++ b/components/concepts/signal-desk/pond-environment.tsx
@@ -56,6 +56,8 @@ export function PondEnvironment({ reduced, mode = "ascii" }: { reduced: boolean;
transitionRef.current = null;
});
observer.observe(pond, { attributes: true, attributeFilter: ["data-renderer"] });
+ let paintFrame = 0;
+ const startAfterPaint = () => {
void (mode === "pixel" ? import("./pond-pixel-renderer") : import("./pond-renderer"))
.then(({ startPondRenderer }) => disposed ? undefined : startPondRenderer({
pond,
@@ -78,8 +80,11 @@ export function PondEnvironment({ reduced, mode = "ascii" }: { reduced: boolean;
host.dataset.pondState = "fallback";
host.dataset.fallbackReason = error instanceof Error ? error.message : "renderer-init-failed";
});
+ };
+ paintFrame = requestAnimationFrame(() => { paintFrame = requestAnimationFrame(startAfterPaint); });
return () => {
+ cancelAnimationFrame(paintFrame);
disposed = true;
observer.disconnect();
if (!reduced && !reducedRef.current && capture && pond.dataset.renderer === "three") {
@@ -113,7 +118,7 @@ export function PondEnvironment({ reduced, mode = "ascii" }: { reduced: boolean;
-
+
>
);
}
diff --git a/components/concepts/signal-desk/pond-input.ts b/components/concepts/signal-desk/pond-input.ts
index 6590467..f8f9c8c 100644
--- a/components/concepts/signal-desk/pond-input.ts
+++ b/components/concepts/signal-desk/pond-input.ts
@@ -132,6 +132,19 @@ export function installPondInput(options: PondInputOptions) {
if (dropFood(point(event))) event.preventDefault();
};
+ const feedFromControl = (event: Event) => {
+ for (const y of [0.5, 0.3, 0.7, 0.15, 0.85, 0.05, 0.95]) {
+ for (const x of [0.5, 0.3, 0.7, 0.15, 0.85, 0.05, 0.95]) {
+ const screen = { x: innerWidth * x, y: innerHeight * y };
+ const obscured = [...document.querySelectorAll("[data-pond-obscures]")].some(element => {
+ const box = element.getBoundingClientRect();
+ return screen.x >= box.left && screen.x <= box.right && screen.y >= box.top && screen.y <= box.bottom;
+ });
+ if (!obscured && ownsPoint(screen) && dropFood(screen)) { event.preventDefault(); return; }
+ }
+ }
+ };
+
const resize = () => {
activeTouch = null;
clearPendingTap();
@@ -142,6 +155,7 @@ export function installPondInput(options: PondInputOptions) {
interaction.addEventListener("pointerup", release, { passive: false });
interaction.addEventListener("pointercancel", cancel, { passive: true });
interaction.addEventListener("contextmenu", contextMenu);
+ interaction.addEventListener("pond-feed", feedFromControl);
window.addEventListener("resize", resize, { passive: true });
return () => {
@@ -151,6 +165,7 @@ export function installPondInput(options: PondInputOptions) {
interaction.removeEventListener("pointerup", release);
interaction.removeEventListener("pointercancel", cancel);
interaction.removeEventListener("contextmenu", contextMenu);
+ interaction.removeEventListener("pond-feed", feedFromControl);
window.removeEventListener("resize", resize);
};
}
diff --git a/components/concepts/signal-desk/pond-pixel-renderer.ts b/components/concepts/signal-desk/pond-pixel-renderer.ts
index 22737bf..76fbb26 100644
--- a/components/concepts/signal-desk/pond-pixel-renderer.ts
+++ b/components/concepts/signal-desk/pond-pixel-renderer.ts
@@ -177,7 +177,7 @@ export async function startPondRenderer(options: StartOptions) {
return asset;
};
const [pondTexture, waterTexture, koiAtlas, foregroundTexture, pondWaterTexture] = await Promise.all([
- load("pixel-pond-world.webp"),
+ load(window.matchMedia("(max-width: 720px)").matches ? "pixel-pond-world-mobile.webp" : "pixel-pond-world.webp"),
load("water-displacement.jpg"),
load("pixel-koi-atlas.webp"),
load("pixel-pond-foreground.webp"),
diff --git a/components/concepts/signal-desk/pond-renderer.ts b/components/concepts/signal-desk/pond-renderer.ts
index 3ce59d3..29a6cc3 100644
--- a/components/concepts/signal-desk/pond-renderer.ts
+++ b/components/concepts/signal-desk/pond-renderer.ts
@@ -201,7 +201,7 @@ export async function startPondRenderer(options: StartOptions) {
return asset;
};
const koiAtlas = await load("pixel-koi-atlas.webp");
- const colorAssets = await Promise.all([
+ const colorAssets = liveAscii ? null : await Promise.all([
load("pixel-pond-world.webp"),
load("water-displacement.jpg"),
load("pixel-pond-foreground.webp"),
@@ -221,9 +221,9 @@ export async function startPondRenderer(options: StartOptions) {
const waterTexture = colorAssets ? colorAssets[1] : blankAsset(1024, 1024);
const foregroundTexture = colorAssets ? colorAssets[2] : blankAsset(1586, 992);
const pondWaterTexture = colorAssets ? colorAssets[3] : blankAsset(1586, 992);
- const hitCanvas = document.createElement("canvas");
+ const hitCanvas = colorAssets ? document.createElement("canvas") : null;
let hitPixels = new Uint8ClampedArray(0);
- if (colorAssets) {
+ if (hitCanvas) {
hitCanvas.width = pondWaterTexture.width;
hitCanvas.height = pondWaterTexture.height;
const hitContext = hitCanvas.getContext("2d", { willReadFrequently: true });
@@ -326,6 +326,7 @@ export async function startPondRenderer(options: StartOptions) {
if (diagnostics) host.dataset.pondCard = "none";
};
const isPixelWater = (x: number, y: number) => {
+ if (!hitCanvas) return false;
const pixelX = Math.round(x);
const pixelY = Math.round(y);
return pixelX >= 0 && pixelY >= 0 && pixelX < hitCanvas.width && pixelY < hitCanvas.height &&
@@ -1185,7 +1186,7 @@ export async function startPondRenderer(options: StartOptions) {
previousTime = now;
const frameInterval = 1000 / POND_ASCII_FPS_CAP;
let stepMs = deltaMs;
- if (liveAscii && !reduced) {
+ if (liveAscii) {
const paced = pondFrameBudget(frameBudget, deltaMs, frameInterval);
frameBudget = paced.budget;
if (paced.stepMs === 0) {
@@ -1194,7 +1195,7 @@ export async function startPondRenderer(options: StartOptions) {
}
stepMs = paced.stepMs;
}
- const delta = Math.min(0.08, Math.max(1 / 120, stepMs / 1000));
+ const delta = Math.min(0.08, stepMs / 1000);
try {
step(now, delta);
present();
diff --git a/components/concepts/signal-desk/project-explorer.module.css b/components/concepts/signal-desk/project-explorer.module.css
index 9b72dd5..f7df6ae 100644
--- a/components/concepts/signal-desk/project-explorer.module.css
+++ b/components/concepts/signal-desk/project-explorer.module.css
@@ -44,7 +44,7 @@
}
.projectNav small {
- font-size:10px;
+ font-size:12px;
line-height:1.4;
}
@@ -148,7 +148,7 @@
.projectEyebrow {
color:var(--muted);
- font:11px var(--font-space-mono),monospace;
+ font:12px var(--font-space-mono),monospace;
line-height:1.6;
}
@@ -184,7 +184,7 @@
.proofRow span {
color:var(--muted);
- font-size:11px;
+ font-size:12px;
line-height:1.4;
}
@@ -292,3 +292,9 @@
border:2px solid Highlight;
}
}
+
+.projectIllustration { display:grid; place-items:center; height:100%; min-height:260px; background:var(--paper); color:var(--green); }
+.projectIllustration svg { display:block; width:100%; max-width:560px; height:auto; }
+.artWater { stroke:var(--line); stroke-width:2; }
+.artAccent { stroke:var(--orange); }
+@media(prefers-color-scheme:dark) { .projectIllustration { color:var(--muted); } }
diff --git a/components/concepts/signal-desk/signal-desk.module.css b/components/concepts/signal-desk/signal-desk.module.css
index c512efe..9ca3043 100644
--- a/components/concepts/signal-desk/signal-desk.module.css
+++ b/components/concepts/signal-desk/signal-desk.module.css
@@ -82,7 +82,7 @@
.brandNote {
font-family:var(--font-space-mono),monospace;
- font-size:10px;
+ font-size:12px;
max-width:150px;
line-height:1.6;
}
@@ -165,7 +165,7 @@
align-items:center;
gap:8px;
font-family:var(--font-space-mono),monospace;
- font-size:11px;
+ font-size:12px;
line-height:1.8;
color:var(--muted);
}
@@ -213,7 +213,7 @@
gap:10px;
align-items:center;
min-height:46px;
- font-size:12px;
+ font-size:14px;
font-weight:700;
pointer-events:auto;
}
@@ -249,7 +249,7 @@
padding:0;
color:var(--muted);
font-family:var(--font-space-mono),monospace;
- font-size:10px;
+ font-size:12px;
}
.pondChoices {
@@ -305,7 +305,7 @@
}
.pondCurrent {
- font-size:10px;
+ font-size:12px;
color:var(--muted);
}
@@ -330,7 +330,7 @@
.pondNote small {
display:block;
margin-top:7px;
- font:9px var(--font-space-mono),monospace;
+ font:12px var(--font-space-mono),monospace;
color:var(--muted);
}
@@ -429,7 +429,7 @@
}
.moreGrid small {
- font-size:11px;
+ font-size:12px;
color:var(--muted);
}
@@ -455,7 +455,7 @@
}
.contactLabel {
- font:11px var(--font-space-mono),monospace;
+ font:12px var(--font-space-mono),monospace;
color:var(--muted);
}
@@ -468,7 +468,7 @@
.contactFacts {
display:grid;
gap:12px;
- font-size:11px;
+ font-size:12px;
color:var(--muted);
}
@@ -507,7 +507,7 @@
padding:16px 24px;
border-radius:3px;
background:var(--paper);
- font:10px var(--font-space-mono),monospace;
+ font:12px var(--font-space-mono),monospace;
}
.footer > div {
@@ -601,7 +601,7 @@
.footer {
width:calc(100% - 32px);
padding:12px 18px;
- font-size:9px;
+ font-size:12px;
}
}
@@ -625,7 +625,7 @@
gap:10px;
}
.heroActions a {
- font-size:11px;
+ font-size:12px;
}
.footer {
flex-wrap:wrap;
@@ -637,3 +637,14 @@
border:2px solid Highlight;
}
}
+
+.heroActions a:nth-child(2) { text-decoration:underline; text-underline-offset:5px; }
+@media(max-width:800px) { .heroActions a { font-size:14px; } }
+
+.header nav a[aria-current="location"] { text-decoration:underline; text-decoration-color:var(--orange); text-decoration-thickness:2px; text-underline-offset:7px; }
+
+@media(max-width:800px) { .footer { flex-wrap:wrap; gap:4px; } }
+.feedButton { display:block; width:100%; min-height:44px; margin-top:8px; border:1px solid var(--line); border-radius:3px; background:var(--paper); color:var(--ink); font-size:14px; cursor:pointer; }
+.feedButton:disabled { opacity:.6; cursor:wait; }
+
+@media(max-width:800px) { .hero { padding-top:270px; min-height:1050px; } }
diff --git a/components/concepts/signal-desk/signal-desk.tsx b/components/concepts/signal-desk/signal-desk.tsx
index 0f10d44..bc9aaec 100644
--- a/components/concepts/signal-desk/signal-desk.tsx
+++ b/components/concepts/signal-desk/signal-desk.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
-import { ArrowDown, ArrowUpRight, AudioLines, Github, Globe2, Mail, MapPin } from "lucide-react";
+import { ArrowDown, ArrowUpRight, Github, Globe2, Mail, MapPin } from "lucide-react";
import {
portfolioIdentity,
@@ -57,6 +57,7 @@ function SiteFooter({ reduced, entrance, ready }: { reduced: boolean; entrance:
>
© {import.meta.env.BUILD_YEAR} Lu / 6C75
@@ -65,28 +66,35 @@ function SiteFooter({ reduced, entrance, ready }: { reduced: boolean; entrance:
}
function ProjectArtwork({ project }: { project: PortfolioProject }) {
- if (!project.image) {
- return (
-
-
-
{project.name}
-
{project.category}
-
- );
- }
-
return (
-
-
+
+
+
+ {project.id === "rakazo-android" && <>
+
+
+
+
+ >}
+ {project.id === "linux-sonar" && <>
+
+ {[118,179,240,301,362].map((x,i)=> )}
+
+ >}
+ {project.id === "homebot" && <>
+
+
+
+
+
+ >}
+ {project.id === "connectormap" && <>
+
+
+ {[[178,151],[247,205],[317,151],[178,259],[317,259]].map(([x,y])=> )}
+
+ >}
+
);
}
@@ -238,7 +246,22 @@ export function SignalDesk() {
const { ready, reduced } = useReducedMotionPreference();
const [pondView, setPondView] = useState<"ascii" | "pixel">("ascii");
const pageRef = useRef
(null);
+ const [activeSection, setActiveSection] = useState("");
+
+ useEffect(() => {
+ const sync = () => {
+ const sections = ["work", "about", "contact"];
+ const visible = sections.filter(id => (document.getElementById(id)?.getBoundingClientRect().top ?? Infinity) <= 180);
+ const atBottom = window.scrollY > 0 && window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
+ setActiveSection(atBottom ? "contact" : visible.at(-1) ?? "");
+ };
+ window.addEventListener("scroll", sync, { passive: true });
+ window.addEventListener("hashchange", sync);
+ sync();
+ return () => { window.removeEventListener("scroll", sync); window.removeEventListener("hashchange", sync); };
+ }, []);
const [pondStatus, setPondStatus] = useState<{ mode: string; renderer: string }>({ mode: "ascii", renderer: "loading" });
+ const [feedStatus, setFeedStatus] = useState("");
const entrance = ready && !reduced;
useEffect(() => {
@@ -254,7 +277,7 @@ export function SignalDesk() {
return (
-
The decorative pond responds to pointer movement. Right-click open water, or double-tap it on touch devices, to feed the fish.
+
The decorative pond responds to pointer movement. Right-click open water, or double-tap it on touch devices, to feed the fish, or use the Feed fish button.
@@ -289,8 +312,14 @@ export function SignalDesk() {
? "Pond unavailable. Try the other view."
: `Current view: ${pondView === "ascii" ? "ASCII" : "Pixel"}`}
+ {
+ const surface = pageRef.current?.querySelector("[data-pond-input]");
+ const handled = surface && !surface.dispatchEvent(new Event("pond-feed", { cancelable: true }));
+ setFeedStatus(handled ? "Food dropped in the pond." : "No open water visible. Return to the top and try again.");
+ }}>Feed fish
+ {feedStatus}
-
+
@@ -305,7 +334,7 @@ export function SignalDesk() {
Say hello
-
↳ Stay a while. Feed the fish.Right click the water · double tap on touch
+
↳ Stay a while. Feed the fish.Right click the water · double tap on touch
@@ -362,15 +391,11 @@ export function SignalDesk() {
-
03 / Say hello
@@ -386,7 +411,7 @@ export function SignalDesk() {
Start a conversation
-
+
diff --git a/docs/security-headers.md b/docs/security-headers.md
new file mode 100644
index 0000000..328f025
--- /dev/null
+++ b/docs/security-headers.md
@@ -0,0 +1,84 @@
+# Public response headers
+
+Issue [#88](https://github.com/luinbytes/luinbytes.dev/issues/88) remains open for
+production hardening. This document records the current limit and the hosting
+decision needed to meet its acceptance criteria. It does not change hosting.
+
+## Verified public behaviour
+
+On 9 November 2026 at 00:43 UTC, certificate validated HTTPS GET and HEAD requests
+to `https://luinbytes.dev` returned the following results. No request followed a
+redirect to a different URL.
+
+| Route | GET status | HEAD status | CSP response header | X-Frame-Options | X-Content-Type-Options | Strict-Transport-Security |
+| --- | --- | --- | --- | --- | --- | --- |
+| `/` | 200 | 200 | Absent | Absent | Absent | Absent |
+| `/privacy` | 200 | 200 | Absent | Absent | Absent | Absent |
+| `/404` | 200 | 200 | Absent | Absent | Absent | Absent |
+| `/header-check-nonexistent-20261109` | 404 | 404 | Absent | Absent | Absent | Absent |
+
+Every GET contained the meta CSP from `scripts/security-policy.mjs`, with an
+exact value match. All eight responses reported `Server: cloudflare`.
+These observations establish public behaviour at that time, not account settings
+or future responses. The `/404` status differs from the local preview server;
+status changes are outside this header task.
+
+To repeat the response check, run this command for each route in the table:
+
+```bash
+curl --silent --show-error --dump-header - --output /dev/null https://luinbytes.dev/privacy
+curl --silent --show-error --head https://luinbytes.dev/privacy
+```
+
+## What the repository can enforce
+
+The Pages workflow uploads static files from `out`. It does not run
+`scripts/serve-preview.mjs` in production. GitHub Pages has no supported custom
+response header configuration. Adding `_headers`, `.htaccess`, or a header to the
+local Node server does not configure the current Pages deployment. See
+[GitHub's response about custom headers](https://github.com/orgs/community/discussions/54257).
+
+The existing meta CSP remains useful and must stay intact. Its `frame-src 'none'`
+blocks frames embedded by this site. It does not block other sites embedding this
+site. Browsers require a response header for `frame-ancestors`; adding that directive,
+`X-Frame-Options`, `nosniff`, or HSTS to HTML meta tags does not provide those protections.
+
+The local preview adds the full CSP with `frame-ancestors 'none'`,
+`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, and
+`Referrer-Policy: no-referrer`. Preview HTTP has no HSTS header.
+Browser tests against that server verify preview behaviour, not public headers.
+
+## Hosting judgement for Lu
+
+Prefer response header rules on the existing Cloudflare edge if Lu confirms the
+zone is controlled by the intended account and that rules cover all public
+responses, including cached pages and errors. Cloudflare documents
+[response header transforms](https://developers.cloudflare.com/rules/transform/response-header-modification/).
+The observed server header supports investigating this path; it does not prove
+account access, plan eligibility, or configured rules. No account or DNS change
+has been made.
+
+The intended policy is the unchanged `contentSecurityPolicy` from
+`scripts/security-policy.mjs`, plus `frame-ancestors 'none'` as a response CSP.
+Also send `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, and
+`Referrer-Policy: no-referrer`. Keep the header policy aligned with that source
+when the CSP changes. Apply it to successful HTML and error responses and check
+asset MIME types before enabling `nosniff`.
+
+HSTS needs its own coverage check. These apex HTTPS samples do not establish safe
+coverage for HTTP redirects, `www`, other subdomains, or certificate renewal.
+After those checks, begin with a short apex `max-age` and increase it only after
+verification. Do not enable `includeSubDomains` or `preload` without a separate
+inventory and decision. See [Cloudflare's HSTS guidance](https://developers.cloudflare.com/ssl/edge-certificates/additional-options/http-strict-transport-security/).
+
+If the existing edge cannot supply these headers, a host with explicit header
+support is the alternative. For example, [Cloudflare Pages supports a `_headers`
+file](https://developers.cloudflare.com/pages/configuration/headers/). That would
+be a hosting migration requiring Lu's decision and deployment verification, not
+a file fix for GitHub Pages. For this static portfolio, retaining Pages with the
+documented gap is also a valid decision if Lu accepts it.
+
+After any authorised hosting change, repeat GET and HEAD checks for all four
+routes, check the meta CSP is preserved, and verify both pond modes and normal
+navigation. Until those public checks pass, do not claim #88 is fixed or close
+its production acceptance criteria.
diff --git a/package.json b/package.json
index b0f76b5..370f215 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
"lint": "eslint",
"test:e2e:setup": "python3 -m pip install -r requirements-test.txt && python3 -m playwright install chromium",
"test:e2e": "npm run build && python3 -m unittest discover -s tests/browser -p '*_test.py' -v",
- "test:pond": "node --test tests/pond-world.test.mts tests/pond-food-priority.test.mts tests/pond-water-mask.test.mts tests/pond-pixel.test.mts"
+ "test:pond": "node --test tests/pond-world.test.mts tests/pond-food-priority.test.mts tests/pond-water-mask.test.mts tests/pond-pixel.test.mts tests/pond-renderer.test.mts"
},
"dependencies": {
"@fontsource-variable/pixelify-sans": "5.3.0",
diff --git a/public/images/portfolio/pixel-pond-world-mobile.webp b/public/images/portfolio/pixel-pond-world-mobile.webp
new file mode 100644
index 0000000..0050f2f
Binary files /dev/null and b/public/images/portfolio/pixel-pond-world-mobile.webp differ
diff --git a/review-previews/issues-77-89/README.md b/review-previews/issues-77-89/README.md
new file mode 100644
index 0000000..a118a5f
--- /dev/null
+++ b/review-previews/issues-77-89/README.md
@@ -0,0 +1,27 @@
+# Ready issue verification
+
+Base: master 32949742dc6a294df4e924ec57101f01ce8e9e55. Before captures use the live site on 9 November 2026; after captures use the local production export. The proposed illustration style is for Lu to judge, not a claim of project screenshots or live previews. SVGs are decorative and static, with the real project descriptions adjacent.
+
+| Issue | Original evidence | Change and verification |
+| --- | --- | --- |
+| 77 | Home first Tab already focused Skip to content in both OpenBot browser and Chromium. The reported home failure did not reproduce. ConnectorMap lacked the link. | Explicit main focus activation; first Tab reveals skip, Enter focuses main. Companion ConnectorMap PR 33 fixes its reproduced failure. |
+| 78 | Live labels 12px at 1440/390, 11px at 320; heights already 46px. | Labels 14px, 46px minimum height, secondary action underlined. Actual computed sizes verified at all three widths. |
+| 79 | Live Contact click produced no aria-current item. | Visible section tracked on scroll/hash; current link has an accent underline and aria-current=location; keyboard rings retained. |
+| 80 | Live footer had no privacy link. | Visible /privacy link, wrapping footer on phones; policy content unchanged. |
+| 81 | Before media captures show the ambiguous existing visual. | Four static inline SVG illustrations with no image requests or video states. All projects captured at 1440/768/390/320 and in dark mode. No overflow. |
+| 82 | Synthetic scheduling regression failed before patch at high refresh. | Shared pacing, no inflated minimum delta; 60/120/144/240Hz synthetic checks preserve intended reduced rate and ordinary cadence. Real high refresh GPU/battery profiling unrun. |
+| 83 | Regression rejecting nonatlas requests failed before patch. Live page fetched desktop world despite ASCII. | ASCII uses atlas and explicit world dimensions; no raster hit map; Pixel retains raster map. Failure test proves unused assets cannot force ASCII fallback. |
+| 84 | Live had no Feed fish button or keyboard operation. | Native button chooses unobscured valid visible water and invokes the same dropFood path as gestures. Enter increments food count in ASCII and Pixel; live region feedback. |
+| 85 | Initial effect immediately imported renderer; desktop world file 1449726 bytes. | Renderer starts after two animation frames. Bootstrap pending state suppresses fallback fetch. Mobile world WebP is 106456 bytes, same dimensions and hit map/physics unchanged. This is byte size evidence, not a measured page speed or battery improvement. |
+| 86 | Immediate live Contact click read computed opacity 0. | Contact reveal removed; click and direct hash read opacity 1. Other reveals retained. |
+| 87 | Live section labels 11px, view legend 10px, feeding hint 9px. | Captions at least 12px; palette/contrast colours retained, no overflow at checked widths. |
+| 88 | Fresh GET and HEAD samples lack production response headers. | Documented current gap, exact proposed hosting policy and decision for Lu. Production acceptance remains OPEN; no hosting changes claimed. |
+| 89 | Deploy workflow previously only linted/built, independently of PR suite. | Shared full verification checks explicit github.sha; deploy build needs verification, deploy needs that exact artifact. No reliance on branch protection. Workflow dispatch/deployment not run. |
+
+## Capture coverage
+
+`before/after-{1440,390,320}-{hero,work,contact}.jpg` show action sizes, captions, navigation and footer. Project captures `before/after-{1440,768,390,320}-{rakazo,linux-sonar,homebot,connectormap}.jpg` show all four proposed illustrations. `after-{width}-dark.jpg` records dark tokens. The 320px hero intentionally permits a second action row to retain readable text and usable touch targets.
+
+## Limits
+
+No physical touchscreen, screen reader, high refresh hardware profiling, hosting edits, production deployment or merge. GitHub Actions execution/billing must be reported from actual run state, separately from local gates. Dependency audit reports existing moderate development dependency advisories; high severity gate passes without dependency changes.
diff --git a/review-previews/issues-77-89/after-1440-connectormap.jpg b/review-previews/issues-77-89/after-1440-connectormap.jpg
new file mode 100644
index 0000000..4a1aba5
Binary files /dev/null and b/review-previews/issues-77-89/after-1440-connectormap.jpg differ
diff --git a/review-previews/issues-77-89/after-1440-contact.jpg b/review-previews/issues-77-89/after-1440-contact.jpg
new file mode 100644
index 0000000..ca518de
Binary files /dev/null and b/review-previews/issues-77-89/after-1440-contact.jpg differ
diff --git a/review-previews/issues-77-89/after-1440-dark.jpg b/review-previews/issues-77-89/after-1440-dark.jpg
new file mode 100644
index 0000000..a604dd6
Binary files /dev/null and b/review-previews/issues-77-89/after-1440-dark.jpg differ
diff --git a/review-previews/issues-77-89/after-1440-hero.jpg b/review-previews/issues-77-89/after-1440-hero.jpg
new file mode 100644
index 0000000..415ec08
Binary files /dev/null and b/review-previews/issues-77-89/after-1440-hero.jpg differ
diff --git a/review-previews/issues-77-89/after-1440-homebot.jpg b/review-previews/issues-77-89/after-1440-homebot.jpg
new file mode 100644
index 0000000..505e9f2
Binary files /dev/null and b/review-previews/issues-77-89/after-1440-homebot.jpg differ
diff --git a/review-previews/issues-77-89/after-1440-linux-sonar.jpg b/review-previews/issues-77-89/after-1440-linux-sonar.jpg
new file mode 100644
index 0000000..54f6709
Binary files /dev/null and b/review-previews/issues-77-89/after-1440-linux-sonar.jpg differ
diff --git a/review-previews/issues-77-89/after-1440-rakazo.jpg b/review-previews/issues-77-89/after-1440-rakazo.jpg
new file mode 100644
index 0000000..0065205
Binary files /dev/null and b/review-previews/issues-77-89/after-1440-rakazo.jpg differ
diff --git a/review-previews/issues-77-89/after-1440-work.jpg b/review-previews/issues-77-89/after-1440-work.jpg
new file mode 100644
index 0000000..114d886
Binary files /dev/null and b/review-previews/issues-77-89/after-1440-work.jpg differ
diff --git a/review-previews/issues-77-89/after-320-connectormap.jpg b/review-previews/issues-77-89/after-320-connectormap.jpg
new file mode 100644
index 0000000..eb264c0
Binary files /dev/null and b/review-previews/issues-77-89/after-320-connectormap.jpg differ
diff --git a/review-previews/issues-77-89/after-320-contact.jpg b/review-previews/issues-77-89/after-320-contact.jpg
new file mode 100644
index 0000000..6fec08d
Binary files /dev/null and b/review-previews/issues-77-89/after-320-contact.jpg differ
diff --git a/review-previews/issues-77-89/after-320-dark.jpg b/review-previews/issues-77-89/after-320-dark.jpg
new file mode 100644
index 0000000..3c79ff0
Binary files /dev/null and b/review-previews/issues-77-89/after-320-dark.jpg differ
diff --git a/review-previews/issues-77-89/after-320-hero.jpg b/review-previews/issues-77-89/after-320-hero.jpg
new file mode 100644
index 0000000..7884411
Binary files /dev/null and b/review-previews/issues-77-89/after-320-hero.jpg differ
diff --git a/review-previews/issues-77-89/after-320-homebot.jpg b/review-previews/issues-77-89/after-320-homebot.jpg
new file mode 100644
index 0000000..f393d1e
Binary files /dev/null and b/review-previews/issues-77-89/after-320-homebot.jpg differ
diff --git a/review-previews/issues-77-89/after-320-linux-sonar.jpg b/review-previews/issues-77-89/after-320-linux-sonar.jpg
new file mode 100644
index 0000000..5710030
Binary files /dev/null and b/review-previews/issues-77-89/after-320-linux-sonar.jpg differ
diff --git a/review-previews/issues-77-89/after-320-rakazo.jpg b/review-previews/issues-77-89/after-320-rakazo.jpg
new file mode 100644
index 0000000..bc1b596
Binary files /dev/null and b/review-previews/issues-77-89/after-320-rakazo.jpg differ
diff --git a/review-previews/issues-77-89/after-320-work.jpg b/review-previews/issues-77-89/after-320-work.jpg
new file mode 100644
index 0000000..6ca830c
Binary files /dev/null and b/review-previews/issues-77-89/after-320-work.jpg differ
diff --git a/review-previews/issues-77-89/after-390-connectormap.jpg b/review-previews/issues-77-89/after-390-connectormap.jpg
new file mode 100644
index 0000000..baa1cca
Binary files /dev/null and b/review-previews/issues-77-89/after-390-connectormap.jpg differ
diff --git a/review-previews/issues-77-89/after-390-contact.jpg b/review-previews/issues-77-89/after-390-contact.jpg
new file mode 100644
index 0000000..6411152
Binary files /dev/null and b/review-previews/issues-77-89/after-390-contact.jpg differ
diff --git a/review-previews/issues-77-89/after-390-dark.jpg b/review-previews/issues-77-89/after-390-dark.jpg
new file mode 100644
index 0000000..c9b8738
Binary files /dev/null and b/review-previews/issues-77-89/after-390-dark.jpg differ
diff --git a/review-previews/issues-77-89/after-390-hero.jpg b/review-previews/issues-77-89/after-390-hero.jpg
new file mode 100644
index 0000000..ac6058e
Binary files /dev/null and b/review-previews/issues-77-89/after-390-hero.jpg differ
diff --git a/review-previews/issues-77-89/after-390-homebot.jpg b/review-previews/issues-77-89/after-390-homebot.jpg
new file mode 100644
index 0000000..7de3674
Binary files /dev/null and b/review-previews/issues-77-89/after-390-homebot.jpg differ
diff --git a/review-previews/issues-77-89/after-390-linux-sonar.jpg b/review-previews/issues-77-89/after-390-linux-sonar.jpg
new file mode 100644
index 0000000..b328fdd
Binary files /dev/null and b/review-previews/issues-77-89/after-390-linux-sonar.jpg differ
diff --git a/review-previews/issues-77-89/after-390-rakazo.jpg b/review-previews/issues-77-89/after-390-rakazo.jpg
new file mode 100644
index 0000000..8894d45
Binary files /dev/null and b/review-previews/issues-77-89/after-390-rakazo.jpg differ
diff --git a/review-previews/issues-77-89/after-390-work.jpg b/review-previews/issues-77-89/after-390-work.jpg
new file mode 100644
index 0000000..9fee696
Binary files /dev/null and b/review-previews/issues-77-89/after-390-work.jpg differ
diff --git a/review-previews/issues-77-89/after-768-connectormap.jpg b/review-previews/issues-77-89/after-768-connectormap.jpg
new file mode 100644
index 0000000..b02d00e
Binary files /dev/null and b/review-previews/issues-77-89/after-768-connectormap.jpg differ
diff --git a/review-previews/issues-77-89/after-768-dark.jpg b/review-previews/issues-77-89/after-768-dark.jpg
new file mode 100644
index 0000000..a26a572
Binary files /dev/null and b/review-previews/issues-77-89/after-768-dark.jpg differ
diff --git a/review-previews/issues-77-89/after-768-homebot.jpg b/review-previews/issues-77-89/after-768-homebot.jpg
new file mode 100644
index 0000000..44272a8
Binary files /dev/null and b/review-previews/issues-77-89/after-768-homebot.jpg differ
diff --git a/review-previews/issues-77-89/after-768-linux-sonar.jpg b/review-previews/issues-77-89/after-768-linux-sonar.jpg
new file mode 100644
index 0000000..4cc8d30
Binary files /dev/null and b/review-previews/issues-77-89/after-768-linux-sonar.jpg differ
diff --git a/review-previews/issues-77-89/after-768-rakazo.jpg b/review-previews/issues-77-89/after-768-rakazo.jpg
new file mode 100644
index 0000000..064919e
Binary files /dev/null and b/review-previews/issues-77-89/after-768-rakazo.jpg differ
diff --git a/review-previews/issues-77-89/before-1440-connectormap.jpg b/review-previews/issues-77-89/before-1440-connectormap.jpg
new file mode 100644
index 0000000..a28b76f
Binary files /dev/null and b/review-previews/issues-77-89/before-1440-connectormap.jpg differ
diff --git a/review-previews/issues-77-89/before-1440-contact.jpg b/review-previews/issues-77-89/before-1440-contact.jpg
new file mode 100644
index 0000000..4842909
Binary files /dev/null and b/review-previews/issues-77-89/before-1440-contact.jpg differ
diff --git a/review-previews/issues-77-89/before-1440-hero.jpg b/review-previews/issues-77-89/before-1440-hero.jpg
new file mode 100644
index 0000000..9e2618e
Binary files /dev/null and b/review-previews/issues-77-89/before-1440-hero.jpg differ
diff --git a/review-previews/issues-77-89/before-1440-homebot.jpg b/review-previews/issues-77-89/before-1440-homebot.jpg
new file mode 100644
index 0000000..705c34b
Binary files /dev/null and b/review-previews/issues-77-89/before-1440-homebot.jpg differ
diff --git a/review-previews/issues-77-89/before-1440-linux-sonar.jpg b/review-previews/issues-77-89/before-1440-linux-sonar.jpg
new file mode 100644
index 0000000..07ea9f6
Binary files /dev/null and b/review-previews/issues-77-89/before-1440-linux-sonar.jpg differ
diff --git a/review-previews/issues-77-89/before-1440-rakazo.jpg b/review-previews/issues-77-89/before-1440-rakazo.jpg
new file mode 100644
index 0000000..3a4ff9c
Binary files /dev/null and b/review-previews/issues-77-89/before-1440-rakazo.jpg differ
diff --git a/review-previews/issues-77-89/before-1440-work.jpg b/review-previews/issues-77-89/before-1440-work.jpg
new file mode 100644
index 0000000..6b8184a
Binary files /dev/null and b/review-previews/issues-77-89/before-1440-work.jpg differ
diff --git a/review-previews/issues-77-89/before-320-connectormap.jpg b/review-previews/issues-77-89/before-320-connectormap.jpg
new file mode 100644
index 0000000..5c0fa2f
Binary files /dev/null and b/review-previews/issues-77-89/before-320-connectormap.jpg differ
diff --git a/review-previews/issues-77-89/before-320-contact.jpg b/review-previews/issues-77-89/before-320-contact.jpg
new file mode 100644
index 0000000..2fc8e82
Binary files /dev/null and b/review-previews/issues-77-89/before-320-contact.jpg differ
diff --git a/review-previews/issues-77-89/before-320-hero.jpg b/review-previews/issues-77-89/before-320-hero.jpg
new file mode 100644
index 0000000..abd7e91
Binary files /dev/null and b/review-previews/issues-77-89/before-320-hero.jpg differ
diff --git a/review-previews/issues-77-89/before-320-homebot.jpg b/review-previews/issues-77-89/before-320-homebot.jpg
new file mode 100644
index 0000000..3d1bab8
Binary files /dev/null and b/review-previews/issues-77-89/before-320-homebot.jpg differ
diff --git a/review-previews/issues-77-89/before-320-linux-sonar.jpg b/review-previews/issues-77-89/before-320-linux-sonar.jpg
new file mode 100644
index 0000000..fb4596a
Binary files /dev/null and b/review-previews/issues-77-89/before-320-linux-sonar.jpg differ
diff --git a/review-previews/issues-77-89/before-320-rakazo.jpg b/review-previews/issues-77-89/before-320-rakazo.jpg
new file mode 100644
index 0000000..d8e11de
Binary files /dev/null and b/review-previews/issues-77-89/before-320-rakazo.jpg differ
diff --git a/review-previews/issues-77-89/before-320-work.jpg b/review-previews/issues-77-89/before-320-work.jpg
new file mode 100644
index 0000000..45a3ed4
Binary files /dev/null and b/review-previews/issues-77-89/before-320-work.jpg differ
diff --git a/review-previews/issues-77-89/before-390-connectormap.jpg b/review-previews/issues-77-89/before-390-connectormap.jpg
new file mode 100644
index 0000000..f656992
Binary files /dev/null and b/review-previews/issues-77-89/before-390-connectormap.jpg differ
diff --git a/review-previews/issues-77-89/before-390-contact.jpg b/review-previews/issues-77-89/before-390-contact.jpg
new file mode 100644
index 0000000..994820c
Binary files /dev/null and b/review-previews/issues-77-89/before-390-contact.jpg differ
diff --git a/review-previews/issues-77-89/before-390-hero.jpg b/review-previews/issues-77-89/before-390-hero.jpg
new file mode 100644
index 0000000..e6cac10
Binary files /dev/null and b/review-previews/issues-77-89/before-390-hero.jpg differ
diff --git a/review-previews/issues-77-89/before-390-homebot.jpg b/review-previews/issues-77-89/before-390-homebot.jpg
new file mode 100644
index 0000000..9d38ed9
Binary files /dev/null and b/review-previews/issues-77-89/before-390-homebot.jpg differ
diff --git a/review-previews/issues-77-89/before-390-linux-sonar.jpg b/review-previews/issues-77-89/before-390-linux-sonar.jpg
new file mode 100644
index 0000000..2108541
Binary files /dev/null and b/review-previews/issues-77-89/before-390-linux-sonar.jpg differ
diff --git a/review-previews/issues-77-89/before-390-rakazo.jpg b/review-previews/issues-77-89/before-390-rakazo.jpg
new file mode 100644
index 0000000..15155df
Binary files /dev/null and b/review-previews/issues-77-89/before-390-rakazo.jpg differ
diff --git a/review-previews/issues-77-89/before-390-work.jpg b/review-previews/issues-77-89/before-390-work.jpg
new file mode 100644
index 0000000..eaac960
Binary files /dev/null and b/review-previews/issues-77-89/before-390-work.jpg differ
diff --git a/review-previews/issues-77-89/before-768-connectormap.jpg b/review-previews/issues-77-89/before-768-connectormap.jpg
new file mode 100644
index 0000000..a487c5d
Binary files /dev/null and b/review-previews/issues-77-89/before-768-connectormap.jpg differ
diff --git a/review-previews/issues-77-89/before-768-homebot.jpg b/review-previews/issues-77-89/before-768-homebot.jpg
new file mode 100644
index 0000000..4b48ad5
Binary files /dev/null and b/review-previews/issues-77-89/before-768-homebot.jpg differ
diff --git a/review-previews/issues-77-89/before-768-linux-sonar.jpg b/review-previews/issues-77-89/before-768-linux-sonar.jpg
new file mode 100644
index 0000000..d904b4a
Binary files /dev/null and b/review-previews/issues-77-89/before-768-linux-sonar.jpg differ
diff --git a/review-previews/issues-77-89/before-768-rakazo.jpg b/review-previews/issues-77-89/before-768-rakazo.jpg
new file mode 100644
index 0000000..044fcb7
Binary files /dev/null and b/review-previews/issues-77-89/before-768-rakazo.jpg differ
diff --git a/tests/browser/browser_test.py b/tests/browser/browser_test.py
index 46d3243..fb392b1 100644
--- a/tests/browser/browser_test.py
+++ b/tests/browser/browser_test.py
@@ -1396,10 +1396,11 @@ def test_loading_shows_skeleton_then_fades_in_the_live_ecosystem(self) -> None:
page.goto(f"{self.base_url}/?pond-seed=e2e-loading", wait_until="domcontentloaded")
pond = page.locator("[data-renderer]")
host = page.locator("[data-pond-state]")
+ wait_for_condition(page, "document.querySelector('[data-renderer]')?.dataset.renderer === 'loading'")
self.assertEqual(pond.get_attribute("data-renderer"), "loading")
self.assertEqual(host.locator("canvas").count(), 0)
- self.assertIn(
- "pixel-pond-world.webp",
+ self.assertEqual(
+ "none",
pond.locator(":scope > div").first.evaluate(
"element => getComputedStyle(element).backgroundImage"
),
@@ -1524,33 +1525,10 @@ def test_navigation_and_bordered_controls_keep_their_visual_hierarchy(self) -> N
}
def assert_homebot_image_fits(panel) -> None:
- image = panel.get_by_role(
- "img", name="HomeBot native desktop chat interface", exact=True
- )
- wait_for_condition(
- page,
- """alt => {
- const image = [...document.images].find(candidate => candidate.alt === alt);
- return image?.complete && image.naturalWidth > 0;
- }""",
- arg="HomeBot native desktop chat interface",
- )
- self.assertTrue(image.is_visible())
- self.assertIn("homebot-chat.png", image.get_attribute("src"))
- self.assertTrue(
- image.evaluate(
- """element => {
- const image = element.getBoundingClientRect();
- const media = element.parentElement.getBoundingClientRect();
- return media.width > 0 && media.height > 0
- && getComputedStyle(element).objectFit === 'contain'
- && image.left >= media.left - 1
- && image.top >= media.top - 1
- && image.right <= media.right + 1
- && image.bottom <= media.bottom + 1;
- }"""
- )
- )
+ illustration = panel.locator('svg[aria-hidden="true"]').first
+ expect(illustration).to_be_visible()
+ self.assertGreater(illustration.bounding_box()["width"], 100)
+ self.assertEqual(panel.locator("video, img").count(), 0)
for project, href in project_hrefs.items():
button = page.get_by_role("button", name=re.compile(project, re.I))
@@ -1576,16 +1554,7 @@ def assert_homebot_image_fits(panel) -> None:
)).to_be_visible()
for tag in ("Read + act", "Marked inferences", "Source links"):
expect(panel.locator("[class*='proofRow']").get_by_text(tag, exact=True)).to_be_visible()
- image = panel.get_by_role("img", name="ConnectorMap matrix of AI agents and apps", exact=True)
- expect(image).to_be_visible()
- expect(image).to_have_attribute("src", "/images/portfolio/connectormap-card.webp")
- wait_for_condition(
- page,
- """() => {
- const image = document.querySelector('img[alt="ConnectorMap matrix of AI agents and apps"]');
- return image?.complete && image.naturalWidth > 0;
- }"""
- )
+ expect(panel.locator('svg[aria-hidden="true"]').first).to_be_visible()
link = panel.get_by_role("link", name="View project", exact=True)
expect(link).to_have_attribute("target", "_blank")
expect(link).to_have_attribute("rel", "noreferrer")
@@ -2242,9 +2211,10 @@ def test_complete_image_never_shows_a_skeleton(self) -> None:
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.add_init_script(self.SKULLET_WATCH)
held_scripts = []
+ holding_scripts = True
def hold_scripts(route):
- if route.request.resource_type == "script":
+ if holding_scripts and route.request.resource_type == "script":
held_scripts.append(route)
return
route.continue_()
@@ -2258,10 +2228,11 @@ def hold_scripts(route):
}""",
arg=self.HERO_ALT,
)
+ holding_scripts = False
for route in held_scripts:
route.continue_()
page.unroute("**/*", hold_scripts)
- page.wait_for_load_state("networkidle")
+ wait_for_condition(page, "document.querySelector('[data-view]')?.dataset.view === 'ascii'")
page.wait_for_timeout(250)
image = page.get_by_alt_text(self.HERO_ALT)
self.assertGreater(image.evaluate("node => node.naturalWidth"), 0)
diff --git a/tests/browser/issues_test.py b/tests/browser/issues_test.py
new file mode 100644
index 0000000..c45154c
--- /dev/null
+++ b/tests/browser/issues_test.py
@@ -0,0 +1,55 @@
+"""Behaviour checks for the ready homepage issue batch."""
+from browser_test import BrowserTestCase, expect, wait_for_condition
+
+class HomepageIssueTests(BrowserTestCase):
+ def test_skip_link_is_first_and_focuses_main(self):
+ browser = self.playwright.chromium.launch()
+ self.addCleanup(browser.close)
+ page = browser.new_page()
+ page.goto(self.base_url, wait_until='networkidle')
+ page.keyboard.press('Tab')
+ skip = page.get_by_role('link', name='Skip to content')
+ expect(skip).to_be_focused()
+ self.assertGreaterEqual(skip.bounding_box()['y'], 0)
+ page.keyboard.press('Enter')
+ expect(page.locator('#main')).to_be_focused()
+
+ def test_actions_captions_privacy_and_immediate_navigation(self):
+ browser = self.playwright.chromium.launch()
+ self.addCleanup(browser.close)
+ for width in (1440,390,320):
+ page = browser.new_page(viewport={'width':width,'height':844})
+ page.goto(self.base_url,wait_until='networkidle')
+ for link in page.locator('[class*=heroActions] a').all():
+ self.assertGreaterEqual(link.evaluate('e=>parseFloat(getComputedStyle(e).fontSize)'),14)
+ self.assertGreaterEqual(link.bounding_box()['height'],44)
+ expect(page.locator('footer').get_by_role('link',name='Privacy')).to_have_attribute('href','/privacy')
+ for section in ('Work','About','Contact'):
+ page.get_by_role('navigation',name='Portfolio navigation').get_by_role('link',name=section,exact=True).click()
+ expect(page.locator('header [aria-current="location"]')).to_have_text(section)
+ self.assertEqual(page.locator('#contact').evaluate('e=>getComputedStyle(e).opacity'),'1')
+ for caption in page.locator('legend, [class*=eyebrow], [class*=pondNote] small, [aria-label="Featured projects"] small').all():
+ self.assertGreaterEqual(caption.evaluate('e=>parseFloat(getComputedStyle(e).fontSize)'),12)
+ self.assertLessEqual(page.evaluate('document.documentElement.scrollWidth'),width)
+ page.goto(self.base_url+'/#contact',wait_until='networkidle')
+ self.assertEqual(page.locator('#contact').evaluate('e=>getComputedStyle(e).opacity'),'1')
+ page.close()
+
+ def test_feed_button_keyboard_and_ascii_without_unused_rasters(self):
+ browser = self.playwright.chromium.launch()
+ self.addCleanup(browser.close)
+ page = self.pond_page(browser)
+ rasters=[]
+ page.on('request',lambda r:rasters.append(r.url) if any(x in r.url for x in ('pixel-pond-world','water-displacement','pixel-pond-foreground','pixel-pond-water-layer')) else None)
+ page.goto(self.base_url,wait_until='networkidle')
+ wait_for_condition(page, "document.querySelector('[data-pond-state]')?.dataset.pondState==='running'")
+ self.assertEqual(rasters,[])
+ for mode in ('ASCII','Pixel'):
+ page.get_by_label(mode,exact=True).check()
+ wait_for_condition(page, "document.querySelector('[data-pond-state]')?.dataset.pondState==='running'")
+ feed=page.get_by_role('button',name='Feed fish',exact=True)
+ expect(feed).to_be_enabled()
+ before=int(page.locator('[data-pond-state]').get_attribute('data-food-dropped-count'))
+ feed.focus();page.keyboard.press('Enter')
+ wait_for_condition(page, "before=>Number(document.querySelector('[data-pond-state]').dataset.foodDroppedCount)>before",arg=before)
+ expect(page.get_by_role('status',name='').filter(has_text='Food dropped')).to_be_visible()
diff --git a/tests/pond-renderer.test.mts b/tests/pond-renderer.test.mts
new file mode 100644
index 0000000..eb580b3
--- /dev/null
+++ b/tests/pond-renderer.test.mts
@@ -0,0 +1,77 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { runInNewContext } from "node:vm";
+import test from "node:test";
+import ts from "typescript";
+import { POND_ASCII_FPS_CAP, pondFrameBudget } from "../components/concepts/signal-desk/pond-ascii.ts";
+
+const source = readFileSync(new URL("../components/concepts/signal-desk/pond-renderer.ts", import.meta.url), "utf8");
+const javascript = (text: string) => ts.transpileModule(text, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText;
+
+test("reduced ASCII preserves wall time and pacing at 60, 120, 144 and 240 Hz", () => {
+ const draw = source.slice(source.indexOf(" const draw = (now:"), source.indexOf(" const visibility ="));
+ function schedule(hz: number, reduced: boolean) {
+ let seconds = 0;
+ let renders = 0;
+ const tick = runInNewContext(javascript(`let animationFrame = null; let previousTime = 0; let frameBudget = 0;\n${draw}\ndraw;`), {
+ disposed: false, document: { hidden: false }, liveAscii: true, heroOnScreen: true, reduced,
+ POND_ASCII_FPS_CAP, pondFrameBudget, Math, revealed: true,
+ step: (_now: number, delta: number) => { seconds += delta * (reduced ? 0.2 : 1); },
+ present: () => { renders += 1; },
+ window: { requestAnimationFrame: () => 1 },
+ fallback: (reason: string) => { throw new Error(reason); },
+ });
+ for (let index = 1; index <= hz; index += 1) tick(index * 1000 / hz);
+ return { seconds, renders };
+ }
+ const results = [60, 120, 144, 240].map((hz) => ({ hz, reduced: schedule(hz, true), ordinary: schedule(hz, false) }));
+ assert.ok(results.every(({ reduced }) => Math.abs(reduced.seconds - 0.2) < 0.004), JSON.stringify(results));
+ for (const { hz, reduced, ordinary } of results) {
+ assert.equal(reduced.renders, ordinary.renders, `${hz} Hz changed render cadence`);
+ assert.ok(reduced.renders <= 61);
+ }
+});
+
+test("ASCII starts with only the atlas while raster mode still loads its hit map", async () => {
+ const assets = source.slice(source.indexOf(" const koiAtlas ="), source.indexOf(" host.replaceChildren(canvas);"));
+ const pixelSource = readFileSync(new URL("../components/concepts/signal-desk/pond-pixel-renderer.ts", import.meta.url), "utf8");
+ const pixelAssets = pixelSource.slice(pixelSource.indexOf(" const [pondTexture,"), pixelSource.indexOf(" host.replaceChildren(canvas);"));
+ async function initialize(liveAscii: boolean, fail: (path: string) => boolean, pixel = false, mobile = false) {
+ const loaded: string[] = [];
+ let reads = 0;
+ const initializeAssets = runInNewContext(javascript(`(async () => { ${pixel ? pixelAssets : assets} })`), {
+ liveAscii,
+ window: { matchMedia: () => ({ matches: mobile }) },
+ load: async (path: string) => {
+ loaded.push(path);
+ if (fail(path)) throw new Error(`failed ${path}`);
+ return { width: 1586, height: 992, texture: {}, image: {} };
+ },
+ loads: { signal: { throwIfAborted: () => {} } },
+ own: (value: unknown) => value,
+ THREE: { Texture: class { needsUpdate = false; } },
+ document: { createElement: () => ({ width: 0, height: 0, getContext: () => ({
+ drawImage: () => {},
+ getImageData: () => { reads += 1; return { data: new Uint8ClampedArray(4) }; },
+ }) }) },
+ Uint8ClampedArray,
+ });
+ await initializeAssets();
+ return { loaded, reads };
+ }
+ const ascii = await initialize(true, (path) => path !== "pixel-koi-atlas.webp");
+ assert.deepEqual(ascii.loaded, ["pixel-koi-atlas.webp"]);
+ assert.equal(ascii.reads, 0);
+ const raster = await initialize(false, () => false);
+ assert.equal(raster.loaded.length, 5);
+ assert.equal(raster.reads, 1);
+ const pixel = await initialize(false, () => false, true);
+ assert.equal(pixel.loaded.length, 5);
+ assert.equal(pixel.reads, 1);
+ assert.ok(pixel.loaded.includes("pixel-pond-world.webp"));
+ const mobilePixel = await initialize(false, () => false, true, true);
+ assert.ok(mobilePixel.loaded.includes("pixel-pond-world-mobile.webp"));
+ assert.equal(mobilePixel.reads, 1);
+ await assert.rejects(initialize(false, (path) => path === "water-displacement.jpg"), /failed water-displacement/);
+ await assert.rejects(initialize(true, () => true), /failed pixel-koi-atlas/);
+});