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 -The 0-draft desk +The 0-draft desk 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(/]*\swidth="(\d+(?:\.\d+)?)"/)?.[1]); + const h = Number(svg.match(/]*\sheight="(\d+(?:\.\d+)?)"/)?.[1]); + return w && h ? { w, h } : null; + } catch { + return null; + } +} + +// Sizes the previous games.json already knew, so a transient fetch failure doesn't drop them. +async function knownSizes(): Promise> { + const known = new Map(); + try { + const previous = JSON.parse(await readFile(data("games.json"), "utf8")) as Record; + 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) { + 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 = {}; + const out: Record = {}; + 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; @@ -61,14 +115,16 @@ async function games() { 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; @@ -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) { diff --git a/public/seal.png b/src/assets/seal.png similarity index 100% rename from public/seal.png rename to src/assets/seal.png diff --git a/src/components/Blocks.astro b/src/components/Blocks.astro index e84471b..86933e3 100644 --- a/src/components/Blocks.astro +++ b/src/components/Blocks.astro @@ -17,10 +17,10 @@ const { blocks } = Astro.props; {row.map((c) => c.href ? ( - {c.alt} + {c.alt} ) : ( - {c.alt} + {c.alt} ), )} @@ -40,6 +40,16 @@ const { blocks } = Astro.props;

) : ( + {b.head.some((h) => h) && ( + + + {/* A blank header still names its column for screen readers. */} + {b.head.map((h, i) => ( + + ))} + + + )} {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 = `
9a white lance
 9g black pawn
`; 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' &#39; &lt;b&gt;

"); + expect(p).toEqual({ kind: "text", segs: [{ text: "it's 'fine' ' <b>" }] }); + }); + + it("survives references that name no real character", () => { + const [p] = parseBlocks("

a�b�c�d&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( - `
{h || Column {i + 1}}
x
2026-10-14@kanywst
`, + `
Hand
2026-10-14@kanywst
`, ); 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(`
x
`)).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 ("&lt;" 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);
 
     
- + 0-draft