Repository navigation
Description
Summary
Today every pad link shows the same static docs.plus image in Slack, X, LinkedIn, WhatsApp and other apps. This tracker adds a social preview card per link: a 1200 × 630 PNG that shows what the link opens. The card is drawn on our server from stored values only.
The design went through four review rounds, with peer research on about 60 products. The maintainer approved the family on 2026-11-01. The build ships in slices. Each slice ships with its card kind switched off, and turns on only after both REST replicas run the new code.
Maintainer: please drag the final card PNGs onto this issue.
ghcannot attach images.
The card family
One frame for every kind: the docs.plus mark at the top left, a kind label at the top right, and a white sheet below. The kind is a word on the label, not a new layout.
| Kind | Link | What the card draws |
|---|---|---|
| Document | /slug |
The pad name, then its first outline rows and "+N more sections". A pad with no outline draws its first blocks; a pad with nothing stored draws one large title. A pad that opens with a picture draws the title and first line beside a stored thumbnail. |
| Heading | /slug?h=…&id=<tocId> |
The Document card for one section: the pad name as a small line, the heading as the title, then the first real blocks under it (paragraphs, list items). |
| Chat room | /slug?chatroom=<id> |
The room name as the title, beside a small fixed conversation panel with the bucketed message count ("10+ messages"). |
| Message | /slug?chatroom=<id>&msg_id=<id> |
The chat room with the linked message lit, as the app lights a jumped-to message. Real values: its date, its top reaction and its reply count. Its text and its author are never drawn. |
| Version | /slug?version=<n> (needs R6) |
"Version from ", the pad name, and three numbers: words added and removed, sections added, sections changed. |
| Filtered view | /slug/term1/term2?mode=and |
The pad name, the filter chips, "Match any" or "Match all", "N sections match", then the first matches. |
| Fallback | an ineligible pad | One file glyph and "A document on docs.plus". |
Rules every card follows
- Only stored values. A card never draws text from the URL. A filter chip draws the stored heading text that the term matched, so a made-up link like
/demo/your-account-is-closedcannot print its own words on a docs.plus card. A term with no match draws no chip. - No people. No names, no faces, no presence, no message text. Every grey shape in a chat sketch is fixed in number and size, so none leaks a member count or an author.
- English only. Every drawn line passes one drawable check. An undrawable line is dropped. An undrawable Pad title gives the Fallback card.
- Read size. Every line meant to be read is at least 45 px at 1200 wide (13.5 px in a 360 px Slack unfurl). Measured text contrast is at least 5.4:1 at 360 px.
- One URL cleaner. A URL inside a drawn line becomes
[link]. A line of only links is dropped. - The X label corner stays clear. X draws its own label at the bottom left of the image. A fact shown only once ends at y 550 or above on the left half.
- One eligibility function. A pad gets a card only when it exists, is not Private, is not deleted, has an owner, and has a drawable Pad title. Every refusal answers the same 404, byte for byte, so the route never tells a Private pad from a missing one.
Architecture
One module in REST (apps/hocuspocus.server/src/modules/social-card/), with five small guards. Each guard has a measured reason.
Page head. Next calls REST once, as today. REST picks the kind, runs eligibility and returns card: { kind, v, … }. The slug response grows by 50 to 166 B (measured, arm64 laptop). DocumentHead in apps/webapp/src/pages/_app.tsx points og:image at the card route and sets a per-kind og:title and og:url. Addresses are parsed with padUrlCore.parse from @docs.plus/pad-url; there is no card-only parser.
Card route. GET /api/documents/:documentId/card.png?v=<cardVersion> (sub-ids id, c, m, t, mode per kind). Steps:
- Shape check on the ids. A bad shape gets the 404 with no read.
- A rate bucket per address.
- Single-flight row read. Concurrent reads of one
documentIdshare one query. Nothing is kept after it settles. - Eligibility on this row. Refusal: the identical 404,
max-age=60. - Resolve the kind, then compute
cardVersion(a hash of the template id and every drawn value).If-None-Matchequal to it gives 304. - Render LRU, 8 MiB per replica, keyed
kind:documentId:cardVersion, plus in-flight dedupe. Never stale: any drawn change makes a new key. - One render slot per replica, with a queue of 4, over the fit and the render. A full queue answers a 302 to the static image and counts a shed.
- Render with Takumi (
@takumi-rs/core, exact pin) from object trees. 200image/png,Cache-Control: public, max-age=3600,ETag,X-Robots-Tag: noindex.
Card failures answer 302 to the static image, never 5xx. So the card has its own counter (outcome = ok, fallback, shed, error), seeded at 0 on start.
No CDN, no Redis card store, no render process. None has a measured need.
Chat and Message add one anon PostgREST read, in parallel with the owner read the head already makes. Guards: an id pre-filter, a 60 s memo of definite answers, a cap of 4 reads per replica and a 300 ms abort.
Numbers (arm64 laptop, Takumi 2.15.1, 20 runs per card)
| Item | Value |
|---|---|
| Render per card, p50 | 2.5 to 4.9 ms |
| Size per card | 8.8 to 42.6 KB; picture cards 233 and 244 KB (budget 250 KB) |
?v= flood, 20 clients, no cache |
145.2 renders/s, event-loop lag p99 56.7 ms |
| Same flood, LRU and dedupe | 1 render, lag max 4.5 ms |
| 1,000 servers in 1 s, LRU and dedupe | 1 render, lag max ≤ 7.3 ms, RSS peak about 63 MB |
| Card route, LRU hit or 304 | 0.19 to 1.00 ms p50 |
| Production RTT, RSS and amd64 timing | not measured; owed before switch-on |
Caches we cannot purge
Slack keeps a page about 30 minutes, X about 7 days, Facebook about 30 days, and Mastodon 14 days per server. Bluesky, Telegram and Signal keep their own copy. A shared card cannot be recalled. A pad that turns Private gets the 404 at once on our route, but crawler copies stay until they expire. The privacy page gets one sentence about this (R3).
Stored data the cards need
| Data | Used by | Status |
|---|---|---|
DocumentMetadata.title, owner, flags |
every kind | exists |
preview.blocks: first blocks, cut to 80 graphemes |
Document without an outline | new, written by the existing preview pass in the worker |
DocumentMetadata.outline: heading rows |
Document, Heading, Chat label, Filter | new column (R-A) |
| First blocks per outline item | Heading | new (R-K); today the outline would keep one lead line |
| Stored 600 px thumbnail | Document and Heading with picture | new (R-I) |
Documents.changeSummary and each version's own title |
Version | new (R7, R-D), its own worker job |
| Anon chat count, message date, top reaction, reply count | Chat, Message | read live, bucketed |
The worker writes all of it from the one decode it already runs. On any extract or write error, it writes null (fail closed), and the card falls back to fewer details.
Build slices
Each slice ends at a review checkpoint and ships with its kind off.
- Slice 1: page head description rule (R2). Social card foundation: description rule, card route and the title-only Document card #664. When the stored description is empty or equals the Pad title or the slug, send the generic description.
og:titlestays. - Slice 2: foundation and the title-only Document card. Social card foundation: description rule, card route and the title-only Document card #664. The module, route, eligibility, frame, LRU, dedupe, slot, single-flight read, rate bucket, metrics and switch.
og:imageand size and alt tags inDocumentHead. - Slice 3: first blocks (R1, R3). Worker extract, fail-closed write, backfill script, privacy sentence. Official demo pads set Read-only first.
- Slice 4: outline and Heading card (R-A, R-K).
outlinecolumn, the grapheme-cut fix in the sanitizer, worker pass, backfill,idon the slug read. - Slice 5: Chat and Message (R14, R-G, R-H, R-J).
- Slice 6a:
?version=URL mirror (R6). 6b: Version image (R7, R-D). - Slice 7: Filter card (R-C ruled; R11 ships it last).
- Static image swap for ineligible pads (R5), any time after slice 2.
Preconditions before any kind turns on
- Production
rest-apiRSS on both replicas, and the REST to Postgres and REST to Supabase round trips (p50 and p95). - Takumi render timing on amd64 at 1 CPU.
- A live unfurl check on Slack, X, LinkedIn and Facebook, including where X draws its label.
Open decisions
Recommended answer first. All ruled on 2026-11-01: the recommended answer in every case. See the Maintainer ruling, 2026-11-01 comment.
These gate slices 1 and 2:
- R2. Description rule. Recommended: yes. Without it the first card repeats the title on 42% of eligible local pads, and today's head already prints the slug twice.
- R10. Takumi in REST. Recommended: yes. Cards are their own module, not link-metadata. The link-metadata trigger still applies: event-loop lag above 50 ms reopens it.
- R9. Design-system catalog row. Recommended: yes, one row listing the frame and its existing tokens. No new token. The design system is locked, so this needs the maintainer's word.
These gate later slices:
- R1. Content trust. Recommended: accept and record, with the URL cleaner, the fail-closed write and the privacy sentence. A visitor can write the first blocks of a writable pad, share the link, then revert; X keeps that card about 7 days, Facebook about 30. Make official demo pads Read-only. Build an owner-authorship gate on the first defacement report.
- R3. Privacy page sentence. Recommended: yes, one sentence on unfurl caches.
- R4 / N1. Slug headline. Recommended: yes, in the image only. A Title replaces a Pad title that equals the slug;
og:titlestays the Pad title. One resolver for every kind. - R5. Ineligible image. Recommended: swap to the Fallback card after slice 2.
- R6. Version URL mirror. Recommended: yes,
?version=<n>beside the hash, stripped on arrival. This changes the History link format. - R7 and R-D. Change summary and version title. Recommended: yes, as slice 6b, stored per version by its own job.
- R8. Strip chat values in heading links. Recommended: yes, so a heading link copied after a chat link cannot unfurl as a Message card.
- R11. Filter ships last. Recommended: yes.
- R12. Deleted pads. Recommended: keep "no image".
- R13. Invite only. Recommended: hold the lock card until [Tracking] Document access, sharing and reading redesign #610 ships the "Invite only" copy.
- R14. Chat counts. Recommended: keep the bucketed count (1 to 9, then 10+). Never message text or author.
- R15. Bare tiles. Recommended: draw nothing for a node with no resolved detail.
- R-A. Outline rows on the Document card. Recommended: yes. This overturns an earlier decision. Every section title drawn on a shared card stays in other apps' copies.
- R-B. Chat counts on outline rows. Recommended: later, after one grouped count read is measured in the page head.
- R-C. Filter chips. Ruled yes by the maintainer on 2026-11-01. Chips draw stored matched text only; rows carry no highlight.
- R-E. X label corner. Recommended: keep it clear now; check a live X unfurl.
- R-F. One level of context. Recommended: the pad name only, no breadcrumb arrows.
- R-G. Chat panel. Recommended: yes, the fixed conversation panel. Also: should Message move onto the same panel? Recommended: yes, for one family look.
- R-H. Person emoji. Recommended: skip person emoji in reactions; keep emoji in titles as typed.
- R-I. Stored thumbnail. Recommended: later, as an optional slice. It is new stored data, and its worker cost is not measured.
- R-J. Primary colour on Message. Recommended: yes. It copies the app's own deep-link highlight.
- R-K. First blocks per heading. Recommended: yes, after measuring the outline size on the real corpus.
- Attachment noun on Message. Recommended: no attachment pill until a ruling names the noun.
Out of scope and rejected
- A CDN, a Redis card store, or a separate render process.
- Delta storage or any change to collab storage.
- Drawing message text, author names, faces or presence.
- A dark card image. No platform swaps in a dark og:image.
- Highlighting search words inside filter rows.
Notes
Numbers are local measurements on an arm64 laptop unless marked "modeled" or "not measured". Production is amd64, so every timing above is owed again on production hardware before switch-on.
Activity
HMarzban commentedon Nov 1, 2026
Maintainer ruling, 2026-11-01
The maintainer took the recommended answer on every open decision in this tracker. R-C was ruled earlier the same day.
Gate slices 1 and 2 (#664)
Content and privacy
og:titlestays the Pad title.Document and Heading
Chat and Message
Version, Filter and the X corner
?version=<n>beside the hash, stripped on arrival. This overturns the Pad URL ruling that kept the History link unchanged.HMarzban commentedon Nov 1, 2026
R-K measurement, 2026-11-01
R-K was ruled "yes, after measuring the outline size on the real corpus". Measured on the local corpus: 341 eligible pads, 255 with content, arm64 laptop. Production sizes are not measured.
The extractor is the outline pass with up to N text lines per heading. A list item counts as one line, and each line is cut to 80 graphemes.
The median grows by 19 B, p95 does not move, and the total grows by 11%. The largest outline roughly doubles but stays near 22 KB. The condition is met. Slice 4 keeps its oversize rule: an outline over the cap gives the Document card.
HMarzban commentedon Nov 1, 2026
Build status, 2026-11-01
Every slice is built on
mainand checked locally. Nothing is committed yet. Every kind ships off.?version=linkChanges from the plan
DocumentMetadata.cardDatacolumn (blocksandoutline), notpreview.blocksplus anoutlinecolumn. Fail closed is oneSET NULL, and the Documents list is untouched.card=1on a full page load.versionis on.Checks: all 12 approved card PNGs are byte-identical, apart from one intended chip change ("fold" became the whole word "folding"). Server tests 1019 pass, 0 fail. Both apps typecheck. A live local run covered every kind, a 304, and identical 404s. Two independent reviews ran, and their fixes are applied.
Owed before any kind turns on (maintainer)
(build): back front, worker before REST).--applyin a one-off container with--memory 768m.social_card_requests_total(shed, error).SOCIAL_CARD_KINDSindocs/self-hosting/configuration.md.Visible on deploy: R5 swaps the static pad image at once, so every pad link shows "A document on docs.plus" until a kind turns on.
HMarzban commentedon Nov 2, 2026
Status 2026-11-02: performance review, fixes and observability
All of this is built and checked locally. Nothing is committed yet.
Review verdict
A 13-agent review measured the cache, Redis and queue design. Verdict: keep it.
max-age=3600, ETag, 304)card=1)Rejected, with evidence:
immutableor a 1-yearmax-age(the route ignoresv, so an old URL can return new bytes), a CDN, a Redis PNG store or chat memo, a render process or a second slot, the extract on its own queue, a lazy summary, and a Redis render lock.Fixes applied
joined.card.png. The image relay fetched it through Traefik, so every reader's hover shared one card bucket, and a429became a blank image cached for 10 minutes. The relay now reads the card in process. A302still goes over the network, to the static image.HEADoncard.pngnow sends the realContent-Length.outlineOfchecks a heading's id before the text work. That work took 0.9 s on 20,000 headings with no id.Data for later tuning
social_card_requests_total{outcome}:hit,render,not-modified,fallback,refused,chat-shed,slot-shed,error.social_card_render_seconds{kind}andsocial_card_chat_read_seconds: render time and chat round trip.social-card-errors,social-card-shed,worker-summary-queue-backlog.Before switch-on
migratestep applies both columns.scripts/backfill-card-data.tsonce, in a one-off container.302,429andHEAD.SOCIAL_CARD_KINDS=documenton both REST replicas and on the worker.Only when the numbers ask for it: raise the render queue from 4 to 16 when
slot-shedis above 0. Run the owner read in parallel when the REST to Supabase round trip is slow.HMarzban commentedon Nov 2, 2026
Status 2026-11-02: committed on
main, not pushedEvery slice is now committed. Nothing is deployed, and every card kind ships off.
34116d296card, hovercard in-process read674dfc189version-change-summaryqueue, the backfill scripte0acaba4cog:imagenames the card; the static Fallback card43eca20ea?version=7a40fb507e00fb150fEach code commit typechecks on its own. 1,032 server tests pass.
Reviews since the last update
skippedoutcome for card data writes.fallback, so no alert fired. It is nowchat-unavailable, andsocial-card-errorscounts it.A 12-case parity check covers every kind, every Document layout and both failure paths. It proves every card renders byte for byte the same as before these passes. Only the filter card's version changed, and that was on purpose.
Next, in order
migratestep adds both columns.scripts/backfill-card-data.tsonce: a dry run, then--apply, in a one-off container.302,429andHEAD. The new dashboard row shows most of these.SOCIAL_CARD_KINDS=documenton both REST replicas and the worker.Also owed: the Twemoji CC-BY 4.0 attribution.