From 223c30a2c9751b00a7aff733995959fde0565821 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies Date: Fri, 23 Oct 2026 17:45:23 -0400 Subject: [PATCH 01/12] docs: design for the site refresh (thumbnails, cards/list views, fonts, perf) Co-Authored-By: Claude Fable 5.1 --- .../specs/2026-10-23-site-refresh-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-23-site-refresh-design.md diff --git a/docs/superpowers/specs/2026-10-23-site-refresh-design.md b/docs/superpowers/specs/2026-10-23-site-refresh-design.md new file mode 100644 index 0000000..aba6b6c --- /dev/null +++ b/docs/superpowers/specs/2026-10-23-site-refresh-design.md @@ -0,0 +1,104 @@ +# Site refresh: show the assets, keep the identity + +Date: 2026-10-23. Status: approved direction (A + B blend chosen in brainstorming; image storage and remaining calls delegated to the builder by the maintainer: "Your call. Do it"). + +## Intent + +The GitHub Pages site scores 100/100/100 on accessibility, best practices and SEO and 83 on performance, but it looks dated and plain, and nothing on it shows the assets it catalogs. A catalog of visual things should be visual. This refresh keeps the current identity (palette, type, copy, page structure, the "every licence read at source" promise) and makes the assets themselves carry the page, while fixing the performance and layout items from the 2026-10-23 technical audit. + +## Goals + +1. Every entry that can legally show its publisher's preview image does, on the homepage, category pages, entry page and stack pages. +2. The first screen of the homepage shows real assets, not only a QA ledger. +3. The homepage loads fast on a phone: no render-blocking third-party fonts, no 631 KB pre-rendered HTML. +4. The entry page has one obvious primary action and readable facts. +5. Everything stays zero-npm-dependency, static, and works without JavaScript. + +## Non-goals + +- A redesign. No new palette, type, logo, or copy voice. "Refinement preserves." +- A new client-side framework or bundler. +- Fetching images at page view time. Visitors never hit publisher servers for thumbnails. +- Rendering font specimens or audio waveforms from the actual asset files (a later project). + +## Anti-goals (would make this a failure even if it works) + +- Showing a preview for an entry whose licence does not permit redistributing it. +- Publisher logos or GitHub social cards passing as asset previews. +- Hotlinking that leaks visitor requests to itch.io, Kenney and others. +- Breaking no-JS reading, search engines, or the 100 accessibility score. +- A homepage that still needs 66,000 px of scroll to reach the footer. + +## Constraints + +- Node 22+, no npm dependencies. System tools (ffmpeg, ImageMagick) may be used by the thumbnail script only, and only when present; the build never needs them. +- `site/build.mjs` marker substitution stays; tests in `site/checks.test.mjs` and `site/lib/lib.test.mjs` keep passing; `npm run check` is the gate. +- Impeccable craft floor applies to UI edits; run the detector once after. +- Work happens on branch `site-refresh`, lands as one PR that closes #75 and #87. + +## Decided: the look (from the brainstorm mockups) + +**Hero.** Two-line headline ("416 free game assets. Every licence read at the source." with the count live from the build), one-line subtitle naming the categories and the commercial/attribution promise, the search box, and the category chips. The verification ledger copy shrinks to one line ("414 re-checked in the last 180 days") and the full ledger stays on the freshness page. Counts in the hero and in the Catalog heading come from the same number. + +**Filmstrip.** Under the hero, a single row of square thumbnails (9 on desktop, scrolling or 5 on phones) chosen deterministically at build time: entries from card-default categories that have a thumbnail, most recently verified first, at most one per publisher. Each links to its entry. + +**Category sections with two views.** Each category section has a Cards / List toggle at its heading. +- Cards (the A mockup): a responsive grid, three columns at the 920 px max width, image on top at 16:10, then name, publisher and short descriptor, then a bottom line with the licence pill left and up to three formats right in the mono face. +- List (the B mockup): the existing dense row with a 56 px square thumbnail at the left, name, one metadata line, pills at the right. +- Default view is Cards for `3d`, `2d`, `characters`, `environment`, `animation`, `shaders-vfx`; List for `tools`, `audio`, `fonts`, `video`. The visitor's toggle is remembered in localStorage per category group kind (visual / non-visual), not per category. +- Each section shows 6 entries (Cards) or 8 (List), then a "Show all N in " link to a new static category page. With JavaScript, the chips and search render the full filtered set in place from data.js, in the active view; search results always use the List form. + +**Category pages.** New static pages at `category//` listing every entry in that category, both views available, no JavaScript needed to see everything. These carry the full pre-render that the homepage used to. + +**Typographic tile.** Entries with no permitted image get a tile: striped background from the surface and line tokens, the entry name in the display weight, and a small mono caption. Motif per category: a waveform for audio, "Aa" for fonts, a grid for tools, nothing special for the rest. Tiles are pure CSS and HTML so dark mode follows the tokens. + +**Entry page.** The thumbnail becomes a header strip above the title (object-fit contain on the tile background, max 360 px tall). One primary button, "Go to source". "View the file on GitHub", "Report a problem" and "Add to shortlist" become secondary text links in a row. "At a glance" becomes a definition list in the body face; the "What this stack owes" box on stack pages gets the same treatment. Copy buttons meet 44 px. A one-line credit under the strip: "Preview image from , shown under ." + +**Stack pages.** The entries in a stack render as Cards. + +**Audit fixes carried in this work.** Self-hosted fonts with preload (closes #87). Group heading text no longer reads "3D77" to screen readers and text extraction (count in its own labelled element). Side-tab 3 px borders removed in favour of the existing edge variable treatment. Mobile header collapses to brand plus a menu row; nav links meet 44 px. The floating "↑ Search" button moves to the bottom-left and hides while a card's actions are focused. Lazy loading and explicit width/height on every thumbnail so CLS stays near zero. + +## Decided: thumbnail pipeline (builder's call, now fixed) + +**Storage.** Thumbnails are committed to the repo under `site/public/thumbs/.webp`, 640 px wide at most, WebP quality about 78, target under 40 KB each, with a manifest `site/thumbs.json` recording for each id: source page URL, image URL, fetched date, content hash, width, height, and the licence value at fetch time. A full run for the current catalog is expected to add roughly 150 to 250 files and under 8 MB. + +**Fetch script.** `site/thumbs.mjs` (plus `site/lib/thumbs.mjs` for the testable pure parts). For each eligible entry it fetches the entry URL, reads `og:image` (fallback `twitter:image`), downloads it, converts with ffmpeg if on PATH, else ImageMagick, else skips that entry and reports it. Animated GIFs take the first frame. It is incremental: entries already in the manifest with the same licence and a fetch date under 180 days old are skipped unless `--force`. It never edits catalog entries. Exit code is non-zero only on script errors, not on individual fetch failures, which are listed in a summary. + +**Licence gate.** An entry is eligible when its `license` maps to a vocabulary family that permits redistribution of modified copies: CC0, public-domain, Unlicense, CC-BY (3.0, 4.0, unversioned), CC-BY-SA, MIT, ISC, BSD (all variants), Apache-2.0, MPL-2.0, SIL OFL, GPL and LGPL (all variants), CDLA-Permissive-2.0. Ineligible: `custom`, `varies`, `unknown`, any ND or NC variant, ODbL, CPL-1.0, and anything not in the list. The gate is implemented as an explicit allowlist in `site/lib/thumbs.mjs` with a test, not by string prefix. + +**Host and duplicate rules.** `github.com` pages are skipped (their `og:image` is a social card, not a preview). `fonts.google.com` is skipped (logo). Any image whose content hash is shared by three or more entries is treated as a publisher logo and dropped for all of them. Quaternius pages are allowed but will mostly hit the duplicate rule; a later pass can add per-pack images by override. + +**Overrides.** New optional frontmatter field `preview`: either an absolute image URL the maintainer has checked is covered by the entry's licence, or the literal `none` to opt out. The validator accepts the field and rejects any other value. CONTRIBUTING gains a short section: how previews are chosen, and that a publisher can ask for removal, which is done by setting `preview: none`. + +**Workflow.** `.github/workflows/thumbs.yml`: monthly schedule plus manual dispatch, runs the script on `ubuntu-latest` (ffmpeg is present on the runner image; the workflow installs it with apt if missing). The repository does not allow Actions to open pull requests, and a `GITHUB_TOKEN` pull request would not run CI, so the workflow follows the `reverify.yml` pattern instead: it pushes the changed files to a `thumbs/` branch and opens or updates one issue with the compare link and the script's summary. The maintainer opens the pull request from that link so CI runs on it. Permissions: `contents: write`, `issues: write`. No third-party actions beyond the pinned checkout and setup-node already in use. + +**First batch.** The launch pull request ships the pipeline and the first full batch of thumbnails, generated locally with ffmpeg, so the site never goes live with an empty filmstrip and the performance targets are measured on the real page. The validator's allowed binary prefixes gain `site/public/thumbs/` and `site/public/fonts/`. + +**Status gate.** Only entries with `status: active` get a thumbnail or a place in the filmstrip. `needs-review` and `deprecated` entries get the tile, since their licence facts are not trusted yet or the source is gone. + +**Publisher credit when `publisher` is absent.** Only about 170 entries set `publisher`. The credit line uses it when present ("Preview image from Kenney, shown under CC0") and otherwise reads "Preview image from the source page, shown under ". The filmstrip's one-per-publisher rule groups by `publisher` when set, else by the source hostname; that is a diversity rule, not an attribution, so hostname grouping is acceptable there. + +**Build.** `build.mjs` reads the manifest. For an entry with a thumbnail it emits `` (decorative; the adjacent name is the accessible text). Otherwise it emits the tile. `data.js` gains a `thumb` field (relative path or null) so the client renders the same thing. + +**Credit.** Each thumbnail's publisher and licence are already on the entry; the entry page states them under the strip, and the site footer gains one line: "Preview images belong to their publishers and are shown under each entry's licence." + +## Decided: performance + +- Fonts: Public Sans 400/500/600/700 (500 is used by the current stylesheet) and IBM Plex Mono 400/500 as latin-subset WOFF2 under `site/public/fonts/` with the OFL licence file, `font-display: swap`, preload for the two body weights. Google Fonts ``s removed from every page template. The `` template is shared by index, entry, stack, category and freshness pages (the second half of #87). +- Homepage HTML pre-renders only the per-section cut-offs. `data.js` stays, loaded with `defer`, and is the single full copy of the catalog (closes #75). Category pages carry the full static lists. +- Target after the change: Lighthouse performance 90 or better on the homepage mobile run, CLS under 0.05, accessibility still 100. + +## Left to the builder + +- Exact CSS for cards, rows, tiles, strip and toggle, within the existing tokens; breakpoints; whether the filmstrip scrolls or truncates on phones. +- How app.js reproduces Cards and List on hydrate without duplicating templates badly (shared string templates in `site/lib/` are preferred). +- Category page layout details and whether they include the search box (a filtered link back to the homepage is acceptable). +- The precise filmstrip picking order beyond the rules above. +- Test coverage: gate allowlist, duplicate-hash rule, manifest incremental skip, override validation, build output for both branches (image / tile), heading text, and a page check that no page references Google Fonts. + +## Out of scope, noted for later + +- Font specimen tiles rendered from the font files (fonts category). +- Audio waveform tiles rendered from sample files. +- Per-pack images for Quaternius and other single-page publishers. +- Search over Notes and Evidence (#92). From 6a5bfddc0e0e7ef3cbfef6dc80af93def9cc6790 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies Date: Fri, 23 Oct 2026 17:54:17 -0400 Subject: [PATCH 02/12] docs: implementation plan for the site refresh Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-10-23-site-refresh.md | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-23-site-refresh.md diff --git a/docs/superpowers/plans/2026-10-23-site-refresh.md b/docs/superpowers/plans/2026-10-23-site-refresh.md new file mode 100644 index 0000000..10e10b8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-site-refresh.md @@ -0,0 +1,515 @@ +# Site Refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the catalog site show the assets it lists (licence-gated publisher thumbnails on cards, rows, entry and stack pages), cut the homepage to per-category previews with static category pages behind them, self-host the fonts, and fix the audit's layout items, without changing the site's identity. + +**Architecture:** A new `site/thumbs.mjs` script fetches each eligible entry's `og:image`, resizes it with ffmpeg, and commits `site/public/thumbs/.webp` plus a manifest `site/thumbs.json`; the validator refuses a thumbnail the gate no longer allows. The build reads the manifest, attaches `thumb` to each entry, and renders every entry through one browser-safe template module `site/lib/cards.mjs` that the homepage, category pages, stack pages and `app.js` all share; Cards vs List is a `data-view` attribute flipped by CSS, not a second template. Fonts become WOFF2 files under `site/public/fonts/` with `@font-face` rules, and every page's `` comes from the shared helper. + +**Tech Stack:** Node 22+ ESM, no npm dependencies; plain CSS and ES modules in the browser; ffmpeg (optional, for the thumbnail script only); GitHub Actions with the already-pinned checkout and setup-node. + +**Spec:** `docs/superpowers/specs/2026-10-23-site-refresh-design.md` + +## Global Constraints + +- No npm dependencies; `package.json` keeps `"description": "Catalog checks and static site build. No dependencies."` true. +- `npm run check` (tests, validate, build) is the gate for every task; `site/checks.test.mjs` ends with `checks.test ok: N assertions across M checks` and `site/lib/lib.test.mjs` with `lib.test ok: N assertions`. +- Every page must be complete without JavaScript; the build's `checkPages` must pass on every generated page. +- Refinement preserves: existing tokens in `site/public/styles.css` (`--bg`, `--ink`, `--muted`, `--line`, `--surface`, `--accent`, `--warn`, `--danger`, `--font`, `--mono`, `--max`), the copy voice, and the "every licence read at the source" promise. No new colours outside the token set. +- Thumbnails only for entries with `status: active` and a licence in the allowlist; never for `custom`, `varies`, `unknown`, `github.com` or `fonts.google.com` sources without an explicit `preview:` URL. +- Before any UI edit (Tasks 4 to 7 and 9), read `C:\Users\16mat\.claude\skills\impeccable\reference\craft-floor.md`. After Task 9's build, run the detector once: `sh C:/Users/16mat/.claude/skills/impeccable/scripts/impeccable detect --json site/public/styles.css site/public/index.html site/lib/cards.mjs site/lib/entry-page.mjs site/lib/stack-page.mjs site/lib/category-page.mjs`. +- Commit after each task on branch `site-refresh`; commit messages end with `Co-Authored-By: Claude Fable 5.1 `. + +## Review Focus + +1. **An entry's licence changes from CC0 to custom after its thumbnail was committed.** The next `npm run validate` must fail naming that entry; the next `node site/thumbs.mjs` run must delete the file and record. (Task 2, `checkThumbManifest` test "rejects a thumbnail whose entry is no longer eligible".) +2. **A publisher's `og:image` is their logo, shared by every pack page.** Three or more entries with the same image hash must all lose the image and get the tile. (Task 1, `sharedImageIds` test.) +3. **A page with no `og:image`, a 404, a timeout, or an image over 15 MB.** The script records the failure in its report and moves on; exit code stays 0. (Task 2, `--dry-run` fixture run and the "no converter" path.) +4. **JavaScript off, or `data.js` fails to load.** Homepage shows 6 or 8 entries per category and a working "Show all" link to a category page that lists everything; the view toggle is simply absent. (Task 6 page check; Task 5 no-JS screenshot in Task 9.) +5. **Category with zero thumbnails (Tools, Fonts) in Cards view, and a visitor who switched it to Cards.** Tiles must render with real names, not blank boxes; the grid must not be taller than the list it replaced by more than the image height. (Task 4 `tileHtml` tests; Task 9 screenshot of Tools in Cards.) + +--- + +### Task 1: Thumbnail rules library (pure) + +**Files:** +- Create: `site/lib/thumbs.mjs` +- Test: `site/lib/lib.test.mjs` (append a section `// Site refresh: thumbnail rules.` before the report block) + +**Interfaces:** +- Consumes: nothing from the repo except `hostname` parsing via `URL`. +- Produces (all exported from `site/lib/thumbs.mjs`): + - `PREVIEW_LICENSES: Set` = `CC0, Unlicense, public-domain, CC-BY, CC-BY-3.0, CC-BY-4.0, CC-BY-SA, CC-BY-SA-3.0, CC-BY-SA-4.0, MIT, ISC, BSD, BSD-2-Clause, BSD-3-Clause, BSD-3-Clause-Clear, Apache-2.0, MPL-2.0, SIL OFL, GPL-2.0, GPL-2.0-only, GPL-2.0-or-later, GPL-3.0, GPL-3.0-only, GPL-3.0-or-later, LGPL-3.0, CDLA-Permissive-2.0` + - `SKIP_HOSTS: Set` = `github.com, www.github.com, fonts.google.com` + - `previewEligibility(entry) -> { ok: boolean, reason: string | null }` where `entry` has `license, status, url, preview?`. Reasons, exact strings: `"status is needs-review"` / `"status is deprecated"`, `"licence CUSTOM is not in the preview allowlist"` (with the real value in place of CUSTOM), `"preview: none"`, `"host github.com is skipped"` (host in place). A `preview` that is an `https://` URL overrides only the host rule. Order of checks: preview none, status, licence, host. + - `imageUrlFromHtml(html, pageUrl) -> string | null`: `og:image` first (`property` before or after `content`), then `twitter:image`; relative values resolved against `pageUrl`; `&` in the value decoded; returns null when absent or not `http(s)`. + - `sharedImageIds(records: Record) -> Set`: ids whose `hash` appears on 3 or more records; `pinned` records (from a `preview:` URL) never count and are never returned. + - `needsFetch(record | undefined, { license, today, force = false, maxAgeDays = 180 }) -> boolean`: true when no record, `force`, `record.license !== license`, or `record.fetched` older than `maxAgeDays` days before `today` (ISO dates). + - `webpSize(buf: Buffer) -> { width, height } | null` for VP8, VP8L and VP8X containers; null when not `RIFF....WEBP`. + - `publisherKey(entry) -> string`: `entry.publisher` if set, else the hostname of `entry.url` without `www.`. + - `previewCredit(entry) -> string`: `Preview image from Kenney, shown under CC0` or `Preview image from the source page, shown under CC-BY-4.0`. + - `filmstripPick(entries, { count = 9, cardCategories }) -> entry[]`: entries with `status === "active"`, `thumb` set, `category` in `cardCategories`; sorted by `verified` descending then `name`; at most one per `publisherKey`; first `count`. + - `thumbRelPath(id) -> string` = `thumbs/.webp`. + +- [ ] **Step 1: Write the failing tests** in `site/lib/lib.test.mjs` using the file's `eq`/`has` helpers: + +```js +import { filmstripPick, imageUrlFromHtml, needsFetch, previewCredit, previewEligibility, publisherKey, sharedImageIds, webpSize } from "./thumbs.mjs"; +eq("thumbs: CC0 active itch page is eligible", previewEligibility({ license: "CC0", status: "active", url: "https://kaylousberg.itch.io/block-bits" }).ok, true); +eq("thumbs: custom licence refused", previewEligibility({ license: "custom", status: "active", url: "https://x.itch.io/a" }).reason, "licence custom is not in the preview allowlist"); +eq("thumbs: needs-review refused before licence", previewEligibility({ license: "custom", status: "needs-review", url: "https://x.itch.io/a" }).reason, "status is needs-review"); +eq("thumbs: github host skipped", previewEligibility({ license: "MIT", status: "active", url: "https://github.com/a/b" }).reason, "host github.com is skipped"); +eq("thumbs: preview url overrides host only", previewEligibility({ license: "MIT", status: "active", url: "https://github.com/a/b", preview: "https://a.b/c.png" }).ok, true); +eq("thumbs: preview url cannot override licence", previewEligibility({ license: "custom", status: "active", url: "https://a.b/", preview: "https://a.b/c.png" }).ok, false); +eq("thumbs: preview none", previewEligibility({ license: "CC0", status: "active", url: "https://a.b/", preview: "none" }).reason, "preview: none"); +eq("thumbs: og:image property-first", imageUrlFromHtml('', "https://x/"), "https://i.x/a.png?w=1&h=2"); +eq("thumbs: og:image content-first", imageUrlFromHtml('', "https://x.y/p/"), "https://x.y/img/a.jpg"); +eq("thumbs: twitter fallback", imageUrlFromHtml('', "https://x/"), "https://i/t.png"); +eq("thumbs: no image", imageUrlFromHtml("", "https://x/"), null); +eq("thumbs: shared hash dropped at three", [...sharedImageIds({ a: { hash: "h1" }, b: { hash: "h1" }, c: { hash: "h1" }, d: { hash: "h2" }, e: { hash: "h2" } })].sort().join(), "a,b,c"); +eq("thumbs: pinned never dropped", [...sharedImageIds({ a: { hash: "h1", pinned: true }, b: { hash: "h1" }, c: { hash: "h1" }, d: { hash: "h1" } })].sort().join(), "b,c,d"); +eq("thumbs: needsFetch no record", needsFetch(undefined, { license: "CC0", today: "2026-10-23" }), true); +eq("thumbs: needsFetch fresh same licence", needsFetch({ fetched: "2026-09-01", license: "CC0" }, { license: "CC0", today: "2026-10-23" }), false); +eq("thumbs: needsFetch licence changed", needsFetch({ fetched: "2026-09-01", license: "CC0" }, { license: "CC-BY-4.0", today: "2026-10-23" }), true); +eq("thumbs: needsFetch stale", needsFetch({ fetched: "2026-01-01", license: "CC0" }, { license: "CC0", today: "2026-10-23" }), true); +eq("thumbs: webp VP8X size", webpSize(Buffer.from("52494646 24000000 57454250 56503858 0a000000 10000000 7f020000 8f010000".replaceAll(" ", ""), "hex")).width, 640); +eq("thumbs: not webp", webpSize(Buffer.from("89504e47", "hex")), null); +eq("thumbs: publisherKey falls back to host", publisherKey({ url: "https://www.kenney.nl/assets/x" }), "kenney.nl"); +eq("thumbs: credit with publisher", previewCredit({ publisher: "Kenney", license: "CC0" }), "Preview image from Kenney, shown under CC0"); +eq("thumbs: credit without publisher", previewCredit({ license: "CC-BY-4.0" }), "Preview image from the source page, shown under CC-BY-4.0"); +{ + const t = { path: "thumbs/x.webp", width: 640, height: 400 }; + const pool = [ + { id: "a", name: "A", status: "active", category: "3d", verified: "2026-10-01", publisher: "P1", url: "https://p1/", thumb: t }, + { id: "b", name: "B", status: "active", category: "3d", verified: "2026-10-02", publisher: "P1", url: "https://p1/", thumb: t }, + { id: "c", name: "C", status: "active", category: "tools", verified: "2026-10-03", url: "https://p2/", thumb: t }, + { id: "d", name: "D", status: "needs-review", category: "2d", verified: "2026-10-04", url: "https://p3/", thumb: t }, + { id: "e", name: "E", status: "active", category: "2d", verified: "2026-10-05", url: "https://p4/", thumb: null }, + { id: "f", name: "F", status: "active", category: "2d", verified: "2026-09-01", url: "https://p5/", thumb: t }, + ]; + eq("thumbs: filmstrip picks newest, one per publisher, card categories, active, with thumb", filmstripPick(pool, { count: 9, cardCategories: ["3d", "2d"] }).map((e) => e.id).join(), "b,f"); +} +``` + +For `webpSize`: the VP8X test bytes are a RIFF header, `WEBP`, a `VP8X` chunk of size 10 whose canvas is 24 bits `width-1` = 639 (`7f0200`) and `height-1` = 399 (`8f0100`); add a VP8L case (`VP8L` chunk, signature byte `0x2f`, 14 bits `width-1` then 14 bits `height-1`) and a lossy VP8 case (frame tag, `9d 01 2a`, then 16-bit width and height with the top two bits masked) in the same style. + +- [ ] **Step 2: Run the tests to see them fail** + +Run: `node site/lib/lib.test.mjs` +Expected: fails to import `./thumbs.mjs`. + +- [ ] **Step 3: Implement `site/lib/thumbs.mjs`** with the exports above. `webpSize` reads: VP8X at offset 24 (three little-endian bytes each for width-1 and height-1); VP8L at offset 21 (bits 0 to 13 width-1, bits 14 to 27 height-1 across the 4 bytes after the `0x2f` signature); VP8 at offset 26 (`uint16le & 0x3fff` each). No `node:` imports in this file; it is copied into dist later. + +- [ ] **Step 4: Run the tests to see them pass** + +Run: `node site/lib/lib.test.mjs` +Expected: `lib.test ok: ` with a count above 553. + +- [ ] **Step 5: Commit** + +```bash +git add site/lib/thumbs.mjs site/lib/lib.test.mjs +git commit -m "site: thumbnail eligibility, image-url and filmstrip rules" +``` + +--- + +### Task 2: Thumbnail script, manifest, validator rules, first batch + +**Files:** +- Create: `site/thumbs.mjs` (CLI), `site/thumbs.json` (manifest, written by the CLI), `site/public/thumbs/*.webp` (first batch) +- Modify: `site/validate.mjs:104` (`ALLOWED_BINARY_PREFIXES`), `site/validate.mjs` entry loop (the `preview` field), `site/checks.mjs` (new `checkThumbManifest`), `site/checks.test.mjs`, `package.json` scripts, `CONTRIBUTING.md`, `catalog/TEMPLATE.md` (one commented `preview:` line) +- Test: `site/checks.test.mjs` + +**Interfaces:** +- Consumes: Task 1 exports. +- Produces: + - `site/thumbs.json` shape: `{ "generated": "YYYY-MM-DD", "thumbs": { "": { "source": "", "image": "", "fetched": "YYYY-MM-DD", "hash": "<16 hex of sha256 of the downloaded bytes>", "width": N, "height": N, "license": "", "pinned": true? } } }` sorted by id. + - `checkThumbManifest(manifest, entriesById: Map, files: string[]) -> string[]` in `site/checks.mjs`; `files` are the basenames under `site/public/thumbs/`. + - npm script `"thumbs": "node site/thumbs.mjs"`. + - CLI flags: `--force`, `--only `, `--limit `, `--dry-run`, `--report `; environment `GITHUB_OUTPUT` gets `changed=` when set. + +- [ ] **Step 1: Write the failing validator tests** in `site/checks.test.mjs`: + +```js +import { checkThumbManifest } from "./checks.mjs"; +const okEntry = { id: "a", license: "CC0", status: "active", url: "https://a.itch.io/x" }; +const manifest = { thumbs: { a: { fetched: "2026-10-01", license: "CC0", hash: "h", width: 640, height: 400, source: "https://a.itch.io/x", image: "https://i/a.png" } } }; +accepts("thumbs manifest: file and record agree", checkThumbManifest(manifest, new Map([["a", okEntry]]), ["a.webp"])); +rejects("thumbs manifest: file without record", checkThumbManifest({ thumbs: {} }, new Map([["a", okEntry]]), ["a.webp"]), "site/public/thumbs/a.webp has no record in site/thumbs.json"); +rejects("thumbs manifest: record without file", checkThumbManifest(manifest, new Map([["a", okEntry]]), []), 'site/thumbs.json has "a" but site/public/thumbs/a.webp is missing'); +rejects("thumbs manifest: record for unknown entry", checkThumbManifest(manifest, new Map(), ["a.webp"]), 'site/thumbs.json has "a" but no catalog entry has that id'); +rejects("thumbs manifest: rejects a thumbnail whose entry is no longer eligible", checkThumbManifest(manifest, new Map([["a", { ...okEntry, license: "custom" }]]), ["a.webp"]), 'thumbnail for "a" must be removed: licence custom is not in the preview allowlist (run node site/thumbs.mjs)'); +rejects("thumbs manifest: licence drift", checkThumbManifest(manifest, new Map([["a", { ...okEntry, license: "MIT" }]]), ["a.webp"]), 'thumbnail for "a" was fetched under CC0 but the entry now says MIT (run node site/thumbs.mjs)'); +``` + +Also, in the same file, a fixture test for the `preview` field through whatever helper `validate.mjs` exposes for per-entry field checks; if the field checks live inline in `validate.mjs main()`, add `checkPreviewField(file, meta) -> string[]` to `site/checks.mjs` and test: accepts absent, accepts `none`, accepts `https://a/b.png`, rejects `http://a/b.png` with `preview must be "none" or an https:// image URL`, rejects `yes`. + +- [ ] **Step 2: Run the tests to see them fail** + +Run: `node site/checks.test.mjs` +Expected: import of `checkThumbManifest` is undefined / throws. + +- [ ] **Step 3: Implement `checkThumbManifest` and `checkPreviewField` in `site/checks.mjs`**, using `previewEligibility` from `site/lib/thumbs.mjs`. Wire both into `site/validate.mjs`: read `site/thumbs.json` if present (an absent manifest with an empty `site/public/thumbs/` is fine), list `site/public/thumbs/`, and push the errors; add `"site/public/thumbs/"` and `"site/public/fonts/"` to `ALLOWED_BINARY_PREFIXES`. Update the count in the `checks.test ok` line's `across N checks` text if the file prints a fixed number. + +- [ ] **Step 4: Run the tests and validate** + +Run: `node site/checks.test.mjs && node site/validate.mjs` +Expected: both pass (no manifest yet). + +- [ ] **Step 5: Implement `site/thumbs.mjs`.** Reads entries with `listEntryFiles` + `parseFrontmatter` (fields: id, url, license, status, publisher, preview). Converter detection: `ffmpeg -version` via `execFileSync`, else `magick -version`, else none (then every fetch is reported as `skipped: no converter on PATH`). For each entry: `previewEligibility`; `needsFetch` against the current manifest; fetch the page (20 s `AbortSignal.timeout`, `User-Agent: Mozilla/5.0 (compatible; FreeGameDevAssets-thumbs; +https://github.com/TMHSDigital/Free-Game-Dev-Assets)`), `imageUrlFromHtml` (or the `preview` URL directly, which sets `pinned: true`); download (cap 15 MB, else fail); hash; convert to a temp file with + +``` +ffmpeg -y -loglevel error -i -frames:v 1 -vf "scale='min(640,iw)':-2" -c:v libwebp -quality 78 .webp +``` + +(ImageMagick: `magick [0] -resize 640x> -quality 78 .webp`); `webpSize` on the result; write `site/public/thumbs/.webp`. After the loop: remove records and files for entries that are now ineligible or gone; `sharedImageIds` over the manifest, delete those files and records, and list them in the report under "Dropped as shared images". Write the manifest sorted by id. Print a summary (`fetched N, kept N, skipped N, failed N, dropped N`), write the markdown report when `--report`, and append `changed=` to `$GITHUB_OUTPUT` when set. Exit 0 unless the script itself throws. `--dry-run` does everything but write. + +- [ ] **Step 6: Smoke-test the script on two entries** + +Run: `node site/thumbs.mjs --only kenney-car-kit,kaykit-block-bits --report "C:\Users\16mat\AppData\Local\Temp\claude\E--Free-Game-Dev-Assets\926d9f61-c4d8-441c-ab73-6503b49d0158\scratchpad\thumbs-smoke.md"` +Expected: two files under `site/public/thumbs/`, each under 60 KB, manifest with two records with width 640, `node site/validate.mjs` passes. + +- [ ] **Step 7: Run the first full batch** + +Run: `node site/thumbs.mjs --report "C:\Users\16mat\AppData\Local\Temp\claude\E--Free-Game-Dev-Assets\926d9f61-c4d8-441c-ab73-6503b49d0158\scratchpad\thumbs-first-batch.md"` (allow 10 minutes; run in the background and wait for the notification). +Expected: roughly 150 to 250 files, total under 8 MB (`du -sh site/public/thumbs`), validate passes. Note the failures list from the report in the commit body. + +- [ ] **Step 8: Add `"thumbs": "node site/thumbs.mjs"` to `package.json`, a commented `# preview: none` line to `catalog/TEMPLATE.md`, and a CONTRIBUTING section "Preview images"** stating: previews are the publisher's own `og:image`, fetched only for active entries whose licence is in the allowlist, resized and committed; a publisher can ask for removal by issue, which is done by setting `preview: none`; a maintainer may set `preview: ` to a specific image they have checked is covered by the entry's licence. + +- [ ] **Step 9: Run the full check and commit** + +Run: `npm run check` +Expected: all green. + +```bash +git add site/thumbs.mjs site/thumbs.json site/public/thumbs site/validate.mjs site/checks.mjs site/checks.test.mjs package.json CONTRIBUTING.md catalog/TEMPLATE.md +git commit -m "site: licence-gated thumbnail pipeline and first batch" +``` + +--- + +### Task 3: Self-hosted fonts and one shared head + +**Files:** +- Create: `site/public/fonts/public-sans-400.woff2`, `-400-italic`, `-500`, `-600`, `-700`, `site/public/fonts/ibm-plex-mono-400.woff2`, `-500`, `site/public/fonts/OFL.txt`, `site/public/fonts/README.md` +- Modify: `site/public/styles.css` (top: `@font-face` rules), `site/lib/shared.mjs:196` (`baseHeadHtml` gains `root` and emits the stylesheet, icon and font preloads), `site/public/index.html:12-19`, `site/lib/entry-page.mjs:115-119`, `site/lib/stack-page.mjs:121-125`, `site/lib/freshness.mjs:110-113`, `site/build.mjs:324-351` (`notFoundHtml`), `site/lib/page-checks.mjs` +- Test: `site/lib/lib.test.mjs` + +**Interfaces:** +- Produces: `baseHeadHtml({ site, title, description, url, type, hasCard, indent, root })` where `root` is `"./"`, `"../"` or `"../../"` and the output now also contains ``, ``, the same for `public-sans-600`, and ``. Callers remove their own icon, font and stylesheet lines. +- `checkPage` adds the error `${file}: references Google Fonts` when the html contains `fonts.googleapis.com` or `fonts.gstatic.com`. + +- [ ] **Step 1: Write the failing tests** in `site/lib/lib.test.mjs`: + +```js +import { checkPage } from "./page-checks.mjs"; +has("head: preloads body font", baseHeadHtml({ site: { siteUrl: "https://s/", title: "T", tagline: "x" }, title: "T", description: "d", url: "https://s/", root: "../../" }), 'x

x

', { file: "p.html", kindOf: () => "file" }).join(), "p.html: references Google Fonts"); +``` + +- [ ] **Step 2: Run the tests to see them fail** + +Run: `node site/lib/lib.test.mjs` +Expected: the three new assertions fail. + +- [ ] **Step 3: Download the fonts.** Write a throwaway script `C:\Users\16mat\AppData\Local\Temp\claude\E--Free-Game-Dev-Assets\926d9f61-c4d8-441c-ab73-6503b49d0158\scratchpad\fetch-fonts.mjs` (run with `node`, not pasted into Bash) that requests `https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Public+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap` with a Chrome User-Agent, parses each `@font-face` block whose `unicode-range` is the latin block (starts `U+0000-00FF`), downloads its `url(...)`, and saves to the names above. Write `site/public/fonts/README.md` (source: Google Fonts CSS API, families and weights, date, licence OFL 1.1) and `OFL.txt` (the SIL Open Font License 1.1 text from `https://openfontlicense.org/open-font-license-official-text/`, fetched by the same script). +Expected: seven `.woff2` files, each 10 to 40 KB. + +- [ ] **Step 4: Add `@font-face` rules** at the top of `site/public/styles.css`, one per file: `font-family: "Public Sans"` / `"IBM Plex Mono"`, matching `font-weight`/`font-style`, `font-display: swap`, `src: url("fonts/.woff2") format("woff2")`, `unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD`. Stylesheet URLs resolve against `styles.css`, so `fonts/` works from every page depth. + +- [ ] **Step 5: Implement the `root` parameter in `baseHeadHtml`** and remove the icon, preconnect, Google Fonts and stylesheet lines from `index.html`, `entry-page.mjs`, `stack-page.mjs`, `freshness.mjs` and `notFoundHtml` (that one builds its own head from `base`; give it the same preload and stylesheet lines with absolute `${base}/` paths). The homepage passes `root: "./"` through `headMetaHtml`. Add the Google Fonts check to `checkPage`. + +- [ ] **Step 6: Build and check** + +Run: `npm run check && grep -rl "fonts.googleapis" site/dist | head` +Expected: check green; grep prints nothing. + +- [ ] **Step 7: Commit** + +```bash +git add site/public/fonts site/public/styles.css site/lib/shared.mjs site/public/index.html site/lib/entry-page.mjs site/lib/stack-page.mjs site/lib/freshness.mjs site/lib/page-checks.mjs site/build.mjs site/lib/lib.test.mjs +git commit -m "site: self-host Public Sans and IBM Plex Mono, one shared head + +Closes #87" +``` + +--- + +### Task 4: Shared entry templates with thumbnails, cards and list views + +**Files:** +- Create: `site/lib/cards.mjs` (browser-safe; imports only `./shared.mjs`) +- Modify: `site/config.json` (each category gains `"view": "cards" | "list"`), `site/build.mjs:168-240` (`entryRowHtml`, `groupedRowsHtml` replaced by calls into cards.mjs; `loadEntries` attaches `thumb`), `site/build.mjs` main (copy `cards.mjs` and `shared.mjs` into dist with their `.mjs` names), `site/public/app.js:54-110, 222-266, 381-389` (delete the duplicated helpers and `cardHtml`/`headingHtml`; import from `./cards.mjs`), `site/public/styles.css` (`.entry-list` becomes a grid; new `.entry-thumb`, `.entry-tile`, `[data-view]` rules) +- Test: `site/lib/lib.test.mjs` + +**Interfaces:** +- Consumes: `thumbRelPath`, `previewCredit` from Task 1; `thumbs.json` from Task 2. +- Produces (exports of `site/lib/cards.mjs`): + - `CARD_LIMIT = 6`, `LIST_LIMIT = 8`. + - `thumbHtml(entry, root) -> string`: with `entry.thumb` → ``; otherwise `tileHtml(entry)`. + - `tileHtml(entry) -> string`: ``; the motif is CSS by `data-cat`. + - `entryCardHtml(entry, { root, repo, now, view, lead = "", trail = "" }) -> string`: the one `
` markup with `
` first, then the existing `.entry-body` (heading link `href="entry//"`, flags, summary, meta-line, entry-links), `lead` HTML inserted before the heading and `trail` after the meta-line. Keeps `id`, `data-id`, `data-status`, `style="--edge:..."`, `.entry-edge`. + - `groupHeadingHtml({ cat, label, count, view, root }) -> string`: `

LABEL, COUNT entries

` (the toggle is injected into `.group-tools` by app.js, Task 5). + - `groupMoreHtml({ cat, label, count, root }) -> string`: `Show all COUNT in LABEL`. + - `groupedEntriesHtml(entries, categories, { root, repo, now, limit = true }) -> string`: per category in config order: heading, then the first `CARD_LIMIT`/`LIST_LIMIT` entries (by the category's `view`) when `limit`, else all, then `groupMoreHtml` when `limit` and the category has more than the limit. + - Re-exports `escapeHtml` (alias of `esc`), `commercialLabel`, `verifiedAge`, `edgeVar`, `PERSPECTIVE_LABELS`, `MAINTENANCE_NOTES`, `ENGINE_LOCK_NOTES`, `ENGINE_LOCK_LABELS` so `app.js` drops its copies. + - `data.js` entries gain `thumb: { path, width, height } | null` (build attaches it from the manifest in `loadEntries`). + +- [ ] **Step 1: Write the failing tests** in `site/lib/lib.test.mjs`: + +```js +import { entryCardHtml, groupHeadingHtml, groupMoreHtml, groupedEntriesHtml, thumbHtml, tileHtml } from "./cards.mjs"; +const base = { id: "x", name: "X Pack", url: "https://x.itch.io/p", category: "3d", license: "CC0", commercial: true, status: "active", formats: ["GLB"], verified: "2026-10-01", path: "catalog/3d/x.md", summary: "S." }; +has("cards: img with dimensions and lazy", thumbHtml({ ...base, thumb: { path: "thumbs/x.webp", width: 640, height: 400 } }, "../../"), ''); +has("cards: tile when no thumb", thumbHtml(base, ""), 'class="entry-tile" data-cat="3d"'); +has("cards: tile carries name", tileHtml({ ...base, name: "A " }), "A <b>"); +has("cards: card view attribute", entryCardHtml(base, { root: "", repo: "https://r", now: Date.parse("2026-10-23"), view: "cards" }), '
Need

", trail: "

Why

" }), '

Need

'); +has("cards: heading count readable", groupHeadingHtml({ cat: "3d", label: "3D", count: 77, view: "cards", root: "" }), ', 77 entries'); +has("cards: show-all link", groupMoreHtml({ cat: "3d", label: "3D", count: 77, root: "" }), 'Show all 77 in 3D'); +{ + const many = Array.from({ length: 9 }, (_, i) => ({ ...base, id: `e${i}`, name: `E${i}` })); + const html = groupedEntriesHtml(many, { "3d": { label: "3D", view: "cards" } }, { root: "", repo: "https://r", now: 0 }); + eq("cards: limit six cards", (html.match(/
h3.sr-only { grid-column: 1 / -1; }`; `.entry-card[data-view="cards"]` stacks `.entry-thumb` (aspect-ratio 16 / 10, `object-fit: cover`, background `var(--line)`) above `.entry-body`; `.entry-card[data-view="list"]` is `display: grid; grid-template-columns: 56px 1fr; gap: 12px` with the thumb square (`aspect-ratio: 1`, border-radius 4px) and the summary paragraph hidden on narrow screens only; `.entry-tile` uses `repeating-linear-gradient(135deg, var(--surface) 0 6px, var(--bg) 6px 12px)`, name in 600 weight, note in `var(--mono)` 11px `var(--muted)`; `[data-cat="audio"]` tile uses a 90deg stripe, `[data-cat="fonts"]` prefixes the name with "Aa " via `::before`, `[data-cat="tools"]` uses a 0deg 8px rule pattern. Dark mode needs no new rules because only tokens are used. Keep the existing `.entry-card` hover, focus and `:target` rules. + +- [ ] **Step 7: Build, test, and look** + +Run: `npm run check` +Expected: green. Then open `site/dist/index.html` via `npx --yes serve site/dist` in the background and take one desktop screenshot of the 3D section with Playwright (save under `E:\Free-Game-Dev-Assets\.playwright-mcp\t4-cards.jpeg`); confirm images render and tiles show names. + +- [ ] **Step 8: Commit** + +```bash +git add site/lib/cards.mjs site/lib/shared.mjs site/build.mjs site/public/app.js site/public/styles.css site/config.json site/lib/lib.test.mjs +git commit -m "site: one entry template for build and app.js, thumbnails, cards and list views" +``` + +--- + +### Task 5: Homepage: hero, filmstrip, per-category cut-off, view toggle, mobile header + +**Files:** +- Modify: `site/public/index.html:36-49` (hero), `:195` (grid), `:230-232` (to-top), `site/build.mjs` main (substitutions `HERO_COUNT`, `FILMSTRIP`, hero copy), `site/lib/freshness.mjs:36-48` (`freshnessLineHtml` shortened), `site/public/app.js` (cut-off, `.group-more` click, view toggle, list form while searching), `site/public/styles.css` (hero, filmstrip, toggle, topbar at ≤640px, to-top, side-tab rule at line ~700) +- Test: `site/lib/lib.test.mjs` + +**Interfaces:** +- Consumes: `filmstripPick` (Task 1), `groupedEntriesHtml`, `CARD_LIMIT`, `LIST_LIMIT` (Task 4). +- Produces: `filmstripHtml(entries, { root }) -> string` exported from `site/lib/cards.mjs`: `
- -
`; -} - -/** - * The catalog grouped under category headings. 300 rows in one flat list is - * not navigable; headings give the page structure a reader can scan and an - * anchor they can link to. app.js reproduces this exact shape on hydrate. - * - * Grouping is only correct under the default sort. Sorting by verified date - * or license across category buckets would be meaningless, so app.js falls - * back to a flat list in those cases; the prerender is always the default. - */ -function groupedRowsHtml(entries, categories, repo, now) { - const order = Object.keys(categories); - const seen = new Set(entries.map((e) => e.category)); - const out = []; - for (const cat of order) { - if (!seen.has(cat)) continue; - const group = entries.filter((e) => e.category === cat); - if (!group.length) continue; - const label = categories[cat]?.label || cat; - out.push( - `

` + - `${esc(label)}` + - `${group.length}` + - `

` - ); - out.push(...group.map((e) => entryRowHtml(e, repo, now))); - } - return out.join("\n"); -} - /** * Without JavaScript the chips cannot filter, so the prerender emits them as * jump links to the group headings instead. app.js replaces them with real @@ -629,6 +549,10 @@ function main() { copyOut(path.join(__dirname, "lib", "owes.mjs"), "owes.js"); // The catalog search and the 404 page's guesses, tested in lib.test.mjs. copyOut(path.join(__dirname, "lib", "search.mjs"), "search.js"); + // The entry template and its helpers, served as modules to app.js and the + // category pages. Neither may import from node:. + copyOut(path.join(__dirname, "lib", "cards.mjs"), "cards.mjs"); + copyOut(path.join(__dirname, "lib", "shared.mjs"), "shared.mjs"); const hasCard = fs.existsSync(OG_CARD_SRC); if (hasCard) copyOut(OG_CARD_SRC, OG_CARD_NAME); // data.json is the public machine-readable catalog (see site/README.md); @@ -651,7 +575,8 @@ function main() { HEAD_META: headMetaHtml(config.site, stats, payload.generatedAt, hasCard), STACK_ROWS: stackRowsHtml(stacks), STARTER_ROWS: starterRowsHtml(featured), - ENTRY_ROWS: groupedRowsHtml(visible, config.categories, repoUrl, now), + // Full prerender until the category pages exist (Task 6); the per-category cut-off comes with them. + ENTRY_ROWS: groupedEntriesHtml(visible, config.categories, { root: "", repo: repoUrl, now, limit: false }), CATEGORY_CHIPS: categoryChipsHtml(visible, config.categories), GUIDE_ROWS: guideRowsHtml(config.guides, repoUrl), CATALOG_BLURB: `${stats.total} sources · ${stats.active} active · ${stats.commercialOk} commercial-ok · ${stats.commercialVaries} per-file · ${stats.deprecated} deprecated`, diff --git a/site/config.json b/site/config.json index e7edbec..36960da 100644 --- a/site/config.json +++ b/site/config.json @@ -8,16 +8,16 @@ }, "expectedEntryCount": 416, "categories": { - "3d": { "label": "3D", "blurb": "Models, scans, PBR textures" }, - "environment": { "label": "Environment", "blurb": "HDRI, terrain, geodata" }, - "2d": { "label": "2D", "blurb": "Sprites, UI, icons, palettes" }, - "characters": { "label": "Characters", "blurb": "Generators & modular humanoids" }, - "audio": { "label": "Audio", "blurb": "SFX, music, foley, IRs" }, - "animation": { "label": "Animation", "blurb": "MoCap & character clips" }, - "video": { "label": "Video", "blurb": "Stock footage, archival clips" }, - "shaders-vfx": { "label": "Shaders & VFX", "blurb": "Shaders, particles, FX" }, - "fonts": { "label": "Fonts", "blurb": "OFL / commercial-ok type" }, - "tools": { "label": "Tools", "blurb": "Editors, pipeline, TTS, add-ons" } + "3d": { "label": "3D", "blurb": "Models, scans, PBR textures", "view": "cards" }, + "environment": { "label": "Environment", "blurb": "HDRI, terrain, geodata", "view": "cards" }, + "2d": { "label": "2D", "blurb": "Sprites, UI, icons, palettes", "view": "cards" }, + "characters": { "label": "Characters", "blurb": "Generators & modular humanoids", "view": "cards" }, + "audio": { "label": "Audio", "blurb": "SFX, music, foley, IRs", "view": "list" }, + "animation": { "label": "Animation", "blurb": "MoCap & character clips", "view": "cards" }, + "video": { "label": "Video", "blurb": "Stock footage, archival clips", "view": "list" }, + "shaders-vfx": { "label": "Shaders & VFX", "blurb": "Shaders, particles, FX", "view": "cards" }, + "fonts": { "label": "Fonts", "blurb": "OFL / commercial-ok type", "view": "list" }, + "tools": { "label": "Tools", "blurb": "Editors, pipeline, TTS, add-ons", "view": "list" } }, "featured": [ { "id": "kenney", "need": "Modular low-poly 3D / UI" }, diff --git a/site/lib/cards.mjs b/site/lib/cards.mjs new file mode 100644 index 0000000..bf08a80 --- /dev/null +++ b/site/lib/cards.mjs @@ -0,0 +1,122 @@ +/** + * The one entry template: the homepage, the category and stack pages and + * app.js all render an entry through entryCardHtml, so the prerender and the + * hydrated page can never drift apart. Cards and List are the same markup + * under a `data-view` attribute; CSS does the rest. + * + * Browser-safe: this file and shared.mjs are copied into dist as modules, so + * neither may import anything from node:. + */ +import { commercialLabel, edgeVar, ENGINE_LOCK_LABELS, ENGINE_LOCK_NOTES, esc, MAINTENANCE_NOTES, PERSPECTIVE_LABELS, verifiedAge } from "./shared.mjs"; + +export { commercialLabel, edgeVar, ENGINE_LOCK_LABELS, ENGINE_LOCK_NOTES, MAINTENANCE_NOTES, PERSPECTIVE_LABELS, verifiedAge }; +export const escapeHtml = esc; + +/** How many entries a category shows on the homepage before "Show all". */ +export const CARD_LIMIT = 6; +export const LIST_LIMIT = 8; + +/** The typographic tile for an entry with no permitted image: name and a short note on an editor-style checker. */ +export function tileHtml(entry, label = entry.category) { + return ( + `` + ); +} + +/** The entry's thumbnail image, or its tile. `root` is the page's path to the site root. */ +export function thumbHtml(entry, root = "", label) { + const t = entry.thumb; + if (t && t.path) { + return ``; + } + return tileHtml(entry, label); +} + +function maintenanceFlagHtml(entry) { + if (!entry.maintenance) return ""; + return ` ${esc(entry.maintenance)}`; +} + +function engineLockFlagHtml(entry) { + if (!entry.engine_lock) return ""; + return ` ${esc(ENGINE_LOCK_LABELS[entry.engine_lock] || entry.engine_lock)}`; +} + +/** + * One entry. The card is an
with a real heading link (a permalink + * that keeps heading navigation working), never a + + +
`; +} + +/** A category heading. The count reads as ", 77 entries" to assistive tech instead of running into the label. */ +export function groupHeadingHtml({ cat, label, count, view = "list", root = "" }) { + return ( + `

` + + `${esc(label)}` + + `, ${Number(count) || 0} entries` + + `` + + `

` + ); +} + +/** The "Show all" link under a category's homepage preview; it leads to the static category page. */ +export function groupMoreHtml({ cat, label, count, root = "" }) { + return `Show all ${Number(count) || 0} in ${esc(label)}`; +} + +/** + * Entries grouped under category headings in config order. With `limit` + * (the homepage) each category shows its first CARD_LIMIT or LIST_LIMIT + * entries and a "Show all" link; without it (category pages) everything. + */ +export function groupedEntriesHtml(entries, categories, { root = "", repo, now, limit = true } = {}) { + const out = []; + for (const [cat, meta] of Object.entries(categories)) { + const group = entries.filter((e) => e.category === cat); + if (!group.length) continue; + const view = meta.view === "cards" ? "cards" : "list"; + const label = meta.label || cat; + const max = view === "cards" ? CARD_LIMIT : LIST_LIMIT; + const shown = limit ? group.slice(0, max) : group; + out.push(groupHeadingHtml({ cat, label, count: group.length, view, root })); + out.push(...shown.map((e) => entryCardHtml(e, { root, repo, now, view, label }))); + if (limit && group.length > max) out.push(groupMoreHtml({ cat, label, count: group.length, root })); + } + return out.join("\n"); +} diff --git a/site/lib/lib.test.mjs b/site/lib/lib.test.mjs index 219130c..cbd7226 100644 --- a/site/lib/lib.test.mjs +++ b/site/lib/lib.test.mjs @@ -954,6 +954,31 @@ has("head: stylesheet from root", baseHeadHtml({ site: { siteUrl: "https://s/", has("head: icon from root", baseHeadHtml({ site: { siteUrl: "https://s/", title: "T", tagline: "x" }, title: "T", description: "d", url: "https://s/", root: "../" }), 'x

x

', { file: "p.html", kindOf: () => "file" }).join(), "p.html: references Google Fonts"); +// Site refresh: one entry template for the build and app.js. +import { CARD_LIMIT, LIST_LIMIT, entryCardHtml, groupHeadingHtml, groupMoreHtml, groupedEntriesHtml, thumbHtml, tileHtml } from "./cards.mjs"; +const cardBase = { id: "x", name: "X Pack", url: "https://x.itch.io/p", category: "3d", license: "CC0", commercial: true, status: "active", formats: ["GLB"], verified: "2026-10-01", path: "catalog/3d/x.md", summary: "S." }; +eq("cards: limits", `${CARD_LIMIT}/${LIST_LIMIT}`, "6/8"); +has("cards: img with dimensions and lazy", thumbHtml({ ...cardBase, thumb: { path: "thumbs/x.webp", width: 640, height: 400 } }, "../../"), ''); +has("cards: tile when no thumb", thumbHtml(cardBase, ""), 'class="entry-tile" data-cat="3d"'); +has("cards: tile carries name", tileHtml({ ...cardBase, name: "A " }), "A <b>"); +has("cards: tile note names licence", tileHtml(cardBase, "3D"), '3D · CC0'); +has("cards: card view attribute", entryCardHtml(cardBase, { root: "", repo: "https://r", now: Date.parse("2026-10-23"), view: "cards" }), '
Need

', trail: "

Why

" }), '

Need

'); +has("cards: heading count readable", groupHeadingHtml({ cat: "3d", label: "3D", count: 77, view: "cards", root: "" }), ', 77 entries'); +has("cards: heading carries view", groupHeadingHtml({ cat: "3d", label: "3D", count: 77, view: "list", root: "" }), '

'); +has("cards: show-all link", groupMoreHtml({ cat: "3d", label: "3D", count: 77, root: "" }), 'Show all 77 in 3D'); +{ + const many = Array.from({ length: 9 }, (_, i) => ({ ...cardBase, id: `e${i}`, name: `E${i}` })); + const html = groupedEntriesHtml(many, { "3d": { label: "3D", view: "cards" } }, { root: "", repo: "https://r", now: 0 }); + eq("cards: limit six cards", (html.match(/
{ const data = window.__CATALOG__; @@ -51,21 +52,6 @@ import { closestEntries, passesFilters as passes, queryTests as compileQuery, re location.replace(`entry/${encodeURIComponent(legacyId)}/`); return; } - const PERSPECTIVE_LABELS = { - top_down: "top-down", - isometric_3_4: "3/4 view", - side_scroller: "side-scroller", - "2d_flat": "flat UI", - }; - const MAINTENANCE_NOTES = { - archived: "The source repository is archived: read-only, no fixes or updates.", - inactive: "The source has had no new commits for over three years.", - }; - // Mirrors shared.mjs ENGINE_LOCK_NOTES / ENGINE_LOCK_LABELS. - const ENGINE_LOCK_NOTES = { - unreal: "Licensed for use only in products built with Unreal Engine (Epic's UE-Only Content).", - }; - const ENGINE_LOCK_LABELS = { unreal: "UE only" }; // Ages are measured at build time, like the prerendered cards and the // freshness line, so the page never disagrees with itself. A weekly // scheduled build keeps the build time recent. @@ -76,39 +62,6 @@ import { closestEntries, passesFilters as passes, queryTests as compileQuery, re /* ------------------------------------------------------------ helpers */ - function escapeHtml(str) { - return String(str) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); - } - - function edgeColor(entry) { - if (entry.commercial === true) return "var(--ok)"; - if (entry.commercial === false) return "var(--danger)"; - if (entry.commercial === "varies") return "var(--warn)"; - return "var(--unknown)"; - } - - function commercialLabel(v) { - if (v === true) return "commercial OK"; - if (v === false) return "non-commercial"; - if (v === "varies") return "per-file review"; - return "commercial ?"; - } - - /** Age of the license check, which is this catalog's main credibility signal. */ - function verifiedAge(verified) { - if (!verified) return { days: null, bucket: "unknown", text: "verified date unknown" }; - const t = Date.parse(`${verified}T00:00:00Z`); - if (Number.isNaN(t)) return { days: null, bucket: "unknown", text: "verified date unknown" }; - const days = Math.max(0, Math.floor((NOW - t) / 86400000)); - const bucket = days <= FRESH_DAYS ? "fresh" : days <= AGING_DAYS ? "aging" : "stale"; - return { days, bucket, text: `verified ${verified} (${days}d ago)` }; - } - /* -------------------------------------------------------- url <-> state */ /** @@ -219,50 +172,13 @@ import { closestEntries, passesFilters as passes, queryTests as compileQuery, re /* ---------------------------------------------------------- rendering */ + /** A category's view on this page: the config default (cards or list). */ + function viewFor(cat) { + return categoryLabels[cat]?.view === "cards" ? "cards" : "list"; + } + function cardHtml(entry) { - const age = verifiedAge(entry.verified); - const flags = [entry.status, commercialLabel(entry.commercial), entry.license] - .map(escapeHtml) - .join(" · "); - const formats = (entry.formats || []) - .slice(0, 3) - .map((f) => `${escapeHtml(f)}`) - .join(""); - const taxonomy = [ - entry.grid_dimensions - ? `${escapeHtml(entry.grid_dimensions)}` - : "", - entry.camera_perspective - ? `${escapeHtml(PERSPECTIVE_LABELS[entry.camera_perspective] || entry.camera_perspective)}` - : "", - ].join(""); - // The same badge as build.mjs maintenanceFlagHtml (titles from shared.mjs MAINTENANCE_NOTES). - const maintenance = entry.maintenance - ? ` ${escapeHtml(entry.maintenance)}` - : ""; - // The same badge as build.mjs engineLockFlagHtml. - const engineLock = entry.engine_lock - ? ` ${escapeHtml(ENGINE_LOCK_LABELS[entry.engine_lock] || entry.engine_lock)}` - : ""; - return ``; + return entryCardHtml(entry, { root: "", repo, now: NOW, view: viewFor(entry.category), label: categoryLabels[entry.category]?.label }); } /** @@ -379,13 +295,7 @@ import { closestEntries, passesFilters as passes, queryTests as compileQuery, re } function headingHtml(cat, count) { - const label = categoryLabels[cat]?.label || cat; - return ( - `

` + - `${escapeHtml(label)}` + - `${count}` + - `

` - ); + return groupHeadingHtml({ cat, label: categoryLabels[cat]?.label || cat, count, view: viewFor(cat), root: "" }); } /* @@ -396,6 +306,8 @@ import { closestEntries, passesFilters as passes, queryTests as compileQuery, re */ const cardNodes = new Map(); const headingNodes = new Map(); + // "Show all N in " links under the homepage previews. + const moreNodes = new Map(); let flatHeading = null; let placedKey = null; @@ -403,20 +315,25 @@ import { closestEntries, passesFilters as passes, queryTests as compileQuery, re const grid = $("#entry-grid"); for (const el of $$(".entry-card", grid)) cardNodes.set(el.getAttribute("data-id"), el); for (const el of $$(".group-heading", grid)) headingNodes.set(el.getAttribute("data-cat"), el); + for (const el of $$(".group-more", grid)) moreNodes.set(el.getAttribute("data-cat"), el); const tpl = document.createElement("template"); tpl.innerHTML = [ ...data.entries.filter((e) => !cardNodes.has(e.id)).map(cardHtml), ...Object.keys(categoryLabels) .filter((cat) => !headingNodes.has(cat)) .map((cat) => headingHtml(cat, 0)), + ...Object.keys(categoryLabels) + .filter((cat) => !moreNodes.has(cat)) + .map((cat) => groupMoreHtml({ cat, label: categoryLabels[cat]?.label || cat, count: 0, root: "" })), ].join(""); for (const el of [...tpl.content.children]) { if (el.matches(".entry-card")) cardNodes.set(el.getAttribute("data-id"), el); + else if (el.matches(".group-more")) moreNodes.set(el.getAttribute("data-cat"), el); else headingNodes.set(el.getAttribute("data-cat"), el); } // Anything else the grid held (a stale prerender) goes. for (const el of [...grid.children]) { - if (!el.matches(".entry-card, .group-heading")) el.remove(); + if (!el.matches(".entry-card, .group-heading, .group-more")) el.remove(); } // Card titles are h4 under the h3 category headings. A flat list has no // category headings, so this one keeps the outline from skipping a level. @@ -490,11 +407,12 @@ import { closestEntries, passesFilters as passes, queryTests as compileQuery, re for (const cat of Object.keys(categoryLabels)) { order.push(headingNodes.get(cat)); for (const e of all) if (e.category === cat) order.push(cardNodes.get(e.id)); + order.push(moreNodes.get(cat)); } // Entries outside the configured categories never show grouped. for (const e of all) if (!categoryLabels[e.category]) order.push(cardNodes.get(e.id)); } else { - order.push(...headingNodes.values(), ...all.map((e) => cardNodes.get(e.id))); + order.push(...headingNodes.values(), ...moreNodes.values(), ...all.map((e) => cardNodes.get(e.id))); } placeNodes(order); } @@ -507,11 +425,12 @@ import { closestEntries, passesFilters as passes, queryTests as compileQuery, re perCat.set(e.category, (perCat.get(e.category) || 0) + 1); } for (const [id, el] of cardNodes) el.hidden = !shown.has(id); + for (const el of moreNodes.values()) el.hidden = true; flatHeading.hidden = group || !list.length; for (const [cat, el] of headingNodes) { const n = group ? perCat.get(cat) || 0 : 0; el.hidden = !n; - if (n) el.querySelector(".group-count").textContent = String(n); + if (n) el.querySelector(".group-n").textContent = String(n); } groupHeadings = [...grid.children].filter((el) => el.matches(".group-heading") && !el.hidden); // The first visible heading sits flush with the top of the list. diff --git a/site/public/styles.css b/site/public/styles.css index 39f9c6e..38a5643 100644 --- a/site/public/styles.css +++ b/site/public/styles.css @@ -1684,3 +1684,198 @@ input:focus-visible, background: none; } } + +/* ------------------------------------------------------------------ * + * Site refresh (2026-10-23): thumbnails, cards and list views. + * One markup for both views (site/lib/cards.mjs); `data-view` on the card + * and on its category heading picks the layout. The list keeps the dense + * row the catalog always had; the card puts the publisher's preview first. + * ------------------------------------------------------------------ */ + +.entry-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + align-items: start; + border-top: 0; +} + +.entry-list > .group-heading, +.entry-list > .group-more, +.entry-list > .entry-card[data-view="list"], +.entry-list > h3.sr-only { + grid-column: 1 / -1; +} + +.entry-card { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); + overflow: hidden; + transition: border-color 120ms ease-out; +} + +.entry-card:hover, +.entry-card:focus-within { + background: var(--surface); + border-color: var(--control-line); +} + +.entry-card:target { + box-shadow: inset 0 0 0 2px var(--accent); +} + +/* The commercial-use colour used to be a 4px bar down the card's edge. It is + a dot before the status flags now: the same signal, no coloured border. */ +.entry-edge { + display: none; +} + +.entry-flags::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 0.4rem; + border-radius: 50%; + background: var(--edge, var(--unknown)); + vertical-align: 0; +} + +.entry-thumb { + position: relative; + overflow: hidden; + background: var(--bg); +} + +.entry-thumb-img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* No permitted image: the entry's name on the checker an editor shows behind + a sprite, so the tile still reads as "asset", not "missing". */ +.entry-tile { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + height: 100%; + padding: 0.75rem 0.85rem; + color: var(--ink); + background-color: var(--surface); + background-image: conic-gradient(var(--bg) 25%, transparent 0 50%, var(--bg) 0 75%, transparent 0); + background-size: 16px 16px; +} + +.entry-tile-name { + font-weight: 600; + font-size: 1.05rem; + line-height: 1.2; + letter-spacing: -0.015em; + text-wrap: balance; +} + +.entry-tile-note { + font-family: var(--mono); + font-size: 0.72rem; + color: var(--muted); +} + +/* Cards: image on top at 16:10, two-line summary. */ +.entry-card[data-view="cards"] .entry-thumb { + aspect-ratio: 16 / 10; +} + +.entry-card[data-view="cards"] .entry-body { + padding: 0.75rem 0.85rem 0.85rem; +} + +.entry-card[data-view="cards"] .entry-top { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; +} + +.entry-card[data-view="cards"] .entry-card p, +.entry-card[data-view="cards"] > .entry-body > p { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +/* List: the dense row with a square thumbnail at the left. */ +.entry-card[data-view="list"] { + grid-template-columns: 56px minmax(0, 1fr); + gap: 0.75rem; + padding: 0.5rem 0.65rem 0.5rem 0.5rem; + border-radius: 8px; +} + +.entry-card[data-view="list"] .entry-thumb { + aspect-ratio: 1; + border-radius: 4px; + align-self: start; +} + +.entry-card[data-view="list"] .entry-body { + padding: 0; + gap: 0.25rem; +} + +.entry-card[data-view="list"] .entry-tile { + padding: 0; +} + +.entry-card[data-view="list"] .entry-tile-name, +.entry-card[data-view="list"] .entry-tile-note { + display: none; +} + +.group-more { + display: inline-block; + justify-self: start; + margin: -4px 0 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface); + color: var(--ink); + font-weight: 600; + font-size: 0.85rem; + text-decoration: none; +} + +.group-more:hover { + border-color: var(--control-line); + color: var(--accent); +} + +.group-tools { + margin-left: auto; +} + +@media (max-width: 720px) { + .entry-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 480px) { + .entry-list { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + .entry-card { + transition: none; + } +} From bf4a2663bfc7bb8a38d12b89d54b2d247c7a8224 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies Date: Fri, 23 Oct 2026 18:12:35 -0400 Subject: [PATCH 07/12] site: static category pages behind the homepage previews Co-Authored-By: Claude Fable 5.1 --- site/build.mjs | 26 +++- site/lib/category-page.mjs | 68 +++++++++ site/lib/entry-page.mjs | 2 +- site/lib/exports.mjs | 293 +++++++++++++++++++------------------ site/lib/lib.test.mjs | 19 ++- site/public/category.js | 10 ++ site/public/styles.css | 48 ++++++ site/public/views.js | 76 ++++++++++ 8 files changed, 391 insertions(+), 151 deletions(-) create mode 100644 site/lib/category-page.mjs create mode 100644 site/public/category.js create mode 100644 site/public/views.js diff --git a/site/build.mjs b/site/build.mjs index 3f9157a..e2abab1 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -16,6 +16,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { baseHeadHtml, commercialLabel, headAssetsHtml, esc, latestAllowedDate, OG_CARD_NAME, sponsorButtonHtml } from "./lib/shared.mjs"; import { groupedEntriesHtml } from "./lib/cards.mjs"; +import { categoryPageHtml } from "./lib/category-page.mjs"; import { thumbRelPath } from "./lib/thumbs.mjs"; import { parseFrontmatter, summaryFromBody } from "./lib/frontmatter.mjs"; import { listEntryFiles } from "./lib/entry-files.mjs"; @@ -452,6 +453,21 @@ function writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now, return { errors, written }; } +/** Writes dist/category//index.html for every configured category with a visible entry. */ +function writeCategoryPages({ entries, config, stats, stamp, hasCard, now }) { + const written = []; + for (const [cat, meta] of Object.entries(config.categories)) { + const group = entries.filter((e) => e.category === cat); + if (!group.length) continue; + writeOut( + path.join("category", cat, "index.html"), + categoryPageHtml({ cat, meta, entries: group, site: config.site, stamp, total: stats.total, hasCard, now }) + ); + written.push(`category/${cat}/index.html`); + } + return { written }; +} + /** Runs the page checks over dist-relative files. */ function checkPages(files) { // Link targets resolve against what this build produced, not what is on @@ -596,18 +612,22 @@ function main() { } writeOut("index.html", html); - writeOut("sitemap.xml", sitemapXml({ site: config.site, entries: visible, stacks, stamp, modified: lastCommitDates() })); + const categoriesInSitemap = Object.keys(config.categories) + .filter((cat) => visible.some((e) => e.category === cat)) + .map((id) => ({ id, lastmod: stamp })); + writeOut("sitemap.xml", sitemapXml({ site: config.site, entries: visible, stacks, stamp, modified: lastCommitDates(), categories: categoriesInSitemap })); writeOut("robots.txt", robotsTxt(config.site)); writeOut("404.html", notFoundHtml(config.site)); writeOut(path.join("freshness", "index.html"), freshnessPageHtml({ stats: fresh, site: config.site, stamp, total: stats.total, hasCard })); const pages = writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now, usedIn }); const stackPages = writeStackPages({ stacks, entries, config, stats, stamp, hasCard, now }); + const categoryPages = writeCategoryPages({ entries: visible, config, stats, stamp, hasCard, now }); const pageErrors = [ ...stackErrors, ...pages.errors, ...stackPages.errors, - ...checkPages(["index.html", "freshness/index.html", ...pages.written, ...stackPages.written]), + ...checkPages(["index.html", "freshness/index.html", ...pages.written, ...stackPages.written, ...categoryPages.written]), ]; if (pageErrors.length) { console.error(`Page build failed (${pageErrors.length}):`); @@ -639,7 +659,7 @@ function main() { `Built ${entries.length} entries → site/dist (${stats.active} active, ${stats.commercialOk} commercial-ok, ${stats.commercialVaries} per-file)` ); console.log( - `Prerendered ${visible.length} entry rows into index.html; wrote ${pages.written.length} entry pages, ${stackPages.written.length} stack pages, sitemap.xml, robots.txt, 404.html, freshness/index.html` + `Prerendered ${visible.length} entry rows into index.html; wrote ${pages.written.length} entry pages, ${stackPages.written.length} stack pages, ${categoryPages.written.length} category pages, sitemap.xml, robots.txt, 404.html, freshness/index.html` ); console.log(`${writes} of ${produced.size} output files changed${clean ? " (clean build)" : ""}; ${((Date.now() - started) / 1000).toFixed(1)}s`); } diff --git a/site/lib/category-page.mjs b/site/lib/category-page.mjs new file mode 100644 index 0000000..957054f --- /dev/null +++ b/site/lib/category-page.mjs @@ -0,0 +1,68 @@ +/** One category's page: every entry in it, both views, no JavaScript needed to see everything. Pure. */ +import { groupedEntriesHtml } from "./cards.mjs"; +import { baseHeadHtml, clipText, esc, SEO_DESCRIPTION_MAX, SEO_TITLE_MAX, sponsorButtonHtml } from "./shared.mjs"; + +export function categoryPageUrl(site, cat) { + return `${String(site.siteUrl).replace(/\/+$/, "")}/category/${cat}/`; +} + +/** The credit line every page footer carries once thumbnails exist. */ +export const PREVIEW_CREDIT_LINE = "Preview images belong to their publishers and are shown under each entry's licence."; + +export function categoryPageHtml({ cat, meta, entries, site, stamp, total, hasCard, now }) { + const label = meta.label || cat; + const blurb = meta.blurb || ""; + const url = categoryPageUrl(site, cat); + const title = `${label} assets | ${site.title}`; + const n = entries.length; + const sources = `${n} ${n === 1 ? "source" : "sources"}`; + const description = clipText(`${sources} of free ${label} assets: ${blurb}. Every licence read at the source.`, SEO_DESCRIPTION_MAX); + const repo = site.repo; + const grid = groupedEntriesHtml(entries, { [cat]: meta }, { root: "../../", repo, now, limit: false }); + return ` + + + + + ${esc(clipText(title, SEO_TITLE_MAX))} + + ${baseHeadHtml({ site, title, description, url, hasCard, root: "../../" })} + + + +
+ Free Game Dev Assets + +
+
+ +

${esc(label)}

+

${esc(sources)} · ${esc(blurb)}

+ +
+${grid} +
+
+
+

Catalog metadata is CC0. Linked assets keep their own licences. This is the catalog's reading of each licence, not legal advice: re-check the live source before shipping.

+ +

Licence freshness

+ + +
+ + + + +`; +} diff --git a/site/lib/entry-page.mjs b/site/lib/entry-page.mjs index 74f7f6e..766692e 100644 --- a/site/lib/entry-page.mjs +++ b/site/lib/entry-page.mjs @@ -130,7 +130,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
${banner} diff --git a/site/lib/exports.mjs b/site/lib/exports.mjs index 75e3b9b..9587091 100644 --- a/site/lib/exports.mjs +++ b/site/lib/exports.mjs @@ -1,146 +1,147 @@ -/** - * Flat exports of the catalog: catalog.csv for spreadsheets, feed.xml (Atom) - * for readers and sitemap.xml for crawlers. data.json stays the full - * machine-readable catalog. - */ -import { esc } from "./shared.mjs"; - -/** - * sitemap.xml. Every URL carries a lastmod that is never earlier than the - * last content change: the build date for the home and freshness pages, which - * change every build by design; for entries and stacks the later of the - * catalog date (`verified`, `walked`) and the file's last commit date from - * `modified` (repo path => YYYY-MM-DD), falling back to the build date. - */ -export function sitemapXml({ site, entries, stacks = [], stamp, modified = new Map() }) { - const base = String(site.siteUrl).replace(/\/+$/, ""); - const latest = (...dates) => dates.filter(Boolean).sort().pop() || stamp; - const url = (loc, lastmod) => ` ${esc(loc)}${esc(lastmod)}`; - const urls = [ - url(`${base}/`, stamp), - url(`${base}/freshness/`, stamp), - ...stacks.map((s) => url(`${base}/stack/${s.meta.id}/`, latest(s.meta.walked, modified.get(s.rel)))), - ...entries.map((e) => url(`${base}/entry/${e.id}/`, latest(e.verified, modified.get(e.path)))), - ]; - return `\n\n${urls.join("\n")}\n\n`; -} - -const CSV_COLUMNS = [ - "id", - "name", - "url", - "category", - "license", - "license_spdx", - "commercial", - "attribution_required", - "formats", - "tags", - "added", - "verified", - "status", - "page", -]; - -/** - * One CSV field, quoted when it holds a comma, quote or line break. A field - * a spreadsheet would read as a formula (= + - @, tab, CR) gets a leading - * apostrophe first (OWASP CSV injection guidance). - */ -export function csvField(value) { - let text = Array.isArray(value) ? value.join("; ") : value === undefined || value === null ? "" : String(value); - if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`; - return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; -} - -export function catalogCsv(entries, siteUrl) { - const base = siteUrl.replace(/\/+$/, ""); - const rows = entries.map((e) => - CSV_COLUMNS.map((col) => csvField(col === "page" ? `${base}/${e.page}` : e[col])).join(",") - ); - return `${CSV_COLUMNS.join(",")}\r\n${rows.join("\r\n")}\r\n`; -} - -/** - * Homepage JSON-LD: the site (with its ?q= search) and the catalog as a - * Dataset, so the CSV and JSON exports can surface in dataset search. - */ -export function homeJsonLd({ site, generatedAt, total }) { - const base = site.siteUrl.replace(/\/+$/, ""); - return [ - { - "@context": "https://schema.org", - "@type": "WebSite", - name: site.title, - url: `${base}/`, - description: site.tagline, - potentialAction: { - "@type": "SearchAction", - target: { "@type": "EntryPoint", urlTemplate: `${base}/?q={search_term_string}` }, - "query-input": "required name=search_term_string", - }, - }, - { - "@context": "https://schema.org", - "@type": "Dataset", - name: site.title, - description: `${site.tagline} ${total} sources, each with its licence as the source states it and the date it was read there.`, - url: `${base}/`, - license: "https://creativecommons.org/publicdomain/zero/1.0/", - isAccessibleForFree: true, - dateModified: generatedAt.slice(0, 10), - distribution: [ - { "@type": "DataDownload", encodingFormat: "text/csv", contentUrl: `${base}/catalog.csv` }, - { "@type": "DataDownload", encodingFormat: "application/json", contentUrl: `${base}/data.json` }, - ], - }, - ]; -} - -/** The two Atom feeds: new sources (by `added`) and licence re-reads (by `verified`). */ -export const FEEDS = { - added: { file: "feed.xml", title: "new sources", what: "entries by the day they joined the catalog" }, - verified: { file: "reverified.xml", title: "recently verified", what: "entries by the day their licence was last read at the source" }, -}; - -/** - * Atom feed of the most recent entries by `kind`: "added" (new sources, - * feed.xml) or "verified" (licence re-reads, reverified.xml). Two feeds, so - * a re-verification sweep of old entries cannot bury the new ones. - */ -export function atomFeed({ entries, site, generatedAt, limit = 40, kind = "added" }) { - const spec = FEEDS[kind]; - if (!spec) throw new Error(`atomFeed: unknown kind "${kind}"`); - const base = site.siteUrl.replace(/\/+$/, ""); - const recent = entries - .filter((e) => e.status !== "deprecated" && e[kind]) - .sort((a, b) => String(b[kind]).localeCompare(String(a[kind])) || a.name.localeCompare(b.name)) - .slice(0, limit); - const items = recent.map((e) => { - const link = `${base}/${e.page}`; - return [ - " ", - ` ${esc(link)}`, - ` ${esc(e.name)}`, - ` `, - ` ${esc(e[kind])}T00:00:00Z`, - ` ${esc(`${e.license}. ${e.summary || ""}`.trim())}`, - " ", - ].join("\n"); - }); - return [ - '', - '', - ` ${esc(base)}/${kind === "added" ? "" : spec.file}`, - ` ${esc(site.title)}: ${spec.title}`, - ` ${esc(`${site.tagline} ${spec.what[0].toUpperCase()}${spec.what.slice(1)}.`)}`, - // RFC 4287 4.1.1: a feed-level author covers every entry. - ` ${esc(site.title)}${esc(base)}/`, - ` `, - ` `, - ` ${esc(generatedAt)}`, - ...items, - "", - "", - ].join("\n"); -} +/** + * Flat exports of the catalog: catalog.csv for spreadsheets, feed.xml (Atom) + * for readers and sitemap.xml for crawlers. data.json stays the full + * machine-readable catalog. + */ +import { esc } from "./shared.mjs"; + +/** + * sitemap.xml. Every URL carries a lastmod that is never earlier than the + * last content change: the build date for the home and freshness pages, which + * change every build by design; for entries and stacks the later of the + * catalog date (`verified`, `walked`) and the file's last commit date from + * `modified` (repo path => YYYY-MM-DD), falling back to the build date. + */ +export function sitemapXml({ site, entries, stacks = [], stamp, modified = new Map(), categories = [] }) { + const base = String(site.siteUrl).replace(/\/+$/, ""); + const latest = (...dates) => dates.filter(Boolean).sort().pop() || stamp; + const url = (loc, lastmod) => ` ${esc(loc)}${esc(lastmod)}`; + const urls = [ + url(`${base}/`, stamp), + url(`${base}/freshness/`, stamp), + ...categories.map((c) => url(`${base}/category/${c.id}/`, c.lastmod || stamp)), + ...stacks.map((s) => url(`${base}/stack/${s.meta.id}/`, latest(s.meta.walked, modified.get(s.rel)))), + ...entries.map((e) => url(`${base}/entry/${e.id}/`, latest(e.verified, modified.get(e.path)))), + ]; + return `\n\n${urls.join("\n")}\n\n`; +} + +const CSV_COLUMNS = [ + "id", + "name", + "url", + "category", + "license", + "license_spdx", + "commercial", + "attribution_required", + "formats", + "tags", + "added", + "verified", + "status", + "page", +]; + +/** + * One CSV field, quoted when it holds a comma, quote or line break. A field + * a spreadsheet would read as a formula (= + - @, tab, CR) gets a leading + * apostrophe first (OWASP CSV injection guidance). + */ +export function csvField(value) { + let text = Array.isArray(value) ? value.join("; ") : value === undefined || value === null ? "" : String(value); + if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`; + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +export function catalogCsv(entries, siteUrl) { + const base = siteUrl.replace(/\/+$/, ""); + const rows = entries.map((e) => + CSV_COLUMNS.map((col) => csvField(col === "page" ? `${base}/${e.page}` : e[col])).join(",") + ); + return `${CSV_COLUMNS.join(",")}\r\n${rows.join("\r\n")}\r\n`; +} + +/** + * Homepage JSON-LD: the site (with its ?q= search) and the catalog as a + * Dataset, so the CSV and JSON exports can surface in dataset search. + */ +export function homeJsonLd({ site, generatedAt, total }) { + const base = site.siteUrl.replace(/\/+$/, ""); + return [ + { + "@context": "https://schema.org", + "@type": "WebSite", + name: site.title, + url: `${base}/`, + description: site.tagline, + potentialAction: { + "@type": "SearchAction", + target: { "@type": "EntryPoint", urlTemplate: `${base}/?q={search_term_string}` }, + "query-input": "required name=search_term_string", + }, + }, + { + "@context": "https://schema.org", + "@type": "Dataset", + name: site.title, + description: `${site.tagline} ${total} sources, each with its licence as the source states it and the date it was read there.`, + url: `${base}/`, + license: "https://creativecommons.org/publicdomain/zero/1.0/", + isAccessibleForFree: true, + dateModified: generatedAt.slice(0, 10), + distribution: [ + { "@type": "DataDownload", encodingFormat: "text/csv", contentUrl: `${base}/catalog.csv` }, + { "@type": "DataDownload", encodingFormat: "application/json", contentUrl: `${base}/data.json` }, + ], + }, + ]; +} + +/** The two Atom feeds: new sources (by `added`) and licence re-reads (by `verified`). */ +export const FEEDS = { + added: { file: "feed.xml", title: "new sources", what: "entries by the day they joined the catalog" }, + verified: { file: "reverified.xml", title: "recently verified", what: "entries by the day their licence was last read at the source" }, +}; + +/** + * Atom feed of the most recent entries by `kind`: "added" (new sources, + * feed.xml) or "verified" (licence re-reads, reverified.xml). Two feeds, so + * a re-verification sweep of old entries cannot bury the new ones. + */ +export function atomFeed({ entries, site, generatedAt, limit = 40, kind = "added" }) { + const spec = FEEDS[kind]; + if (!spec) throw new Error(`atomFeed: unknown kind "${kind}"`); + const base = site.siteUrl.replace(/\/+$/, ""); + const recent = entries + .filter((e) => e.status !== "deprecated" && e[kind]) + .sort((a, b) => String(b[kind]).localeCompare(String(a[kind])) || a.name.localeCompare(b.name)) + .slice(0, limit); + const items = recent.map((e) => { + const link = `${base}/${e.page}`; + return [ + " ", + ` ${esc(link)}`, + ` ${esc(e.name)}`, + ` `, + ` ${esc(e[kind])}T00:00:00Z`, + ` ${esc(`${e.license}. ${e.summary || ""}`.trim())}`, + " ", + ].join("\n"); + }); + return [ + '', + '', + ` ${esc(base)}/${kind === "added" ? "" : spec.file}`, + ` ${esc(site.title)}: ${spec.title}`, + ` ${esc(`${site.tagline} ${spec.what[0].toUpperCase()}${spec.what.slice(1)}.`)}`, + // RFC 4287 4.1.1: a feed-level author covers every entry. + ` ${esc(site.title)}${esc(base)}/`, + ` `, + ` `, + ` ${esc(generatedAt)}`, + ...items, + "", + "", + ].join("\n"); +} diff --git a/site/lib/lib.test.mjs b/site/lib/lib.test.mjs index cbd7226..fe0dada 100644 --- a/site/lib/lib.test.mjs +++ b/site/lib/lib.test.mjs @@ -141,7 +141,7 @@ has("entry script without one, for the back link", page({ attribution_string: un lacks("no copy button without a credit line", page({ attribution_string: undefined }), "copy-attribution"); has("breadcrumb catalog link can return to the results", p, 'Catalog'); has("note when credit is required but not canned", page({ attribution_string: undefined }), "no canned credit line"); -has("breadcrumb category link", p, "../../?cat=2d#catalog"); +has("breadcrumb category link", p, "../../category/2d/"); has("next link", p, 'href="../e2/" rel="next"'); has("report link prefills the entry", p, "template=correction.yml"); lacks("active is indexable", p, 'content="noindex"'); @@ -979,6 +979,23 @@ has("cards: show-all link", groupMoreHtml({ cat: "3d", label: "3D", count: 77, r eq("cards: empty category skipped", groupedEntriesHtml(many, { "3d": { label: "3D", view: "cards" }, audio: { label: "Audio", view: "list" } }, { root: "", repo: "https://r", now: 0 }).includes("group-audio"), false); } +// Site refresh: static category pages. +import { categoryPageHtml } from "./category-page.mjs"; +{ + const html = categoryPageHtml({ cat: "3d", meta: { label: "3D", blurb: "Models, scans, PBR textures", view: "cards" }, entries: [cardBase], site: { title: "Free Game Dev Assets", tagline: "t", siteUrl: "https://s/x/", repo: "https://r" }, stamp: "2026-10-23", total: 1, hasCard: false, now: 0 }); + has("category page: title", html, "3D assets | Free Game Dev Assets"); + has("category page: root-relative entry link", html, 'href="../../entry/x/"'); + has("category page: canonical", html, ''); + has("category page: one h1", html, "

3D

"); + has("category page: count and blurb", html, "1 source · Models, scans, PBR textures"); + has("category page: search link back", html, 'href="../../?cat=3d#catalog"'); + eq("category page: no more link", html.includes("group-more"), false); + has("category page: footer credit line", html, "Preview images belong to their publishers and are shown under each entry's licence."); + has("category page: loads the view toggle module", html, ''); + eq("category page: page checks pass", checkPage(html, { file: "category/3d/index.html", kindOf: () => "file" }).join(), ""); +} +has("sitemap: category pages", sitemapXml({ site: { siteUrl: "https://s/" }, entries: [], stacks: [], stamp: "2026-10-23", categories: [{ id: "3d", lastmod: "2026-10-23" }] }), "https://s/category/3d/"); + /* report (keep last) ------------------------------------------------------ */ if (failures.length) { console.error(`lib.test failed (${failures.length}):`); diff --git a/site/public/category.js b/site/public/category.js new file mode 100644 index 0000000..e23eade --- /dev/null +++ b/site/public/category.js @@ -0,0 +1,10 @@ +/** A category page: the Cards / List toggle, nothing else. The list is complete without it. */ +import { installViewToggles } from "./views.js"; + +(() => { + const grid = document.getElementById("entry-grid"); + if (!grid) return; + const defaults = {}; + for (const h of grid.querySelectorAll(".group-heading")) defaults[h.getAttribute("data-cat")] = h.getAttribute("data-view") || "list"; + installViewToggles(grid, defaults); +})(); diff --git a/site/public/styles.css b/site/public/styles.css index 38a5643..e072888 100644 --- a/site/public/styles.css +++ b/site/public/styles.css @@ -1879,3 +1879,51 @@ input:focus-visible, transition: none; } } + +/* Category pages (site/lib/category-page.mjs) and the view toggle (views.js). */ +.category-page .section-blurb { + margin: 0.25rem 0 0; + color: var(--muted); +} + +.category-page .category-search { + margin: 0.5rem 0 1.25rem; + font-size: 0.92rem; +} + +.view-toggle { + display: inline-flex; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface); + overflow: hidden; + font-family: var(--font); + letter-spacing: 0; + text-transform: none; +} + +.view-toggle button { + min-height: 32px; + padding: 0 0.75rem; + border: 0; + background: transparent; + color: var(--muted); + font: 600 0.78rem var(--font); + cursor: pointer; +} + +.view-toggle button[aria-pressed="true"] { + background: var(--ink); + color: var(--bg); +} + +.view-toggle button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +@media (pointer: coarse) { + .view-toggle button { + min-height: 44px; + } +} diff --git a/site/public/views.js b/site/public/views.js new file mode 100644 index 0000000..d86bc85 --- /dev/null +++ b/site/public/views.js @@ -0,0 +1,76 @@ +/** + * Cards / List toggle on category headings. The same markup serves both + * views (site/lib/cards.mjs); the toggle flips `data-view` on the heading + * and on every card of that category. The choice is remembered per kind of + * category: all card-default categories move together, all list-default + * ones move together. Pages work without this script; it only adds the + * control. + */ + +const KEY = (defaultView) => `fgda:view:${defaultView}-default`; + +export function readStoredView(defaultView) { + try { + const v = localStorage.getItem(KEY(defaultView)); + return v === "cards" || v === "list" ? v : null; + } catch { + return null; + } +} + +export function storeView(defaultView, view) { + try { + if (view === defaultView) localStorage.removeItem(KEY(defaultView)); + else localStorage.setItem(KEY(defaultView), view); + } catch { + // Storage can be unavailable; the view still changes for this visit. + } +} + +/** Sets a category's view on its heading and its cards. */ +export function applyView(root, cat, view) { + for (const el of root.querySelectorAll(`.group-heading[data-cat="${CSS.escape(cat)}"], .entry-card[data-cat="${CSS.escape(cat)}"]`)) { + el.setAttribute("data-view", view); + } + const heading = root.querySelector(`.group-heading[data-cat="${CSS.escape(cat)}"]`); + if (!heading) return; + for (const btn of heading.querySelectorAll(".view-toggle button")) { + btn.setAttribute("aria-pressed", btn.getAttribute("data-view") === view ? "true" : "false"); + } +} + +function toggleHtml() { + return ( + '
' + + '' + + '' + + "
" + ); +} + +/** + * Adds a toggle to every category heading under `root` and applies any + * stored choice. `defaults` maps category id to its config view; a heading + * whose category is not listed keeps the view it was rendered with. + */ +export function installViewToggles(root, defaults, { onChange } = {}) { + for (const heading of root.querySelectorAll(".group-heading")) { + const cat = heading.getAttribute("data-cat"); + const defaultView = defaults[cat] || heading.getAttribute("data-view") || "list"; + const tools = heading.querySelector(".group-tools"); + if (tools && !tools.querySelector(".view-toggle")) tools.innerHTML = toggleHtml(); + applyView(root, cat, readStoredView(defaultView) || defaultView); + heading.addEventListener("click", (e) => { + const btn = e.target.closest(".view-toggle button"); + if (!btn) return; + const view = btn.getAttribute("data-view"); + storeView(defaultView, view); + // Every category with the same default follows, so the page stays consistent. + for (const h of root.querySelectorAll(".group-heading")) { + const c = h.getAttribute("data-cat"); + if ((defaults[c] || h.getAttribute("data-view")) === defaultView || c === cat) applyView(root, c, view); + } + if (onChange) onChange({ cat, view }); + }); + } +} From 0b963d153c00bbd2dbd0fa021fa932204bce3bf0 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies Date: Fri, 23 Oct 2026 18:16:00 -0400 Subject: [PATCH 08/12] site: hero with filmstrip, per-category previews, cards/list toggle, mobile header Closes #75 Co-Authored-By: Claude Fable 5.1 --- site/build.mjs | 14 +++-- site/lib/cards.mjs | 20 +++++++ site/lib/freshness.mjs | 12 +--- site/lib/lib.test.mjs | 28 ++++----- site/public/app.js | 53 ++++++++++++++++- site/public/index.html | 11 ++-- site/public/styles.css | 131 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 232 insertions(+), 37 deletions(-) diff --git a/site/build.mjs b/site/build.mjs index e2abab1..da69f6c 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -15,9 +15,9 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { baseHeadHtml, commercialLabel, headAssetsHtml, esc, latestAllowedDate, OG_CARD_NAME, sponsorButtonHtml } from "./lib/shared.mjs"; -import { groupedEntriesHtml } from "./lib/cards.mjs"; +import { filmstripHtml, groupedEntriesHtml } from "./lib/cards.mjs"; import { categoryPageHtml } from "./lib/category-page.mjs"; -import { thumbRelPath } from "./lib/thumbs.mjs"; +import { filmstripPick, thumbRelPath } from "./lib/thumbs.mjs"; import { parseFrontmatter, summaryFromBody } from "./lib/frontmatter.mjs"; import { listEntryFiles } from "./lib/entry-files.mjs"; import { entryPageHtml, scriptJson } from "./lib/entry-page.mjs"; @@ -585,14 +585,20 @@ function main() { const stamp = payload.generatedAt.slice(0, 10); const visible = entries.filter((e) => e.status !== "deprecated"); const fresh = freshnessStats(entries, now); + // The hero filmstrip draws from the categories that default to cards. + const cardCategories = Object.entries(config.categories) + .filter(([, meta]) => meta.view === "cards") + .map(([cat]) => cat); const substitutions = { FRESHNESS_LINE: freshnessLineHtml(fresh, stamp, "freshness/"), NEW_LINE: newEntriesLineHtml(visible, stamp), HEAD_META: headMetaHtml(config.site, stats, payload.generatedAt, hasCard), STACK_ROWS: stackRowsHtml(stacks), STARTER_ROWS: starterRowsHtml(featured), - // Full prerender until the category pages exist (Task 6); the per-category cut-off comes with them. - ENTRY_ROWS: groupedEntriesHtml(visible, config.categories, { root: "", repo: repoUrl, now, limit: false }), + // Each category shows its first few entries; the category pages hold the rest. + ENTRY_ROWS: groupedEntriesHtml(visible, config.categories, { root: "", repo: repoUrl, now }), + HERO_COUNT: String(stats.total), + FILMSTRIP: filmstripHtml(filmstripPick(visible, { count: 9, cardCategories }), { root: "" }), CATEGORY_CHIPS: categoryChipsHtml(visible, config.categories), GUIDE_ROWS: guideRowsHtml(config.guides, repoUrl), CATALOG_BLURB: `${stats.total} sources · ${stats.active} active · ${stats.commercialOk} commercial-ok · ${stats.commercialVaries} per-file · ${stats.deprecated} deprecated`, diff --git a/site/lib/cards.mjs b/site/lib/cards.mjs index bf08a80..b01ee9f 100644 --- a/site/lib/cards.mjs +++ b/site/lib/cards.mjs @@ -120,3 +120,23 @@ export function groupedEntriesHtml(entries, categories, { root = "", repo, now, } return out.join("\n"); } + +/** + * The hero filmstrip: a row of square thumbnails linking to their entries, + * so the first screen already shows what the catalog holds. The first three + * load eagerly (they are above the fold); the rest lazily. + */ +export function filmstripHtml(entries, { root = "" } = {}) { + if (!entries.length) return ""; + const items = entries + .map((e, i) => { + const t = e.thumb; + return ( + `
  • ` + + `${esc(e.name)}` + + `
  • ` + ); + }) + .join(""); + return `
      ${items}
    `; +} diff --git a/site/lib/freshness.mjs b/site/lib/freshness.mjs index 2197044..d013e40 100644 --- a/site/lib/freshness.mjs +++ b/site/lib/freshness.mjs @@ -34,17 +34,7 @@ export function freshnessStats(entries, now) { /** The one-line summary, for the homepage and the top of /freshness/. */ export function freshnessLineHtml(stats, stamp, href) { - const parts = [ - "Every licence here was read at its source.", - `Checked within 180 days: ${stats.fresh} of ${stats.total}.`, - ]; - if (stats.aging) parts.push(`Aging (181 to 365 days): ${stats.aging}.`); - parts.push(`Older than a year: ${stats.stale}.`); - if (stats.unknown) parts.push(`No check date: ${stats.unknown}.`); - if (stats.oldest) parts.push(`Oldest check: ${esc(stats.oldest.entry.verified)} (${dayWord(stats.oldest.days)}).`); - parts.push(`As of ${esc(stamp)}.`); - parts.push(`See every check, oldest first.`); - return parts.join(" "); + return `${stats.fresh} of ${stats.total} licences re-read in the last 180 days. Every check, oldest first.`; } /** Days an entry counts as new on the home page. */ diff --git a/site/lib/lib.test.mjs b/site/lib/lib.test.mjs index fe0dada..83eb9e6 100644 --- a/site/lib/lib.test.mjs +++ b/site/lib/lib.test.mjs @@ -441,21 +441,10 @@ eq("recent window includes day 30, excludes 31", fs1.recent.map((r) => r.entry.i eq("rows oldest first, unknown last", fs1.rows.map((r) => r.entry.id).join(","), "i,d,c,b,a,f,e,j,g"); eq("buckets match the cards", fs1.rows.every((r) => r.bucket === verifiedAge(r.entry.verified, fNow).bucket), true); const fl = freshnessLineHtml(fs1, "2026-09-25", "freshness/"); -has("line counts fresh of total", fl, "Checked within 180 days: 4 of 9."); -has("line shows aging when present", fl, "Aging (181 to 365 days): 2."); -has("line shows stale", fl, "Older than a year: 2."); -has("line shows unknown when present", fl, "No check date: 1."); -has("line shows the oldest check", fl, `Oldest check: ${daysAgo(366)} (366 days).`); -has("line shows as-of", fl, "As of 2026-09-25."); -has("line links the page", fl, 'See every check, oldest first.'); -const flClean = freshnessLineHtml(freshnessStats([fe("a", "Alpha", 10)], fNow), "2026-09-25", "freshness/"); -lacks("no aging clause at zero", flClean, "Aging"); -lacks("no unknown clause at zero", flClean, "No check date"); -has("stale clause even at zero", flClean, "Older than a year: 0."); -has("one day is singular", freshnessLineHtml(freshnessStats([fe("a", "Alpha", 1)], fNow), "2026-09-25", "f/"), "(1 day)"); +eq("freshness line is one sentence", fl, '4 of 9 licences re-read in the last 180 days. Every check, oldest first.'); const fEmpty = freshnessStats([], fNow); eq("empty catalog has no oldest", fEmpty.oldest, null); -lacks("empty catalog line has no oldest clause", freshnessLineHtml(fEmpty, "2026-09-25", "f/"), "Oldest check"); +eq("empty catalog line still reads", freshnessLineHtml(fEmpty, "2026-09-25", "f/"), '0 of 0 licences re-read in the last 180 days. Every check, oldest first.'); /* freshness: page ---------------------------------------------------------- */ import { freshnessPageHtml } from "./freshness.mjs"; @@ -465,7 +454,7 @@ has("freshness canonical", fp, ']/g) || []).length, 1); has("freshness links entries one level up", fp, 'href="../entry/i/"'); has("freshness names escaped", fp, "Q "<b>""); -has("freshness line links the table", fp, 'See every check, oldest first'); +has("freshness line links the table", fp, 'Every check, oldest first'); has("freshness table cells carry labels", fp, ''); eq("freshness table rows oldest first", [...fp.matchAll(/ m[1]).slice(0, 3).join(","), "i,d,c"); has("an undated row says so", fp, "no check date"); @@ -996,6 +985,17 @@ import { categoryPageHtml } from "./category-page.mjs"; } has("sitemap: category pages", sitemapXml({ site: { siteUrl: "https://s/" }, entries: [], stacks: [], stamp: "2026-10-23", categories: [{ id: "3d", lastmod: "2026-10-23" }] }), "https://s/category/3d/"); +// Site refresh: hero filmstrip. +import { filmstripHtml } from "./cards.mjs"; +has("filmstrip: links and alt", filmstripHtml([{ id: "x", name: "X Pack", thumb: { path: "thumbs/x.webp", width: 640, height: 400 } }], { root: "./" }), 'X Pack ({ id: `e${i}`, name: `E${i}`, thumb: { path: `thumbs/e${i}.webp`, width: 640, height: 400 } })); + const strip = filmstripHtml(nine, { root: "" }); + eq("filmstrip: first three eager, rest lazy", `${(strip.match(/loading="eager"/g) || []).length}/${(strip.match(/loading="lazy"/g) || []).length}`, "3/6"); + has("filmstrip: labelled list", strip, '
    `; } -/** A category heading. The count reads as ", 77 entries" to assistive tech instead of running into the label. */ -export function groupHeadingHtml({ cat, label, count, view = "list", root = "" }) { +/** + * A category heading. The count reads as ", 77 entries" to assistive tech + * instead of running into the label. `level` keeps the outline honest: h3 + * under the homepage's h2, h2 on a category page whose h1 is the category. + */ +export function groupHeadingHtml({ cat, label, count, view = "list", level = 3 }) { return ( - `

    ` + + `` + `${esc(label)}` + `, ${Number(count) || 0} entries` + `` + - `

    ` + `` ); } @@ -105,7 +109,7 @@ export function groupMoreHtml({ cat, label, count, root = "" }) { * (the homepage) each category shows its first CARD_LIMIT or LIST_LIMIT * entries and a "Show all" link; without it (category pages) everything. */ -export function groupedEntriesHtml(entries, categories, { root = "", repo, now, limit = true } = {}) { +export function groupedEntriesHtml(entries, categories, { root = "", repo, now, limit = true, headingLevel = 3, titleLevel = 4 } = {}) { const out = []; for (const [cat, meta] of Object.entries(categories)) { const group = entries.filter((e) => e.category === cat); @@ -114,8 +118,8 @@ export function groupedEntriesHtml(entries, categories, { root = "", repo, now, const label = meta.label || cat; const max = view === "cards" ? CARD_LIMIT : LIST_LIMIT; const shown = limit ? group.slice(0, max) : group; - out.push(groupHeadingHtml({ cat, label, count: group.length, view, root })); - out.push(...shown.map((e) => entryCardHtml(e, { root, repo, now, view, label }))); + out.push(groupHeadingHtml({ cat, label, count: group.length, view, level: headingLevel })); + out.push(...shown.map((e) => entryCardHtml(e, { root, repo, now, view, label, titleLevel }))); if (limit && group.length > max) out.push(groupMoreHtml({ cat, label, count: group.length, root })); } return out.join("\n"); diff --git a/site/lib/category-page.mjs b/site/lib/category-page.mjs index d32c8ba..ad5a2f3 100644 --- a/site/lib/category-page.mjs +++ b/site/lib/category-page.mjs @@ -15,7 +15,8 @@ export function categoryPageHtml({ cat, meta, entries, site, stamp, total, hasCa const sources = `${n} ${n === 1 ? "source" : "sources"}`; const description = clipText(`${sources} of free ${label} assets: ${blurb}. Every licence read at the source.`, SEO_DESCRIPTION_MAX); const repo = site.repo; - const grid = groupedEntriesHtml(entries, { [cat]: meta }, { root: "../../", repo, now, limit: false }); + // The category is the h1, so its group heading is an h2 and the card titles h3. + const grid = groupedEntriesHtml(entries, { [cat]: meta }, { root: "../../", repo, now, limit: false, headingLevel: 2, titleLevel: 3 }); return ` diff --git a/site/lib/lib.test.mjs b/site/lib/lib.test.mjs index fb52ea4..e259593 100644 --- a/site/lib/lib.test.mjs +++ b/site/lib/lib.test.mjs @@ -1020,6 +1020,21 @@ eq("filmstrip: empty when nothing to show", filmstripHtml([], { root: "" }), "") has("stack page: footer credit line", html, "Preview images belong to their publishers and are shown under each entry's licence."); } +// Final review: heading levels never skip on category and stack pages. +has("cards: heading level option", groupHeadingHtml({ cat: "3d", label: "3D", count: 1, view: "cards", level: 2 }), '

    needs review' : ""; const lead = `

    ${esc(r.need)}${flag}

    `; const trail = `

    ${r.whyHtml}

    `; - return `
  • ${entryCardHtml(r.entry, { root: "../../", repo, now, view: "cards", lead, trail })}
  • `; + // Picks sit under a section h2, so the card titles are h3. + return `
  • ${entryCardHtml(r.entry, { root: "../../", repo, now, view: "cards", lead, trail, titleLevel: 3 })}
  • `; } export function stackPageHtml({ stack, sections, gapsHtml, leadHtml, owed, site, stamp, total, hasCard, now }) { diff --git a/site/public/app.js b/site/public/app.js index d70fe72..ab1e092 100644 --- a/site/public/app.js +++ b/site/public/app.js @@ -357,9 +357,13 @@ import { installViewToggles } from "./views.js"; // catalog is rendered here once, the first time a search, a filter or a // "Show all" needs it, so the page loads without that work. let allRendered = false; + // Set when ensureAllNodes added nodes; renderGrid announces them once + // they are in the DOM (shortlist.js wires its buttons on the event). + let announceRendered = false; function ensureAllNodes() { if (allRendered) return; allRendered = true; + announceRendered = true; const missing = data.entries.filter((e) => !cardNodes.has(e.id)); if (!missing.length) return; const tpl = document.createElement("template"); @@ -488,6 +492,10 @@ import { installViewToggles } from "./views.js"; el.hidden = !n; if (n) el.querySelector(".group-n").textContent = String(n); } + if (announceRendered) { + announceRendered = false; + document.dispatchEvent(new CustomEvent("fgda:cards-rendered")); + } groupHeadings = [...grid.children].filter((el) => el.matches(".group-heading") && !el.hidden); // The first visible heading sits flush with the top of the list. for (const el of headingNodes.values()) el.classList.toggle("is-lead", el === groupHeadings[0]); @@ -709,7 +717,9 @@ import { installViewToggles } from "./views.js"; e.preventDefault(); state.category = more.getAttribute("data-cat"); apply({ push: true }); - headingNodes.get(state.category)?.scrollIntoView({ block: "start" }); + // The category headings are hidden under a category filter, so land on + // the first card of the list instead (it clears the sticky bar). + $("#entry-grid .entry-card:not([hidden])")?.scrollIntoView({ block: "start" }); $("#result-count").focus({ preventScroll: true }); }); // Cards / List on each category heading (views.js); re-render so the diff --git a/site/public/shortlist.js b/site/public/shortlist.js index daa27e4..6aaadc2 100644 --- a/site/public/shortlist.js +++ b/site/public/shortlist.js @@ -134,3 +134,6 @@ window.addEventListener("storage", (e) => { syncButtons(); renderPanel(); + +// app.js renders the non-previewed cards lazily; their buttons get wired here. +document.addEventListener("fgda:cards-rendered", syncButtons); diff --git a/site/public/styles.css b/site/public/styles.css index 36b57e7..14c1022 100644 --- a/site/public/styles.css +++ b/site/public/styles.css @@ -415,7 +415,8 @@ code { align-items: baseline; } -.entry-top h4 { +.entry-top h4, +.entry-top h3 { margin: 0; font-size: 1.02rem; letter-spacing: -0.015em; @@ -582,12 +583,14 @@ input:focus-visible, box-shadow: inset 3px 0 0 var(--accent); } -.entry-top h4 a { +.entry-top h4 a, +.entry-top h3 a { color: var(--ink); text-decoration: none; } -.entry-top h4 a:hover { +.entry-top h4 a:hover, +.entry-top h3 a:hover { color: var(--accent); text-decoration: underline; } diff --git a/site/public/thumbs/blender-ellie-poses.webp b/site/public/thumbs/blender-ellie-poses.webp deleted file mode 100644 index e64de2fc5d7db23563f22c20c807027188f6570f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14664 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 diff --git a/site/thumbs.json b/site/thumbs.json index b5398be..6a35cef 100644 --- a/site/thumbs.json +++ b/site/thumbs.json @@ -145,24 +145,6 @@ "height": 270, "license": "GPL-2.0-or-later" }, - "blender-ellie-poses": { - "source": "https://www.blender.org/download/demo-files/#assets", - "image": "https://www.blender.org/wp-content/uploads/2025/11/blender_lab_2025-480x270.jpg", - "fetched": "2026-10-23", - "hash": "e3d81a8c2db1a187", - "width": 480, - "height": 270, - "license": "CC-BY" - }, - "blender-human-base-meshes": { - "source": "https://www.blender.org/download/demo-files/#assets", - "image": "https://www.blender.org/wp-content/uploads/2025/11/blender_lab_2025-480x270.jpg", - "fetched": "2026-10-23", - "hash": "e3d81a8c2db1a187", - "width": 480, - "height": 270, - "license": "CC0" - }, "blender-studio-critter": { "source": "https://studio.blender.org/characters/critter/", "image": "https://studio.blender.org/files/public/thumbnail/74/ea/74ead8db4c04dbfa2f64efd401b43841_m.webp", @@ -595,15 +577,6 @@ "height": 360, "license": "CC0" }, - "kenney-animated-characters": { - "source": "https://kenney.nl/assets?series=Animated+Characters", - "image": "https://kenney.nl/data/img/kenney-promo.png", - "fetched": "2026-10-23", - "hash": "f719591c31137707", - "width": 600, - "height": 300, - "license": "CC0" - }, "kenney-boardgame-pack": { "source": "https://kenney.nl/assets/boardgame-pack", "image": "https://kenney.nl/media/pages/assets/boardgame-pack/993d44b363-1677667639/sample.png",