# SaaStock — BrandOps Playbook The single reference for **how every company logo, brand color, and canonical company name** on saastock.com gets resolved, cached, and rendered. Powered by [Brandfetch](https://brandfetch.com). One-shot enrichment script crawls every speaker and sponsor in the site data, caches a logo per brand, and feeds every consuming surface from a single manifest. This playbook extends [SearchOps](/agents/searchops/) (page-level SEO/AEO) and is consumed by [SpeakerOps](/agents/speakerops/) (which speakers render on home cards) and the sponsor-section conventions on every event page. --- ## 1. The one-shot enrichment rule **Whenever a new speaker or a new sponsor is added to the site data, re-run `scripts/fetch_brand_logos.py` and the site is fully logo-enriched in one pass.** The script is idempotent — only new (uncached) companies are fetched — so it is safe to re-run on every PR that touches speakers or sponsors. ```bash python3 scripts/fetch_brand_logos.py # full pass python3 scripts/fetch_brand_logos.py --limit 20 # probe with a small slice python3 scripts/fetch_brand_logos.py --only intercom # retry one ``` The script is the only place that talks to Brandfetch. Renderers never call the API at runtime. --- ## 2. What the script crawls The script collects unique company names from every "source of truth" on the site, in this priority order: 1. **`apps/web/app/speakers/registry.ts`** — every speaker's `latestCompany`. ~500–600 companies once de-duplicated. 2. **`apps/web/app/home/summary.json#topSponsors[].display`** — the 16+ recurring sponsors shown on the home page. When you add a new event `data.ts` with sponsor names that aren't otherwise in the registry, also add their names here (the summary feed) so they get enriched. Sponsor names that DO appear in the registry (because someone from that company has spoken) are already covered automatically — that's the common case. If a name appears in multiple sources, the FIRST source wins for the source-tag in the manifest, but the logo is the same either way. --- ## 3. The fetch pipeline (per company) 1. Check `DOMAIN_OVERRIDES` at the top of `scripts/fetch_brand_logos.py` — used to disambiguate common names (`Front` → `front.com`, `ConvertKit` → `kit.com` post-rebrand, `Roam` → `ro.am`). Add to this map when the auto-resolved domain is wrong. 2. Else call `GET https://api.brandfetch.io/v2/search/{name}` and pick `(verified, qualityScore)` max. 3. `GET https://api.brandfetch.io/v2/brands/{domain}` for the full record. 4. Pick best logo: `type=icon` > `symbol` > `logo` > `other`; prefer `theme=light` (dark logo on light bg); prefer `svg` > `png` > `webp` > `jpg`. 5. Download to `apps/web/public/logos/.`. 6. Extract a primary brand color (`type=brand` preferred, then `type=accent`, skipping pure black/white). 7. Append to `apps/web/generated/brands/index.json`. The script throttles to ~8 requests/second via a 120 ms sleep between calls so we don't trip Brandfetch's rate limiter on full passes. The Brandfetch API key lives in `apps/web/.env.local` as `BRANDFETCH_API_KEY` (gitignored). Rotate via the Brandfetch dashboard and update the local file; CI/Vercel reads from the same env var name. --- ## 4. Where logos render automatically Every surface below reads from `apps/web/generated/brands/index.json` via a one-line lookup. Running the script enriches all of them simultaneously. | Surface | File | Key used for lookup | |--------------------------------------------|-----------------------------------------------------------|----------------------------| | Home featured-speakers cards | `apps/web/app/home/top-speakers.ts` | `speaker.latestCompany` | | Home top-sponsors cards | `apps/web/app/page.tsx` | `sponsor.display` | | /speakers master list cards | `apps/web/app/speakers/SpeakersBrowser.tsx` | `speaker.latestCompany` | | Master nav speakers dropdown | `apps/web/components/site-chrome.tsx` | `speaker.latestCompany` | | Event-page sponsor rows | `apps/web/components/event-page.tsx` | `eventSponsor.name` | When you wire up a new surface that should show logos, follow Section 6's consumer pattern. --- ## 5. Domain overrides (the disambiguation rule) The Search API gets it right ~70% of the time. The other 30% are ambiguous names ("Front" — many companies share it), rebrands ("ConvertKit" → `kit.com`), and short product names that don't match the company URL ("Roam" → `ro.am`). For these, we pin the canonical domain in `DOMAIN_OVERRIDES` at the top of `scripts/fetch_brand_logos.py`. When you spot a wrong logo on the site: 1. Verify by visiting `https://api.brandfetch.io/v2/search/{name}` — note the domain the top result points at. 2. If wrong, add an override: `"": ""`. Use the **exact** string from `latestCompany` in the speaker registry or `display`/`name` in the sponsor data — that's the key. 3. Delete the bad logo file from `apps/web/public/logos/`. 4. Re-run `scripts/fetch_brand_logos.py`. The override + missing file together force a fresh fetch. --- ## 6. Consumer pattern (use this when wiring up a new surface) In any `.ts`/`.tsx` file: ```ts import brandManifest from "@/generated/brands/index.json"; // or, depending on relative depth: import brandManifest from "../../generated/brands/index.json"; const brands = brandManifest as Record; const logoPath = brands[company]?.logoPath; // "/logos/intercom.png" or undefined ``` Render: ```tsx {logoPath ? ( // eslint-disable-next-line @next/next/no-img-element ) : null} ``` Rules: - **Always use `object-contain`** — never `object-cover`. Square crop destroys wordmarks. - **Always lazy-load below the fold** (`loading="lazy" decoding="async"`). Logos are not LCP candidates. - **Never use the Brandfetch CDN URL directly in JSX.** Always go through the manifest and the cached file in `/public/logos/`. The CDN URL contains a cache-bust query param that changes on rebuild and breaks our build-time HTTP cache. - **Raw `` over `next/image`** — logos are tiny (<20 KB), already optimised at fetch time, and the Next image loader adds more overhead than it saves. - **Light backgrounds only, for now.** If we add dark sections that need light logos, the script needs to fetch the `theme=dark` variant too and the manifest needs a second `logoPathDark` field. Not yet shipped. --- ## 7. What this agent does NOT own - **Who appears on the home page or in the nav** — that's [SpeakerOps](/agents/speakerops/) (`FEATURED_*_SLUGS` in `top-speakers.ts`). - **The speaker registry itself** — that's [SearchOps](/agents/searchops/) (`scripts/generate_speaker_registry.py`). - **Photo / video file naming and gallery schema** — that's [MediaOps](/agents/mediaops/). Logos are flat brand marks, not editorial photography; different domain. - **Sponsor tier mapping or which sponsors appear at which level** — sponsor curation lives in `apps/web/app/home/summary.json` and per-event `data.ts` files. BrandOps provides the logo file path; the renderer decides which sponsors to show. If you change which speakers or sponsors appear anywhere on the site, re-run `scripts/fetch_brand_logos.py` so any new companies get their logos cached.