diff --git a/architecture/brand-marks.md b/architecture/brand-marks.md new file mode 100644 index 0000000..482786d --- /dev/null +++ b/architecture/brand-marks.md @@ -0,0 +1,17 @@ +# Brand marks + +The org's logo assets, generated by `brand/build/` (no frontmatter; living prose). + +## Org marks (`brand/org/`) +Favicon, avatar, social cards — the interlocked-snakes pinwheel with a chevron. +Used everywhere small (favicons, avatars). See `site-branding.md` for site wiring. + +## Per-project marks (`brand/projects//`) +One large-format logo per repo: the constant green+gold snake-frame +(`geometry.py::project_frame`, margin 9 / arm 53 / stroke 11) with a single +gold inner symbol (`symbols.py`) chosen per repo in `projects.py::MANIFEST`. +Two-colour (green + gold); repos differ by symbol shape, not colour. The two +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`. diff --git a/brand/README.md b/brand/README.md index f383b97..2925f4e 100644 --- a/brand/README.md +++ b/brand/README.md @@ -38,7 +38,14 @@ lockup** pulls them into crop marks framing `MODERN` / `PYTHON` set in **Jost** | `social-card-green.svg` / `.png` | 1280×640, green alternate | | `social-square.svg` / `.png`, `social-square-green.*` | 640×640 (Telegram) | +## Per-project marks (`brand/projects/`) + +Each repo gets a large-format mark: the constant green+gold snake-frame with +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. + ## Deferred (not in this kit) -Per-project / per-repo marks, the subfamily system, and any inner glyphs are a -later task. The header nav logo redesign is also a follow-up. +The header nav logo redesign is a follow-up. diff --git a/brand/build/geometry.py b/brand/build/geometry.py index 0801926..96fe650 100644 --- a/brand/build/geometry.py +++ b/brand/build/geometry.py @@ -19,27 +19,40 @@ def _icon_mark(struct: str, gold: str) -> str: def icon(*, bg: str, struct: str, gold: str) -> str: """Full-bleed square icon — favicon, apple-touch, GitHub avatar.""" - return (_SVG_OPEN.format(w=100, h=100) - + f'' - + _icon_mark(struct, gold) + "") + return ( + _SVG_OPEN.format(w=100, h=100) + + f'' + + _icon_mark(struct, gold) + + "" + ) def icon_circle(*, bg: str, struct: str, gold: str, scale: float = 0.74) -> str: """Padded variant centered for circular crops (e.g. Telegram). The mark is scaled about the center so it fits inside the inscribed circle with margin.""" - return (_SVG_OPEN.format(w=100, h=100) - + f'' - + f'{_icon_mark(struct, gold)}' - + "") + return ( + _SVG_OPEN.format(w=100, h=100) + + f'' + + f'{_icon_mark(struct, gold)}' + + "" + ) def lockup_body(*, struct: str, gold: str) -> str: """The MODERN/PYTHON crop-mark lockup, drawn in a 540x250 coordinate space. Returned as bare markup (no wrapper, no background) for embedding.""" - modern, _ = outline_text("MODERN", 50, x=270, baseline_y=126, anchor="middle", - color=struct, fit_width=210) - python, _ = outline_text("PYTHON", 50, x=270, baseline_y=166, anchor="middle", - color=gold, fit_width=210) + modern, _ = outline_text( + "MODERN", + 50, + x=270, + baseline_y=126, + anchor="middle", + color=struct, + fit_width=210, + ) + python, _ = outline_text( + "PYTHON", 50, x=270, baseline_y=166, anchor="middle", color=gold, fit_width=210 + ) crops = ( '' f'' @@ -72,8 +85,15 @@ def mark(*, struct: str, gold: str) -> str: def social_card(*, bg: str, struct: str, gold: str, url_color: str) -> str: body = lockup_body(struct=struct, gold=gold) - url, _ = outline_text("modern-python.org", 34, x=640, baseline_y=575, - anchor="middle", color=url_color, letter_spacing=4) + url, _ = outline_text( + "modern-python.org", + 34, + x=640, + baseline_y=575, + anchor="middle", + color=url_color, + letter_spacing=4, + ) return ( _SVG_OPEN.format(w=1280, h=640) + f'' @@ -94,3 +114,28 @@ def social_square(*, bg: str, struct: str, gold: str) -> str: + f'{body}' + "" ) + + +def project_frame( + *, + struct: str, + accent: str, + w: int = 100, + h: int = 100, + m: int = 9, + lx: int = 53, + ly: int = 53, + s: int = 11, +) -> str: + """Two pinwheeled L-snakes in opposite corners — the constant project frame. + Returns bare markup (no wrapper).""" + hs = s + 3 + parts = [ + f'', + f'', + f'', + f'', + f'', + f'', + ] + return "".join(parts) diff --git a/brand/build/projects.py b/brand/build/projects.py new file mode 100644 index 0000000..4c3f9d8 --- /dev/null +++ b/brand/build/projects.py @@ -0,0 +1,96 @@ +from collections.abc import Callable +from pathlib import Path + +from brand.build import geometry as g +from brand.build import symbols as sym +from brand.build import tokens as t +from brand.build.raster import export_png +from brand.build.text import outline_text + +R = 23 +_CX = _CY = 50 + +ALLOWED_COLORS: frozenset[str] = frozenset( + c.lower() for c in (t.GREEN_INK, t.GOLD_LIGHT, t.CREAM, *sym._BAR_TINTS) +) + +MANIFEST: dict[str, Callable[[], str]] = { + # dependency injection + "modern-di": lambda: sym.graph(_CX, _CY, R, dashed=True), + "that-depends": lambda: sym.graph(_CX, _CY, R, dashed=False), + "modern-di-fastapi": lambda: sym.bolt_disc(_CX, _CY, R), + "modern-di-litestar": lambda: sym.star_disc(_CX, _CY, R), + "modern-di-faststream": lambda: sym.faststream(_CX, _CY, R), + "modern-di-typer": lambda: sym.terminal(_CX, _CY, R), + "modern-di-pytest": lambda: sym.bars(_CX, _CY, R), + # templates — reuse the org chevron + "fastapi-sqlalchemy-template": lambda: sym.chevron(_CX, _CY, R - 1), + "litestar-sqlalchemy-template": lambda: sym.chevron(_CX, _CY, R - 1), + # microservices, http & messaging + "lite-bootstrap": lambda: sym.rocket(_CX, _CY, R), + "httpware": lambda: sym.chain(_CX, _CY, R), + "faststream-redis-timers": lambda: sym.stopwatch(_CX, _CY, R), + "faststream-concurrent-aiokafka": lambda: sym.lanes(_CX, _CY, R), + "faststream-outbox": lambda: sym.outbox(_CX, _CY, R), + # utilities + "db-retry": lambda: sym.db_retry(_CX, _CY, R), + "eof-fixer": lambda: sym.eof_fixer(_CX, _CY, R), + "semvertag": lambda: sym.tag(_CX, _CY, R), +} + + +ROOT = Path(__file__).resolve().parents[2] +PROJECTS = ROOT / "brand" / "projects" +_PNG_SIZES = (512, 1024) + +_LOCKUP_H = 100 +_NAME_SIZE = 34 +_GAP = 18 + + +def project_mark(repo: str) -> str: + """Full for a repo: constant frame + its gold inner symbol.""" + frame = g.project_frame(struct=t.GREEN_INK, accent=t.GOLD_LIGHT) + inner = MANIFEST[repo]() + return ( + '{frame}{inner}' + ) + + +def project_lockup(repo: str) -> str: + """Framed mark on the left + the repo name in Jost (green) to its right.""" + mark_frame = g.project_frame(struct=t.GREEN_INK, accent=t.GOLD_LIGHT) + inner = MANIFEST[repo]() + name_x = _LOCKUP_H + _GAP + name_svg, name_w = outline_text( + repo, + _NAME_SIZE, + x=name_x, + baseline_y=_LOCKUP_H / 2 + _NAME_SIZE * 0.34, + anchor="start", + color=t.GREEN_INK, + ) + total_w = round(name_x + name_w + _GAP) + return ( + f'' + f"{mark_frame}{inner}" + f"{name_svg}" + ) + + +def render_projects(out_dir: Path | None = None) -> list[Path]: + """Write mark.svg (+ PNGs) for every repo under out_dir//.""" + base = out_dir if out_dir is not None else PROJECTS + written: list[Path] = [] + for repo in MANIFEST: + d = base / repo + d.mkdir(parents=True, exist_ok=True) + svg = d / "mark.svg" + svg.write_text(project_mark(repo) + "\n", encoding="utf-8") + 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") + written.append(svg) + return written diff --git a/brand/build/raster.py b/brand/build/raster.py new file mode 100644 index 0000000..90f19bc --- /dev/null +++ b/brand/build/raster.py @@ -0,0 +1,23 @@ +import shutil +import subprocess +from pathlib import Path + + +def export_png( + svg_path: Path, + png_path: Path, + *, + width: int | None = None, + height: int | None = None, +) -> bool: + exe = shutil.which("rsvg-convert") + if exe is None: + return False + args = [exe] + if width is not None: + args += ["-w", str(width)] + if height is not None: + args += ["-h", str(height)] + args += [str(svg_path), "-o", str(png_path)] + subprocess.run(args, check=True) + return True diff --git a/brand/build/render.py b/brand/build/render.py index 29244b7..4248c1e 100644 --- a/brand/build/render.py +++ b/brand/build/render.py @@ -1,9 +1,9 @@ -import shutil -import subprocess from pathlib import Path from brand.build import geometry as g from brand.build import tokens as t +from brand.build.projects import render_projects +from brand.build.raster import export_png ROOT = Path(__file__).resolve().parents[2] ORG = ROOT / "brand" / "org" @@ -14,26 +14,6 @@ def _write(path: Path, content: str) -> None: path.write_text(content + "\n", encoding="utf-8") -def export_png( - svg_path: Path, - png_path: Path, - *, - width: int | None = None, - height: int | None = None, -) -> bool: - exe = shutil.which("rsvg-convert") - if exe is None: - return False - args = [exe] - if width is not None: - args += ["-w", str(width)] - if height is not None: - args += ["-h", str(height)] - args += [str(svg_path), "-o", str(png_path)] - subprocess.run(args, check=True) - return True - - def render() -> None: ORG.mkdir(parents=True, exist_ok=True) ic = dict(bg=t.GREEN_SURFACE, struct=t.CREAM, gold=t.GOLD_DARK) @@ -43,13 +23,25 @@ def render() -> None: export_png(ORG / "favicon.svg", ORG / f"favicon-{sz}.png", width=sz, height=sz) # apple-touch (same mark, already full-bleed/square) _write(ORG / "apple-touch-icon.svg", g.icon(**ic)) - export_png(ORG / "apple-touch-icon.svg", ORG / "apple-touch-icon-180.png", width=180, height=180) + export_png( + ORG / "apple-touch-icon.svg", + ORG / "apple-touch-icon-180.png", + width=180, + height=180, + ) # avatar (same mark, large raster) _write(ORG / "avatar.svg", g.icon(**ic)) export_png(ORG / "avatar.svg", ORG / "avatar-1024.png", width=1024, height=1024) - _write(ORG / "avatar-circle.svg", - g.icon_circle(bg=t.GREEN_SURFACE, struct=t.CREAM, gold=t.GOLD_DARK)) - export_png(ORG / "avatar-circle.svg", ORG / "avatar-circle-1024.png", width=1024, height=1024) + _write( + ORG / "avatar-circle.svg", + g.icon_circle(bg=t.GREEN_SURFACE, struct=t.CREAM, gold=t.GOLD_DARK), + ) + export_png( + ORG / "avatar-circle.svg", + ORG / "avatar-circle-1024.png", + width=1024, + height=1024, + ) # Site logos — transparent, no background. # wordmark (hero): two-color lockup, light + dark variants @@ -59,20 +51,47 @@ def render() -> None: _write(ORG / "mark.svg", g.mark(struct=t.CREAM, gold=t.GOLD_DARK)) # Social cards — cream (primary) + green (alternate). - _write(ORG / "social-card.svg", - g.social_card(bg=t.CREAM, struct=t.GREEN_INK, gold=t.GOLD_LIGHT, url_color=t.GOLD_LIGHT)) + _write( + ORG / "social-card.svg", + g.social_card( + bg=t.CREAM, struct=t.GREEN_INK, gold=t.GOLD_LIGHT, url_color=t.GOLD_LIGHT + ), + ) export_png(ORG / "social-card.svg", ORG / "social-card.png", width=1280, height=640) - _write(ORG / "social-card-green.svg", - g.social_card(bg=t.GREEN_SURFACE, struct=t.CREAM, gold=t.GOLD_DARK, url_color=t.GOLD_DARK)) - export_png(ORG / "social-card-green.svg", ORG / "social-card-green.png", width=1280, height=640) + _write( + ORG / "social-card-green.svg", + g.social_card( + bg=t.GREEN_SURFACE, struct=t.CREAM, gold=t.GOLD_DARK, url_color=t.GOLD_DARK + ), + ) + export_png( + ORG / "social-card-green.svg", + ORG / "social-card-green.png", + width=1280, + height=640, + ) # Square (Telegram / square social) — cream + green. - _write(ORG / "social-square.svg", - g.social_square(bg=t.CREAM, struct=t.GREEN_INK, gold=t.GOLD_LIGHT)) - export_png(ORG / "social-square.svg", ORG / "social-square.png", width=640, height=640) - _write(ORG / "social-square-green.svg", - g.social_square(bg=t.GREEN_SURFACE, struct=t.CREAM, gold=t.GOLD_DARK)) - export_png(ORG / "social-square-green.svg", ORG / "social-square-green.png", width=640, height=640) + _write( + ORG / "social-square.svg", + g.social_square(bg=t.CREAM, struct=t.GREEN_INK, gold=t.GOLD_LIGHT), + ) + export_png( + ORG / "social-square.svg", ORG / "social-square.png", width=640, height=640 + ) + _write( + ORG / "social-square-green.svg", + g.social_square(bg=t.GREEN_SURFACE, struct=t.CREAM, gold=t.GOLD_DARK), + ) + export_png( + ORG / "social-square-green.svg", + ORG / "social-square-green.png", + width=640, + height=640, + ) + + # Per-project marks (brand/projects//). + render_projects() def main() -> None: diff --git a/brand/build/symbols.py b/brand/build/symbols.py new file mode 100644 index 0000000..0262ab8 --- /dev/null +++ b/brand/build/symbols.py @@ -0,0 +1,319 @@ +import math + +from brand.build.tokens import CREAM, GOLD_LIGHT + +GOLD = GOLD_LIGHT +# pytest emblem bar tints (light->dark) — the one allowed non-token palette +_BAR_TINTS = ("#e6b14d", "#d99a1f", GOLD, "#9c6c00") + + +def _ah(tx: float, ty: float, ang: float, sz: float, fill: str = GOLD) -> str: + """Simple isoceles arrowhead, tip at (tx,ty) pointing toward `ang` (radians).""" + a1 = ang + math.radians(150) + a2 = ang - math.radians(150) + return ( + f'' + ) + + +def _cyl( + cx: float, cy: float, r: float, h: float = 0.78, w: float = 1.0, fill: str = GOLD +) -> str: + """Database cylinder centred on (cx,cy).""" + rx = 0.5 * r * w + return ( + f'' + f'' + f'' + f'' + ) + + +def _star5(cx: float, cy: float, radius: float, color: str, inner: float = 0.42) -> str: + """Five-pointed star centred on (cx,cy).""" + pts: list[tuple[float, float]] = [] + for i in range(5): + ao = -90 + i * 72 + pts.append( + ( + cx + radius * math.cos(math.radians(ao)), + cy + radius * math.sin(math.radians(ao)), + ) + ) + ai = ao + 36 + pts.append( + ( + cx + radius * inner * math.cos(math.radians(ai)), + cy + radius * inner * math.sin(math.radians(ai)), + ) + ) + body = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts) + return f'' + + +def _circ_arc(cx: float, cy: float, rad: float, a0: float, a1: float, w: float) -> str: + """Clockwise arc a0->a1 (deg, increasing) with a leading arrowhead at a1.""" + a1s = a1 - 7 # stop the stroke short so the head caps it cleanly + x0 = cx + rad * math.cos(math.radians(a0)) + y0 = cy + rad * math.sin(math.radians(a0)) + x1 = cx + rad * math.cos(math.radians(a1s)) + y1 = cy + rad * math.sin(math.radians(a1s)) + large = 1 if (a1s - a0) % 360 > 180 else 0 + d = ( + f'' + ) + ex = cx + rad * math.cos(math.radians(a1)) + ey = cy + rad * math.sin(math.radians(a1)) + ang = math.radians(a1 + 90) # forward (clockwise) tangent + length = w * 3.0 + width = w * 1.7 + dx, dy = math.cos(ang), math.sin(ang) + px, py = -dy, dx + tip = (ex + 0.55 * length * dx, ey + 0.55 * length * dy) + base = (ex - 0.45 * length * dx, ey - 0.45 * length * dy) + d += ( + f'' + ) + return d + + +FASTSTREAM_PATH = ( + "m499.61,356.87l-92.61-160.41-36.48-63.19-10.46,251.02c.07,2.86-.78,6.05-2.51,8.6" + "-2.98,4.41-7.42,5.31-9.92,2.02l.02-.03-68.85-90.48-107.13,38.09v.04c-3.89,1.38-7.11" + "-1.8-7.2-7.12-.05-3.08.97-6.22,2.6-8.57L327.1,58.07l-12.71-22.02c-25.95-44.94-90.82" + "-44.94-116.77,0l-92.61,160.41L12.39,356.87c-25.95,44.94,6.49,101.12,58.38,101.12" + "h370.45c51.9,0,84.33-56.18,58.38-101.12Z" +) + + +def bolt_disc(cx: float, cy: float, r: float) -> str: + """FastAPI cue: lightning bolt knocked out of a gold disc.""" + norm = [ + (0.30, -0.80), + (-0.42, 0.18), + (0.05, 0.18), + (-0.22, 0.82), + (0.48, -0.22), + (0.05, -0.22), + ] + pts = " ".join( + f"{cx + dx * r * 0.82:.1f},{cy + dy * r * 0.82:.1f}" for dx, dy in norm + ) + return f'' + + +def star_disc(cx: float, cy: float, r: float) -> str: + """Litestar cue: star knocked out of a gold disc.""" + return f'' + _star5( + cx, cy, r * 0.72, CREAM + ) + + +def faststream(cx: float, cy: float, r: float) -> str: + """FastStream's own delta/stream mark, recoloured gold (sized ~2r tall).""" + size = r * 2.1 + sc = size / 462.0 + return ( + f'' + f'' + ) + + +def terminal(cx: float, cy: float, r: float) -> str: + """Typer cue: terminal window showing a bold >T prompt (notched chevron + T), + drawn as paths so it is font-independent.""" + screen = ( + f'' + ) + # prompt chevron "❯": constant-thickness angle with a back V-notch + chx = cx - 0.40 * r + reach, hgt, th = 0.30 * r, 0.34 * r, 0.20 * r + pts = [ + (chx - reach, cy - hgt), + (chx - reach + th, cy - hgt), + (chx + reach, cy - th * 0.15), + (chx + reach, cy + th * 0.15), + (chx - reach + th, cy + hgt), + (chx - reach, cy + hgt), + (chx - reach + th * 1.7, cy), + ] + chevron = '' + # bold T + tx = cx + 0.42 * r + half, hbar, stem, h = 0.32 * r, 0.16 * r, 0.16 * r, 0.62 * r + tee = ( + f'' + f'' + ) + return screen + chevron + tee + + +def bars(cx: float, cy: float, r: float) -> str: + """pytest cue: stepped bars hanging from a crossbar (gold tints), vertically centred.""" + bw = r * 0.34 + gap = r * 0.22 + x0 = cx - r + stub = r * 0.18 + cb = r * 0.2 + maxlen = r * 1.0 + total = stub + r * 0.12 + cb + maxlen + top = cy - total / 2 + y_stub = top + y_cb = top + stub + r * 0.12 + y_bar = y_cb + cb + heights = [1.0, 0.78, 0.55, 0.38] + out = [ + f'' + ] + for i in range(4): + x = x0 + i * (bw + gap) + out.append( + f'' + ) + out.append( + f'' + ) + return "".join(out) + + +def chevron(cx: float, cy: float, r: float) -> str: + """The org chevron (used by templates and as a standalone cue).""" + return ( + f'' + ) + + +def graph(cx: float, cy: float, r: float, *, dashed: bool) -> str: + """Dependency graph: 3 nodes + two edges. dashed=auto-wired (modern-di), + solid=explicit (that-depends).""" + top = (cx, cy - 0.62 * r) + bl = (cx - 0.82 * r, cy + 0.6 * r) + br = (cx + 0.82 * r, cy + 0.6 * r) + nr = r * 0.24 + w = r * 0.15 + da = ' stroke-dasharray="4 3"' if dashed else "" + return ( + f'' + f'' + f'' + f'' + f'' + ) + + +def rocket(cx: float, cy: float, r: float) -> str: + """lite-bootstrap: a rocket (launch).""" + body = ( + f'' + ) + fins = ( + f'' + f'' + ) + window = f'' + flame = f'' + return body + fins + window + flame + + +def chain(cx: float, cy: float, r: float) -> str: + """httpware: two interlocked chain links (middleware chain).""" + sw = r * 0.2 + return ( + f'' + f'' + ) + + +def stopwatch(cx: float, cy: float, r: float) -> str: + """faststream-redis-timers: a stopwatch.""" + c = cy + 0.07 * r + rr = r * 0.92 + face = ( + f'' + f'' + f'' + ) + btn = ( + f'' + f'' + ) + return face + btn + + +def lanes(cx: float, cy: float, r: float, length: float = 1.7) -> str: + """faststream-concurrent-aiokafka: three staggered parallel arrows (middle longest).""" + out = "" + for i, dy in enumerate((-0.55 * r, 0.0, 0.55 * r)): + ln = length * r * (0.72 if i != 1 else 1.0) + x1 = cx - length * r / 2 + x2 = x1 + ln + out += ( + f'' + ) + out += _ah(x2, cy + dy, 0.0, r * 0.3) + return out + + +def outbox(cx: float, cy: float, r: float) -> str: + """faststream-outbox: a database cylinder publishing concentric broadcast arcs.""" + base = _cyl(cx - 0.28 * r, cy + 0.28 * r, r * 0.72, 0.72) + bx, by = cx, cy - 0.02 * r + out = f'' + for k in (0.5, 0.82, 1.14): + kk = k * r * 0.72 + out += ( + f'' + ) + return base + out + + +def db_retry(cx: float, cy: float, r: float) -> str: + """db-retry: a database cylinder inside a two-head clockwise retry circle.""" + rad = 0.92 * r + return ( + _cyl(cx, cy, r * 0.6) + + _circ_arc(cx, cy, rad, 285, 425, 4.5) + + _circ_arc(cx, cy, rad, 105, 245, 4.5) + ) + + +def eof_fixer(cx: float, cy: float, r: float) -> str: + """eof-fixer: a document with a newline-return (down-then-left) arrow.""" + doc = ( + f'' + ) + for i in range(3): + doc += ( + f'' + ) + doc += ( + f'' + ) + doc += _ah(cx - 0.2 * r, cy + 0.55 * r, math.pi, r * 0.24) + return doc + + +def tag(cx: float, cy: float, r: float) -> str: + """semvertag: a price/version tag with a punch-hole, vertically centred.""" + return ( + f'' + f'' + ) diff --git a/brand/build/text.py b/brand/build/text.py index c030964..be637d0 100644 --- a/brand/build/text.py +++ b/brand/build/text.py @@ -49,7 +49,9 @@ def outline_text( for ch in text: gname = cmap.get(ord(ch)) if gname is None: - raise ValueError(f"character {ch!r} (U+{ord(ch):04X}) not in {font_path.name} cmap") + raise ValueError( + f"character {ch!r} (U+{ord(ch):04X}) not in {font_path.name} cmap" + ) pen = SVGPathPen(glyphset) glyphset[gname].draw(pen) d = pen.getCommands() diff --git a/brand/build/tokens.py b/brand/build/tokens.py index 2b6ecb1..829b767 100644 --- a/brand/build/tokens.py +++ b/brand/build/tokens.py @@ -1,6 +1,6 @@ # Palette — concrete colors (no CSS variables anywhere downstream). -GREEN_INK = "#356852" # snakes/text on cream (the "structure" color on light) +GREEN_INK = "#356852" # snakes/text on cream (the "structure" color on light) GREEN_SURFACE = "#2f5e4a" # green backgrounds (favicon/avatar/green card) -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) +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) diff --git a/brand/projects/db-retry/lockup.svg b/brand/projects/db-retry/lockup.svg new file mode 100644 index 0000000..ad84956 --- /dev/null +++ b/brand/projects/db-retry/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/db-retry/mark-1024.png b/brand/projects/db-retry/mark-1024.png new file mode 100644 index 0000000..b78163a Binary files /dev/null and b/brand/projects/db-retry/mark-1024.png differ diff --git a/brand/projects/db-retry/mark-512.png b/brand/projects/db-retry/mark-512.png new file mode 100644 index 0000000..d553d50 Binary files /dev/null and b/brand/projects/db-retry/mark-512.png differ diff --git a/brand/projects/db-retry/mark.svg b/brand/projects/db-retry/mark.svg new file mode 100644 index 0000000..d485876 --- /dev/null +++ b/brand/projects/db-retry/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/eof-fixer/lockup.svg b/brand/projects/eof-fixer/lockup.svg new file mode 100644 index 0000000..86a0be9 --- /dev/null +++ b/brand/projects/eof-fixer/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/eof-fixer/mark-1024.png b/brand/projects/eof-fixer/mark-1024.png new file mode 100644 index 0000000..5cb2683 Binary files /dev/null and b/brand/projects/eof-fixer/mark-1024.png differ diff --git a/brand/projects/eof-fixer/mark-512.png b/brand/projects/eof-fixer/mark-512.png new file mode 100644 index 0000000..b88b676 Binary files /dev/null and b/brand/projects/eof-fixer/mark-512.png differ diff --git a/brand/projects/eof-fixer/mark.svg b/brand/projects/eof-fixer/mark.svg new file mode 100644 index 0000000..517087a --- /dev/null +++ b/brand/projects/eof-fixer/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/fastapi-sqlalchemy-template/lockup.svg b/brand/projects/fastapi-sqlalchemy-template/lockup.svg new file mode 100644 index 0000000..821e65b --- /dev/null +++ b/brand/projects/fastapi-sqlalchemy-template/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/fastapi-sqlalchemy-template/mark-1024.png b/brand/projects/fastapi-sqlalchemy-template/mark-1024.png new file mode 100644 index 0000000..4c5e42a Binary files /dev/null and b/brand/projects/fastapi-sqlalchemy-template/mark-1024.png differ diff --git a/brand/projects/fastapi-sqlalchemy-template/mark-512.png b/brand/projects/fastapi-sqlalchemy-template/mark-512.png new file mode 100644 index 0000000..3c0743f Binary files /dev/null and b/brand/projects/fastapi-sqlalchemy-template/mark-512.png differ diff --git a/brand/projects/fastapi-sqlalchemy-template/mark.svg b/brand/projects/fastapi-sqlalchemy-template/mark.svg new file mode 100644 index 0000000..aff4e44 --- /dev/null +++ b/brand/projects/fastapi-sqlalchemy-template/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/faststream-concurrent-aiokafka/lockup.svg b/brand/projects/faststream-concurrent-aiokafka/lockup.svg new file mode 100644 index 0000000..e9ba271 --- /dev/null +++ b/brand/projects/faststream-concurrent-aiokafka/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/faststream-concurrent-aiokafka/mark-1024.png b/brand/projects/faststream-concurrent-aiokafka/mark-1024.png new file mode 100644 index 0000000..4f79910 Binary files /dev/null and b/brand/projects/faststream-concurrent-aiokafka/mark-1024.png differ diff --git a/brand/projects/faststream-concurrent-aiokafka/mark-512.png b/brand/projects/faststream-concurrent-aiokafka/mark-512.png new file mode 100644 index 0000000..2f01630 Binary files /dev/null and b/brand/projects/faststream-concurrent-aiokafka/mark-512.png differ diff --git a/brand/projects/faststream-concurrent-aiokafka/mark.svg b/brand/projects/faststream-concurrent-aiokafka/mark.svg new file mode 100644 index 0000000..750137d --- /dev/null +++ b/brand/projects/faststream-concurrent-aiokafka/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/faststream-outbox/lockup.svg b/brand/projects/faststream-outbox/lockup.svg new file mode 100644 index 0000000..a5f9ac9 --- /dev/null +++ b/brand/projects/faststream-outbox/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/faststream-outbox/mark-1024.png b/brand/projects/faststream-outbox/mark-1024.png new file mode 100644 index 0000000..a719020 Binary files /dev/null and b/brand/projects/faststream-outbox/mark-1024.png differ diff --git a/brand/projects/faststream-outbox/mark-512.png b/brand/projects/faststream-outbox/mark-512.png new file mode 100644 index 0000000..4dd3c8e Binary files /dev/null and b/brand/projects/faststream-outbox/mark-512.png differ diff --git a/brand/projects/faststream-outbox/mark.svg b/brand/projects/faststream-outbox/mark.svg new file mode 100644 index 0000000..10d3337 --- /dev/null +++ b/brand/projects/faststream-outbox/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/faststream-redis-timers/lockup.svg b/brand/projects/faststream-redis-timers/lockup.svg new file mode 100644 index 0000000..541115c --- /dev/null +++ b/brand/projects/faststream-redis-timers/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/faststream-redis-timers/mark-1024.png b/brand/projects/faststream-redis-timers/mark-1024.png new file mode 100644 index 0000000..df941a7 Binary files /dev/null and b/brand/projects/faststream-redis-timers/mark-1024.png differ diff --git a/brand/projects/faststream-redis-timers/mark-512.png b/brand/projects/faststream-redis-timers/mark-512.png new file mode 100644 index 0000000..42e62dd Binary files /dev/null and b/brand/projects/faststream-redis-timers/mark-512.png differ diff --git a/brand/projects/faststream-redis-timers/mark.svg b/brand/projects/faststream-redis-timers/mark.svg new file mode 100644 index 0000000..58743b2 --- /dev/null +++ b/brand/projects/faststream-redis-timers/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/httpware/lockup.svg b/brand/projects/httpware/lockup.svg new file mode 100644 index 0000000..1017649 --- /dev/null +++ b/brand/projects/httpware/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/httpware/mark-1024.png b/brand/projects/httpware/mark-1024.png new file mode 100644 index 0000000..07f5309 Binary files /dev/null and b/brand/projects/httpware/mark-1024.png differ diff --git a/brand/projects/httpware/mark-512.png b/brand/projects/httpware/mark-512.png new file mode 100644 index 0000000..445b15b Binary files /dev/null and b/brand/projects/httpware/mark-512.png differ diff --git a/brand/projects/httpware/mark.svg b/brand/projects/httpware/mark.svg new file mode 100644 index 0000000..e24e7ba --- /dev/null +++ b/brand/projects/httpware/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/lite-bootstrap/lockup.svg b/brand/projects/lite-bootstrap/lockup.svg new file mode 100644 index 0000000..8cbb036 --- /dev/null +++ b/brand/projects/lite-bootstrap/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/lite-bootstrap/mark-1024.png b/brand/projects/lite-bootstrap/mark-1024.png new file mode 100644 index 0000000..9d29e58 Binary files /dev/null and b/brand/projects/lite-bootstrap/mark-1024.png differ diff --git a/brand/projects/lite-bootstrap/mark-512.png b/brand/projects/lite-bootstrap/mark-512.png new file mode 100644 index 0000000..1ab50c6 Binary files /dev/null and b/brand/projects/lite-bootstrap/mark-512.png differ diff --git a/brand/projects/lite-bootstrap/mark.svg b/brand/projects/lite-bootstrap/mark.svg new file mode 100644 index 0000000..fe74d36 --- /dev/null +++ b/brand/projects/lite-bootstrap/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/litestar-sqlalchemy-template/lockup.svg b/brand/projects/litestar-sqlalchemy-template/lockup.svg new file mode 100644 index 0000000..0d7a02d --- /dev/null +++ b/brand/projects/litestar-sqlalchemy-template/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/litestar-sqlalchemy-template/mark-1024.png b/brand/projects/litestar-sqlalchemy-template/mark-1024.png new file mode 100644 index 0000000..4c5e42a Binary files /dev/null and b/brand/projects/litestar-sqlalchemy-template/mark-1024.png differ diff --git a/brand/projects/litestar-sqlalchemy-template/mark-512.png b/brand/projects/litestar-sqlalchemy-template/mark-512.png new file mode 100644 index 0000000..3c0743f Binary files /dev/null and b/brand/projects/litestar-sqlalchemy-template/mark-512.png differ diff --git a/brand/projects/litestar-sqlalchemy-template/mark.svg b/brand/projects/litestar-sqlalchemy-template/mark.svg new file mode 100644 index 0000000..25999df --- /dev/null +++ b/brand/projects/litestar-sqlalchemy-template/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di-fastapi/lockup.svg b/brand/projects/modern-di-fastapi/lockup.svg new file mode 100644 index 0000000..0aa20d0 --- /dev/null +++ b/brand/projects/modern-di-fastapi/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di-fastapi/mark-1024.png b/brand/projects/modern-di-fastapi/mark-1024.png new file mode 100644 index 0000000..7f5d032 Binary files /dev/null and b/brand/projects/modern-di-fastapi/mark-1024.png differ diff --git a/brand/projects/modern-di-fastapi/mark-512.png b/brand/projects/modern-di-fastapi/mark-512.png new file mode 100644 index 0000000..57315b3 Binary files /dev/null and b/brand/projects/modern-di-fastapi/mark-512.png differ diff --git a/brand/projects/modern-di-fastapi/mark.svg b/brand/projects/modern-di-fastapi/mark.svg new file mode 100644 index 0000000..bd7f074 --- /dev/null +++ b/brand/projects/modern-di-fastapi/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di-faststream/lockup.svg b/brand/projects/modern-di-faststream/lockup.svg new file mode 100644 index 0000000..8fc54a0 --- /dev/null +++ b/brand/projects/modern-di-faststream/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di-faststream/mark-1024.png b/brand/projects/modern-di-faststream/mark-1024.png new file mode 100644 index 0000000..d94bb1c Binary files /dev/null and b/brand/projects/modern-di-faststream/mark-1024.png differ diff --git a/brand/projects/modern-di-faststream/mark-512.png b/brand/projects/modern-di-faststream/mark-512.png new file mode 100644 index 0000000..102352d Binary files /dev/null and b/brand/projects/modern-di-faststream/mark-512.png differ diff --git a/brand/projects/modern-di-faststream/mark.svg b/brand/projects/modern-di-faststream/mark.svg new file mode 100644 index 0000000..65fe4ab --- /dev/null +++ b/brand/projects/modern-di-faststream/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di-litestar/lockup.svg b/brand/projects/modern-di-litestar/lockup.svg new file mode 100644 index 0000000..d92aa98 --- /dev/null +++ b/brand/projects/modern-di-litestar/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di-litestar/mark-1024.png b/brand/projects/modern-di-litestar/mark-1024.png new file mode 100644 index 0000000..4960020 Binary files /dev/null and b/brand/projects/modern-di-litestar/mark-1024.png differ diff --git a/brand/projects/modern-di-litestar/mark-512.png b/brand/projects/modern-di-litestar/mark-512.png new file mode 100644 index 0000000..8c58d59 Binary files /dev/null and b/brand/projects/modern-di-litestar/mark-512.png differ diff --git a/brand/projects/modern-di-litestar/mark.svg b/brand/projects/modern-di-litestar/mark.svg new file mode 100644 index 0000000..d9d6918 --- /dev/null +++ b/brand/projects/modern-di-litestar/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di-pytest/lockup.svg b/brand/projects/modern-di-pytest/lockup.svg new file mode 100644 index 0000000..b4c0d1e --- /dev/null +++ b/brand/projects/modern-di-pytest/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di-pytest/mark-1024.png b/brand/projects/modern-di-pytest/mark-1024.png new file mode 100644 index 0000000..8f712b4 Binary files /dev/null and b/brand/projects/modern-di-pytest/mark-1024.png differ diff --git a/brand/projects/modern-di-pytest/mark-512.png b/brand/projects/modern-di-pytest/mark-512.png new file mode 100644 index 0000000..ffb1647 Binary files /dev/null and b/brand/projects/modern-di-pytest/mark-512.png differ diff --git a/brand/projects/modern-di-pytest/mark.svg b/brand/projects/modern-di-pytest/mark.svg new file mode 100644 index 0000000..8f9f56a --- /dev/null +++ b/brand/projects/modern-di-pytest/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di-typer/lockup.svg b/brand/projects/modern-di-typer/lockup.svg new file mode 100644 index 0000000..caf815a --- /dev/null +++ b/brand/projects/modern-di-typer/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di-typer/mark-1024.png b/brand/projects/modern-di-typer/mark-1024.png new file mode 100644 index 0000000..92660a6 Binary files /dev/null and b/brand/projects/modern-di-typer/mark-1024.png differ diff --git a/brand/projects/modern-di-typer/mark-512.png b/brand/projects/modern-di-typer/mark-512.png new file mode 100644 index 0000000..969a8f5 Binary files /dev/null and b/brand/projects/modern-di-typer/mark-512.png differ diff --git a/brand/projects/modern-di-typer/mark.svg b/brand/projects/modern-di-typer/mark.svg new file mode 100644 index 0000000..9da6270 --- /dev/null +++ b/brand/projects/modern-di-typer/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di/lockup.svg b/brand/projects/modern-di/lockup.svg new file mode 100644 index 0000000..31051ab --- /dev/null +++ b/brand/projects/modern-di/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/modern-di/mark-1024.png b/brand/projects/modern-di/mark-1024.png new file mode 100644 index 0000000..32a3c73 Binary files /dev/null and b/brand/projects/modern-di/mark-1024.png differ diff --git a/brand/projects/modern-di/mark-512.png b/brand/projects/modern-di/mark-512.png new file mode 100644 index 0000000..90bfd2d Binary files /dev/null and b/brand/projects/modern-di/mark-512.png differ diff --git a/brand/projects/modern-di/mark.svg b/brand/projects/modern-di/mark.svg new file mode 100644 index 0000000..11ed548 --- /dev/null +++ b/brand/projects/modern-di/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/semvertag/lockup.svg b/brand/projects/semvertag/lockup.svg new file mode 100644 index 0000000..38a7869 --- /dev/null +++ b/brand/projects/semvertag/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/semvertag/mark-1024.png b/brand/projects/semvertag/mark-1024.png new file mode 100644 index 0000000..32df472 Binary files /dev/null and b/brand/projects/semvertag/mark-1024.png differ diff --git a/brand/projects/semvertag/mark-512.png b/brand/projects/semvertag/mark-512.png new file mode 100644 index 0000000..edf82b9 Binary files /dev/null and b/brand/projects/semvertag/mark-512.png differ diff --git a/brand/projects/semvertag/mark.svg b/brand/projects/semvertag/mark.svg new file mode 100644 index 0000000..8a3882e --- /dev/null +++ b/brand/projects/semvertag/mark.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/that-depends/lockup.svg b/brand/projects/that-depends/lockup.svg new file mode 100644 index 0000000..2436945 --- /dev/null +++ b/brand/projects/that-depends/lockup.svg @@ -0,0 +1 @@ + diff --git a/brand/projects/that-depends/mark-1024.png b/brand/projects/that-depends/mark-1024.png new file mode 100644 index 0000000..54cef5f Binary files /dev/null and b/brand/projects/that-depends/mark-1024.png differ diff --git a/brand/projects/that-depends/mark-512.png b/brand/projects/that-depends/mark-512.png new file mode 100644 index 0000000..e2ae6e1 Binary files /dev/null and b/brand/projects/that-depends/mark-512.png differ diff --git a/brand/projects/that-depends/mark.svg b/brand/projects/that-depends/mark.svg new file mode 100644 index 0000000..9263cde --- /dev/null +++ b/brand/projects/that-depends/mark.svg @@ -0,0 +1 @@ + diff --git a/planning/changes/2026-06-29.01-per-project-brand-marks/design.md b/planning/changes/2026-06-29.01-per-project-brand-marks/design.md new file mode 100644 index 0000000..5062da4 --- /dev/null +++ b/planning/changes/2026-06-29.01-per-project-brand-marks/design.md @@ -0,0 +1,178 @@ +--- +summary: Per-project marks shipped — 17 repos, constant snake-frame + gold inner symbol, generated into brand/projects/. +--- + +# Design: Per-project and per-family brand marks + +## Summary + +Extend the org brand kit (currently only the org favicon/social card) with a +**per-repository logo system**. Every repo gets a large-format mark built from +the **same green+gold "snake" frame** with a single **gold inner symbol** that +says what the project does. It is a strict two-colour system (forest green + +ochre gold on cream), differentiated by *symbol shape*, not colour. Marks are +generated programmatically in `brand/build/` (the same pipeline that draws the +org mark) and emitted under `brand/projects//`. The org favicon/avatar and +the small-size identity are unchanged — these new marks are **large-format only** +(docs hero, README banner). + +## Motivation + +The org mark shipped in `brand/org/` (favicon, avatar, social cards) and the +backstage story (`brand/STORY.md`, "Act 2 — the family that never shipped") +explicitly deferred the per-project system: *"the frame stays constant and an +inner glyph marks each sub-family."* `brand/projects/` exists but is empty, and +`brand/README.md` lists "per-project / per-repo marks, the subfamily system" as +a later task. This change delivers that system across all ~17 repos so each +project's docs site and README can carry a recognisable, on-brand mark. + +Research into comparable systems informed the approach: Astral (uv/ruff/ty) and +the JetBrains/Adobe families show that a **constant frame + a per-product inner +mark** reads as a family while staying individually identifiable, and that +leaning on *two* discriminators is only needed when marks must survive tiny +sizes. Because these marks are large-format only (favicons stay the org mark), +shape alone is a sufficient discriminator and a single accent colour (gold) +keeps the system tight. + +## Non-goals + +- Small-size / favicon use — every repo keeps the **org** mark as its favicon + and avatar. These project marks are for large contexts only. +- Per-family accent colours — explored (a "Heritage" palette: terracotta / + slate / plum / teal) and **deliberately dropped**; three colours read as busy + against the green+gold frame. See + `planning/decisions/2026-06-29-project-marks-single-gold-inner.md`. +- Redesigning the org mark, header logo, or wordmark — unchanged. +- Separate marks for the two project templates — they reuse the org chevron mark. +- The horizontal name lockup typography is specified at a high level only; exact + kerning/wordmark rendering is an implementation detail for `plan.md`. + +## Design + +### 1. The constant frame + +The two interlocked pinwheel "snakes" from the org mark, drawn parametrically on +a 100×100 viewBox with the snakes pushed to the edges so a large inner symbol +gets clearance: + +- **Geometry:** `margin = 9`, arm length `Lx = Ly = 53`, stroke `s = 11`. + Snake 1 (top-left corner) `struct` colour; snake 2 (bottom-right) `accent` + colour — same pinwheel construction as `geometry.py::_icon_mark`, but + parameterised by `(W, H, margin, Lx, Ly, stroke)` so the corners can be moved + and the canvas can be non-square if ever needed. +- **Colours (from `brand/build/tokens.py`):** `struct = GREEN_INK #356852`, + `accent = GOLD_LIGHT #c98a00`. The frame is identical on every mark. +- **Heads & tails:** square block-head at each arm's outer end; diagonal-cut + tail at the inner end — carried over from the org mark. + +This frame is a new function alongside the existing `icon()`/`mark()`; it does +not replace them. The org favicon/avatar keep the original tighter geometry. + +### 2. The inner symbol + +One gold symbol, centred at (50,50), nominal radius **r ≈ 23**, in +`GOLD_LIGHT #c98a00` with `CREAM #f4f1e8` for negative space / cut-outs (e.g. the +bolt inside a filled disc). Two-colour total (green + gold); no third hue. + +Filled "disc" marks (a gold disc with a cream symbol knocked out) are used where +the partner's own logo is a filled badge; line/figure marks are used elsewhere. +Every symbol is **redrawn in our geometry** — we evoke a partner, we do not paste +their logo (see Risk for the one exception). + +### 3. The per-repo symbols (17 repos, 4 families) + +**Dependency injection** (7): +| Repo | Inner symbol | +|------|--------------| +| `modern-di` | **dashed** dependency graph (3 nodes, dashed edges = "auto-wired by annotations") | +| `that-depends` | **solid** dependency graph (same 3 nodes, solid edges = the explicit predecessor) | +| `modern-di-fastapi` | lightning **bolt knocked out of a gold disc** (evokes FastAPI's teal disc+bolt) | +| `modern-di-litestar` | 5-point **star knocked out of a gold disc** (evokes Litestar's star) | +| `modern-di-faststream` | the **FastStream delta/stream** shape, recoloured gold | +| `modern-di-typer` | terminal window showing **`>T`** prompt (evokes Typer) | +| `modern-di-pytest` | **stepped bars** hanging from a crossbar, gold tints (evokes pytest's bar emblem) | + +**Templates** (2) — reuse the **org chevron mark** (no bespoke symbol): +`fastapi-sqlalchemy-template`, `litestar-sqlalchemy-template`. + +**Microservices, HTTP & messaging** (5): +| Repo | Inner symbol | +|------|--------------| +| `lite-bootstrap` | **rocket** (launch / bootstrap) | +| `httpware` | two interlocked **chain links** (middleware chain) | +| `faststream-redis-timers` | **stopwatch** (distributed timers) | +| `faststream-concurrent-aiokafka` | three **parallel lane arrows**, middle longest (staggered, concurrency) | +| `faststream-outbox` | **database cylinder publishing broadcast arcs** (DB emits events; transactional outbox) | + +**Utilities** (3): +| Repo | Inner symbol | +|------|--------------| +| `db-retry` | database **cylinder inside a two-head clockwise retry circle** | +| `eof-fixer` | **document with a newline-return (↵)** arrow | +| `semvertag` | a **price/version tag** with punch-hole | + +The three `faststream-*` messaging repos use their own concept symbols (timer / +lanes / outbox), *not* the FastStream delta — only the DI integration +`modern-di-faststream` co-brands with FastStream, per the "evoke the integrated +partner" rule that applies to the `modern-di-*` family. + +### 4. Build pipeline & outputs + +- Add symbol/frame functions to `brand/build/geometry.py` (parametric + `project_frame(...)` + one function per inner symbol). Reuse + `tokens.py` colours and `text.py` outlining for any text glyphs (`T>`). +- `brand/build/render.py` gains a pass that, for each repo in a manifest + (repo → symbol), writes `brand/projects//mark.svg` and rasterises + `mark-.png` (sizes TBD in plan; large-format, e.g. 512/1024) via the + existing `export_png` (`rsvg-convert`). +- A **horizontal lockup** (`lockup.svg`: the framed mark + the repo name set in + Jost, green/gold) is produced per repo for README banners. Name typography + reuses `text.py::outline_text`. +- `brand/README.md` table of "Deferred" items is updated to mark the per-project + system as shipped, and a short generation note is added. + +### 5. Prototype (validated) + +The full system was prototyped as standalone SVG generators and rendered with +`rsvg-convert` to verify every mark visually (centering checked against a +crosshair for `faststream-outbox` and `semvertag`; the `db-retry` circular-arrow +arrowheads were corrected to lead the arc with a butt cap). The implementation +ports that validated geometry into `brand/build/`. + +## Operations + +None out-of-repo for generation. Rolling each mark out to its repo's docs +site / README (copying `mark.svg` into each downstream repo, wiring MkDocs +`theme.logo`) is **per-repo follow-up work**, tracked separately — this change +produces the assets in this repo only. + +## Out of scope + +- Wiring the marks into each downstream repo's site/README (follow-up per repo). +- PNG size matrix and any `@2x` social-card variants per project. +- Animations or dark-mode inversions of the project marks. + +## Testing + +- `uv run python -m brand.build.render` produces a `mark.svg` (+ PNGs) for every + manifest repo with no errors; `brand/projects//` is populated. +- A contact-sheet render (all marks on one sheet) is eyeballed for consistency + and centering — the prototype workflow, retained as a dev check. +- SVGs are well-formed (parse) and use only `tokens.py` colours (a small test + asserting no stray hex values outside the token set is feasible). +- `just check-planning` passes; `architecture/` updated (see below). + +## Risk + +- **Trademark / partner-logo use (most likely concern).** `modern-di-faststream` + uses the *actual* FastStream delta path recoloured; the fastapi/litestar/typer/ + pytest cues are redrawn evocations, not copies. Likelihood medium, impact low + (these are clearly our snake-frame marks indicating an integration, fair-use + nominative). *Mitigation:* keep the FastStream path as the only literal partner + asset, recoloured into our palette; reconsider if any project objects. +- **Symbol legibility / taste.** Some glyphs (rocket, outbox arcs) are detailed; + at large format this is fine but a few may need a second pass. *Mitigation:* + the generator makes iteration cheap; review the contact sheet before shipping. +- **Architecture doc drift.** This adds a capability; `architecture/` must gain a + `brand-marks.md` (or extend `site-branding.md`) in the implementing PR per the + planning convention. diff --git a/planning/changes/2026-06-29.01-per-project-brand-marks/plan.md b/planning/changes/2026-06-29.01-per-project-brand-marks/plan.md new file mode 100644 index 0000000..8887b4d --- /dev/null +++ b/planning/changes/2026-06-29.01-per-project-brand-marks/plan.md @@ -0,0 +1,981 @@ +# per-project-brand-marks — 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 large-format logo for every org repo — the constant +green+gold snake-frame with one gold inner symbol per repo — from `brand/build/` +into `brand/projects//`. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `brand-project-marks` (already created) + +**Commit strategy:** Per-task commits. + +## Global constraints + +- **Colours come only from `brand/build/tokens.py`** — `GREEN_INK #356852` + (frame struct), `GOLD_LIGHT #c98a00` (frame accent + inner symbol), + `CREAM #f4f1e8` (negative space). The only exception: `pytest` bar tints + (declared in `symbols.py` as `_BAR_TINTS`). +- **Frame geometry is fixed:** 100×100 viewBox, `margin=9`, arm `Lx=Ly=53`, + stroke `s=11`. Inner symbol centred at `(50, 50)`, nominal radius `R=23`. +- **Two colours only** (green + gold); no per-family accent. Differentiate by + symbol shape. +- **Large-format only.** Do not touch the org favicon/avatar (`brand/org/`). +- All new code: imports at module level, annotate function args, `ty: ignore` + (not `type: ignore`) if a suppression is ever needed. +- Tooling: `uv run python -m brand.build.render` regenerates; `rsvg-convert` + rasterises PNGs (skipped gracefully if absent, per existing `export_png`). + +--- + +### Task 1: Symbol module scaffold + shared helpers + +**Files:** +- Create: `brand/build/symbols.py` +- Test: `tests/test_symbols.py` + +**Interfaces:** +- Produces: `_ah(tx,ty,ang,sz,fill=GOLD)`, `_cyl(cx,cy,r,h=0.78,w=1.0,fill=GOLD)`, + `_star5(cx,cy,R,color,inner=0.42)`, `_circ_arc(cx,cy,rad,a0,a1,w)` — all return + SVG-fragment `str`. Module constants `GOLD=GOLD_LIGHT`, `_BAR_TINTS`. + +- [ ] **Step 1: Write the failing test** + + ```python + # tests/test_symbols.py + from xml.dom import minidom + from brand.build import symbols as sym + + def _wrap(markup: str) -> str: + return f'{markup}' + + def test_helpers_emit_parseable_svg() -> None: + for markup in ( + sym._ah(50, 50, 0.0, 6), + sym._cyl(50, 50, 20), + sym._star5(50, 50, 18, sym.GOLD), + sym._circ_arc(50, 50, 20, 285, 425, 4.5), + ): + minidom.parseString(_wrap(markup)) # raises on malformed XML + ``` + +- [ ] **Step 2: Run test to verify it fails** + + Run: `uv run pytest tests/test_symbols.py -q` + Expected: FAIL — `ModuleNotFoundError`/`AttributeError` (symbols not defined). + +- [ ] **Step 3: Implement the module + helpers** + + ```python + # brand/build/symbols.py + import math + + from brand.build.tokens import CREAM, GOLD_LIGHT + + GOLD = GOLD_LIGHT + # pytest emblem bar tints (light->dark) — the one allowed non-token palette + _BAR_TINTS = ("#e6b14d", "#d99a1f", GOLD, "#9c6c00") + + + def _ah(tx: float, ty: float, ang: float, sz: float, fill: str = GOLD) -> str: + """Simple isoceles arrowhead, tip at (tx,ty) pointing toward `ang` (radians).""" + a1 = ang + math.radians(150) + a2 = ang - math.radians(150) + return ( + f'' + ) + + + def _cyl(cx: float, cy: float, r: float, h: float = 0.78, w: float = 1.0, fill: str = GOLD) -> str: + """Database cylinder centred on (cx,cy).""" + rx = 0.5 * r * w + return ( + f'' + f'' + f'' + f'' + ) + + + def _star5(cx: float, cy: float, radius: float, color: str, inner: float = 0.42) -> str: + """Five-pointed star centred on (cx,cy).""" + pts: list[tuple[float, float]] = [] + for i in range(5): + ao = -90 + i * 72 + pts.append((cx + radius * math.cos(math.radians(ao)), cy + radius * math.sin(math.radians(ao)))) + ai = ao + 36 + pts.append((cx + radius * inner * math.cos(math.radians(ai)), cy + radius * inner * math.sin(math.radians(ai)))) + body = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts) + return f'' + + + def _circ_arc(cx: float, cy: float, rad: float, a0: float, a1: float, w: float) -> str: + """Clockwise arc a0->a1 (deg, increasing) with a leading arrowhead at a1.""" + a1s = a1 - 7 # stop the stroke short so the head caps it cleanly + x0 = cx + rad * math.cos(math.radians(a0)) + y0 = cy + rad * math.sin(math.radians(a0)) + x1 = cx + rad * math.cos(math.radians(a1s)) + y1 = cy + rad * math.sin(math.radians(a1s)) + large = 1 if (a1s - a0) % 360 > 180 else 0 + d = ( + f'' + ) + ex = cx + rad * math.cos(math.radians(a1)) + ey = cy + rad * math.sin(math.radians(a1)) + ang = math.radians(a1 + 90) # forward (clockwise) tangent + length = w * 3.0 + width = w * 1.7 + dx, dy = math.cos(ang), math.sin(ang) + px, py = -dy, dx + tip = (ex + 0.55 * length * dx, ey + 0.55 * length * dy) + base = (ex - 0.45 * length * dx, ey - 0.45 * length * dy) + d += ( + f'' + ) + return d + ``` + +- [ ] **Step 4: Run test to verify it passes** + + Run: `uv run pytest tests/test_symbols.py -q` + Expected: PASS (1 test). + +- [ ] **Step 5: Commit** + + ```bash + git add brand/build/symbols.py tests/test_symbols.py + git commit -m "feat(brand): symbol module scaffold + shared helpers" + ``` + +--- + +### Task 2: Dependency-injection symbols + +**Files:** +- Modify: `brand/build/symbols.py` +- Test: `tests/test_symbols.py` + +**Interfaces:** +- Produces (each `(cx,cy,r) -> str`): `graph(cx,cy,r,*,dashed)`, + `bolt_disc(cx,cy,r)`, `star_disc(cx,cy,r)`, `faststream(cx,cy,r)`, + `terminal(cx,cy,r)`, `bars(cx,cy,r)`, `chevron(cx,cy,r)`. Module constant + `FASTSTREAM_PATH`. + +- [ ] **Step 1: Write the failing test** + + ```python + # append to tests/test_symbols.py + import pytest + + DI_SYMBOLS = ["bolt_disc", "star_disc", "faststream", "terminal", "bars", "chevron"] + + @pytest.mark.parametrize("name", DI_SYMBOLS) + def test_di_symbol_parses(name: str) -> None: + markup = getattr(sym, name)(50, 50, 23) + minidom.parseString(_wrap(markup)) + + def test_graph_dashed_vs_solid() -> None: + assert "stroke-dasharray" in sym.graph(50, 50, 23, dashed=True) + assert "stroke-dasharray" not in sym.graph(50, 50, 23, dashed=False) + ``` + +- [ ] **Step 2: Run test to verify it fails** + + Run: `uv run pytest tests/test_symbols.py -q` + Expected: FAIL — `AttributeError` on the new symbol names. + +- [ ] **Step 3: Implement the DI symbols** + + ```python + # append to brand/build/symbols.py + FASTSTREAM_PATH = ( + "m499.61,356.87l-92.61-160.41-36.48-63.19-10.46,251.02c.07,2.86-.78,6.05-2.51,8.6" + "-2.98,4.41-7.42,5.31-9.92,2.02l.02-.03-68.85-90.48-107.13,38.09v.04c-3.89,1.38-7.11" + "-1.8-7.2-7.12-.05-3.08.97-6.22,2.6-8.57L327.1,58.07l-12.71-22.02c-25.95-44.94-90.82" + "-44.94-116.77,0l-92.61,160.41L12.39,356.87c-25.95,44.94,6.49,101.12,58.38,101.12" + "h370.45c51.9,0,84.33-56.18,58.38-101.12Z" + ) + + + def bolt_disc(cx: float, cy: float, r: float) -> str: + """FastAPI cue: lightning bolt knocked out of a gold disc.""" + norm = [(0.30, -0.80), (-0.42, 0.18), (0.05, 0.18), (-0.22, 0.82), (0.48, -0.22), (0.05, -0.22)] + pts = " ".join(f"{cx + dx * r * 0.82:.1f},{cy + dy * r * 0.82:.1f}" for dx, dy in norm) + return f'' + + + def star_disc(cx: float, cy: float, r: float) -> str: + """Litestar cue: star knocked out of a gold disc.""" + return f'' + _star5(cx, cy, r * 0.72, CREAM) + + + def faststream(cx: float, cy: float, r: float) -> str: + """FastStream's own delta/stream mark, recoloured gold (sized ~2r tall).""" + size = r * 2.1 + sc = size / 462.0 + return ( + f'' + f'' + ) + + + def terminal(cx: float, cy: float, r: float) -> str: + """Typer cue: terminal window showing a T> prompt.""" + return ( + f'' + f'T>' + ) + + + def bars(cx: float, cy: float, r: float) -> str: + """pytest cue: stepped bars hanging from a crossbar (gold tints), vertically centred.""" + bw = r * 0.34 + gap = r * 0.22 + x0 = cx - r + stub = r * 0.18 + cb = r * 0.2 + maxlen = r * 1.0 + total = stub + r * 0.12 + cb + maxlen + top = cy - total / 2 + y_stub = top + y_cb = top + stub + r * 0.12 + y_bar = y_cb + cb + heights = [1.0, 0.78, 0.55, 0.38] + out = [f''] + for i in range(4): + x = x0 + i * (bw + gap) + out.append(f'') + out.append(f'') + return "".join(out) + + + def chevron(cx: float, cy: float, r: float) -> str: + """The org chevron (used by templates and as a standalone cue).""" + return ( + f'' + ) + + + def graph(cx: float, cy: float, r: float, *, dashed: bool) -> str: + """Dependency graph: 3 nodes + two edges. dashed=auto-wired (modern-di), + solid=explicit (that-depends).""" + top = (cx, cy - 0.62 * r) + bl = (cx - 0.82 * r, cy + 0.6 * r) + br = (cx + 0.82 * r, cy + 0.6 * r) + nr = r * 0.24 + w = r * 0.15 + da = ' stroke-dasharray="4 3"' if dashed else "" + return ( + f'' + f'' + f'' + f'' + f'' + ) + ``` + +- [ ] **Step 4: Run test to verify it passes** + + Run: `uv run pytest tests/test_symbols.py -q` + Expected: PASS (helpers + 6 parametrized DI symbols + graph test). + +- [ ] **Step 5: Commit** + + ```bash + git add brand/build/symbols.py tests/test_symbols.py + git commit -m "feat(brand): dependency-injection inner symbols" + ``` + +--- + +### Task 3: Microservices/messaging symbols + +**Files:** +- Modify: `brand/build/symbols.py` +- Test: `tests/test_symbols.py` + +**Interfaces:** +- Produces (each `(cx,cy,r) -> str`): `rocket`, `chain`, `stopwatch`, + `lanes`, `outbox`. + +- [ ] **Step 1: Write the failing test** + + ```python + # append to tests/test_symbols.py + MSG_SYMBOLS = ["rocket", "chain", "stopwatch", "lanes", "outbox"] + + @pytest.mark.parametrize("name", MSG_SYMBOLS) + def test_msg_symbol_parses(name: str) -> None: + minidom.parseString(_wrap(getattr(sym, name)(50, 50, 23))) + ``` + +- [ ] **Step 2: Run test to verify it fails** + + Run: `uv run pytest tests/test_symbols.py -q` + Expected: FAIL — `AttributeError` on the new names. + +- [ ] **Step 3: Implement the messaging symbols** + + ```python + # append to brand/build/symbols.py + def rocket(cx: float, cy: float, r: float) -> str: + """lite-bootstrap: a rocket (launch).""" + body = ( + f'' + ) + fins = ( + f'' + f'' + ) + window = f'' + flame = f'' + return body + fins + window + flame + + + def chain(cx: float, cy: float, r: float) -> str: + """httpware: two interlocked chain links (middleware chain).""" + sw = r * 0.2 + return ( + f'' + f'' + ) + + + def stopwatch(cx: float, cy: float, r: float) -> str: + """faststream-redis-timers: a stopwatch.""" + c = cy + 0.07 * r + rr = r * 0.92 + face = ( + f'' + f'' + f'' + ) + btn = ( + f'' + f'' + ) + return face + btn + + + def lanes(cx: float, cy: float, r: float, length: float = 1.7) -> str: + """faststream-concurrent-aiokafka: three staggered parallel arrows (middle longest).""" + out = "" + for i, dy in enumerate((-0.55 * r, 0.0, 0.55 * r)): + ln = length * r * (0.72 if i != 1 else 1.0) + x1 = cx - length * r / 2 + x2 = x1 + ln + out += ( + f'' + ) + out += _ah(x2, cy + dy, 0.0, r * 0.3) + return out + + + def outbox(cx: float, cy: float, r: float) -> str: + """faststream-outbox: a database cylinder publishing concentric broadcast arcs.""" + base = _cyl(cx - 0.28 * r, cy + 0.28 * r, r * 0.72, 0.72) + bx, by = cx, cy - 0.02 * r + out = f'' + for k in (0.5, 0.82, 1.14): + kk = k * r * 0.72 + out += ( + f'' + ) + return base + out + ``` + +- [ ] **Step 4: Run test to verify it passes** + + Run: `uv run pytest tests/test_symbols.py -q` + Expected: PASS (5 new parametrized cases). + +- [ ] **Step 5: Commit** + + ```bash + git add brand/build/symbols.py tests/test_symbols.py + git commit -m "feat(brand): microservices/messaging inner symbols" + ``` + +--- + +### Task 4: Utility symbols + +**Files:** +- Modify: `brand/build/symbols.py` +- Test: `tests/test_symbols.py` + +**Interfaces:** +- Produces (each `(cx,cy,r) -> str`): `db_retry`, `eof_fixer`, `tag`. + +- [ ] **Step 1: Write the failing test** + + ```python + # append to tests/test_symbols.py + UTIL_SYMBOLS = ["db_retry", "eof_fixer", "tag"] + + @pytest.mark.parametrize("name", UTIL_SYMBOLS) + def test_util_symbol_parses(name: str) -> None: + minidom.parseString(_wrap(getattr(sym, name)(50, 50, 23))) + ``` + +- [ ] **Step 2: Run test to verify it fails** + + Run: `uv run pytest tests/test_symbols.py -q` + Expected: FAIL — `AttributeError`. + +- [ ] **Step 3: Implement the utility symbols** + + ```python + # append to brand/build/symbols.py + def db_retry(cx: float, cy: float, r: float) -> str: + """db-retry: a database cylinder inside a two-head clockwise retry circle.""" + rad = 0.92 * r + return _cyl(cx, cy, r * 0.6) + _circ_arc(cx, cy, rad, 285, 425, 4.5) + _circ_arc(cx, cy, rad, 105, 245, 4.5) + + + def eof_fixer(cx: float, cy: float, r: float) -> str: + """eof-fixer: a document with a newline-return (down-then-left) arrow.""" + doc = ( + f'' + ) + for i in range(3): + doc += ( + f'' + ) + doc += ( + f'' + ) + doc += _ah(cx - 0.2 * r, cy + 0.55 * r, math.pi, r * 0.24) + return doc + + + def tag(cx: float, cy: float, r: float) -> str: + """semvertag: a price/version tag with a punch-hole, vertically centred.""" + return ( + f'' + f'' + ) + ``` + +- [ ] **Step 4: Run test to verify it passes** + + Run: `uv run pytest tests/test_symbols.py -q` + Expected: PASS. + +- [ ] **Step 5: Commit** + + ```bash + git add brand/build/symbols.py tests/test_symbols.py + git commit -m "feat(brand): utility inner symbols" + ``` + +--- + +### Task 5: Parametric project frame + +**Files:** +- Modify: `brand/build/geometry.py` +- Test: `tests/test_projects.py` + +**Interfaces:** +- Produces: `project_frame(*, struct: str, accent: str, w: int = 100, h: int = 100, + m: int = 9, lx: int = 53, ly: int = 53, s: int = 11) -> str` — bare frame markup + (no `` wrapper), two pinwheeled L-snakes. + +- [ ] **Step 1: Write the failing test** + + ```python + # tests/test_projects.py + from xml.dom import minidom + from brand.build import geometry as g + from brand.build import tokens as t + + def test_project_frame_parses_and_uses_tokens() -> None: + frame = g.project_frame(struct=t.GREEN_INK, accent=t.GOLD_LIGHT) + minidom.parseString(f'{frame}') + assert t.GREEN_INK in frame and t.GOLD_LIGHT in frame + ``` + +- [ ] **Step 2: Run test to verify it fails** + + Run: `uv run pytest tests/test_projects.py -q` + Expected: FAIL — `AttributeError: module ... has no attribute 'project_frame'`. + +- [ ] **Step 3: Implement `project_frame`** + + ```python + # append to brand/build/geometry.py + def project_frame( + *, + struct: str, + accent: str, + w: int = 100, + h: int = 100, + m: int = 9, + lx: int = 53, + ly: int = 53, + s: int = 11, + ) -> str: + """Two pinwheeled L-snakes in opposite corners — the constant project frame. + Returns bare markup (no wrapper).""" + hs = s + 3 + parts = [ + f'', + f'', + f'', + f'', + f'', + f'', + ] + return "".join(parts) + ``` + +- [ ] **Step 4: Run test to verify it passes** + + Run: `uv run pytest tests/test_projects.py -q` + Expected: PASS. + +- [ ] **Step 5: Commit** + + ```bash + git add brand/build/geometry.py tests/test_projects.py + git commit -m "feat(brand): parametric project snake-frame" + ``` + +--- + +### Task 6: Repo manifest + mark composition + +**Files:** +- Create: `brand/build/projects.py` +- Test: `tests/test_projects.py` + +**Interfaces:** +- Consumes: `geometry.project_frame`, all `symbols.*` functions, `tokens.*`. +- Produces: `MANIFEST: dict[str, Callable[[], str]]` (17 repos → inner-symbol + thunks at `R=23`), `project_mark(repo: str) -> str` (full ``), + `ALLOWED_COLORS: frozenset[str]`. + +- [ ] **Step 1: Write the failing test** + + ```python + # append to tests/test_projects.py + import re + import pytest + from brand.build import projects as p + + EXPECTED_REPOS = { + "modern-di", "that-depends", "modern-di-fastapi", "modern-di-litestar", + "modern-di-faststream", "modern-di-typer", "modern-di-pytest", + "fastapi-sqlalchemy-template", "litestar-sqlalchemy-template", + "lite-bootstrap", "httpware", "faststream-redis-timers", + "faststream-concurrent-aiokafka", "faststream-outbox", + "db-retry", "eof-fixer", "semvertag", + } + + def test_manifest_covers_every_repo() -> None: + assert set(p.MANIFEST) == EXPECTED_REPOS + + @pytest.mark.parametrize("repo", sorted(EXPECTED_REPOS)) + def test_project_mark_is_valid_svg(repo: str) -> None: + svg = p.project_mark(repo) + minidom.parseString(svg) + assert svg.startswith(" None: + hexes = {h.lower() for h in re.findall(r"#[0-9a-fA-F]{6}", p.project_mark(repo))} + assert hexes <= p.ALLOWED_COLORS, f"{repo} stray colours: {hexes - p.ALLOWED_COLORS}" + + def test_templates_use_chevron() -> None: + # both templates share the org chevron (a polyline), not a bespoke symbol + for repo in ("fastapi-sqlalchemy-template", "litestar-sqlalchemy-template"): + assert " str: + """Full for a repo: constant frame + its gold inner symbol.""" + frame = g.project_frame(struct=t.GREEN_INK, accent=t.GOLD_LIGHT) + inner = MANIFEST[repo]() + return ( + '{frame}{inner}' + ) + ``` + +- [ ] **Step 4: Run test to verify it passes** + + Run: `uv run pytest tests/test_projects.py -q` + Expected: PASS (manifest coverage + 17 valid-SVG + 17 colour + templates). + +- [ ] **Step 5: Commit** + + ```bash + git add brand/build/projects.py tests/test_projects.py + git commit -m "feat(brand): repo manifest + project mark composition" + ``` + +--- + +### Task 7: Render marks to `brand/projects//` + +**Files:** +- Create: `brand/build/raster.py` +- Modify: `brand/build/render.py` +- Modify: `brand/build/projects.py` +- Test: `tests/test_projects.py` + +**Interfaces:** +- Produces: `raster.export_png(svg_path, png_path, *, width=None, height=None) -> bool` + (moved verbatim from `render.py`); `projects.render_projects(out_dir: Path | None = None) -> list[Path]` + — writes `mark.svg` (+ `mark-512.png`, `mark-1024.png`) per repo, returns the + written `mark.svg` paths. + +Why `raster.py`: `projects` needs `export_png` and `render` needs +`render_projects`. Putting `export_png` in a third module both import keeps every +import at module level with no cycle (`render → projects → raster`, `render → raster`). + +- [ ] **Step 1: Write the failing test** + + ```python + # append to tests/test_projects.py + from pathlib import Path + + def test_render_projects_writes_every_mark(tmp_path: Path) -> None: + written = p.render_projects(out_dir=tmp_path) + assert len(written) == len(EXPECTED_REPOS) + for repo in EXPECTED_REPOS: + svg = tmp_path / repo / "mark.svg" + assert svg.is_file() and svg.read_text(encoding="utf-8").startswith(" bool: + exe = shutil.which("rsvg-convert") + if exe is None: + return False + args = [exe] + if width is not None: + args += ["-w", str(width)] + if height is not None: + args += ["-h", str(height)] + args += [str(svg_path), "-o", str(png_path)] + subprocess.run(args, check=True) + return True + ``` + + Then in `render.py`: delete its local `export_png` def and `shutil`/`subprocess` + imports, and add at the top (module level): + + ```python + from brand.build.projects import render_projects + from brand.build.raster import export_png + ``` + +- [ ] **Step 3b: Implement `render_projects` and call it from `render()`** + + ```python + # in brand/build/projects.py — add to the top-level imports: + from pathlib import Path + + from brand.build.raster import export_png + + # ... after project_mark(); module-level constants then the function: + ROOT = Path(__file__).resolve().parents[2] + PROJECTS = ROOT / "brand" / "projects" + _PNG_SIZES = (512, 1024) + + + def render_projects(out_dir: Path | None = None) -> list[Path]: + """Write mark.svg (+ PNGs) for every repo under out_dir//.""" + base = out_dir if out_dir is not None else PROJECTS + written: list[Path] = [] + for repo in MANIFEST: + d = base / repo + d.mkdir(parents=True, exist_ok=True) + svg = d / "mark.svg" + svg.write_text(project_mark(repo) + "\n", encoding="utf-8") + for sz in _PNG_SIZES: + export_png(svg, d / f"mark-{sz}.png", width=sz, height=sz) + written.append(svg) + return written + ``` + + ```python + # in brand/build/render.py, inside render() after the org marks block: + # Per-project marks (brand/projects//). + render_projects() + ``` + +- [ ] **Step 4: Run the test + full render** + + Run: `uv run pytest tests/test_projects.py -q` + Expected: PASS. + Run: `uv run python -m brand.build.render` + Expected: no error; org marks still written to `brand/org/`; `ls brand/projects/` + shows all 17 repo folders, each with `mark.svg` (+ PNGs if `rsvg-convert` present). + +- [ ] **Step 5: Commit** + + ```bash + git add brand/build/raster.py brand/build/render.py brand/build/projects.py tests/test_projects.py brand/projects + git commit -m "feat(brand): render per-project marks to brand/projects/" + ``` + +--- + +### Task 8: Horizontal name lockup per repo + +**Files:** +- Modify: `brand/build/projects.py` +- Test: `tests/test_projects.py` + +**Interfaces:** +- Consumes: `text.outline_text`, `project_mark` internals. +- Produces: `project_lockup(repo: str) -> str` (mark at left + repo name in Jost + to its right); `render_projects` also writes `lockup.svg` per repo. + +- [ ] **Step 1: Write the failing test** + + ```python + # append to tests/test_projects.py + @pytest.mark.parametrize("repo", ["modern-di", "faststream-outbox", "semvertag"]) + def test_lockup_is_valid_and_names_repo(repo: str) -> None: + svg = p.project_lockup(repo) + minidom.parseString(svg) + assert svg.startswith(" None: + p.render_projects(out_dir=tmp_path) + assert (tmp_path / "modern-di" / "lockup.svg").is_file() + ``` + +- [ ] **Step 2: Run test to verify it fails** + + Run: `uv run pytest tests/test_projects.py -q -k lockup` + Expected: FAIL — `AttributeError: ... 'project_lockup'`. + +- [ ] **Step 3: Implement `project_lockup` and write it in `render_projects`** + + ```python + # add near the top imports of brand/build/projects.py + from brand.build.text import outline_text + + # add to brand/build/projects.py + _LOCKUP_H = 100 + _NAME_SIZE = 34 + _GAP = 18 + + + def project_lockup(repo: str) -> str: + """Framed mark on the left + the repo name in Jost (green) to its right.""" + mark_frame = g.project_frame(struct=t.GREEN_INK, accent=t.GOLD_LIGHT) + inner = MANIFEST[repo]() + name_x = _LOCKUP_H + _GAP + name_svg, name_w = outline_text( + repo, _NAME_SIZE, x=name_x, baseline_y=_LOCKUP_H / 2 + _NAME_SIZE * 0.34, + anchor="start", color=t.GREEN_INK, + ) + total_w = round(name_x + name_w + _GAP) + return ( + f'' + f'{mark_frame}{inner}' + f"{name_svg}" + ) + ``` + + ```python + # in render_projects(), inside the for-loop after writing mark.svg: + (d / "lockup.svg").write_text(project_lockup(repo) + "\n", encoding="utf-8") + ``` + +- [ ] **Step 4: Run the tests + render** + + Run: `uv run pytest tests/test_projects.py -q` + Expected: PASS. + Run: `uv run python -m brand.build.render` then `ls brand/projects/modern-di/` + Expected: `mark.svg`, `lockup.svg` (+ PNGs) present. + +- [ ] **Step 5: Commit** + + ```bash + git add brand/build/projects.py tests/test_projects.py brand/projects + git commit -m "feat(brand): per-project horizontal name lockups" + ``` + +--- + +### Task 9: Docs — README + architecture + finalize bundle + +**Files:** +- Modify: `brand/README.md` +- Create: `architecture/brand-marks.md` +- Modify: `planning/changes/2026-06-29.01-per-project-brand-marks/design.md` (summary) + +One sentence: document the shipped capability and promote it into +`architecture/`, per the planning convention. + +- [ ] **Step 1: Update `brand/README.md`** + + Replace the "Deferred" section's first line so per-project marks are no longer + listed as deferred; add a short subsection: + + ```markdown + ## Per-project marks (`brand/projects/`) + + Each repo gets a large-format mark: the constant green+gold snake-frame with + 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. + ``` + +- [ ] **Step 2: Create `architecture/brand-marks.md`** + + ```markdown + # Brand marks + + The org's logo assets, generated by `brand/build/` (no frontmatter; living prose). + + ## Org marks (`brand/org/`) + Favicon, avatar, social cards — the interlocked-snakes pinwheel with a chevron. + Used everywhere small (favicons, avatars). See `site-branding.md` for site wiring. + + ## Per-project marks (`brand/projects//`) + One large-format logo per repo: the constant green+gold snake-frame + (`geometry.py::project_frame`, margin 9 / arm 53 / stroke 11) with a single + gold inner symbol (`symbols.py`) chosen per repo in `projects.py::MANIFEST`. + Two-colour (green + gold); repos differ by symbol shape, not colour. The two + 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`. + ``` + +- [ ] **Step 3: Finalize the bundle summary** + + Edit the `summary:` in `design.md` to the realized result, e.g.: + `summary: Per-project marks shipped — 17 repos, constant snake-frame + gold inner symbol, generated into brand/projects/.` + +- [ ] **Step 4: Verify everything** + + Run: `uv run pytest -q` (full suite) → 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-29.01-per-project-brand-marks/design.md + git commit -m "docs(brand): document per-project marks + promote to architecture" + ``` + +--- + +## Notes for the executor + +- After all tasks: push the branch and open a PR (do not local-merge); watch CI. +- The validated visual prototype lives only in the brainstorm scratchpad; this + plan is the source of truth. If a rendered mark looks off, render a contact + sheet (`rsvg-convert` each `mark.svg` to PNG) and compare — the geometry here + is the version that passed visual review. +- Do not alter `brand/org/` (output) or the org-mark `render()` blocks; only + move `export_png` to `raster.py` and add the per-project pass. +- `terminal()` uses a live `` "T>" (generic monospace). Unlike the + org wordmark (outlined via `text.py` for font-independence), this is a tiny + glyph and renders fine with `rsvg-convert`'s default fonts. If a downstream + context needs font-independence, swap it to `text.outline_text` with Jost. diff --git a/planning/decisions/2026-06-29-project-marks-single-gold-inner.md b/planning/decisions/2026-06-29-project-marks-single-gold-inner.md new file mode 100644 index 0000000..557dba9 --- /dev/null +++ b/planning/decisions/2026-06-29-project-marks-single-gold-inner.md @@ -0,0 +1,44 @@ +--- +status: accepted +summary: Project marks use a single gold inner symbol; per-family accent colours rejected as too busy. +supersedes: null +superseded_by: null +--- + +# Project marks are two-colour (green+gold), differentiated by symbol not colour + +**Decision:** Every per-project mark uses the constant green+gold snake-frame +with a single **gold** inner symbol. Projects are told apart by the *shape* of +the inner symbol, not by colour. No per-family accent hue. + +## Context + +While designing the per-project system we evaluated giving each of the four +families its own accent colour for the inner symbol — a "Heritage" palette +(dependency-injection = terracotta, templates = slate blue, microservices = +plum, utilities = teal) and a brighter "Vivid" alternative. The research on +comparable systems (JetBrains, Adobe, Astral) recommends two discriminators +(shape **and** colour) — but only because those marks must survive favicon +sizes. Our project marks are **large-format only**; the org mark remains every +repo's favicon. + +Options on the table: +1. Green+gold frame + gold inner (one colour) — chosen. +2. Green+gold frame + per-family accent inner (three colours on the mark). +3. All-green frame + per-family accent inner. + +## Decision & rationale + +Rendered side by side, the per-family accent made each mark a **three-colour** +object (green frame + gold frame + accent inner), which read as busy and diluted +the org identity. Since favicon-size legibility is explicitly a non-goal, the +second discriminator (colour) buys little. A single gold inner keeps every mark +unmistakably part of one family, matches the existing brand palette exactly, and +means a new repo only needs a new *shape*, not a new colour to keep harmonious. +Templates don't even get a symbol — they reuse the org chevron mark. + +## Revisit trigger + +If the org ever needs these marks at favicon scale (shape alone insufficient to +disambiguate), or if a family grows large enough that a colour band materially +helps wayfinding on the org site, reopen and reconsider per-family accents. diff --git a/tests/conftest.py b/tests/conftest.py index e3a10a2..879baa0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,4 +6,5 @@ def parse_svg(): def _parse(svg: str) -> ET.Element: return ET.fromstring(svg) # raises on malformed XML + return _parse diff --git a/tests/test_assets.py b/tests/test_assets.py index 4d06c1d..bb8eca2 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -30,13 +30,15 @@ def test_render_writes_avatar_and_apple_touch(): _render() assert (ORG / "avatar.svg").exists() ET.parse(ORG / "avatar.svg") - apple = (ORG / "apple-touch-icon.svg") + apple = ORG / "apple-touch-icon.svg" assert apple.exists() assert 'points="45,40 57,50 45,60"' in apple.read_text() assert 'points="45,40 57,50 45,60"' in (ORG / "avatar.svg").read_text() if shutil.which("rsvg-convert"): assert (ORG / "avatar-1024.png").read_bytes()[:8] == b"\x89PNG\r\n\x1a\n" - assert (ORG / "apple-touch-icon-180.png").read_bytes()[:8] == b"\x89PNG\r\n\x1a\n" + assert (ORG / "apple-touch-icon-180.png").read_bytes()[ + :8 + ] == b"\x89PNG\r\n\x1a\n" def test_render_writes_avatar_circle(): @@ -51,15 +53,15 @@ def test_render_writes_site_wordmark_and_mark(): _render() light = (ORG / "wordmark.svg").read_text() assert ET.parse(ORG / "wordmark.svg") is not None - assert "#356852" in light and "#c98a00" in light # green ink + gold-light + assert "#356852" in light and "#c98a00" in light # green ink + gold-light dark = (ORG / "wordmark-dark.svg").read_text() - assert "#f4f1e8" in dark and "#f0b528" in dark # cream + gold-dark + assert "#f4f1e8" in dark and "#f0b528" in dark # cream + gold-dark for wm in ("wordmark.svg", "wordmark-dark.svg"): - assert "{body}') - assert "M138 122 L138 50 L210 50" in body # TL crop + assert "M138 122 L138 50 L210 50" in body # TL crop assert "M402 128 L402 200 L330 200" in body # BR crop assert "#356852" in body and "#c98a00" in body - assert "= 4 # 2 crops + >=2 glyph paths - assert 'points="134,120 142,120 142,122 134,130"' in body # TL snake tail - assert 'points="406,130 398,130 398,128 406,120"' in body # BR snake tail + assert "= 4 # 2 crops + >=2 glyph paths + assert 'points="134,120 142,120 142,122 134,130"' in body # TL snake tail + assert 'points="406,130 398,130 398,128 406,120"' in body # BR snake tail def test_wordmark_is_transparent_two_color_lockup(parse_svg): svg = g.wordmark(struct="#356852", gold="#c98a00") el = parse_svg(svg) - assert el.attrib["viewBox"] == "118 32 304 184" # tight, centered on the lockup - assert " None: + frame = g.project_frame(struct=t.GREEN_INK, accent=t.GOLD_LIGHT) + minidom.parseString( + f'{frame}' + ) + assert t.GREEN_INK in frame and t.GOLD_LIGHT in frame + + +EXPECTED_REPOS = { + "modern-di", + "that-depends", + "modern-di-fastapi", + "modern-di-litestar", + "modern-di-faststream", + "modern-di-typer", + "modern-di-pytest", + "fastapi-sqlalchemy-template", + "litestar-sqlalchemy-template", + "lite-bootstrap", + "httpware", + "faststream-redis-timers", + "faststream-concurrent-aiokafka", + "faststream-outbox", + "db-retry", + "eof-fixer", + "semvertag", +} + + +def test_manifest_covers_every_repo() -> None: + assert set(p.MANIFEST) == EXPECTED_REPOS + + +@pytest.mark.parametrize("repo", sorted(EXPECTED_REPOS)) +def test_project_mark_is_valid_svg(repo: str) -> None: + svg = p.project_mark(repo) + minidom.parseString(svg) + assert svg.startswith(" None: + hexes = {h.lower() for h in re.findall(r"#[0-9a-fA-F]{6}", p.project_mark(repo))} + assert hexes <= p.ALLOWED_COLORS, ( + f"{repo} stray colours: {hexes - p.ALLOWED_COLORS}" + ) + + +def test_templates_use_chevron() -> None: + # both templates share the org chevron (a polyline), not a bespoke symbol + for repo in ("fastapi-sqlalchemy-template", "litestar-sqlalchemy-template"): + assert " None: + written = p.render_projects(out_dir=tmp_path) + assert len(written) == len(EXPECTED_REPOS) + for repo in EXPECTED_REPOS: + svg = tmp_path / repo / "mark.svg" + assert svg.is_file() and svg.read_text(encoding="utf-8").startswith(" None: + svg = p.project_lockup(repo) + minidom.parseString(svg) + assert svg.startswith(" None: + p.render_projects(out_dir=tmp_path) + assert (tmp_path / "modern-di" / "lockup.svg").is_file() diff --git a/tests/test_symbols.py b/tests/test_symbols.py new file mode 100644 index 0000000..1f228ae --- /dev/null +++ b/tests/test_symbols.py @@ -0,0 +1,51 @@ +from xml.dom import minidom + +import pytest + +from brand.build import symbols as sym + + +def _wrap(markup: str) -> str: + return ( + f'{markup}' + ) + + +def test_helpers_emit_parseable_svg() -> None: + for markup in ( + sym._ah(50, 50, 0.0, 6), + sym._cyl(50, 50, 20), + sym._star5(50, 50, 18, sym.GOLD), + sym._circ_arc(50, 50, 20, 285, 425, 4.5), + ): + minidom.parseString(_wrap(markup)) # raises on malformed XML + + +DI_SYMBOLS = ["bolt_disc", "star_disc", "faststream", "terminal", "bars", "chevron"] + + +@pytest.mark.parametrize("name", DI_SYMBOLS) +def test_di_symbol_parses(name: str) -> None: + markup = getattr(sym, name)(50, 50, 23) + minidom.parseString(_wrap(markup)) + + +def test_graph_dashed_vs_solid() -> None: + assert "stroke-dasharray" in sym.graph(50, 50, 23, dashed=True) + assert "stroke-dasharray" not in sym.graph(50, 50, 23, dashed=False) + + +MSG_SYMBOLS = ["rocket", "chain", "stopwatch", "lanes", "outbox"] + + +@pytest.mark.parametrize("name", MSG_SYMBOLS) +def test_msg_symbol_parses(name: str) -> None: + minidom.parseString(_wrap(getattr(sym, name)(50, 50, 23))) + + +UTIL_SYMBOLS = ["db_retry", "eof_fixer", "tag"] + + +@pytest.mark.parametrize("name", UTIL_SYMBOLS) +def test_util_symbol_parses(name: str) -> None: + minidom.parseString(_wrap(getattr(sym, name)(50, 50, 23))) diff --git a/tests/test_text.py b/tests/test_text.py index bfd0acf..b3b0c0c 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -2,8 +2,9 @@ def test_outline_returns_group_and_paths(parse_svg): - g, width = outline_text("MODERN", 50, x=270, baseline_y=126, - anchor="middle", color="#356852") + g, width = outline_text( + "MODERN", 50, x=270, baseline_y=126, anchor="middle", color="#356852" + ) el = parse_svg(g) assert el.tag.endswith("g") assert el.attrib["fill"] == "#356852" @@ -19,8 +20,7 @@ def test_width_grows_with_length(): def test_fit_width_pins_rendered_width(): - _, w = outline_text("MODERN", 50, x=270, baseline_y=126, - anchor="middle", fit_width=210) + _, w = outline_text( + "MODERN", 50, x=270, baseline_y=126, anchor="middle", fit_width=210 + ) assert abs(w - 210.0) < 1e-6 - -