Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fe92692
fix(desk): camera, focus and input bugs found in a full review
kanywst Oct 23, 2026
f433353
fix(readme): parse URLs for the allow-lists, and render boards access…
kanywst Oct 23, 2026
26098f8
fix(layout): keep the unfiled pile clear of the others, and test the …
kanywst Oct 23, 2026
35ac2aa
perf(images): send the mascot through the image pipeline
kanywst Oct 23, 2026
893600e
fix(shots): fail only when every attempted shot fails, and use the in…
kanywst Oct 23, 2026
26bf614
fix(a11y): name blank table headers for screen readers
kanywst Oct 23, 2026
83b3bb6
fix(security): sandbox the live frame and tighten the CSP
kanywst Oct 23, 2026
748ff0f
fix(sync): keep only https links from the APIs, and decode numeric en…
kanywst Oct 23, 2026
4850c69
fix(shots): validate screenshots chunk by chunk, and keep untrusted j…
kanywst Oct 23, 2026
b8d3efb
ci: pin the actionlint image by digest
kanywst Oct 23, 2026
750bb66
fix(readme): decode references in one pass and survive invalid ones
kanywst Oct 23, 2026
bf2a892
fix(sync): an unreadable board image no longer fails the games sync
kanywst Oct 23, 2026
86689ad
fix(desk): close an open site by pressing the desk around it
kanywst Oct 23, 2026
87c8806
fix(sync): fall back to the sizes the previous games.json knew
kanywst Oct 23, 2026
ee0014e
test(e2e): the HUD can't take focus while a site is open either
kanywst Oct 24, 2026
f7673e5
fix(desk): make the HUD inert while a site is open
kanywst Oct 24, 2026
a051ddb
fix(css): keep images in proportion when they shrink
kanywst Oct 24, 2026
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
8 changes: 7 additions & 1 deletion .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
name: Set up
description: pnpm, Node 24 and the locked dependencies, shared by every CI job.

inputs:
cache:
description: Use the shared pnpm store cache. Jobs that run untrusted code turn it off so they can't seed it.
default: "true"

runs:
using: composite
steps:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: pnpm
cache: ${{ inputs.cache == 'true' && 'pnpm' || '' }}
package-manager-cache: ${{ inputs.cache == 'true' }}
- run: pnpm install --frozen-lockfile
shell: bash
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12 -color
- run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 -color

typecheck:
runs-on: ubuntu-latest
Expand Down
17 changes: 10 additions & 7 deletions .github/workflows/shots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/setup
# The installed version, as in ci.yml, so the image tag is exact even if the spec is a range.
- id: v
run: echo "playwright=$(node -p 'require("./package.json").devDependencies["@playwright/test"]')" >> "$GITHUB_OUTPUT"
run: echo "playwright=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"

# Shooting runs third-party code (the npm install, the sites themselves) with a
# read-only token. Only the propose job can write, and it installs nothing.
Expand All @@ -55,6 +57,8 @@ jobs:
with:
persist-credentials: false
- uses: ./.github/actions/setup
with:
cache: "false"
# The committed repo list can lag behind the org; read the current one.
- run: pnpm run sync
env:
Expand Down Expand Up @@ -103,18 +107,17 @@ jobs:
exit 0
fi
# The artifact comes from a job that ran third-party code, so commit nothing
# but real PNGs directly inside src/assets/shots.
git status --porcelain --untracked-files=all -- src/assets/shots | cut -c4- | while read -r f; do
# but well-formed PNGs directly inside src/assets/shots (checked chunk by chunk).
changed="$(git status --porcelain --untracked-files=all -- src/assets/shots | cut -c4-)"
for f in $changed; do
case "$f" in
src/assets/shots/*/*) echo "::error::unexpected path $f"; exit 1 ;;
src/assets/shots/*.png) ;;
*) echo "::error::unexpected file $f"; exit 1 ;;
esac
if [ "$(head -c 8 "$f" | od -An -tx1 | tr -d ' \n')" != "89504e470d0a1a0a" ]; then
echo "::error::$f is not a PNG"
exit 1
fi
done
# shellcheck disable=SC2086 # one path per word, and the case above allows no spaces
python3 scripts/check_png.py $changed
branch="shots/$RUN_ID"
git switch -c "$branch"
git add -- 'src/assets/shots/*.png'
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0-draft.github.io

<img alt="The 0-draft desk" src="public/og.png">
<img alt="The 0-draft desk" src="public/og.jpg">

Everything public in the [0-draft](https://github.com/0-draft) org, pulled as proofs and laid out on a print-shop desk at night: <https://0-draft.github.io/>.

Expand Down
5 changes: 3 additions & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ export default defineConfig({
csp: {
directives: [
"default-src 'self'",
"img-src 'self' data: https://raw.githubusercontent.com https://avatars.githubusercontent.com",
"img-src 'self' https://raw.githubusercontent.com",
"connect-src 'self' https://raw.githubusercontent.com",
"frame-src 'self' https://0-draft.github.io",
"frame-src https://0-draft.github.io",
"object-src 'none'",
"font-src 'self'",
"base-uri 'none'",
"form-action 'none'",
Expand Down
76 changes: 75 additions & 1 deletion e2e/desk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ test("ships a CSP that only allows what the page needs", async ({ page }) => {
await page.goto("/");
const csp = await page.locator('meta[http-equiv="content-security-policy"]').getAttribute("content");
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("frame-src 'self' https://0-draft.github.io");
expect(csp).toContain("frame-src https://0-draft.github.io");
expect(csp).toContain("object-src 'none'");
// The project sites share this origin; the frame must not get it back.
const sandbox = await page.locator(".live-frame").getAttribute("sandbox");
expect(sandbox).toContain("allow-scripts");
expect(sandbox).not.toContain("allow-same-origin");
expect(csp).not.toContain("unsafe-inline");
expect(csp).not.toContain("unsafe-eval");
});
Expand Down Expand Up @@ -90,6 +95,20 @@ test("breaking a seal opens the live site on the desk and Escape puts it back",
await expect(sheet.getByRole("button", { name: /^Break the seal/ })).toBeFocused();
});

test("pressing the darkened desk around an open site closes it", async ({ page, isMobile }) => {
test.skip(isMobile, "on a phone the site fills the screen, leaving no desk to press");
await openDesk(page);
const target = repos.find((r) => r.homepage?.startsWith("https://0-draft.github.io/"));
test.skip(!target, "no framable site in the data");
const button = page.locator(`article[id="${target?.name}"]`).getByRole("button", { name: /^Break the seal/ });
await button.focus();
await button.click();
await expect(page.locator(".live")).toBeVisible();
const vp = page.viewportSize();
await page.mouse.click(4, (vp?.height ?? 800) - 4);
await expect(page.locator(".live")).toBeHidden();
});

test("Escape while a seal is still cracking cancels the open", async ({ page }) => {
// Arrive with motion reduced so the camera settles at once, then turn motion back
// on so pressing the seal starts the real crack-then-dive sequence.
Expand Down Expand Up @@ -120,6 +139,61 @@ test("the redacted password lifts when pressed", async ({ page }) => {
await expect(redacted).not.toHaveCSS("color", "rgba(0, 0, 0, 0)");
});

test("a plain click on a sheet brings it to the middle of the screen", async ({ page }) => {
await openDesk(page);
// From the whole-desk view every sheet is on screen but none is centred.
await page.keyboard.press("0");
const sheet = page.locator("#s3-atlas");
const before = await sheet.boundingBox();
if (!before) throw new Error("sheet not laid out");
// A real pointer press in the middle of the print (the screenshot, not a link or the seal).
await page.mouse.click(before.x + before.width / 2, before.y + before.height * 0.3);
await expect
.poll(async () => {
const box = await sheet.boundingBox();
const vp = page.viewportSize();
if (!box || !vp) return Number.POSITIVE_INFINITY;
return Math.abs(box.x + box.width / 2 - vp.width / 2);
})
.toBeLessThan(24);
});

test("switching to list view mid-flight leaves the list untouched by the camera", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await openDesk(page);
await page.getByRole("button", { name: "Played in the README" }).click();
await page.getByRole("button", { name: "List view" }).click();
await page.waitForTimeout(1500);
await expect(page.locator("#world")).not.toHaveAttribute("style", /transform/);
});

test("while a site is open, focus cannot wander onto the desk behind it", async ({ page }) => {
await openDesk(page);
const target = repos.find((r) => r.homepage?.startsWith("https://0-draft.github.io/"));
test.skip(!target, "no framable site in the data");
const button = page.locator(`article[id="${target?.name}"]`).getByRole("button", { name: /^Break the seal/ });
await button.focus();
await button.click();
await expect(page.locator(".live")).toBeVisible();
// Keep the frame from taking focus with a page we don't control.
await page.locator(".live iframe").evaluate((f) => f.setAttribute("tabindex", "-1"));
for (let i = 0; i < 6; i++) {
await page.keyboard.press("Tab");
// Neither the desk nor the HUD over it (hidden while live) can take focus.
expect(await page.evaluate(() => !!document.activeElement?.closest("#world, .tray, .minimap, .hud-top"))).toBe(
false,
);
}
});

test("board images carry their size, so the boards don't grow after the desk is measured", async ({ page }) => {
await openDesk(page);
const unsized = await page
.locator('[data-game="shogi"] img, [data-game="blackjack"] img')
.evaluateAll((imgs) => imgs.filter((i) => !i.getAttribute("width") || !i.getAttribute("height")).length);
expect(unsized).toBe(0);
});

test("the README games render as playable boards", async ({ page }) => {
await openDesk(page);
const shogi = page.locator('[data-game="shogi"] .b-grid .b-row');
Expand Down
Binary file removed public/avatar.png
Binary file not shown.
Binary file added public/og.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/og.png
Binary file not shown.
54 changes: 54 additions & 0 deletions scripts/check_png.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Refuse anything but a well-formed PNG of sane size.

The shots workflow's propose job runs this over screenshots that came from a job which
ran third-party code, before they are committed and later decoded by the build. Only
the standard library, so the privileged job still installs nothing.
"""

import struct
import sys
import zlib

MAX_BYTES = 5 * 1024 * 1024
MAX_SIDE = 4096
SIGNATURE = b"\x89PNG\r\n\x1a\n"


def check(path: str) -> str | None:
data = open(path, "rb").read()
if len(data) > MAX_BYTES:
return f"larger than {MAX_BYTES} bytes"
if not data.startswith(SIGNATURE):
return "no PNG signature"
pos, chunks = len(SIGNATURE), []
while pos < len(data):
if pos + 12 > len(data):
return "truncated chunk"
length, kind = struct.unpack(">I4s", data[pos : pos + 8])
body = data[pos + 8 : pos + 8 + length]
if len(body) != length or pos + 12 + length > len(data):
return f"chunk {kind!r} runs past the end"
(crc,) = struct.unpack(">I", data[pos + 8 + length : pos + 12 + length])
if zlib.crc32(kind + body) != crc:
return f"bad CRC in {kind!r}"
chunks.append((kind, body))
pos += 12 + length
if kind == b"IEND":
break
if pos != len(data):
return "data after IEND"
if not chunks or chunks[0][0] != b"IHDR" or chunks[-1][0] != b"IEND":
return "IHDR must come first and IEND last"
width, height = struct.unpack(">II", chunks[0][1][:8])
if not (0 < width <= MAX_SIDE and 0 < height <= MAX_SIDE):
return f"{width}x{height} is out of range"
return None


failed = False
for path in sys.argv[1:]:
problem = check(path)
if problem:
failed = True
print(f"::error file={path}::{problem}")
sys.exit(1 if failed else 0)
4 changes: 3 additions & 1 deletion scripts/shoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" });
let taken = 0;
let failed = 0;
let attempted = 0;
for (const r of targets) {
const path = shot(r.name);
if (!all && (await exists(path))) continue;
attempted++;
try {
// An error page must never be printed: without --all it would never be retaken.
const res = await page.goto(r.homepage as string, { waitUntil: "networkidle", timeout: 45_000 });
Expand All @@ -47,4 +49,4 @@ await browser.close();
console.log(`${taken} taken, ${failed} failed, ${targets.length - taken - failed} kept`);
// One broken site is a warning, not a red run every Monday. Only when every
// target fails is something wrong with the run itself (network, browser).
if (targets.length > 0 && failed === targets.length) process.exit(1);
if (attempted > 0 && taken === 0) process.exit(1);
84 changes: 72 additions & 12 deletions scripts/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// src/data/articles.json latest dev.to articles
// A source that fails keeps its previous file, so one flaky API never breaks the build.
import { readFile, writeFile } from "node:fs/promises";
import { GAMES, type GameName, parseGame, readmeUrl } from "../src/lib/readme.ts";
import { type Block, GAMES, type GameName, parseGame, readmeUrl } from "../src/lib/readme.ts";

const ORG = "0-draft";
const SKIP = new Set([".github", `${ORG}.github.io`]);
Expand All @@ -18,6 +18,16 @@ const ghHeaders: Record<string, string> = {
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
if (token) ghHeaders.Authorization = `Bearer ${token}`;

// Links that reach the page from an API are kept only when they are plain https URLs.
function https(url: unknown, host?: string): string | null {
try {
const u = new URL(String(url));
return u.protocol === "https:" && (!host || u.hostname === host) ? u.href : null;
} catch {
return null;
}
}

async function get(url: string, headers: Record<string, string> = {}): Promise<Response> {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(20_000) });
if (!res.ok) throw new Error(`${res.status} ${url}`);
Expand All @@ -38,7 +48,7 @@ async function repos() {
name: r.name,
description: r.description ?? "",
url: r.html_url,
homepage: r.homepage || null,
homepage: https(r.homepage),
language: r.language,
topics: r.topics ?? [],
stars: r.stargazers_count,
Expand All @@ -48,27 +58,73 @@ async function repos() {
.sort((a, b) => b.pushedAt.localeCompare(a.pushedAt));
}

// Reads each board image's intrinsic size so the boards render at their final size from the start.
// Sizes are cosmetic: an image that can't be read just goes unsized instead of failing the sync.
async function sizeOf(src: string): Promise<{ w: number; h: number } | null> {
try {
const svg = await (await get(src)).text();
const w = Number(svg.match(/<svg[^>]*\swidth="(\d+(?:\.\d+)?)"/)?.[1]);
const h = Number(svg.match(/<svg[^>]*\sheight="(\d+(?:\.\d+)?)"/)?.[1]);
return w && h ? { w, h } : null;
} catch {
return null;
}
Comment thread
kanywst marked this conversation as resolved.
}

// Sizes the previous games.json already knew, so a transient fetch failure doesn't drop them.
async function knownSizes(): Promise<Map<string, { w: number; h: number }>> {
const known = new Map<string, { w: number; h: number }>();
try {
const previous = JSON.parse(await readFile(data("games.json"), "utf8")) as Record<string, Block[]>;
for (const blocks of Object.values(previous)) {
for (const b of blocks) {
if (b.kind !== "grid") continue;
for (const c of b.rows.flat()) if (c.w && c.h) known.set(c.src, { w: c.w, h: c.h });
}
}
} catch {
// No previous file: everything is fetched.
}
return known;
}

async function sizeCells(blocks: Block[], known: Map<string, { w: number; h: number }>) {
const cells = blocks.flatMap((b) => (b.kind === "grid" ? b.rows.flat() : []));
const srcs = [...new Set(cells.map((c) => c.src))];
const sizes = new Map(
await Promise.all(srcs.map(async (src) => [src, (await sizeOf(src)) ?? known.get(src) ?? null] as const)),
);
for (const cell of cells) {
const size = sizes.get(cell.src);
if (size) Object.assign(cell, size);
}
}

async function games() {
const out: Record<string, unknown> = {};
const out: Record<string, Block[]> = {};
const known = await knownSizes();
for (const name of Object.keys(GAMES) as GameName[]) {
const md = await (await get(readmeUrl(name))).text();
const blocks = parseGame(name, md);
if (!blocks) throw new Error(`no live region in ${name}/README.md`);
if (!blocks?.length) throw new Error(`no live region in ${name}/README.md`);
await sizeCells(blocks, known);
out[name] = blocks;
}
return out;
}

async function articles() {
const res = await get(`https://dev.to/api/articles?username=${DEVTO_USER}&per_page=8`);
return (await res.json()).map((a: any) => ({
title: a.title,
url: a.url,
publishedAt: a.published_at,
reactions: a.public_reactions_count ?? a.positive_reactions_count,
tags: a.tag_list,
readingMinutes: a.reading_time_minutes,
}));
return (await res.json())
.map((a: any) => ({
title: a.title,
url: https(a.url, "dev.to"),
publishedAt: a.published_at,
reactions: a.public_reactions_count ?? a.positive_reactions_count,
tags: a.tag_list,
readingMinutes: a.reading_time_minutes,
}))
.filter((a: { url: string | null }) => a.url);
}

let failed = 0;
Expand All @@ -79,6 +135,10 @@ for (const [file, load] of [
] as const) {
try {
const value = await load();
// An empty answer is a failure too: an org rename or a token change must not empty the desk.
if (Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0) {
throw new Error("empty response");
}
await writeFile(data(file), `${JSON.stringify(value, null, 2)}\n`);
console.log(`ok ${file}`);
} catch (err) {
Expand Down
File renamed without changes
Loading
Loading