diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index dc333fd..5b9e6aa 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -1,6 +1,11 @@
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:
@@ -8,6 +13,7 @@ runs:
- 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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 53f7f5e..29e9506 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/shots.yml b/.github/workflows/shots.yml
index cf3dc14..c98d09a 100644
--- a/.github/workflows/shots.yml
+++ b/.github/workflows/shots.yml
@@ -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.
@@ -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:
@@ -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'
diff --git a/README.md b/README.md
index 680fc97..3ce5b97 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 0-draft.github.io
-
+
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: .
diff --git a/astro.config.mjs b/astro.config.mjs
index 7a14a36..bf4e583 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -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'",
diff --git a/e2e/desk.spec.ts b/e2e/desk.spec.ts
index 27f2c18..1a13c92 100644
--- a/e2e/desk.spec.ts
+++ b/e2e/desk.spec.ts
@@ -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");
});
@@ -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.
@@ -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');
diff --git a/public/avatar.png b/public/avatar.png
deleted file mode 100644
index 376d068..0000000
Binary files a/public/avatar.png and /dev/null differ
diff --git a/public/og.jpg b/public/og.jpg
new file mode 100644
index 0000000..771698a
Binary files /dev/null and b/public/og.jpg differ
diff --git a/public/og.png b/public/og.png
deleted file mode 100644
index f7a622a..0000000
Binary files a/public/og.png and /dev/null differ
diff --git a/scripts/check_png.py b/scripts/check_png.py
new file mode 100644
index 0000000..0ce7473
--- /dev/null
+++ b/scripts/check_png.py
@@ -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)
diff --git a/scripts/shoot.ts b/scripts/shoot.ts
index 513a7f5..056a05a 100644
--- a/scripts/shoot.ts
+++ b/scripts/shoot.ts
@@ -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 });
@@ -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);
diff --git a/scripts/sync.ts b/scripts/sync.ts
index f3fe4ec..f3aff94 100644
--- a/scripts/sync.ts
+++ b/scripts/sync.ts
@@ -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`]);
@@ -18,6 +18,16 @@ const ghHeaders: Record = {
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 = {}): Promise {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(20_000) });
if (!res.ok) throw new Error(`${res.status} ${url}`);
@@ -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,
@@ -48,12 +58,56 @@ 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(/
) : (
+ {b.head.some((h) => h) && (
+
+
+ {/* A blank header still names its column for screen readers. */}
+ {b.head.map((h, i) => (
+ | {h || Column {i + 1}} |
+ ))}
+
+
+ )}
{b.rows.map((row) => (
diff --git a/src/components/Sheet.astro b/src/components/Sheet.astro
index 4816c81..1f462dd 100644
--- a/src/components/Sheet.astro
+++ b/src/components/Sheet.astro
@@ -71,5 +71,5 @@ const host = site?.replace(/^https:\/\//, "").replace(/\/$/, "");
Source
-
+
diff --git a/src/data/games.json b/src/data/games.json
index 9ce0176..d946dd1 100644
--- a/src/data/games.json
+++ b/src/data/games.json
@@ -5,36 +5,48 @@
"rows": [
[
{
- "href": "https://github.com/0-draft/blackjack#blackjack",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/blackjack/main/.github/cards/label-dealer.svg",
- "alt": "dealer"
+ "alt": "dealer",
+ "w": 52,
+ "h": 62
},
{
- "href": "https://github.com/0-draft/blackjack#blackjack",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/blackjack/main/.github/cards/7S.svg",
- "alt": "seven of spades"
+ "alt": "seven of spades",
+ "w": 44,
+ "h": 62
},
{
- "href": "https://github.com/0-draft/blackjack#blackjack",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/blackjack/main/.github/cards/back.svg",
- "alt": "the dealer's hole card, face down"
+ "alt": "the dealer's hole card, face down",
+ "w": 44,
+ "h": 62
}
],
[
{
- "href": "https://github.com/0-draft/blackjack#blackjack",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/blackjack/main/.github/cards/label-you.svg",
- "alt": "you"
+ "alt": "you",
+ "w": 52,
+ "h": 62
},
{
- "href": "https://github.com/0-draft/blackjack#blackjack",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/blackjack/main/.github/cards/3D.svg",
- "alt": "three of diamonds"
+ "alt": "three of diamonds",
+ "w": 44,
+ "h": 62
},
{
- "href": "https://github.com/0-draft/blackjack#blackjack",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/blackjack/main/.github/cards/7S.svg",
- "alt": "seven of spades"
+ "alt": "seven of spades",
+ "w": 44,
+ "h": 62
}
]
]
@@ -54,22 +66,30 @@
{
"href": "https://github.com/0-draft/blackjack/issues/new?title=bj%7Chit+12&body=Just+click+Submit+new+issue.+The+table+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/blackjack/main/.github/cards/btn-hit.svg",
- "alt": "hit"
+ "alt": "hit",
+ "w": 49,
+ "h": 30
},
{
"href": "https://github.com/0-draft/blackjack/issues/new?title=bj%7Cstand+12&body=Just+click+Submit+new+issue.+The+table+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/blackjack/main/.github/cards/btn-stand.svg",
- "alt": "stand"
+ "alt": "stand",
+ "w": 66,
+ "h": 30
},
{
"href": "https://github.com/0-draft/blackjack/issues/new?title=bj%7Cdouble+12&body=Just+click+Submit+new+issue.+The+table+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/blackjack/main/.github/cards/btn-double.svg",
- "alt": "double"
+ "alt": "double",
+ "w": 74,
+ "h": 30
},
{
"href": "https://github.com/0-draft/blackjack/issues/new?title=bj%7Csurrender+12&body=Just+click+Submit+new+issue.+The+table+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/blackjack/main/.github/cards/btn-surrender.svg",
- "alt": "surrender"
+ "alt": "surrender",
+ "w": 100,
+ "h": 30
}
]
]
@@ -104,6 +124,12 @@
},
{
"kind": "table",
+ "head": [
+ "Hand",
+ "Player",
+ "Dealer",
+ ""
+ ],
"rows": [
[
[
@@ -211,423 +237,585 @@
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+9a&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gL.svg",
- "alt": "9a white lance to move"
+ "alt": "9a white lance to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+8a&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gN.svg",
- "alt": "8a white knight to move"
+ "alt": "8a white knight to move",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+6a&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gG.svg",
- "alt": "6a white gold to move"
+ "alt": "6a white gold to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+5a&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gK.svg",
- "alt": "5a white king to move"
+ "alt": "5a white king to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+4a&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gG.svg",
- "alt": "4a white gold to move"
+ "alt": "4a white gold to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+3a&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gS.svg",
- "alt": "3a white silver to move"
+ "alt": "3a white silver to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+2a&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gN.svg",
- "alt": "2a white knight to move"
+ "alt": "2a white knight to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+1a&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gL.svg",
- "alt": "1a white lance to move"
+ "alt": "1a white lance to move",
+ "w": 44,
+ "h": 48
}
],
[
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+8b&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gR.svg",
- "alt": "8b white rook to move"
+ "alt": "8b white rook to move",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+6b&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gS.svg",
- "alt": "6b white silver to move"
+ "alt": "6b white silver to move",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+2b&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gB.svg",
- "alt": "2b white bishop to move"
+ "alt": "2b white bishop to move",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
}
],
[
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+9c&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gP.svg",
- "alt": "9c white pawn to move"
+ "alt": "9c white pawn to move",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+7c&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gP.svg",
- "alt": "7c white pawn to move"
+ "alt": "7c white pawn to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+6c&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gP.svg",
- "alt": "6c white pawn to move"
+ "alt": "6c white pawn to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+5c&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gP.svg",
- "alt": "5c white pawn to move"
+ "alt": "5c white pawn to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+4c&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gP.svg",
- "alt": "4c white pawn to move"
+ "alt": "4c white pawn to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+3c&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gP.svg",
- "alt": "3c white pawn to move"
+ "alt": "3c white pawn to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+2c&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gP.svg",
- "alt": "2c white pawn to move"
+ "alt": "2c white pawn to move",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+1c&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gP.svg",
- "alt": "1c white pawn to move"
+ "alt": "1c white pawn to move",
+ "w": 44,
+ "h": 48
}
],
[
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
"href": "https://github.com/0-draft/shogi/issues/new?title=shogi%7Csel+8d&body=Just+click+Submit+new+issue.+The+board+updates+in+about+30+seconds.",
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/gP.svg",
- "alt": "8d white pawn to move"
+ "alt": "8d white pawn to move",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
}
],
[
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
}
],
[
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sP-idle.svg",
- "alt": "7f black pawn"
+ "alt": "7f black pawn",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
}
],
[
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sP-idle.svg",
- "alt": "9g black pawn"
+ "alt": "9g black pawn",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sP-idle.svg",
- "alt": "8g black pawn"
+ "alt": "8g black pawn",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sN-idle.svg",
- "alt": "7g black knight"
+ "alt": "7g black knight",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sP-idle.svg",
- "alt": "6g black pawn"
+ "alt": "6g black pawn",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sP-idle.svg",
- "alt": "5g black pawn"
+ "alt": "5g black pawn",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sP-idle.svg",
- "alt": "4g black pawn"
+ "alt": "4g black pawn",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sP-idle.svg",
- "alt": "3g black pawn"
+ "alt": "3g black pawn",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sP-idle.svg",
- "alt": "2g black pawn"
+ "alt": "2g black pawn",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sP-idle.svg",
- "alt": "1g black pawn"
+ "alt": "1g black pawn",
+ "w": 44,
+ "h": 48
}
],
[
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sB-idle.svg",
- "alt": "8h black bishop"
+ "alt": "8h black bishop",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sR-idle.svg",
- "alt": "5h black rook"
+ "alt": "5h black rook",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
}
],
[
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sL-idle.svg",
- "alt": "9i black lance"
+ "alt": "9i black lance",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/empty.svg",
- "alt": ""
+ "alt": "",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sS-idle.svg",
- "alt": "7i black silver"
+ "alt": "7i black silver",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sG-idle.svg",
- "alt": "6i black gold"
+ "alt": "6i black gold",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sK-idle.svg",
- "alt": "5i black king"
+ "alt": "5i black king",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sG-idle.svg",
- "alt": "4i black gold"
+ "alt": "4i black gold",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sS-idle.svg",
- "alt": "3i black silver"
+ "alt": "3i black silver",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sN-idle.svg",
- "alt": "2i black knight"
+ "alt": "2i black knight",
+ "w": 44,
+ "h": 48
},
{
- "href": "https://github.com/0-draft/shogi#shogi",
+ "href": null,
"src": "https://raw.githubusercontent.com/0-draft/shogi/main/.github/koma/sL-idle.svg",
- "alt": "1i black lance"
+ "alt": "1i black lance",
+ "w": 44,
+ "h": 48
}
]
]
@@ -656,6 +844,11 @@
"guestbook": [
{
"kind": "table",
+ "head": [
+ "🕐",
+ "👤",
+ "💬"
+ ],
"rows": [
[
[
diff --git a/src/lib/desk.test.ts b/src/lib/desk.test.ts
index 5e7b690..2ba2fc3 100644
--- a/src/lib/desk.test.ts
+++ b/src/lib/desk.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { groups } from "../data/groups.ts";
import repos from "../data/repos.json";
-import { ago, framable, type Repo, shelves } from "./desk.ts";
+import { ago, framable, labels, placed, type Repo, shelves } from "./desk.ts";
const DAY = 86_400_000;
const now = Date.parse("2026-10-24T00:00:00Z");
@@ -51,13 +51,13 @@ describe("shelves", () => {
}
});
- it("gives every sheet a spot, a small tilt and a tape style, the same on every build", () => {
+ it("gives every sheet a spot, a small tilt and a stamp variant, the same on every build", () => {
const again = shelves();
for (const [i, shelf] of all.entries()) {
for (const [j, r] of shelf.repos.entries()) {
expect(Number.isFinite(r.spot.x) && Number.isFinite(r.spot.y)).toBe(true);
expect(Math.abs(r.tilt)).toBeLessThanOrEqual(1.6);
- expect([0, 1, 2]).toContain(r.tape);
+ expect([0, 1, 2]).toContain(r.variant);
expect(again[i].repos[j]).toEqual(r);
}
}
@@ -68,3 +68,19 @@ describe("shelves", () => {
expect(new Set(spots).size).toBe(spots.length);
});
});
+
+describe("the hand-made layout", () => {
+ it("has a heading spot for every group", () => {
+ for (const g of groups) expect(labels[g.id], g.id).toBeDefined();
+ });
+
+ it("has a hand-placed spot for every filed repo", () => {
+ for (const name of groups.flatMap((g) => g.repos)) expect(placed[name], name).toBeDefined();
+ });
+
+ it("puts the pile for unfiled repos below every other pile", () => {
+ const lowest = Math.max(...Object.values(placed).map((s) => s.y));
+ // The tallest sheet (the dev.to receipt) is about 1200px; leave room for it.
+ expect(labels.fresh.y).toBeGreaterThan(lowest + 1200);
+ });
+});
diff --git a/src/lib/desk.ts b/src/lib/desk.ts
index e7fa77d..5922ae2 100644
--- a/src/lib/desk.ts
+++ b/src/lib/desk.ts
@@ -13,7 +13,7 @@ export interface Spot {
export const home: Spot = { x: -680, y: -330 };
// Hand-placed so each group reads as one pile on the desk.
-const placed: Record = {
+export const placed: Record = {
"caller-identity": { x: -2860, y: -1600 },
"assume-role": { x: -2180, y: -1540 },
"on-ramp": { x: -1500, y: -1620 },
@@ -33,7 +33,8 @@ export const labels: Record = {
security: { x: 880, y: -1920 },
games: { x: -1620, y: 490 },
writing: { x: 860, y: 470 },
- fresh: { x: 900, y: 1500 },
+ // Below every other pile (the dev.to receipt reaches y ≈ 1970).
+ fresh: { x: -1620, y: 2300 },
};
// Dossier numbers are primes, for anyone who notices.
@@ -45,10 +46,10 @@ export interface Shelf {
title: string;
blurb: string;
label: Spot;
- repos: (Repo & { spot: Spot; tilt: number; tape: number })[];
+ repos: (Repo & { spot: Spot; tilt: number; variant: number })[];
}
-// Same name, same tilt and tape, every build.
+// Same name, same tilt and stamp variant, every build.
function hash(s: string): number {
let h = 2166136261;
for (const c of s) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
@@ -57,7 +58,7 @@ function hash(s: string): number {
function dress(r: Repo, spot: Spot) {
const n = hash(r.name);
- return { ...r, spot, tilt: Math.round((n * 3.2 - 1.6) * 10) / 10, tape: Math.floor(n * 1000) % 3 };
+ return { ...r, spot, tilt: Math.round((n * 3.2 - 1.6) * 10) / 10, variant: Math.floor(n * 1000) % 3 };
}
export function shelves(): Shelf[] {
@@ -68,7 +69,9 @@ export function shelves(): Shelf[] {
label: labels[g.id],
repos: g.repos.flatMap((name) => {
const r = byName.get(name);
- return r ? [dress(r, placed[name] ?? labels[g.id])] : [];
+ // A filed repo without a hand-placed spot goes just under its pile's heading
+ // (desk.test.ts fails the build so this stays a stopgap).
+ return r ? [dress(r, placed[name] ?? { x: labels[g.id].x, y: labels[g.id].y + 320 })] : [];
}),
}));
const fresh = repos.filter((r) => !filed.has(r.name));
@@ -76,7 +79,7 @@ export function shelves(): Shelf[] {
out.push({
...fallback,
label: labels.fresh,
- repos: fresh.map((r, i) => dress(r, { x: labels.fresh.x + i * 700, y: labels.fresh.y + 260 })),
+ repos: fresh.map((r, i) => dress(r, { x: labels.fresh.x + i * 720, y: labels.fresh.y + 300 })),
});
}
return out.filter((s) => s.repos.length > 0);
diff --git a/src/lib/readme.test.ts b/src/lib/readme.test.ts
index 44e8947..c653dfb 100644
--- a/src/lib/readme.test.ts
+++ b/src/lib/readme.test.ts
@@ -19,7 +19,7 @@ describe("liveRegion", () => {
});
describe("parseBlocks", () => {
- it("reads a board into rows of linked cells", () => {
+ it("reads a board into rows, linking only cells that open a move", () => {
const pre = `

`;
const [grid] = parseBlocks(pre);
@@ -32,7 +32,7 @@ describe("parseBlocks", () => {
src: `${IMG}/gL.svg`,
alt: "9a white lance",
},
- { href: "https://github.com/0-draft/shogi#shogi", src: `${IMG}/empty.svg`, alt: "" },
+ { href: null, src: `${IMG}/empty.svg`, alt: "" },
],
[
{
@@ -66,16 +66,47 @@ describe("parseBlocks", () => {
});
});
- it("reads table bodies cell by cell and ignores the head", () => {
+ it("decodes numeric references in one pass, never twice", () => {
+ const [p] = parseBlocks("it's 'fine' ' <b>
");
+ expect(p).toEqual({ kind: "text", segs: [{ text: "it's 'fine' ' <b>" }] });
+ });
+
+ it("survives references that name no real character", () => {
+ const [p] = parseBlocks("abcd&bogus;e
");
+ expect(p).toEqual({ kind: "text", segs: [{ text: "a\ufffdb\ufffdc\ufffdd&bogus;e" }] });
+ });
+
+ it("reads a table's head and its body cell by cell", () => {
const [t] = parseBlocks(
- ``,
+ ``,
);
expect(t).toEqual({
kind: "table",
+ head: ["Hand", ""],
rows: [[[{ text: "2026-10-14" }], [{ text: "@kanywst", href: "https://github.com/kanywst" }]]],
});
});
+ it.each([
+ "https://github.com.evil.example/x",
+ "https://github.com@evil.example/x",
+ "https://user:pw@github.com/x",
+ "http://github.com/x",
+ "//github.com/x",
+ "javascript:alert(1)",
+ ])("refuses the look-alike link %s", (href) => {
+ const [p] = parseBlocks(`x
`);
+ expect(p).toEqual({ kind: "text", segs: [{ text: "x" }] });
+ });
+
+ it.each([
+ "https://raw.githubusercontent.com/0-draft/../someone/x/main/a.svg",
+ "https://raw.githubusercontent.com.evil.example/0-draft/a.svg",
+ "https://raw.githubusercontent.com/someone/0-draft/a.svg",
+ ])("refuses the look-alike image %s", (src) => {
+ expect(parseBlocks(`
`)).toEqual([]);
+ });
+
it("keeps blocks in document order", () => {
const kinds = parseBlocks(`a

b
`).map(
(b) => b.kind,
diff --git a/src/lib/readme.ts b/src/lib/readme.ts
index 6cfe956..bade5eb 100644
--- a/src/lib/readme.ts
+++ b/src/lib/readme.ts
@@ -6,6 +6,9 @@ export interface Cell {
href: string | null;
src: string;
alt: string;
+ // Intrinsic size of the image, filled in at build time so the board doesn't shift as it loads.
+ w?: number;
+ h?: number;
}
export interface Seg {
@@ -16,24 +19,39 @@ export interface Seg {
export type Block =
| { kind: "grid"; rows: Cell[][] }
| { kind: "text"; segs: Seg[] }
- | { kind: "table"; rows: Seg[][][] };
+ | { kind: "table"; head: string[]; rows: Seg[][][] };
-const LINK_HOSTS = ["https://github.com/"];
-const IMG_PREFIX = "https://raw.githubusercontent.com/0-draft/";
+// Compared on parsed URLs, not string prefixes: a prefix check lets
+// "https://github.com.evil/", "https://github.com@evil/" or ".../0-draft/../someone/" through.
+function parsed(url: string): URL | null {
+ try {
+ return new URL(decode(url));
+ } catch {
+ return null;
+ }
+}
function safeHref(url: string): string | null {
- const u = decode(url);
- return LINK_HOSTS.some((h) => u.startsWith(h)) ? u : null;
+ const u = parsed(url);
+ return u && u.origin === "https://github.com" && !u.username && !u.password ? u.href : null;
+}
+
+function safeImg(url: string): string | null {
+ const u = parsed(url);
+ return u && u.origin === "https://raw.githubusercontent.com" && u.pathname.startsWith("/0-draft/") ? u.href : null;
}
+const NAMED: Record = { quot: '"', lt: "<", gt: ">", nbsp: " ", amp: "&" };
+
+// One pass, so nothing is decoded twice ("<" stays the text "<"), and a reference
+// that names no real character stays as written instead of throwing.
function decode(s: string): string {
- return s
- .replace(/"/g, '"')
- .replace(/'/g, "'")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/ /g, " ")
- .replace(/&/g, "&");
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (ref, body: string) => {
+ if (body[0] !== "#") return NAMED[body.toLowerCase()] ?? ref;
+ const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number(body.slice(1));
+ const real = code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff);
+ return real ? String.fromCodePoint(code) : "\ufffd";
+ });
}
function segments(html: string): Seg[] {
@@ -62,9 +80,14 @@ function grid(pre: string): Cell[][] {
return pre
.split("\n")
.map((line) =>
- [...line.matchAll(cellRe)]
- .filter((m) => m[2].startsWith(IMG_PREFIX))
- .map((m) => ({ href: safeHref(m[1]), src: m[2], alt: decode(m[3]) })),
+ [...line.matchAll(cellRe)].flatMap((m) => {
+ const src = safeImg(m[2]);
+ if (!src) return [];
+ // A cell is a link only when it does something: opens a prefilled move issue.
+ // The rest point back at the README itself and would just be empty tab stops.
+ const href = safeHref(m[1]);
+ return [{ href: href?.includes("/issues/new") ? href : null, src, alt: decode(m[3]) }];
+ }),
)
.filter((row) => row.length > 0);
}
@@ -78,7 +101,7 @@ export function liveRegion(markdown: string, marker: string): string | null {
export function parseBlocks(region: string): Block[] {
const blocks: Block[] = [];
- const re = /([\s\S]*?)<\/pre>|]*>([\s\S]*?)<\/p>|
([\s\S]*?)<\/tbody>/g;
+ const re = /([\s\S]*?)<\/pre>|]*>([\s\S]*?)<\/p>|
]*>([\s\S]*?)<\/table>/g;
for (const m of region.matchAll(re)) {
if (m[1] !== undefined) {
const rows = grid(m[1]);
@@ -87,10 +110,17 @@ export function parseBlocks(region: string): Block[] {
const segs = segments(m[2]);
if (segs.length) blocks.push({ kind: "text", segs });
} else if (m[3] !== undefined) {
- const rows = [...m[3].matchAll(/([\s\S]*?)<\/tr>/g)].map((tr) =>
+ const thead = m[3].match(/([\s\S]*?)<\/thead>/)?.[1] ?? "";
+ const head = [...thead.matchAll(/| ]*>([\s\S]*?)<\/th>/g)].map((th) =>
+ segments(th[1])
+ .map((x) => x.text)
+ .join(""),
+ );
+ const tbody = m[3].match(/ |
([\s\S]*?)<\/tbody>/)?.[1] ?? "";
+ const rows = [...tbody.matchAll(/([\s\S]*?)<\/tr>/g)].map((tr) =>
[...tr[1].matchAll(/| ([\s\S]*?)<\/td>/g)].map((td) => segments(td[1])),
);
- if (rows.length) blocks.push({ kind: "table", rows });
+ if (rows.length) blocks.push({ kind: "table", head, rows });
}
}
return blocks;
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 2cef9cb..1459272 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,5 +1,6 @@
---
-import { Font } from "astro:assets";
+import { Font, Image } from "astro:assets";
+import seal from "../assets/seal.png";
import Blocks from "../components/Blocks.astro";
import Sheet from "../components/Sheet.astro";
import articles from "../data/articles.json";
@@ -27,7 +28,7 @@ const count = all.reduce((n, s) => n + s.repos.length, 0);
-
+
@@ -47,7 +48,7 @@ const count = all.reduce((n, s) => n + s.repos.length, 0);
|