Skip to content

[Tracking] Social preview cards for pad links #663

Open

Description

@HMarzban

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. gh cannot 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

  1. 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-closed cannot print its own words on a docs.plus card. A term with no match draws no chip.
  2. 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.
  3. English only. Every drawn line passes one drawable check. An undrawable line is dropped. An undrawable Pad title gives the Fallback card.
  4. 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.
  5. One URL cleaner. A URL inside a drawn line becomes [link]. A line of only links is dropped.
  6. 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.
  7. 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:

  1. Shape check on the ids. A bad shape gets the 404 with no read.
  2. A rate bucket per address.
  3. Single-flight row read. Concurrent reads of one documentId share one query. Nothing is kept after it settles.
  4. Eligibility on this row. Refusal: the identical 404, max-age=60.
  5. Resolve the kind, then compute cardVersion (a hash of the template id and every drawn value). If-None-Match equal to it gives 304.
  6. Render LRU, 8 MiB per replica, keyed kind:documentId:cardVersion, plus in-flight dedupe. Never stale: any drawn change makes a new key.
  7. 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.
  8. Render with Takumi (@takumi-rs/core, exact pin) from object trees. 200 image/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:title stays.
  • 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:image and size and alt tags in DocumentHead.
  • 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). outline column, the grapheme-cut fix in the sanitizer, worker pass, backfill, id on 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-api RSS 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:title stays 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

  1. HMarzban commented on Nov 1, 2026

    @HMarzban
    CollaboratorAuthor

    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)

    • R2. Description rule: yes.
    • R10. Takumi in REST: yes. Cards are their own module. Event-loop lag above 50 ms reopens it.
    • R9. Design-system catalog row: yes. One row for the card family, listing the frame and its existing tokens. No new token.

    Content and privacy

    • R1. Content trust: accept and record, with the URL cleaner, the fail-closed write and the privacy sentence. Official demo pads go Read-only before slice 3. Build an owner-authorship gate on the first defacement report.
    • R3. Privacy page sentence: yes, one sentence on unfurl caches.
    • R4 / N1. Slug headline: yes, in the image only, through one resolver for every kind. og:title stays the Pad title.
    • R12. Deleted pads: keep "no image".
    • R13. Invite only: hold the lock card until [Tracking] Document access, sharing and reading redesign #610 ships the "Invite only" copy.

    Document and Heading

    • R-A. Outline rows on the Document card: yes.
    • R-K. First blocks per heading: yes, after the outline size is measured on the real corpus.
    • R-F. Context: the pad name only, no breadcrumb arrows.
    • R-I. Stored thumbnail: later, as an optional slice.
    • R5. Ineligible image: swap to the Fallback card after slice 2.
    • R15. Bare tiles: draw nothing for a node with no resolved detail.

    Chat and Message

    • R14. Chat counts: keep the bucketed count (1 to 9, then 10+). Never message text or author.
    • R-G. Chat panel: yes, the fixed conversation panel. Message moves onto the same panel, with the linked message lit.
    • R-H. Person emoji: skip person emoji in reactions. Emoji in titles stay as typed.
    • R-J. Primary colour on Message: yes, as the app's deep-link highlight.
    • Attachment noun: no attachment pill for now.
    • R-B. Chat counts on outline rows: later, after one grouped count read is measured in the page head.
    • R8. Strip chat values in heading links: yes.

    Version, Filter and the X corner

    • R6. Version URL mirror: yes, ?version=<n> beside the hash, stripped on arrival. This overturns the Pad URL ruling that kept the History link unchanged.
    • R7 / R-D. Change summary and version title: yes, as slice 6b, stored per version by its own job.
    • R11. Filter ships last: yes.
    • R-E. X label corner: keep it clear now, and check a live X unfurl before switch-on.
  2. HMarzban commented on Nov 1, 2026

    @HMarzban
    CollaboratorAuthor

    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.

    Lines per heading p50 p95 max total
    1 (today's plan) 214 B 9,824 B 11,894 B 259,106 B
    4 (R-K) 233 B 9,824 B 22,116 B 287,473 B

    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.

  3. HMarzban commented on Nov 1, 2026

    @HMarzban
    CollaboratorAuthor

    Build status, 2026-11-01

    Every slice is built on main and checked locally. Nothing is committed yet. Every kind ships off.

    Slice Issue State
    1, 2: description rule, route, title-only card #664 built
    3: first blocks #665 built
    4: outline rows and Heading card #666 built
    5: Chat and Message #667 built
    6a: ?version= link #668 built
    6b: Version card #669 built
    7: Filter card #670 built
    R5: static Fallback card #671 built

    Changes from the plan

    • The stored card data is one DocumentMetadata.cardData column (blocks and outline), not preview.blocks plus an outline column. Fail closed is one SET NULL, and the Documents list is untouched.
    • The slug read resolves a card only when Next sends card=1 on a full page load.
    • When a chat read fails for now (timeout, shed, error), the card route answers the short-cache 302, never a 200 of another kind.
    • A reaction is drawn only when it is exactly one emoji and not a person. A filter chip draws whole stored words.
    • The change summary is enqueued only while version is 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)

    1. Commit, push and deploy ((build): back front, worker before REST).
    2. Production REST RSS on both replicas, REST to Postgres and Supabase round trips, amd64 render timing.
    3. Official demo pads set Read-only. Backfill dry run, approve the counts, then --apply in a one-off container with --memory 768m.
    4. A live unfurl check on Slack, X, LinkedIn and Facebook.
    5. Grafana alerts on social_card_requests_total (shed, error).
    6. SOCIAL_CARD_KINDS in docs/self-hosting/configuration.md.
    7. Turn kinds on one at a time, on REST and the worker.

    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.

  4. HMarzban commented on Nov 2, 2026

    @HMarzban
    CollaboratorAuthor

    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.

    Area Verdict Key number (local)
    HTTP cache (max-age=3600, ETag, 304) Keep 304 p50 0.595 ms
    In-process cache (8 MiB LRU, single-flight, 1 render slot) Keep 1,000 requests for one card: 1 render, 0 sheds
    Redis Keep it out of the image path Rate bucket +0.32 ms p50
    Queues Keep Card data extract p95 0.18 ms; summary 99–148 ms on a 3.7 MB pair
    Page head (card=1) Keep +0.04 to +0.68 ms p50 per kind

    Rejected, with evidence: immutable or a 1-year max-age (the route ignores v, 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

    • Chat read single-flight. Requests for the same chat link at the same time share one Supabase query. In a 1,000-read spike, sheds fell from 996 to 0. New outcome: joined.
    • Hovercard hairpin. A docs.plus pad link's page image is our own card.png. The image relay fetched it through Traefik, so every reader's hover shared one card bucket, and a 429 became a blank image cached for 10 minutes. The relay now reads the card in process. A 302 still goes over the network, to the static image.
    • HEAD on card.png now sends the real Content-Length.
    • outlineOf checks a heading's id before the text work. That work took 0.9 s on 20,000 headings with no id.
    • Alt text: no double period, and no cut in the middle of a word.

    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} and social_card_chat_read_seconds: render time and chat round trip.
    • A "Social preview card" row on the App Performance dashboard. It shows outcomes, cache hit %, render p50/p95 by kind, chat reads, worker writes and the summary queue depth.
    • Grafana rules (warning): social-card-errors, social-card-shed, worker-summary-queue-backlog.

    Before switch-on

    1. Commit and deploy. The migrate step applies both columns.
    2. Run scripts/backfill-card-data.ts once, in a one-off container.
    3. Set the demo pads to Read-only.
    4. Production checks: REST memory, amd64 render time, the Postgres and Supabase round trips, and crawler handling of 302, 429 and HEAD.
    5. Set SOCIAL_CARD_KINDS=document on both REST replicas and on the worker.
    6. Check live unfurls on Slack, X, Mastodon and Discord, and one live hovercard on a pad link.

    Only when the numbers ask for it: raise the render queue from 4 to 16 when slot-shed is above 0. Run the owner read in parallel when the REST to Supabase round trip is slow.

  5. HMarzban commented on Nov 2, 2026

    @HMarzban
    CollaboratorAuthor

    Status 2026-11-02: committed on main, not pushed

    Every slice is now committed. Nothing is deployed, and every card kind ships off.

    Commit What
    34116d296 REST card route, guards, both columns, slug read card, hovercard in-process read
    674dfc189 Worker card data, the version-change-summary queue, the backfill script
    e0acaba4c Web app page head: og:image names the card; the static Fallback card
    43eca20ea History share links carry ?version=
    7a40fb507 Grafana "Social preview card" row and three warnings
    e00fb150f Docs: server CLAUDE.md, API.md, ENV.md, self-hosting, CONTEXT.md, changelogs

    Each code commit typechecks on its own. 1,032 server tests pass.

    Reviews since the last update

    • Architecture review (32 agents): the module is deep, so no deepening is worth its cost now. All 11 candidates were refuted. Four small fixes were applied, among them a level guard on the stored outline and a skipped outcome for card data writes.
    • Code quality review (3 reviewers, 29 findings): 19 fixes applied.
      • A chat-read outage used to count as fallback, so no alert fired. It is now chat-unavailable, and social-card-errors counts it.
      • The filter card's version now hashes the depth it draws.
      • One Title rule, one id rule and the library node type replace copies and casts.
      • The Version check moved out of the store worker.
    • Two deslop passes: comments, casts and nesting only.

    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

    1. Push and deploy. The migrate step adds both columns.
    2. Run scripts/backfill-card-data.ts once: a dry run, then --apply, in a one-off container.
    3. Set the demo pads to Read-only.
    4. Production checks: REST memory, amd64 render time, the Postgres and Supabase round trips, and crawler handling of 302, 429 and HEAD. The new dashboard row shows most of these.
    5. Set SOCIAL_CARD_KINDS=document on both REST replicas and the worker.
    6. Check live unfurls on Slack, X, Mastodon and Discord, and one hovercard on a pad link.

    Also owed: the Twemoji CC-BY 4.0 attribution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions