diff --git a/CLAUDE.md b/CLAUDE.md index ac7f8b0..b7798a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,7 +138,7 @@ consistent with it: `:>` / `$` / `>` prompt. - Per-repo identity is generated, not hand-drawn: `brand/fleet.json` + `brand/generate.py` emit each repo's README banner, badge row, and social-preview - card. See `brand/README.md`; `ci/validate.py`'s **brand** check fails on a + card, plus the home-screen poster for a repo that ships a Roku channel. See `brand/README.md`; `ci/validate.py`'s **brand** check fails on a hand-edited `brand/generated/`. - Tagline: **"Production that shows up when the need does."** (carries over from the former Pitzi Labs — same business, same voice; only name/palette/mark diff --git a/brand/README.md b/brand/README.md index 87806d5..d93d853 100644 --- a/brand/README.md +++ b/brand/README.md @@ -13,7 +13,9 @@ brand/ fonts/ Space Grotesk + JetBrains Mono woff2, vendored for OG rasterizing fleet.json Per-repo identity: mark, category, tagline, prompt line, badges generate.py fleet.json -> generated//{banner.svg, og.html, readme.md} - render.sh generated//og.html -> og.png via headless Chrome + (+ roku-poster.html for a repo with "roku": true) + render.sh og.html -> og.png, roku-poster.html -> roku-poster-{fhd,hd}.png, + via headless Chrome generated/ Committed output. Generated — never hand-edit (CI enforces this). ``` @@ -24,6 +26,7 @@ brand/ | `banner.svg` | the repo's `assets/banner.svg`, shown at the top of its README | copied in by PR | | badge row | the repo's README, under the banner | copied in by PR | | `og.png` | the repo's **social preview** | **manual upload — GitHub has no API for it** | +| `roku-poster-fhd.png` (540×405), `roku-poster-hd.png` (290×218) | a Roku channel's home-screen tile, named by `mm_icon_focus_fhd` / `mm_icon_focus_hd` in its manifest. Only repos with `"roku": true` (brasenia: `roku-app/images/`) | copied in by PR | `generated//readme.md` holds the exact header block a repo should carry, so applying it is mechanical rather than a matter of taste. @@ -33,15 +36,16 @@ so applying it is mechanical rather than a matter of taste. ```bash python3 brand/generate.py # all repos python3 brand/generate.py --repo solidago -./brand/render.sh # og.html -> og.png (needs Chrome/Chromium) +./brand/render.sh # og.png + Roku posters (needs Chrome/Chromium) ./brand/render.sh solidago ``` -`ci/validate.py`'s **brand** check regenerates `banner.svg` and `readme.md` into -memory and diffs them against what's committed, so a hand-edit fails CI. It -skips `og.png` — rasterizing needs a browser, which CI has no reason to carry. -**Re-run `render.sh` yourself whenever `fleet.json` changes**, or the cards drift -from the banners without CI noticing. +`ci/validate.py`'s **brand** check regenerates `banner.svg`, `readme.md` and +`roku-poster.html` into memory and diffs them against what's committed, so a +hand-edit fails CI. It skips the PNGs — rasterizing needs a browser, which CI +has no reason to carry. **Re-run `render.sh` yourself whenever `fleet.json` or +`generate.py` changes**, or the cards and posters drift from the banners without +CI noticing. ## Design notes diff --git a/brand/fleet.json b/brand/fleet.json index 713985f..bb0790e 100644 --- a/brand/fleet.json +++ b/brand/fleet.json @@ -11,7 +11,10 @@ " prompt — the '▲ lentago' field-prompt line. Keep <= 64 chars.", " badges — stack badges appended after the shared CI/license set.", " {label, logo} renders a shields.io simple-icons badge; a bare", - " {label} renders a text-only badge in the same Tidewater colors." + " {label} renders a text-only badge in the same Tidewater colors.", + " roku — optional; true for a repo that ships a Roku channel. Emits", + " roku-poster.html, which render.sh turns into the channel's", + " home-screen posters (roku-poster-fhd.png, roku-poster-hd.png)." ], "solidago": { "mark": "solidago", @@ -143,7 +146,8 @@ "label": "Roku", "logo": "roku" } - ] + ], + "roku": true }, "asclepias": { "mark": "asclepias", diff --git a/brand/generate.py b/brand/generate.py index a945a63..163565e 100755 --- a/brand/generate.py +++ b/brand/generate.py @@ -10,6 +10,10 @@ render.sh turns this into og.png via headless Chrome. readme.md The exact README header block (banner + badge row) the target repo should carry, so applying it is mechanical. + roku-poster.html + Only for repos with "roku": true. The Roku channel's home-screen + poster, drawn on the 540x405 grid; render.sh turns it into + roku-poster-fhd.png (540x405) and roku-poster-hd.png (290x218). Banners deliberately keep the font *stack* rather than converting text to paths: GitHub proxies README SVGs through camo and will not load a webfont, so @@ -228,6 +232,47 @@ def og_html(repo: str, cfg: dict) -> str: """ +def roku_poster_html(repo: str, cfg: dict) -> str: + """Roku home-screen poster: the genus chip over the repo name. + + One SVG on Roku's 540x405 FHD poster grid, scaled to the window, so + render.sh rasterizes both manifest sizes from this one page. The home + screen shows it at thumbnail size, so it carries only the mark and the name. + """ + w, h = 540, 405 + inner_chip = mark_inner(mark_svg(cfg["mark"]), drop_chip=False) + face = font_face("Space Grotesk", "space-grotesk-latin.woff2", "400 700") + size = max(40, min(72, int(440 / (max(len(repo), 1) * 0.58)))) + + return f""" + +{esc(repo)} — roku poster + + +{HERO_DEFS.format(w=w, h=h)} + + + +{contours(292, 318, 0.72)} + + + +{inner_chip} + + + {esc(repo)} + + + + +""" + + def badge(label: str, logo: str | None, color: str = BRAND) -> str: slug = label.replace("-", "--").replace("_", "__").replace(" ", "%20") url = f"{BADGE}/{slug}-{color.lstrip('#')}?{SHIELD_STYLE}" @@ -280,10 +325,12 @@ def main() -> None: (d / "banner.svg").write_text(banner(repo, cfg)) (d / "og.html").write_text(og_html(repo, cfg)) (d / "readme.md").write_text(readme_block(repo, cfg)) + if cfg.get("roku"): + (d / "roku-poster.html").write_text(roku_poster_html(repo, cfg)) print(f" {repo:<30} mark={cfg['mark']:<10} {cfg['kind']}") print(f"\n{len(fleet)} repo(s) → {OUT.relative_to(ROOT.parent)}/") - print("Next: ./brand/render.sh (og.html → og.png via headless Chrome)") + print("Next: ./brand/render.sh (og.html → og.png, roku-poster.html → roku-poster-*.png)") if __name__ == "__main__": diff --git a/brand/generated/brasenia/roku-poster-fhd.png b/brand/generated/brasenia/roku-poster-fhd.png new file mode 100644 index 0000000..d433c85 Binary files /dev/null and b/brand/generated/brasenia/roku-poster-fhd.png differ diff --git a/brand/generated/brasenia/roku-poster-hd.png b/brand/generated/brasenia/roku-poster-hd.png new file mode 100644 index 0000000..8df9adb Binary files /dev/null and b/brand/generated/brasenia/roku-poster-hd.png differ diff --git a/brand/generated/brasenia/roku-poster.html b/brand/generated/brasenia/roku-poster.html new file mode 100644 index 0000000..ac288e9 --- /dev/null +++ b/brand/generated/brasenia/roku-poster.html @@ -0,0 +1,46 @@ + + +brasenia — roku poster + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + brasenia + + + + diff --git a/brand/render.sh b/brand/render.sh index 58e3e48..0ba6032 100755 --- a/brand/render.sh +++ b/brand/render.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # Rasterize brand/generated//og.html -> og.png (1280x640) with headless # Chrome, so the social-preview cards carry the real self-hosted Space Grotesk / -# JetBrains Mono rather than a system fallback. +# JetBrains Mono rather than a system fallback. A repo that ships a Roku channel +# also gets roku-poster.html -> roku-poster-fhd.png (540x405) and +# roku-poster-hd.png (290x218), the channel's home-screen posters. # # Same harness lineage as site-pitzilabs-dev/lab/brand-assets/. # @@ -30,22 +32,35 @@ fi profile="$(mktemp -d)" trap 'rm -rf "$profile"' EXIT -for d in "${targets[@]}"; do +# shoot +shoot() { + local d="$1" page="$2" png="$3" size="$4" repo repo="$(basename "$d")" - [ -f "$d/og.html" ] || { echo " skip $repo (no og.html)" >&2; continue; } + rm -f "${d:?}/$png" "$CHROME" --headless --disable-gpu --no-sandbox --hide-scrollbars \ - --user-data-dir="$profile/$repo" \ - --force-device-scale-factor=1 --window-size=1280,640 \ + --user-data-dir="$profile/$repo-${png%.png}" \ + --force-device-scale-factor=1 --window-size="$size" \ --virtual-time-budget=4000 \ - --screenshot="$d/og.png" "file://$d/og.html" >/dev/null 2>&1 + --screenshot="$d/$png" "file://$d/$page" >/dev/null 2>&1 # Chrome exits 0 even when it writes nothing useful; check the artifact. - if [ ! -s "$d/og.png" ]; then - echo "render.sh: $repo produced no og.png" >&2 + if [ ! -s "$d/$png" ]; then + echo "render.sh: $repo produced no $png" >&2 exit 1 fi - printf ' %-30s %s\n' "$repo" "$(du -h "$d/og.png" | cut -f1)" + printf ' %-30s %-22s %s\n' "$repo" "$png" "$(du -h "$d/$png" | cut -f1)" +} + +for d in "${targets[@]}"; do + repo="$(basename "$d")" + [ -f "$d/og.html" ] || { echo " skip $repo (no og.html)" >&2; continue; } + shoot "$d" og.html og.png 1280,640 + if [ -f "$d/roku-poster.html" ]; then + shoot "$d" roku-poster.html roku-poster-fhd.png 540,405 + shoot "$d" roku-poster.html roku-poster-hd.png 290,218 + fi done echo echo "Social previews are a MANUAL upload — GitHub exposes no API for them." echo "For each repo: Settings -> General -> Social preview -> Edit -> Upload og.png" +echo "Roku posters are copied into the channel repo by PR (roku-app/images/)." diff --git a/ci/validate.py b/ci/validate.py index 762f489..367d1c5 100644 --- a/ci/validate.py +++ b/ci/validate.py @@ -281,8 +281,10 @@ def check_brand_assets(): The banners carry a 'generated — do not hand-edit' header and get copied verbatim into 15 repos, so a hand-tweak here silently forks the fleet's - identity from its source. Regenerate into a temp tree and diff. og.png is - skipped: rasterizing needs Chrome, which CI has no reason to carry. + identity from its source. Regenerate into a temp tree and diff. og.png and + the Roku poster PNGs are skipped: rasterizing needs Chrome, which CI has no + reason to carry. The poster's source page is checked, since its PNGs are + copied verbatim into the channel repo the same way the banners are. """ brand = os.path.join(ROOT, "brand") fleet_path = os.path.join(brand, "fleet.json") @@ -325,10 +327,19 @@ def check_brand_assets(): finally: sys.path.pop(0) - stale = [] + stale, orphaned = [], [] for repo, cfg in sorted(fleet.items()): - for filename, produce in (("banner.svg", generate.banner), - ("readme.md", generate.readme_block)): + outputs = [("banner.svg", generate.banner), + ("readme.md", generate.readme_block)] + if cfg.get("roku"): + outputs.append(("roku-poster.html", generate.roku_poster_html)) + else: + # generate.py never deletes, so a poster left after the key is + # dropped would still read as current output. + for leftover in ("roku-poster.html", "roku-poster-fhd.png", "roku-poster-hd.png"): + if os.path.exists(os.path.join(brand, "generated", repo, leftover)): + orphaned.append(f"{repo}/{leftover}") + for filename, produce in outputs: path = os.path.join(brand, "generated", repo, filename) if not os.path.exists(path): stale.append(f"{repo}/{filename} (missing)") @@ -340,6 +351,9 @@ def check_brand_assets(): if stale: fail("brand", "brand/generated is stale or hand-edited: " + ", ".join(stale) + " — run `python3 brand/generate.py`") + if orphaned: + fail("brand", "brand/generated has Roku posters for repos without \"roku\": true: " + + ", ".join(orphaned) + " — delete them") # ------------------------------------------------------------------ census