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;
-