Skip to content

feat(seo): give each card link its own preview image (#1133) - #1134

Merged
Doberjohn merged 4 commits into
masterfrom
claude/project-thread-s0ce6e
Nov 5, 2026
Merged

Doberjohn merged 4 commits into
masterfrom
claude/project-thread-s0ce6e

Conversation

@Doberjohn

@Doberjohn Doberjohn commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

Closes #1133

What changes

A card link pasted into Facebook, Discord, Reddit or X now previews with an image of that card, not the site banner. Each image is 1200x630 and shows:

  • the card's art, glowing in its ink
  • its name and version
  • "Pairs with N cards"
  • its three best partners, each with a tag word

A card with no synergies shows "{Ink} · {Rarity} {type}" and no partner row. Locations are turned upright, both as the main card and as a partner.

  • scripts/generate-og-images.mjs draws every card in build:vercel, after pnpm build:web and before the crawl, in the Chromium the crawl installs.
    • It fills scripts/og/card-template.html from the build's own card data, synergy files, fonts and art. The page is served from a made-up origin, with every path answered from dist.
    • It writes dist/og/card/{id}.{sha256 prefix}.jpg.
    • It writes a record (apps/web/node_modules/.cache/og-cards.json, outside dist) of each card's file and alt text, removing an earlier build's record first.
    • It logs the count, size and time. Locally: 1,228 of 1,228 cards, 120.9 MiB, 29 s. The largest file is 124 KiB.
  • scripts/lib/cardOgImage.mjs holds the pure parts: the partner pick, what the template fills, the file name, the alt text and the tag swap. Its tests are in scripts/lib/__tests__/cardOgImage.test.mjs.
  • scripts/prerender.mjs swaps each card page's og:image, twitter:image and both alt tags for the card's own. Every other page keeps the banner. The swap throws when the page doesn't hold exactly one of each tag.
  • Check 12 of scripts/check-rendered-html.mjs fails the deploy when a card page with art kept the banner, when its twitter:image differs from its og:image, or when the image it names isn't in the build.
  • vercel.json serves /og/ immutable, since every name carries its hash. vite.config.ts keeps /og/ out of the worker's navigation fallback.
  • apps/web/index.html adds og:site_name "Inkweave" and alt text for the banner on every page.
  • deploy.yml runs the PR dry run when the generator, its template, check 12 or the root package.json (which holds build:vercel) changes. The lockfile stays out, since every Dependabot bump changes it.
  • The docs are updated in CLAUDE.md and IMAGES.md.

Where it differs from the issue

  • Tags come from the card's synergy file: each partner gets the label of the group it scores highest in. The labels are the same playstyle and rule names the issue named, but this way the engine's catalog isn't needed.
  • pairs is an object keyed by partner id, not an array.
  • The generator reads dist, not public/data, so it draws exactly the data and art the deploy ships.
  • A card that fails to draw doesn't stop the run. It is logged, its page keeps the banner, and check 12 then fails the deploy naming the card. The crawl runs even when the whole image step fails, and a drawing page that can't open leaves its cards to the others.
  • The crawl reads a record, not the folder, so it gets each image's alt text too: "{card} and its best Lorcana partners on Inkweave", or "{card}, a Lorcana card on Inkweave" without partners.
  • A card without art keeps the banner (none today: 1,228 of 1,228 drew). There's nothing card-specific to draw for a reveal before its scan, and failing the deploy over it would block shipping reveals.
  • Chromium runs with --disable-partial-raster. Without it, about 70 of 1,228 images changed bytes between identical builds because the logo's masks antialiased differently, so unchanged cards got new URLs. Three full runs now match byte for byte.
  • No prerender.test.mjs or knip.jsonc change was needed. The swap is tested in the helper's tests, and knip sees the generator through package.json.

Verification

  • pnpm test:scripts: 1176 passed (12 new helper tests, 6 for check 12, 2 for the build order)
  • lint:root, web lint and typecheck, knip, CodeScene gate (Code Health 10 on the new and changed scripts)
  • A sample crawl: card pages name https://inkweave.ink/og/card/{id}.{hash}.jpg in both image tags with the card's alt text, and /browse keeps /og-image.png
  • Planted fault: deleting one JPEG makes check 12 fail naming that card
  • A sheet of 12 rendered cards (Max Goof, a song, an item, Locations as hero and partner, dual ink, no partners, the longest name, Waternoose) looked right to me. It goes to the owner before merge.
  • Every JPEG is 1200x630 and under 300 KB
  • No app JavaScript or card data changes
  • The PR's Deploy dry run on 1103dab: 1,228 of 1,228 images in 31 s, 1,269 pages crawled with 0 failed, and check 12 passed on 1,228 card pages

After deploy: curl the og:image of /card/3354/max-goof-karaoke-star, check the image's headers, and try the link in Facebook's Sharing Debugger and a Discord message.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DjrxDUg8t6RHUrGiKw1neQ

A card link shared on Facebook, Discord or X showed the site banner. Each
card page's og:image and twitter:image now name a 1200x630 JPEG of the card:
its art, name and version, how many cards it pairs with, and its three best
partners, each tagged with the group it scores highest in. A card without
synergies shows its ink, rarity and type and no partner row.

- scripts/generate-og-images.mjs draws every card in the deploy build, after
  the web build and before the crawl, from the build's own data, fonts and art
  (scripts/og/card-template.html), into dist/og/card/{id}.{hash}.jpg.
- The crawl swaps index.html's image tags and their alt text for the card's
  (scripts/lib/cardOgImage.mjs); every other page keeps the banner.
- Check 12 of check-rendered-html fails a deploy whose card page kept the
  banner or names an image the build lacks.
- /og/ is served immutable and stays out of the worker's navigation fallback.
- index.html gains og:site_name and image alt tags.

Closes #1133

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjrxDUg8t6RHUrGiKw1neQ
@vercel

vercel Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
inkweave Ready Ready Preview Nov 5, 2026 1:21pm UTC

Request Review

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

🎨 Design-surface change detected

This PR touches files mirrored by the Inkweave design system project.
After merge, refresh the mirror so it doesn't drift out of sync.

What changed:

  • ✏️ Font imports — apps/web/index.html (flag any swap)
  • 📖 CLAUDE.md — design language sections may have shifted

To refresh: open the design system Claude project and prompt:

Refresh against the latest of Doberjohn/inkweave@master. Focus on the files this PR touched: apps/web/index.html, CLAUDE.md

This is a reminder, not a blocker. Configured in .github/workflows/design-system-drift.yml.

@Doberjohn Doberjohn added this to the Fixes and upkeep milestone Nov 5, 2026 — with Claude
@Doberjohn Doberjohn self-assigned this Nov 5, 2026
codescene-delta-analysis[bot]

This comment was marked as outdated.

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
User-facing JS 457.02 KB (0%)
Supabase SDK (lazy) 26.98 KB (0%)
PostHog SDK (lazy) 100.91 KB (0%)
Entry chunk 28.06 KB (0%)
Collection and reveals JS 74.44 KB (0%)
Binders JS (lazy) 24.68 KB (0%)
Release notes page 9.33 KB (0%)
Card data JSON 110.37 KB (0%)
Collection index JSON 63.83 KB (0%)
Collection set chunks 158.18 KB (0%)

Comment thread scripts/og/card-template.html Resolved
Comment thread package.json Resolved
Comment thread scripts/generate-og-images.mjs Resolved
Comment thread scripts/check-rendered-html.mjs Resolved
Comment thread scripts/lib/cardOgImage.mjs Resolved
Comment thread scripts/lib/cardOgImage.mjs Resolved
Comment thread scripts/generate-og-images.mjs Resolved
Comment thread apps/web/index.html Resolved
Comment thread scripts/build-vercel.test.mjs Resolved

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cubic analysis

All reported issues were addressed across 16 files

Linked issue analysis

Linked issue: Doberjohn/inkweave#1133: Give each card page its own link preview image, with its art and three best partners

Status Acceptance criteria Notes
⚠️ Give each card page its own preview image The crawl swaps tags for cards in the manifest, but generation and check 12 skip cards without imageHash.
✅ Show the card, its details, and best partners The template renders card art, name, version, partner count, and tagged partners, with a no-partner layout.
✅ Turn Location art upright Location cards get landscape styling that rotates their portrait art for both hero and partner images.
✅ Hash image URLs and serve them immutable JPEG filenames include a SHA-256 prefix, and Vercel applies immutable caching to the /og/ route.
⚠️ Fail the deploy when a card preview image is missing Check 12 catches missing files for cards with art, but cards without imageHash are excluded from the check.
✅ Add Inkweave as the site name on every page The shared index template adds og:site_name, which the prerendered pages inherit.
✅ Skip image generation for staging builds The generator exits before rendering when SKIP_PRERENDER is set to 1.
✅ Keep app JavaScript and card data unchanged The changes add build scripts and metadata handling without modifying app JavaScript or card data.

Tip: instead of fixing issues one by one fix them all with cubic

View guided diff | Re-trigger cubic

Comment thread .github/workflows/deploy.yml Resolved
Comment thread scripts/check-rendered-html.mjs Resolved
Comment thread scripts/og/card-template.html Resolved
Comment thread apps/web/src/features/cards/IMAGES.md Resolved
Comment thread CLAUDE.md Resolved
Comment thread scripts/generate-og-images.mjs Resolved
… and partial images when drawing fails (#1133)

- The template loads every font face before the first fit: a face loads
  only once text uses it, so each page's first card was measured in the
  fallback serif (567 px against Marcellus's 560 for one name).
- build:vercel runs the crawl even when the image step fails, so check 12
  fails the deploy instead of every page losing its prerender.
- A drawing page that fails to open leaves its cards to the others, and the
  record of what was drawn is written however the run ends.
- Chromium runs without partial raster: with it, about 70 of 1,228 images
  changed bytes between identical builds (the logo's masks antialiased
  differently), so unchanged cards got new URLs. Three runs now match.
- Drops the unused Plus Jakarta Sans 600 face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjrxDUg8t6RHUrGiKw1neQ
codescene-delta-analysis[bot]

This comment was marked as outdated.

…at a time (#1133)

- The tag swap passes replacer functions, so `$&`, `$'` or `$$` in a card's
  name can't splice page HTML into its alt text.
- The generator reads the synergy files synchronously: about 1,200 read at
  once could run out of file descriptors on a runner with a low limit.
- The partner pick finds each tag's first pair in one pass (same picks:
  all 1,228 images are byte for byte the same).
- Alt text follows the issue: "{card} and its best Lorcana partners on
  Inkweave", and "{card}, a Lorcana card on Inkweave" without partners.
- CLAUDE.md says a card without art keeps the banner; doc comments drop
  their issue tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjrxDUg8t6RHUrGiKw1neQ
codescene-delta-analysis[bot]

This comment was marked as outdated.

build:vercel, the deploy's whole build order, lives there, and only the
dry run runs it on a PR. The lockfile stays out: every Dependabot bump
changes it, and CI's build and E2E cover those.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjrxDUg8t6RHUrGiKw1neQ

@codescene-delta-analysis codescene-delta-analysis Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gates Passed
6 Quality Gates Passed

See analysis details in CodeScene

Quality Gate Profile: Pay Down Tech Debt
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.

@Doberjohn
Doberjohn merged commit 23bd92c into master Nov 5, 2026
19 checks passed
@Doberjohn
Doberjohn deleted the claude/project-thread-s0ce6e branch November 5, 2026 14:04

This branch was successfully deployed

1 active deployment
Preview — 775f9f8c Deployed Nov 5, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Give each card page its own link preview image, with its art and three best partners

1 participant