A Dawnscan project link is the thing founders and readers actually post. This document is the contract for what Dawnscan puts behind that link: the social image, the composed post, and the rules both obey.
It is the sharing counterpart to Card V7 — same facts, same wording, different surface.
1. The routes
| Route | What it renders | Used by |
|---|---|---|
GET /og/project/<slug> |
that project's card, 1200×630 PNG | og:image / twitter:image on /project/<slug> |
GET /og/default |
the site card, 1200×630 PNG | the root layout, so /, /explore, /ships, /pulse, the narratives and everything else inherit it |
Both are explicit route handlers (apps/web/src/app/og/**/route.tsx) rather
than Next's opengraph-image file convention. The convention produces a
build-hashed path nobody can type; an address a founder can paste into a card
validator, a test can request and an operator can curl is worth more than the
few lines the convention saves. The two file-convention images that existed
before (app/opengraph-image.tsx and
app/(site)/project/[slug]/opengraph-image.tsx) were removed with this change:
file-based metadata overrides the metadata object, so leaving them in place
would have silently won over openGraph.images.
2. What the project image shows
Exactly the browse card's facts, in the browse card's wording:
| Fact | Source | When absent |
|---|---|---|
| Logo | projects.logo_url, fetched and resized to PNG (lib/og-logo.ts) |
the monogram from ProjectLogo, same initials, same slug-seeded colour |
| Name | projects.name |
never absent |
| Ticker | tokens.symbol of the primary token, $AOS |
omitted — a tokenless project shows no ticker line |
| Activity status | projects.activity_status, with ActivityChip's label and lucide icon |
never absent; research_level = INDEXED renders "Activity not researched yet", the same wording the project page uses |
| Last ship | projects.last_meaningful_ship_at, Last shipped 2d ago |
omitted |
| Market cap | the profile's stored reading (market_snapshots, seven-day window, source precedence) with its provider — $24K market cap · via DEX Screener |
omitted — never $0, never a figure from anywhere else |
| Launch origin | launchedViaFrom — via Pons |
omitted, including when it resolves to Unknown or Independent: those are placeholders for the absence of a launch record, not origins |
Dawnscan mark and dawnscan.fun |
constant | never absent |
Nothing else is on the image: no score, no ship count, no description, no chart, no holders, no price. An absent fact is absent — the image never fills a gap with a zero or a guess.
Design: Midnight ground (#0A111D), the dark theme's ink and status colours
from globals.css, one status accent, no gradient, no glow. Names step down
through four sizes by length and are clamped at 44 characters; tickers clamp at
16. og-card.test.ts covers the fitting, because satori has no ellipsis and an
unclamped name draws straight over the status row.
An unknown slug returns the site card at 200, not a 404: a crawler following a stale link should still render something Dawnscan-shaped.
3. The logo
lib/og-logo.ts. satori decodes the bytes itself and does not read WebP, so
the image cannot point at /api/logo; the same pipeline runs inline instead —
the guarded fetcher from @hey/domain (SSRF check per redirect hop, size cap),
one sharp resize to a 264px PNG, a bounded in-process cache (200 entries,
16MB, failures remembered for ten minutes).
Bounds that matter, because a crawler asks in bursts:
- only hosts in
LOGO_HOSTSare fetched at all; - the fetch times out at 2.5s, tighter than the proxy's 5s — a slow logo must not hold a crawler's connection;
- a failure is never an error: the card draws the monogram, exactly as the
browse card does when an
<img>fails.
4. The copy rules
lib/share.ts composes every share sentence, and share.test.ts enforces the
rules. One composer, so the image, the page metadata and the Share button
cannot drift apart.
AgentOS ($AOS) — Shipping · last shipped 2d ago · via Pons
https://dawnscan.fun/project/agentos
- identity, then what Dawnscan can say about the building, then where the token launched; every clause is dropped rather than filled when Dawnscan lacks it;
- no market figure at all in the text — no market cap, no price, no percentage. A sentence about building must not read as a sentence about price (CLAUDE.md product rules 3, 5, 7);
- no score, no ranking, no percentile;
- banned outright, asserted by test:
100x,alpha,ape,bullish,buy,degen,gem,moon,pump,undervalued; - a project Dawnscan has only indexed says "Activity not researched yet" and makes no activity claim.
5. The share control
components/share-project.tsx, on the project page in the row with Website,
GitHub, X and Sources — a secondary control, because the page keeps one
primary CTA (UI/UX non-negotiable 10).
- Share copies the headline plus the project URL to the clipboard and says
"Copied" for 1.6s, with an
aria-liveannouncement. Falls back to a hidden textarea andexecCommandwhere the clipboard API is refused. - Open on X opens
https://twitter.com/intent/tweet?text=…&url=…in a new tab. The headline and the link are separate parameters, so X does not print the link twice. - Both stop the click from propagating, so dropping the control inside a card body later cannot turn "Share" into "navigate". Both are their own tab stop.
6. Cache policy
lib/og-headers.ts:
cache-control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400
x-content-type-options: nosniff
content-security-policy: default-src 'none'; sandbox
Five minutes in a browser, an hour in a shared cache, a day of serving the
stale copy while the new one renders. A project whose status changed shows its
new image within the hour, and no crawler ever waits on satori twice. The route
is also rate-limited (api.og, 300/minute): a satori render plus a logo fetch
is the most expensive public GET in the app.
7. Regenerating and checking
The image is generated per request; there is nothing to rebuild. To look at one:
# against a local production build
curl -o /tmp/agentos.png http://127.0.0.1:3000/og/project/agentos
curl -o /tmp/default.png http://127.0.0.1:3000/og/default
The one build artefact is the inlined mark, lib/og-mark.ts. It is
apps/web/public/dawn-mark-dark.png at its native 136×128, base64'd, because
the renderer has no access to public/ and a fetch would put a network call in
the render path. Regenerate it when the artwork changes:
node -e "const s=require('sharp');s('apps/web/public/dawn-mark-dark.png').png().toBuffer().then(b=>console.log(b.toString('base64')))"
Coverage: e2e/share.spec.ts (PNG signature and 1200×630 dimensions, the
metadata tags, the clipboard round trip, 375px) and the two unit suites
(share.test.ts, og-card.test.ts).
8. Not done
- No copy-for-X block on
/ships. The ship feed and its card belong to the "Shipped this week" work in flight at the same time; adding a control there would have meant editing the same files. It is a small addition once that lands: the composer already takes aShareFacts, and a ship's card has the facts it needs. - No per-ship image.
/og/project/<slug>states the project's latest position, not one ship event.