Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Check site

on:
workflow_call:
pull_request:
branches: ["master"]

Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/nextjs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ concurrency:
cancel-in-progress: false

jobs:
verify:
uses: ./.github/workflows/checks.yml

build:
needs: verify
permissions:
contents: read
pages: read
Expand All @@ -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
Expand Down
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<>
<a href="#main" className="skip-link">Skip to content</a>
<a href="#main" className="skip-link" onClick={() => document.getElementById("main")?.focus({ preventScroll: true })}>Skip to content</a>
<main id="main" tabIndex={-1}>{children}</main>
</>
);
Expand Down
6 changes: 5 additions & 1 deletion components/concepts/signal-desk/pond-environment.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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] {
Expand Down Expand Up @@ -165,3 +167,5 @@
}

}

@media(max-width:720px) { .pondImage { --pond-fallback:url("/images/portfolio/pixel-pond-world-mobile.webp"); } }
7 changes: 6 additions & 1 deletion components/concepts/signal-desk/pond-environment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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") {
Expand Down Expand Up @@ -113,7 +118,7 @@ export function PondEnvironment({ reduced, mode = "ascii" }: { reduced: boolean;
<div className={styles.pondGrade} />
<div className={styles.surfaceLight} />
</div>
<div ref={interactionRef} className={styles.pondInput} aria-hidden="true" />
<div ref={interactionRef} data-pond-input="" className={styles.pondInput} aria-hidden="true" />
</>
);
}
15 changes: 15 additions & 0 deletions components/concepts/signal-desk/pond-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 () => {
Expand All @@ -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);
};
}
2 changes: 1 addition & 1 deletion components/concepts/signal-desk/pond-pixel-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
11 changes: 6 additions & 5 deletions components/concepts/signal-desk/pond-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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 });
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand Down
12 changes: 9 additions & 3 deletions components/concepts/signal-desk/project-explorer.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
}

.projectNav small {
font-size:10px;
font-size:12px;
line-height:1.4;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -184,7 +184,7 @@

.proofRow span {
color:var(--muted);
font-size:11px;
font-size:12px;
line-height:1.4;
}

Expand Down Expand Up @@ -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); } }
Loading
Loading