diff --git a/architecture/brand-marks.md b/architecture/brand-marks.md index 482786d..f635d3b 100644 --- a/architecture/brand-marks.md +++ b/architecture/brand-marks.md @@ -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. diff --git a/brand/README.md b/brand/README.md index 2925f4e..a232961 100644 --- a/brand/README.md +++ b/brand/README.md @@ -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//` 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) diff --git a/brand/build/projects.py b/brand/build/projects.py index 4c3f9d8..2be4955 100644 --- a/brand/build/projects.py +++ b/brand/build/projects.py @@ -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//.""" + """Write mark.svg, lockup.svg (+ PNGs) for every repo under out_dir//. + + 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: @@ -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'' + f'' + ) + frame = g.project_frame(struct=t.CREAM, accent=t.GOLD_DARK) + inner = MANIFEST[repo]() + mark = f'{frame}{inner}' + + 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'' + f"{panels}{mark}{name_svg}{tag_svg}{url_svg}" + ) diff --git a/brand/build/symbols.py b/brand/build/symbols.py index 0262ab8..c5c2aaf 100644 --- a/brand/build/symbols.py +++ b/brand/build/symbols.py @@ -144,7 +144,11 @@ def terminal(cx: float, cy: float, r: float) -> str: (chx - reach, cy + hgt), (chx - reach + th * 1.7, cy), ] - chevron = '' + chevron = ( + '' + ) # bold T tx = cx + 0.42 * r half, hbar, stem, h = 0.32 * r, 0.16 * r, 0.16 * r, 0.62 * r diff --git a/brand/build/tokens.py b/brand/build/tokens.py index 829b767..c1ab49b 100644 --- a/brand/build/tokens.py +++ b/brand/build/tokens.py @@ -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 diff --git a/brand/projects/faststream-outbox/social-card.png b/brand/projects/faststream-outbox/social-card.png new file mode 100644 index 0000000..d20c472 Binary files /dev/null and b/brand/projects/faststream-outbox/social-card.png differ diff --git a/brand/projects/faststream-outbox/social-card.svg b/brand/projects/faststream-outbox/social-card.svg new file mode 100644 index 0000000..41d3a51 --- /dev/null +++ b/brand/projects/faststream-outbox/social-card.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/faststream-redis-timers/social-card.png b/brand/projects/faststream-redis-timers/social-card.png new file mode 100644 index 0000000..db0fd54 Binary files /dev/null and b/brand/projects/faststream-redis-timers/social-card.png differ diff --git a/brand/projects/faststream-redis-timers/social-card.svg b/brand/projects/faststream-redis-timers/social-card.svg new file mode 100644 index 0000000..07837f1 --- /dev/null +++ b/brand/projects/faststream-redis-timers/social-card.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/httpware/social-card.png b/brand/projects/httpware/social-card.png new file mode 100644 index 0000000..2982796 Binary files /dev/null and b/brand/projects/httpware/social-card.png differ diff --git a/brand/projects/httpware/social-card.svg b/brand/projects/httpware/social-card.svg new file mode 100644 index 0000000..d8732da --- /dev/null +++ b/brand/projects/httpware/social-card.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/lite-bootstrap/social-card.png b/brand/projects/lite-bootstrap/social-card.png new file mode 100644 index 0000000..59aa169 Binary files /dev/null and b/brand/projects/lite-bootstrap/social-card.png differ diff --git a/brand/projects/lite-bootstrap/social-card.svg b/brand/projects/lite-bootstrap/social-card.svg new file mode 100644 index 0000000..c8eec27 --- /dev/null +++ b/brand/projects/lite-bootstrap/social-card.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di/social-card.png b/brand/projects/modern-di/social-card.png new file mode 100644 index 0000000..6864e3f Binary files /dev/null and b/brand/projects/modern-di/social-card.png differ diff --git a/brand/projects/modern-di/social-card.svg b/brand/projects/modern-di/social-card.svg new file mode 100644 index 0000000..e3b70dd --- /dev/null +++ b/brand/projects/modern-di/social-card.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/semvertag/social-card.png b/brand/projects/semvertag/social-card.png new file mode 100644 index 0000000..9353581 Binary files /dev/null and b/brand/projects/semvertag/social-card.png differ diff --git a/brand/projects/semvertag/social-card.svg b/brand/projects/semvertag/social-card.svg new file mode 100644 index 0000000..df9d756 --- /dev/null +++ b/brand/projects/semvertag/social-card.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/that-depends/social-card.png b/brand/projects/that-depends/social-card.png new file mode 100644 index 0000000..f5a2ec1 Binary files /dev/null and b/brand/projects/that-depends/social-card.png differ diff --git a/brand/projects/that-depends/social-card.svg b/brand/projects/that-depends/social-card.svg new file mode 100644 index 0000000..524160b --- /dev/null +++ b/brand/projects/that-depends/social-card.svg @@ -0,0 +1 @@ + diff --git a/planning/changes/2026-06-30.01-per-repo-social-cards/design.md b/planning/changes/2026-06-30.01-per-repo-social-cards/design.md new file mode 100644 index 0000000..7d71f04 --- /dev/null +++ b/planning/changes/2026-06-30.01-per-repo-social-cards/design.md @@ -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//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//social-card.svg` + +`social-card.png`, for the seven docs-site repos only. + +## Motivation + +The per-project marks shipped (`brand/projects//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://.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** — `.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 ``. +- `render_projects` gains a pass: for each `repo, tagline` in `DOCS_REPOS`, write + `brand/projects//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//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. diff --git a/planning/changes/2026-06-30.01-per-repo-social-cards/plan.md b/planning/changes/2026-06-30.01-per-repo-social-cards/plan.md new file mode 100644 index 0000000..0ffca98 --- /dev/null +++ b/planning/changes/2026-06-30.01-per-repo-social-cards/plan.md @@ -0,0 +1,428 @@ +# per-repo-social-cards — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Generate a 1280×640 social card (`og:image`) for each of the 7 +docs-site repos — green mark panel + cream text panel — from `brand/build/` +into `brand/projects//social-card.svg|png`. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `brand-social-cards` (already created) + +**Commit strategy:** Per-task commits. + +## Global constraints + +- **Card is 1280×640**, `viewBox="0 0 1280 640"`. Green panel `x ∈ [0,460)` + (`GREEN_SURFACE`), cream panel `x ∈ [460,1280]` (`CREAM`). +- **Colours only from `brand/build/tokens.py`** — `GREEN_INK #356852`, + `GREEN_SURFACE #2f5e4a`, `GOLD_LIGHT #c98a00`, `GOLD_DARK #f0b528`, + `CREAM #f4f1e8`, plus a new `GREEN_MUTED` (tagline). No stray hex. +- Mark on the green panel uses the on-green colourway: `struct=CREAM`, + `accent=GOLD_DARK`. +- **Taglines are the canonical `profile/README.md` one-liners**, verbatim, held + in `projects.py::DOCS_REPOS`. `DOCS_REPOS` keys ⊆ `MANIFEST` keys. +- The 7 docs repos: `modern-di`, `that-depends`, `lite-bootstrap`, `httpware`, + `faststream-redis-timers`, `faststream-outbox`, `semvertag`. No card for the + other 10. +- All imports at module level; annotate function args; `# ty: ignore` not + `# type: ignore`. CI gate is `just` (= `check-planning` + `pytest`); ruff is + not a CI gate. +- Regenerate everything with `uv run python -m brand.build.render`. + +--- + +### Task 1: GREEN_MUTED token + +**Files:** +- Modify: `brand/build/tokens.py` +- Test: `tests/test_text.py` (or wherever token presence is asserted — if no such + test exists, create `tests/test_tokens.py`) + +**Interfaces:** +- Produces: `tokens.GREEN_MUTED = "#5b6f63"` (a desaturated green for tagline text + on cream). + +- [ ] **Step 1: Write the failing test** + + ```python + # tests/test_tokens.py + from brand.build import tokens as t + + def test_green_muted_present() -> None: + assert t.GREEN_MUTED == "#5b6f63" + ``` + +- [ ] **Step 2: Run test to verify it fails** + + Run: `uv run pytest tests/test_tokens.py -q` + Expected: FAIL — `AttributeError: module 'brand.build.tokens' has no attribute 'GREEN_MUTED'`. + +- [ ] **Step 3: Add the token** + + Append to `brand/build/tokens.py`: + + ```python + GREEN_MUTED = "#5b6f63" # desaturated green for tagline text on cream + ``` + +- [ ] **Step 4: Run test to verify it passes** + + Run: `uv run pytest tests/test_tokens.py -q` + Expected: PASS. + +- [ ] **Step 5: Commit** + + ```bash + git add brand/build/tokens.py tests/test_tokens.py + git commit -m "feat(brand): add GREEN_MUTED token for card taglines" + ``` + +--- + +### Task 2: text fit + wrap helpers + +**Files:** +- Modify: `brand/build/projects.py` +- Test: `tests/test_projects.py` + +**Interfaces:** +- Consumes: `text.outline_text(text, size, *, x, baseline_y, anchor, color, letter_spacing) -> tuple[str, float]` + (already exists; the float is the rendered width). +- Produces: + - `_measure(text: str, size: float) -> float` + - `fit_text(text: str, base_size: float, max_w: float, *, color: str, x: float, baseline_y: float) -> tuple[str, float]` + — returns `(svg, used_size)`; `used_size < base_size` only when the text is + wider than `max_w` at base. + - `wrap_text(text: str, size: float, max_w: float) -> list[str]` — greedy + word-wrap; a string that fits returns exactly one line. + +- [ ] **Step 1: Write the failing test** + + ```python + # append to tests/test_projects.py + def test_fit_text_shrinks_only_when_needed() -> None: + short_svg, short_size = p.fit_text("hi", 74, 700, color="#356852", x=0, baseline_y=0) + assert short_size == 74 # fits, unchanged + long_svg, long_size = p.fit_text("x" * 80, 74, 700, color="#356852", x=0, baseline_y=0) + assert long_size < 74 # too wide -> shrunk + assert " None: + assert len(p.wrap_text("short tagline", 30, 700)) == 1 + assert len(p.wrap_text("word " * 60, 30, 700)) > 1 + ``` + +- [ ] **Step 2: Run test to verify it fails** + + Run: `uv run pytest tests/test_projects.py -q -k "fit_text or wrap_text"` + Expected: FAIL — `AttributeError` on `p.fit_text` / `p.wrap_text`. + +- [ ] **Step 3: Implement the helpers** + + Add `from brand.build.text import outline_text` to the module-level imports of + `brand/build/projects.py` if not already present (Task 8 of the marks change + added it for lockups — confirm it's there; if so, do not duplicate). Then add: + + ```python + 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 + ``` + +- [ ] **Step 4: Run tests to verify they pass** + + Run: `uv run pytest tests/test_projects.py -q -k "fit_text or wrap_text"` + Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + + ```bash + git add brand/build/projects.py tests/test_projects.py + git commit -m "feat(brand): text fit + wrap helpers for cards" + ``` + +--- + +### Task 3: DOCS_REPOS table + project_social_card + +**Files:** +- Modify: `brand/build/projects.py` +- Test: `tests/test_projects.py` + +**Interfaces:** +- Consumes: `geometry.project_frame`, `symbols.*` via `MANIFEST`, `tokens.*`, + `fit_text`, `wrap_text` (Task 2), `GREEN_MUTED` (Task 1). +- Produces: + - `DOCS_REPOS: dict[str, str]` — the 7 docs repos → canonical taglines. + - `project_social_card(repo: str, *, tagline: str) -> str` — full 1280×640 ``. + +- [ ] **Step 1: Write the failing test** + + ```python + # append to tests/test_projects.py + # (this file already imports: `re`, `pytest`, `minidom`, `Path`, + # `projects as p`, `tokens as t` — reuse them, do not re-import) + + DOCS_EXPECTED = { + "modern-di", "that-depends", "lite-bootstrap", "httpware", + "faststream-redis-timers", "faststream-outbox", "semvertag", + } + + CARD_ALLOWED = { + c.lower() for c in ( + t.GREEN_INK, t.GREEN_SURFACE, t.GOLD_LIGHT, t.GOLD_DARK, t.CREAM, t.GREEN_MUTED, + ) + } + + def test_docs_repos_is_subset_of_manifest_and_exact() -> None: + assert set(p.DOCS_REPOS) == DOCS_EXPECTED + assert set(p.DOCS_REPOS) <= set(p.MANIFEST) + + @pytest.mark.parametrize("repo", sorted(DOCS_EXPECTED)) + def test_social_card_valid_and_palette(repo: str) -> None: + svg = p.project_social_card(repo, tagline=p.DOCS_REPOS[repo]) + minidom.parseString(svg) + assert 'viewBox="0 0 1280 640"' in svg + hexes = {h.lower() for h in re.findall(r"#[0-9a-fA-F]{6}", svg)} + assert hexes <= CARD_ALLOWED, f"{repo} stray colours: {hexes - CARD_ALLOWED}" + + def test_social_card_includes_url_and_name(repo: str = "modern-di") -> None: + svg = p.project_social_card(repo, tagline=p.DOCS_REPOS[repo]) + assert f'aria-label="{repo}' in svg # accessible label carries the repo + ``` + +- [ ] **Step 2: Run test to verify it fails** + + Run: `uv run pytest tests/test_projects.py -q -k "docs_repos or social_card"` + Expected: FAIL — `AttributeError` on `p.DOCS_REPOS` / `p.project_social_card`. + +- [ ] **Step 3: Implement DOCS_REPOS + project_social_card** + + Add to `brand/build/projects.py` (taglines are verbatim from + `profile/README.md`): + + ```python + 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'' + f'' + ) + frame = g.project_frame(struct=t.CREAM, accent=t.GOLD_DARK) + inner = MANIFEST[repo]() + mark = f'{frame}{inner}' + + 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'' + f"{panels}{mark}{name_svg}{tag_svg}{url_svg}" + ) + ``` + +- [ ] **Step 4: Run tests to verify they pass** + + Run: `uv run pytest tests/test_projects.py -q -k "docs_repos or social_card"` + Expected: PASS (subset check + 7 parametrized valid/palette + url/name). + +- [ ] **Step 5: Commit** + + ```bash + git add brand/build/projects.py tests/test_projects.py + git commit -m "feat(brand): DOCS_REPOS + project_social_card composition" + ``` + +--- + +### Task 4: render cards + wire into render_projects + +**Files:** +- Modify: `brand/build/projects.py` +- Test: `tests/test_projects.py` + +**Interfaces:** +- Consumes: `project_social_card`, `DOCS_REPOS`, `raster.export_png`, + `render_projects` (existing). +- Produces: `render_projects` additionally writes + `brand/projects//social-card.svg` + `social-card.png` (1280×640) for the + 7 `DOCS_REPOS`, and for no other repo. + +- [ ] **Step 1: Write the failing test** + + ```python + # append to tests/test_projects.py + def test_render_projects_writes_cards_for_docs_repos_only(tmp_path: Path) -> None: + p.render_projects(out_dir=tmp_path) + for repo in DOCS_EXPECTED: + card = tmp_path / repo / "social-card.svg" + assert card.is_file() and card.read_text(encoding="utf-8").startswith("/social-card.*` + +- [ ] **Step 4: Normalize formatting + verify** + + Run: `uvx ruff format brand/build/ tests/` then `uvx ruff check brand/build/ tests/` (clean). + Run: `uv run pytest -q` → all green. + Run: `just check-planning` → `planning: OK`. + Run: `uv run python -m brand.build.render` → no error. + +- [ ] **Step 5: Commit** + + ```bash + git add brand/README.md architecture/brand-marks.md planning/changes/2026-06-30.01-per-repo-social-cards/design.md brand/build tests + git commit -m "docs(brand): document per-repo social cards" + ``` + +--- + +## Notes for the executor + +- After all tasks: push the branch and open a PR (do not local-merge); watch CI. +- This change only adds a card pass; do not alter the org marks, the existing + per-project marks, or the lockups. +- Validate visually if unsure: `rsvg-convert brand/projects//social-card.svg` + to a PNG and eyeball — the geometry here is the version that passed visual + review (short `modern-di`, medium `faststream-outbox`, long `httpware`). diff --git a/tests/test_projects.py b/tests/test_projects.py index 2ecfc75..10f8972 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -79,3 +79,72 @@ def test_lockup_is_valid_and_names_repo(repo: str) -> None: def test_render_projects_writes_lockup(tmp_path: Path) -> None: p.render_projects(out_dir=tmp_path) assert (tmp_path / "modern-di" / "lockup.svg").is_file() + + +def test_fit_text_shrinks_only_when_needed() -> None: + short_svg, short_size = p.fit_text( + "hi", 74, 700, color="#356852", x=0, baseline_y=0 + ) + assert short_size == 74 # fits, unchanged + long_svg, long_size = p.fit_text( + "x" * 80, 74, 700, color="#356852", x=0, baseline_y=0 + ) + assert long_size < 74 # too wide -> shrunk + assert " None: + assert len(p.wrap_text("short tagline", 30, 700)) == 1 + assert len(p.wrap_text("word " * 60, 30, 700)) > 1 + + +DOCS_EXPECTED = { + "modern-di", + "that-depends", + "lite-bootstrap", + "httpware", + "faststream-redis-timers", + "faststream-outbox", + "semvertag", +} + +CARD_ALLOWED = { + c.lower() + for c in ( + t.GREEN_INK, + t.GREEN_SURFACE, + t.GOLD_LIGHT, + t.GOLD_DARK, + t.CREAM, + t.GREEN_MUTED, + ) +} + + +def test_docs_repos_is_subset_of_manifest_and_exact() -> None: + assert set(p.DOCS_REPOS) == DOCS_EXPECTED + assert set(p.DOCS_REPOS) <= set(p.MANIFEST) + + +@pytest.mark.parametrize("repo", sorted(DOCS_EXPECTED)) +def test_social_card_valid_and_palette(repo: str) -> None: + svg = p.project_social_card(repo, tagline=p.DOCS_REPOS[repo]) + minidom.parseString(svg) + assert 'viewBox="0 0 1280 640"' in svg + hexes = {h.lower() for h in re.findall(r"#[0-9a-fA-F]{6}", svg)} + assert hexes <= CARD_ALLOWED, f"{repo} stray colours: {hexes - CARD_ALLOWED}" + + +def test_social_card_includes_url_and_name(repo: str = "modern-di") -> None: + svg = p.project_social_card(repo, tagline=p.DOCS_REPOS[repo]) + assert f'aria-label="{repo}' in svg # accessible label carries the repo + + +def test_render_projects_writes_cards_for_docs_repos_only(tmp_path: Path) -> None: + p.render_projects(out_dir=tmp_path) + for repo in DOCS_EXPECTED: + card = tmp_path / repo / "social-card.svg" + assert card.is_file() and card.read_text(encoding="utf-8").startswith(" None: + assert tokens.GREEN_MUTED == "#5b6f63"