Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions architecture/brand-marks.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ project templates reuse the org chevron. `modern-di-faststream` is the only
mark using a partner's literal logo path (FastStream's, recoloured); other
integration cues are redrawn evocations. Outputs: `mark.svg`, `lockup.svg`
(+ `mark-512/1024.png`). Regenerate via `uv run python -m brand.build.render`.
Repos with a live docs site (`projects.py::DOCS_REPOS`, a subset of `MANIFEST`)
additionally get a 1280×640 `social-card.svg|png` — a two-panel og:image
(green mark panel + cream name/tagline/url), built with the same frame +
symbols and the `fit_text`/`wrap_text` helpers. Taglines are the canonical
`profile/README.md` one-liners.
4 changes: 4 additions & 0 deletions brand/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ one gold inner symbol (see `brand/build/projects.py::MANIFEST`). Regenerate
with `uv run python -m brand.build.render`; outputs land in
`brand/projects/<repo>/` as `mark.svg`, `lockup.svg` (+ PNGs). These are
large-format only — every repo's favicon/avatar stays the org mark.
Repos with a docs site also get a `social-card.svg` + `social-card.png`
(1280×640 og:image): the repo mark on a green panel beside its name, tagline,
and docs URL on cream. The docs-site repos are listed in
`brand/build/projects.py::DOCS_REPOS`.

## Deferred (not in this kit)

Expand Down
119 changes: 118 additions & 1 deletion brand/build/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ def project_lockup(repo: str) -> str:


def render_projects(out_dir: Path | None = None) -> list[Path]:
"""Write mark.svg (+ PNGs) for every repo under out_dir/<repo>/."""
"""Write mark.svg, lockup.svg (+ PNGs) for every repo under out_dir/<repo>/.

Docs-site repos (DOCS_REPOS) also get social-card.svg/png (1280×640)."""
base = out_dir if out_dir is not None else PROJECTS
written: list[Path] = []
for repo in MANIFEST:
Expand All @@ -92,5 +94,120 @@ def render_projects(out_dir: Path | None = None) -> list[Path]:
for sz in _PNG_SIZES:
export_png(svg, d / f"mark-{sz}.png", width=sz, height=sz)
(d / "lockup.svg").write_text(project_lockup(repo) + "\n", encoding="utf-8")
if repo in DOCS_REPOS:
card = d / "social-card.svg"
card.write_text(
project_social_card(repo, tagline=DOCS_REPOS[repo]) + "\n",
encoding="utf-8",
)
export_png(card, d / "social-card.png", width=_CARD_W, height=_CARD_H)
written.append(svg)
return written


def _measure(text: str, size: float) -> float:
_, w = outline_text(text, size, x=0, baseline_y=0, anchor="start", color="#000000")
return w


def fit_text(
text: str,
base_size: float,
max_w: float,
*,
color: str,
x: float,
baseline_y: float,
) -> tuple[str, float]:
"""Render `text` left-anchored; shrink the font so its width fits max_w."""
natural = _measure(text, base_size)
size = base_size if natural <= max_w else base_size * max_w / natural
svg, _ = outline_text(
text, size, x=x, baseline_y=baseline_y, anchor="start", color=color
)
return svg, size


def wrap_text(text: str, size: float, max_w: float) -> list[str]:
"""Greedy word-wrap to lines no wider than max_w."""
lines: list[str] = []
cur = ""
for word in text.split():
trial = (cur + " " + word).strip()
if cur and _measure(trial, size) > max_w:
lines.append(cur)
cur = word
else:
cur = trial
if cur:
lines.append(cur)
return lines


DOCS_REPOS: dict[str, str] = {
"modern-di": "powerful DI framework with scopes",
"that-depends": "predecessor DI framework, still actively maintained",
"lite-bootstrap": "lightweight package for bootstrapping new microservices",
"httpware": "HTTP client framework with sync/async clients, middleware chain, and built-in resilience (retry, bulkhead)",
"faststream-redis-timers": "FastStream broker integration for Redis-backed distributed timer scheduling",
"faststream-outbox": "FastStream broker integration for the transactional outbox pattern with Postgres",
"semvertag": "auto-tag your GitHub/GitLab repo with semantic version tags from CI",
}

_CARD_W = 1280
_CARD_H = 640
_PANEL = 460 # green panel width
_TEXT_X = 520 # text column left edge
_TEXT_W = 700 # text column width
_NAME_BASE = 74
_TAG_SIZE = 30
_URL_SIZE = 26


def project_social_card(repo: str, *, tagline: str) -> str:
"""1280x640 og:image: green mark panel + cream name/tagline/url panel."""
panels = (
f'<rect width="{_PANEL}" height="{_CARD_H}" fill="{t.GREEN_SURFACE}"/>'
f'<rect x="{_PANEL}" width="{_CARD_W - _PANEL}" height="{_CARD_H}" fill="{t.CREAM}"/>'
)
frame = g.project_frame(struct=t.CREAM, accent=t.GOLD_DARK)
inner = MANIFEST[repo]()
mark = f'<g transform="translate(80,170) scale(3.0)">{frame}{inner}</g>'

tag_lines = wrap_text(tagline, _TAG_SIZE, _TEXT_W)
n = len(tag_lines)
# block = name + 26 gap + n*38 tagline lines + 44 gap + url(30); centre vertically
block_h = _NAME_BASE + 26 + n * 38 + 44 + 30
top = (_CARD_H - block_h) / 2
name_base = top + _NAME_BASE
name_svg, _ = fit_text(
repo, _NAME_BASE, _TEXT_W, color=t.GREEN_INK, x=_TEXT_X, baseline_y=name_base
)
y = name_base + 26
tag_svg = ""
for line in tag_lines:
y += 38
seg, _ = outline_text(
line,
_TAG_SIZE,
x=_TEXT_X,
baseline_y=y,
anchor="start",
color=t.GREEN_MUTED,
)
tag_svg += seg
y += 44
url_svg, _ = outline_text(
f"{repo}.modern-python.org",
_URL_SIZE,
x=_TEXT_X,
baseline_y=y,
anchor="start",
color=t.GOLD_LIGHT,
letter_spacing=2,
)
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_CARD_W} {_CARD_H}" '
f'role="img" aria-label="{repo} — {tagline}">'
f"{panels}{mark}{name_svg}{tag_svg}{url_svg}</svg>"
)
6 changes: 5 additions & 1 deletion brand/build/symbols.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,11 @@ def terminal(cx: float, cy: float, r: float) -> str:
(chx - reach, cy + hgt),
(chx - reach + th * 1.7, cy),
]
chevron = '<polygon points="' + " ".join(f"{px:.1f},{py:.1f}" for px, py in pts) + f'" fill="{CREAM}"/>'
chevron = (
'<polygon points="'
+ " ".join(f"{px:.1f},{py:.1f}" for px, py in pts)
+ f'" fill="{CREAM}"/>'
)
# bold T
tx = cx + 0.42 * r
half, hbar, stem, h = 0.32 * r, 0.16 * r, 0.16 * r, 0.62 * r
Expand Down
1 change: 1 addition & 0 deletions brand/build/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
GOLD_LIGHT = "#c98a00" # gold accent on cream
GOLD_DARK = "#f0b528" # gold accent on green/dark
CREAM = "#f4f1e8" # light surface; also the light "ink" on green (not pure white)
GREEN_MUTED = "#5b6f63" # desaturated green for tagline text on cream
Binary file added brand/projects/faststream-outbox/social-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions brand/projects/faststream-outbox/social-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions brand/projects/faststream-redis-timers/social-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added brand/projects/httpware/social-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions brand/projects/httpware/social-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added brand/projects/lite-bootstrap/social-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions brand/projects/lite-bootstrap/social-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added brand/projects/modern-di/social-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions brand/projects/modern-di/social-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added brand/projects/semvertag/social-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions brand/projects/semvertag/social-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added brand/projects/that-depends/social-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions brand/projects/that-depends/social-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
131 changes: 131 additions & 0 deletions planning/changes/2026-06-30.01-per-repo-social-cards/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
---
summary: Per-repo social cards shipped — 1280×640 two-panel og:image for the 7 docs-site repos, generated into brand/projects/<repo>/social-card.*
---

# Design: Per-repo social cards

## Summary

Generate a **social card** (1280×640 `og:image`) for each org repo that has a
live docs site. The card is a two-panel composition: a green panel holding the
repo's project **mark** (the snake-frame + its gold inner symbol, shipped in the
previous change) and a cream panel with the repo **name**, its one-line
**tagline**, and its **docs URL**. Cards are produced by the existing
`brand/build/` pipeline into `brand/projects/<repo>/social-card.svg` +
`social-card.png`, for the seven docs-site repos only.

## Motivation

The per-project marks shipped (`brand/projects/<repo>/mark.svg|lockup.svg`), but
a repo's docs site still has no branded `og:image` — link unfurls on
GitHub/Slack/X fall back to a generic screenshot or nothing. The org site already
ships a social card (`brand/build/geometry.py::social_card`); this extends that
to each docs-site repo, reusing the marks we just built so the family reads
consistently when shared.

The seven repos with live docs sites were determined by probing
`https://<repo>.modern-python.org/` (HTTP 200): `modern-di`, `that-depends`,
`lite-bootstrap`, `httpware`, `faststream-redis-timers`, `faststream-outbox`,
`semvertag`. The other ten repos do not resolve and get no card.

## Non-goals

- Cards for repos without a docs site (the other ten) — `og:image` is only
valuable where a docs site exists.
- Wiring each card into its repo's docs `og:image` / `twitter:image` meta — that
edits each downstream repo and is per-repo follow-up, tracked separately.
- A square / Telegram variant per repo — only the 1280×640 card for now (YAGNI).
- Changing the org card or the project marks — unchanged.

## Design

### 1. Layout (two-panel)

1280×640, two panels:

- **Green panel** — `x ∈ [0, 460)`, fill `GREEN_SURFACE #2f5e4a`. The repo's
mark (its `project_frame` + inner symbol) drawn in **`CREAM`** struct +
**`GOLD_DARK`** accent (the on-green colourway), ~300px, vertically centred
(`translate(80,170) scale(3.0)`).
- **Cream panel** — `x ∈ [460, 1280]`, fill `CREAM`. A vertically-centred text
block at left edge `x = 520`, column width `≈ 700px`:
- **name** — Jost, `GREEN_INK`, base 74px, **auto-shrunk** to the column width
if its natural width exceeds it (size scaled, aspect preserved).
- **tagline** — Jost, `GREEN_MUTED` (new token), 30px, **word-wrapped** to the
column width (1–3 lines in practice).
- **url** — `<repo>.modern-python.org`, Jost, `GOLD_LIGHT`, 26px,
`letter_spacing=2`.

The block (name + N tagline lines + url) is centred vertically by computing its
height from the line count and offsetting from the card centre, so 1-line and
3-line taglines both sit balanced. Validated visually for a short
(`modern-di`), medium (`faststream-outbox`, 2 lines) and long (`httpware`,
3 lines) tagline.

### 2. Data

- **Mark + inner symbol:** reuse `projects.py::MANIFEST[repo]`.
- **Tagline:** the **canonical one-liner** from `profile/README.md` — the same
text kept in sync with each repo's GitHub description and pyproject
`description` (per CLAUDE.md's "three surfaces" rule). Captured as a
`DOCS_REPOS: dict[str, str]` (repo → tagline) in `projects.py`. Verbatim, so
there is no new copy to maintain — if the canonical blurb changes, update this
one table.
- **URL:** derived as `f"{repo}.modern-python.org"`.

`DOCS_REPOS` keys are a strict subset of `MANIFEST`; a test asserts that.

### 3. Build pipeline

- Two text helpers (in `projects.py`, used only by the card):
- `fit_text(text, base_size, max_w, *, color, x, baseline_y) -> str` — renders
via `text.outline_text`; if the natural width exceeds `max_w`, re-renders at
`base_size * max_w / natural` so it fits without horizontal squishing.
- `wrap_text(text, size, max_w) -> list[str]` — greedy word-wrap using
`outline_text`'s measured width per trial line.
- `project_social_card(repo: str, *, tagline: str) -> str` — composes the two
panels + mark + name/tagline/url into a 1280×640 `<svg>`.
- `render_projects` gains a pass: for each `repo, tagline` in `DOCS_REPOS`, write
`brand/projects/<repo>/social-card.svg` and rasterise `social-card.png`
(1280×640) via the existing `raster.export_png`.
- Add `GREEN_MUTED` to `brand/build/tokens.py` (the tagline colour) so the card
uses only named tokens.

### 4. Outputs

`brand/projects/<repo>/social-card.svg` + `social-card.png` for the seven docs
repos. `brand/README.md` and `architecture/brand-marks.md` note the new output.

## Operations

None in-repo. Pointing each docs site's `og:image` at its card (copying the PNG
into the repo and setting the meta) is per-repo follow-up.

## Out of scope

- Downstream `og:image` wiring (per repo).
- Square/alternate sizes; light/dark variants of the card.

## Testing

- Each of the seven cards parses as XML, is `viewBox="0 0 1280 640"`, and uses
only palette colours (`tokens` + `GREEN_MUTED`).
- `fit_text`: a string wider than `max_w` yields a smaller font size than base; a
string that fits stays at base. `wrap_text`: a long tagline returns >1 line; a
short one returns exactly 1.
- `render_projects(out_dir=tmp)` writes `social-card.svg` for exactly the seven
`DOCS_REPOS` and for none of the other ten repos.
- `DOCS_REPOS` ⊆ `MANIFEST` (no orphan/typo repo key).

## Risk

- **Tagline length / overflow.** The longest canonical blurb (`httpware`, ~105
chars) wraps to three lines; anything longer could crowd the card. Likelihood
low (blurbs are capped ~120 chars by convention), impact low (it stays
centred). *Mitigation:* `wrap_text` handles arbitrary length; if a future blurb
is too long for the card, shorten that one `DOCS_REPOS` value.
- **Docs-site set drift.** A repo could gain/lose a docs site later. *Mitigation:*
`DOCS_REPOS` is one explicit table; re-probe and edit it. Cheap.
- **Tagline duplication vs. profile/README.** The blurb now lives in two files.
*Mitigation:* both are governed by the same CLAUDE.md "three surfaces" rule;
the test set is small and the canonical text rarely changes.
Loading