Social preview images for the public pages - #155
Merged
Conversation
Design for og:/twitter: metadata on every shareable page, a per-meeting-type card image, and — first — suppressing previews on the capability URLs that leak invitee details into any chat the token is pasted into. Rendering rests on spike calit-xu12: AWT works in the native musl image, but only with runtime-image changes the build never warns about. Includes the spike bean.
Rendering in-process forecloses a scratch/distroless final layer (AWT is dlopen-based; Chainguard and distroless are glibc), and adds freetype + fontconfig to what must be kept patched. /tmp must stay writable because Font.createFont(InputStream) spills to a temp file. Hardening work itself is calit-gabg, filed alongside.
The JVM runtime cannot render either — liberica-runtime-container:jre-26-musl is missing libfreetype.so.6, so this was a defect, not a risk. BellSoft does publish hardened images (separate repos I had missed). Their distroless musl JRE has no apk and no shell, so the font stack has to be COPYed from a builder stage — verified working, and at 134.2 MB it lands 3.8 MB smaller than today's base that cannot render at all. Corrects the earlier claim that distroless was foreclosed: that applies to a static binary, not to a distroless layer. Base choice stays calit-gabg's call.
Seven tasks, leak fix first. Records that quarkus-awt's own requirement (freetype + fontconfig) is necessary but not sufficient: without an actual font file the JDK font manager will not initialise, which breaks Font.createFont even for an embedded TTF.
base.html emits og:/twitter: only when a page passes an OgCard; absent means noindex,nofollow. The token pages pass nothing, so they cannot unfurl an invitee's name, meeting and time into a chat — by default rather than by flag. Secret meeting types get the product card: naming a hidden meeting in a preview would defeat the flag.
Rubik covers Latin, Cyrillic, Hebrew and Arabic in one file; the site's Hanken Grotesk is Latin-only, so it stays the wordmark. AWT does no automatic fallback for createFont-loaded fonts, hence the run splitting. All faces are static instances baked with fonttools: AWT ignores fvar axes and loads whatever the default instance is.
Regenerate all seven faces with fontTools' --update-name-table so
getFontName() reports the pinned weight instead of the source variable
font's stale default-instance name (e.g. both Rubik weights previously
read "Rubik Light"). Fraunces needs an extra step: its STAT table only
registers exact opsz nominals at 9/72/144, so --update-name-table can't
be pinned directly at opsz=14; derive the name from a throwaway opsz=9
probe (same "9pt" bucket) and transplant it onto the real opsz=14
shape.
semiboldChainUsesSemiboldFallback previously asserted only
contains("Noto"), which passed identically whether chain(true) used
notoSemiBold or notoRegular. With accurate per-weight names it now
asserts the resolved font is specifically "Noto Sans SemiBold" -
verified this fails with the right message when notoRegular is
substituted in, then restored the correct wiring. Tightened two more
assertions (latin/Greek runs) now that exact names are meaningful.
Centred composition keeps everything inside the central 630x630 square, so a square-cropping client still shows the whole card — a left-aligned draft became a fragment of one word with no logo. Centring also removes any need to mirror for RTL. Fixed a fit-ladder gap while adding ellipsize test coverage: an unbreakable single word (or a leading word alone) longer than the safe square skipped the wrap stage without ever reaching ellipsize, so it overflowed past x=915 untouched. fitHeadline now checks the constructed first line's width before returning it and falls through to ellipsize when wrapping can't help. Verified render()/product() are byte-for-byte deterministic: identical SHA-256 across repeated calls in one JVM and across separate JVM launches, and the PNG carries no tIME chunk. Task 4's ETag and identical-bytes assertions depend on this.
Rendered per request behind an ETag over the inputs, so a rename invalidates nothing. Unknown, inactive and secret targets degrade to the product card with 200 — a 404 unfurls as a broken image. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
OgImageResource.findOwner had no `enabled` check, so a disabled owner's real
name and meeting-type detail still rendered (200) via /og/{user}.png and
/og/{user}/{slug}.png even though their actual booking page 404s
(PublicResource.resolveOwner, calit-h8mb). Both routes now fall back to the
product card for a disabled owner, same as an unknown one -- never a 404,
since a 404 unfurls as a broken image.
Collapsed findOwner into the existing AppUser.findByUsername (byte-identical
lookup) and applied the enabled check at both call sites instead of keeping a
duplicate query path in a security-relevant file.
Also closes two test gaps flagged in review: the inactive-type fallback (a
spec-named miss path with no prior test) and the ETag's duration/location
axes (previously only the name axis was proven input-sensitive).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
RoundRectangle2D's arc arguments are diameters, not radii, so the chip's corners were drawn at half the site's 30% radius. The wordmark advanced by fm.charWidth(), an integer-rounded advance that also drops kerning; a font derived with TextAttribute.TRACKING draws "calit" in a single drawString call, honouring real kerning and staying byte-for-byte deterministic. Measured: chip corner inset 7px -> 15px (target ~14px). "calit" ink width stays at 60px with the shipped static Hanken Grotesk instance -- the mechanism is correct and verified monotonic/deterministic, but this specific font asset's natural advance already sits at 63px untracked, so applying the site's own -0.02em overshoots below the browser's 63.2px reference (measured against the live Google-hosted font, not our baked static copy). Left the -0.02 constant alone rather than retuning it to force a match; see task-3b-report.md for the full investigation.
Neither could. The native runtime needs AWT's dlopen'd JDK .so files beside the binary; both need freetype and at least one font on disk, because an empty fontconfig makes the JDK font manager fail to initialise and that breaks Font.createFont even for an embedded TTF. The native build also failed outright (before any request could even happen) on java.awt.Color being embedded in the image heap by CardRenderer's static Color constants; fixed via --initialize-at-build-time=java.awt.Color rather than touching CardRenderer. CI now requests /og.png AND /og/admin.png and checks PNG magic bytes on both, for both the jvm and native matrix legs: every failure of this kind passes the build and dies on the first request. A hardened/distroless base under calit-gabg requires switching both runtime stages from apk to a copy-from-builder stack: libfreetype.so.6, libfontconfig.so.1, libexpat, libbz2, libpng16, libbrotlidec, libbrotlicommon, /etc/fonts, the font files, and /var/cache/fontconfig with fc-cache run in the builder stage. Font.createFont(InputStream) also spills to a temp file, so a read-only root filesystem needs a tmpfs mount at /tmp regardless of base image. Also adds a JVM-mode @QuarkusIntegrationTest (OgImageResourceIT) as a packaging check, wired via a new maven-failsafe-plugin bound to verify. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
Code review flagged the --initialize-at-build-time=java.awt.Color comment as incomplete: it said "safe" without naming the scope risk (the flag names Color specifically, so a future AWT-typed static field on CardRenderer -- a Font, a GradientPaint, a BasicStroke -- reproduces the identical fatal build error) or the durable alternative and why it wasn't taken (--initialize-at-run-time=CardRenderer would cover any future field, but fights Quarkus's build-time CDI bean init untested, to guard against nothing written yet). No code changed; comment-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
Point 1 of the Dockerfile.native comment claimed java.awt.Color defaults to run-time init via a JDK static initializer calling Toolkit.loadLibraries() plus a conditional initIDs(). That mechanism does not exist on Color (no static initializer at all, per JDK 26 src.zip) and belongs to other AWT classes that need a native display connection. Corrected: quarkus-awt's AwtProcessor build step classifies java.awt.Color into the ICC colour-management surface (ColorSpace/ICC_Profile/...) because Color carries a private ColorSpace cs field (Color.java:255), which is what actually needs the native CMM at runtime. Our Color constants use the plain-int sRGB constructor, so cs stays null -- the real reason build-time init is safe here. Points 2-4 of the comment (safety evidence, scope risk, rejected alternative) were already accurate and are unchanged. Comment-only; no code changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
Final-review fix wave for calit-o89d (social preview images):
- CardRenderer.renderable() now rejects a blank type name -- fitHeadline
produces an empty run list for "", and render() indexed into it
unconditionally (line.getFirst()), crashing /og/{user}/{slug}.png with a
500 for any type whose name is blank (reachable: AdminResource has no
server-side blank check, and Slugs.uniqueMeetingTypeSlug turns a blank
base into "meeting"). Falling back to the product card reuses the same
path every other unrenderable input already takes.
- Documented why renderable()'s font-coverage check against chain(false)
also covers what the headline draws with chain(true).
- CI's /og/admin.png smoke probe now asserts the owner card differs from
the product card, so a silent fallback can no longer pass unnoticed.
- Dockerfile: install the AWT font stack before the app COPYs so a
source-only change doesn't bust that layer's cache.
- CLAUDE.md: note that *IT classes run under `mvn verify` via failsafe,
not `mvn test`.
- ADR-0009: note the Cache-Control: public / auth-cookie interaction on
the rendered card endpoints.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
…stack The spec always intended the production JVM image to run on BellSoft's hardened/distroless runtime base rather than the plain musl JRE image with apk-installed freetype/fontconfig -- that apk install was only ever meant to ship long enough to be immediately replaced (calit-gabg). Doing the move now means the apk-based Dockerfile never ships at all. The hardened distroless base has no shell and no package manager, so the font stack AWT card rendering needs (libfreetype, libfontconfig, their transitive libs, /etc/fonts, the font files, and a pre-warmed /var/cache/fontconfig) can no longer be `apk add`ed in the runtime stage. Instead, a new `fontstack` donor stage reuses the jdk-26-musl image the build stage already pulls (no new image enters the build), installs the same apk packages there, and the runtime stage COPY --from=fontstack's the resolved library files. Verified by booting the built image against Postgres with the CI smoke test's exact env vars, completing /setup, and confirming /og.png and /og/admin.png both return distinct real 1200x630 PNGs (not a silent fallback to the generic card) -- "it builds" doesn't prove the font/AWT path still works on a base with no shell to poke at. Image size dropped slightly (220 MB -> 213 MB) and the old "Fontconfig error: No writable cache directories" boot-time log line is gone now that the cache is pre-populated as root in the donor stage instead of built at runtime by UID 1001. Dockerfile.native is untouched -- there is no hardened bare JRE base, so moving it would regress its ~8.2 MB Alpaquita final layer to a 130 MB JRE image. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
Review found the prior COPY's justification for shipping both the versioned real file and the unversioned symlink was wrong: BuildKit's COPY --from= dereferences a symlink source into a full regular file at the destination, it never leaves a dangling link. readelf -d on the hardened base's libfontmanager.so confirms its NEEDED entries reference only the unversioned SONAME (libfreetype.so.6, etc.), never the versioned filename. Drop the versioned copies -- they were dead weight, and the exact thing a Renovate digest bump of the fontstack donor image could break, since renovate.json pins docker digests. Only the SONAME symlink names are copied now; BuildKit resolves each into the real file the dynamic linker needs. Rewrote the adjacent comment to state the correct reasoning instead of leaving the disproven dangling-symlink claim next to corrected code. Re-ran the full boot-and-request acceptance sequence (not just a rebuild): /setup -> 302, /og.png and /og/admin.png both real distinct 1200x630 PNGs, no Fontconfig cache-directory warning, no reflection/init errors. Image size dropped again, 213 MB -> 211 MB, from no longer shipping each lib twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
…out calit-o89d todos Pinning the base image by digest doesn't pin the apk packages inside it -- apk add resolves against the live repo at build time regardless. Document why that is left deliberate (CVE-rich C font parsers benefit from picking up patches on every rebuild; Trivy + the /og.png smoke test already gate both failure modes; Renovate can't manage an apk pin here anyway). Tick calit-o89d's per-locale-card todo with an explicit "not shipped -- deliberate" annotation instead of leaving it dangling forever (og:locale is always en_US since unfurl bots send no Accept-Language). The real-client unfurl-verification todo stays unticked; it needs a human and a public deployment. Also commits the calit-ss7l bean (co-hosted meeting types unfurl with the generic card) filed during the final whole-branch review but not yet staged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
asm0dey
added a commit
that referenced
this pull request
Aug 26, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
FirstRunRedirectFilter redirects every request to /setup while no AppUser exists, but its allow-list never covered /og.png or /og/*.png. This branch made "/", "/privacy" and "/terms" advertise those routes as their og:image on the theory that they're already exempt for exactly this reason (e.g. an unfurl bot on a fresh instance) -- but the image URL itself 302'd to /setup, so a crawler following it got text/html instead of a PNG. OgImageResourceIT caught this in CI: it boots the packaged jar against a brand-new database, so it always hits the zero-user state that local runs mask via the reused Dev Services Postgres. Exempt /og.png and the /og/ prefix. Safe pre-bootstrap: with zero AppUsers there is no tenant data to leak, so every card degrades to the generic product card by construction, and "og" is already reserved in Usernames.RESERVED so the prefix can never shadow a real user. Added FirstRunOgImageTest (mirrors FirstRunLegalPagesTest's real-DB pattern) to catch this without needing the slower IT packaging round-trip; confirmed it fails pre-fix and passes post-fix, and re-ran the full mvn verify both with the reused Dev Services Postgres and with a forced-fresh one (-Dquarkus.datasource.devservices.reuse=false). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
CardRenderer.drawLockup computed the chip+wordmark centring offset with integer division (lockWidth / 2), truncating it by half a pixel whenever the tracked wordmark width was odd -- a real defect, not just Sonar's S2184 cast nit, since the card's whole crop-safety design leans on this lockup being exactly centred. Made the width/offset arithmetic float end to end; the S2184 warning disappears as a consequence. Verified render()/product() stay byte-for-byte deterministic across repeated calls after the change. Raised new-code coverage on CardRenderer/CardFonts/TextRuns/ OgImageResource (77.0% -> comfortably over 80% estimated) by adding tests for the headline fit ladder's three stages, renderable()'s boundary conditions, CardFonts' accessors and chain(), and location()/meta() branches. Discovered along the way that quarkus-jacoco only instruments code reached through a booted @QuarkusTest -- plain-JUnit tests exercising these AWT rendering classes directly are invisible to the coverage report regardless of how real their assertions are, so the new coverage-targeted tests are @QuarkusTest classes with CDI-injected beans instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
A code review confirmed quarkus-jacoco never activates without a booted @QuarkusTest in the run: plain-JUnit tests contribute nothing to the coverage report no matter how thorough their assertions are. This cost real time on this branch (CardRenderer showed 64% new-code coverage despite CardRendererTest exercising it thoroughly) so it's worth being explicit that a low Sonar number here can mean "wrong kind of test", not "no test".
…n allow-list Found reviewing the social-preview card fix (2cb5af1). FirstRunRedirectFilter matches its allow-list against rc.request().path() -- the raw, undecoded path -- while Vert.x dispatches on normalizedPath(), which percent-decodes and collapses dot segments. So /og/%2e%2e/me satisfies startsWith("/og/") on the raw string while actually routing to /me. Pre-existing and broader than the /og entries that surfaced it: the same mismatch applies to /, /img/, /privacy, /terms, /calit.css and /q/. Fixing it changes matching semantics for every exempt path on a filter that runs before security, so it needs its own test pass rather than a branch-tail one-liner. Impact is bounded -- the window exists only pre-bootstrap, the filter runs before security rather than instead of it, and the /og endpoints are safe however they are reached (owner == null is checked first, and ON DELETE CASCADE makes an orphaned MeetingType impossible). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
lockWidth (tile + 14 + wordWidth) is genuinely integral: fractional metrics are never enabled on the Graphics2D, so FontMetrics.stringWidth already equals the true drawn width. Declaring lockWidth as float asserted sub-pixel precision that doesn't exist, and the int+int+int addition still ran in int before the float widened it on assignment, which is exactly the java:S2184 pattern (cast one operand of an addition, not the whole result afterwards). Switching lockWidth back to var/int and keeping the float only on `lockWidth / 2f` removes the pattern honestly instead of relocating it, and leaves the genuine half-pixel centring fix from the previous commit untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
Sonar flags the loop counter being assigned inside the body. That is deliberate: i advances by Character.charCount(cp), which is 2 for a surrogate pair and 1 otherwise, so a fixed i++ would split surrogate pairs and corrupt astral-plane text -- exactly what this run splitter exists to avoid. Suppressed by rule id rather than a bare // NOSONAR, which would silence every rule on that line including any future genuine finding. The quality gate already passed without this; the annotation just stops a known false positive recurring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Adds
og:/twitter:metadata to every shareable page and a rendered card imageper meeting type — and, first, stops the capability URLs from unfurling at all.
Why the token pages come first
/booking/{manageToken}/*and/guest/{declineToken}/*are reachable byanyone holding the link. An unfurl paints the invitee's name, the meeting and
the time into whatever chat the token was pasted into, and some clients
prefetch previews server-side. These pages now emit
noindex,nofollowand noog:tags — by default, not by flag:base.htmlonly renders the tags forpages that pass an
OgCard, so a page that doesn't build one gets nothing toleak.
Card endpoints
Three routes render a PNG on demand:
/og.png— the generic product card/og/{user}.png— an owner's card/og/{user}/{slug}.png— a specific meeting type's card (owner, meetingname, duration(s))
Caching is an
ETagcomputed over the inputs that affect the render(owner name, type name, allowed durations, location kind) plus
Cache-Control: public, max-age=3600— no invalidation step needed, because achanged input produces a changed ETag automatically. Verified with a
rename-changes-the-ETag test, not just stability-across-two-requests.
Every degenerate case — unknown user/slug, an inactive or secret meeting
type, a disabled owner — falls back to the generic product card at HTTP
200, never a 404. A 404 unfurls as a broken image in chat clients, which is
worse than a slightly-wrong preview.
Security fix found in review
A disabled owner's card originally leaked their real name and meeting details
at HTTP 200, while the booking page correctly 404'd the same account —
reopening the enumeration oracle
calit-h8mbhad already closed (a probercould distinguish "disabled" from "never existed"). Card resolution now
applies the same
enabledguard as the booking path and degrades to theproduct card. Fixed and tested in-branch (commit
637df6f).Both container images previously could not render
CardRenderer'sstatic final Colorconstants get folded into the native-image heapbecause
CardRendereris a CDI@ApplicationScopedbean and Quarkusinitializes bean classes at build time, and GraalVM refuses run-time-init
types (
java.awt.Color) there. Confirmed pre-existing on bareHEADbeforeany change on this branch. Fixed with
--initialize-at-build-time=java.awt.ColorinDockerfile.native, decidedover the alternative of forcing run-time init on
CardRendereritself(would fight Quarkus's own build-time bean initialization, untested, to
guard a static field nobody has added yet).
libfreetype/fontconfig/any font on disk, socard rendering failed at request time.
Every failure in this class is build-green and request-red — the build
succeeds, the request 500s. The CI image smoke test now requests
/og.pngand/og/admin.pngand checks PNG magic bytes plus non-identity between the two, sothis class of failure fails the build instead of shipping an image.
Note on where that check runs: the image build/smoke job is gated on
github.event_name == 'push'tomainor a tag, so it does not run on thisPR — it fires on the merge commit, before any image is published. The
guarantee for this branch therefore rests on local verification, which was done
rather than assumed: both images were built, booted against Postgres, and asked
for
/og.pngand/og/admin.png; both returned distinct realPNG image data, 1200 x 630, and the JVM and native outputs were byte-for-byte SHA-256identical.
JVM image moved to a hardened distroless base
Advancing part of
calit-gabg: the JVM production image now runs onbellsoft/hardened-liberica-runtime-container:jre-distroless-musl— noshell, no package manager. The font stack (
freetype,fontconfig,font-dejavu-core, pre-warmed fontconfig cache) is built in a separate donorstage and
COPYed in by SONAME only.Dockerfile.nativeis unchanged —no hardened bare JRE base currently exists, so a native binary would land
in a much larger regular JRE image, which would be a real regression.
Sizes: JVM 214 MB → 211 MB (smaller, despite gaining font rendering);
native ~161 MB.
New to this repo:
@QuarkusIntegrationTest+ failsafeOgImageResourceITis a black-box HTTP test that boots the packaged app as aseparate process and asserts
/og.png(PNG magic, 1200×630, content-type,Cache-Control). It cannot seed data viaQuarkusTransactionthe way theunit-style
OgImageResourceTestdoes, so DB-backed cards stay covered by theCI container smoke's
/og/admin.pngprobe instead.maven-failsafe-pluginbinds to
verify, somvn verifynow packages and boots the app —mvn testis unaffected (test phase runs beforeverify).og:localeAlways
en_US. Unfurl bots send noAccept-Language, so there is nothing tokey a per-locale render off of — this is the one deliberate, documented
exception to the translate-everything rule; the card and preview strings are
fixed English literals.
Still outstanding
reachable deployment and a human pasting a link into an actual chat client
(Slack/Telegram/WhatsApp/iMessage) to confirm the card renders and that a
token URL does not unfurl. Everything short of that has been verified:
HTML output, served PNG bytes/dimensions/determinism, and the container
smoke test.
docs-sitechangelog fix is committed but not pushed (commitdae0608ondocs-site, corrected image-size numbers and split the denseentry per the 1.21.0/1.20.2 precedent). Needs a manual
git pushon thatbranch — outside this repo's remote, and per this repo's norm shared
branches aren't pushed without explicit authorization.
Follow-ups filed
calit-ss7l— co-hosted meeting types unfurl with the generic card, nottheir own (the page resolves via
resolveForAlias, the card viafindBySlug; degraded, not broken — no leak, no crash).calit-fanm— brand-lockup chipbox-shadow(not drawn) and proportionratios vs the live site's
.lp-brand(chip corner radius and wordmarktracking were real bugs, fixed in this branch already).
Spec
docs/superpowers/specs/2026-08-26-social-preview-images-design.md🤖 Generated with Claude Code
https://claude.ai/code/session_01MrnPiNwAQhe2RtmYTteNcj