Lossless Changelog
component

Brand Setup Lands — Paste a URL, Get a Populated Config

First post-memo onboarding step. Click 'Make this look like your firm →' in the JobView's success banner (or 'Brand setup →' in Settings), paste the firm's website URL, watch Claude tool-use its way through the homepage and a couple of linked pages, then review every field on a confirmation form before anything saves to disk. ~15s wall time, ~$0.05–0.15 per fetch via Haiku 4.5. Brand managers stay happy because every Claude guess — colors, fonts, logos, taglines — is editable with live swatches and logo previews before save. Output merges into io/{firm}/configs/brand-{firm}-config.yaml, deep-merging with the firm-creation scaffold so company.conventional_name is never clobbered.

Michael StatonMusic Editoraugmented with Claude Code on Opus 4.7
feat(brand-setup): URL → populated brand config in one flow

Click "Make this look like your firm →" in the JobView success banner
(or "Brand setup →" in Settings), paste the firm's URL, get a populated
brand config in ~15 seconds. Every field is editable before save —
brand managers stay happy.

Backend (sibling investment-memo-orchestrator):
* src/server/brand_fetch.py: Claude tool-use loop with two tools —
  fetch_url(url) for HTML/CSS retrieval (60KB cap, dedupes URLs), and
  submit_brand_config with a 19-field schema. Haiku 4.5 default,
  MEMOPOP_BRAND_MODEL env override. 8-iter cap, ~10–20s wall time,
  ~$0.05–0.15/call. Hex normalization, deep-merge with existing config,
  preserves company.conventional_name from firm-creation scaffold.
* POST /actions/fetch-brand (Claude call in worker thread; SSE streams
  stay responsive). POST /actions/save-brand.
* +4 tests (hex normalization, empty stripping, full shape mapping,
  conventional_name preservation). 22 total passing.

Tauri Rust:
* router.rs: /actions/fetch-brand and /actions/save-brand routed via
  forward_to_sidecar.

Frontend (apps/memopop-native):
* New flow stage `brand_setup`; entry from JobView's green completion
  CTA and Settings.
* BrandSetupModal: prompt_url → fetching (spinner) → confirm (every
  field editable, color swatches next to hex inputs, live <img> logo
  previews, "From Claude:" confidence notes) → saving → saved. Skip
  exit at every step.
* JobView success banner: green CTA next to the artifacts path.
* Settings: "Brand setup →" button appears when active firm is set.

One thing flagged: the running sidecar must be restarted to pick up the
new endpoints (the existing CloseRequested handler now does this
reliably on Cmd+Q).

Changelog - 2026-05-01_01

Brand Setup Lands — Paste a URL, Get a Populated Config

The first post-memo onboarding step is in. Until tonight, every firm's brand config — colors, fonts, logos, tagline — was a hand-written YAML file. The orchestrator's spec for memopop-native called this out as the most-deferred-but-most-important polish step, and pointed at exactly the right shape: "paste your firm's URL, we'll do the rest." That's now built. Claude reads the firm's website with tool-use, fills every field it can, and hands the result to a confirmation form where every value is editable before anything saves to disk. The brand managers — the most opinionated readers of any output — get final say on every pixel of their identity.


Why

The onboarding spec at context-v/specs/An-Onboarding-User-Journey-for-Memopop-Native.md was explicit about what NOT to do during onboarding: don't ask for a logo SVG. Don't ask for a hex color. The 90% drop-off curve is steep, and every field you ask for before the user has seen value is friction they'll bail at. The spec scoped onboarding tightly: capture company.conventional_name only, defer everything else.

The deferred work has a name: Brand setup. It runs after the user has run their first memo, looked at the output, and now cares how it looks because they want to share it. That's when adding a logo and picking a primary color stops feeling like data entry and starts feeling like polish.

The spec named the version we'd ship: an agent that reads the firm's website and does the extraction itself. Tonight that exists. The user's words during scoping: "better yet, we just use my Claude API key, and they put in their firms website if they haven't already. Then claude does tool calls to fetch the html and pulls as much as they can off of it." That's what shipped.


What Landed

Backend — investment-memo-orchestrator/src/server/brand_fetch.py

A new module exposing two functions:

fetch_brand_from_url(firm, url) -> dict — drives a Claude tool-use loop with two tools:

ToolWhat it does
fetch_url(url)Server-side httpx.get with a 60KB body cap, returns text. Dedupes within a session.
submit_brand_config()The "I'm done" call. 19 fields covering identity, light + dark colors, fonts, logo URLs.

Claude's typical run, observed in real testing:

1. fetch_url(homepage)        → catches title, og:image, theme-color, linked CSS
2. fetch_url(/about)          → catches official name, tagline
3. fetch_url(main.css)        → confirms primary/accent colors
4. submit_brand_config(...)   → returns 19-field structured config

Iteration cap of 8 (Claude's been finishing in 3–4 in tests). Haiku 4.5 by default — claude-haiku-4-5-20251001 — chosen for speed and cost; override via MEMOPOP_BRAND_MODEL env var if Sonnet's judgment is needed.

Output passes through _shape_brand_config() which:

  • Normalizes Claude's color guesses to #rrggbb (handles missing #, uppercase, 3-char shorthand expansion).

  • Maps the flat tool output to the nested YAML schema (company, colors, colors_dark, fonts, logo, _meta).

  • Recursively drops empty strings, None, and empty dicts so the YAML stays clean.

  • Carries Claude's confidence_notes paragraph in _meta for the UI to surface.

save_brand_config(firm, config) -> Path — writes the user-confirmed config to io/{firm}/configs/brand-{firm}-config.yaml, deep-merging with any existing file. One special case: company.conventional_name from the firm-creation scaffold is preserved verbatim if the new config doesn't override it. Brand setup must never clobber the name the user typed during firm creation.

Uses the orchestrator's existing ANTHROPIC_API_KEY — same key that powers memo generation. No new secret management.

Backend — src/server/app.py

Two new endpoints:

POST /actions/fetch-brand   { firm, url } → { firm, config }
POST /actions/save-brand    { firm, config } → { firm, path }

The fetch endpoint runs Claude in a worker thread via asyncio.to_thread so the FastAPI event loop stays responsive — long-running brand fetches don't starve concurrent SSE streams from a memo run.

Tests — 22 passing total

Four new ones lock down the shape:

TestWhat it pins down
test_normalize_hex_handles_real_world_inputs#5b21b6, 5b21b6, #5B21B6, #abc → all → #5b21b6 / #aabbcc; rejects garbage.
test_drop_empty_recursively_strips_blanksEmpty strings, None, empty dicts never reach the YAML.
test_shape_brand_config_normalizes_and_drops_emptiesEnd-to-end: Claude output → nested YAML, with hex normalization, alt fallback, empty-field dropping.
test_save_brand_config_preserves_conventional_nameExisting company.conventional_name survives a save that doesn't carry that field.

Tauri Rust — src/api/router.rs

Two new match arms route to the existing forward_to_sidecar:

RUST
("POST", "/actions/fetch-brand") | ("POST", "/actions/save-brand") => {
    forward_to_sidecar(&app, &body, &method, &path).await
}

The forwarder strips repoPath from the body before posting to the sidecar (the sidecar uses cwd-relative Path("io"), so it doesn't need that field), ensures the sidecar is alive, forwards via reqwest. No new infrastructure.

Frontend — BrandSetupModal.svelte

A multi-step modal driven by an internal step state machine:

prompt_url → fetching → confirm → saving → saved

Each step's UX:

  • prompt_url — single URL field, autofocus, Enter submits. "Skip for now" exits cleanly. Hint text: "Claude reads the homepage (and a couple of linked pages if needed), then fills in colors, fonts, logos, and tagline. You'll review every field before anything saves."

  • fetching — purple spinner, "Reading {url}…", static reassurance text. ~10–20s wall time.

  • confirm — the substantive screen. Five sections: Identity, Light-mode colors, Dark-mode colors, Fonts, Logo. Every field is an editable input. Color inputs render a small swatch next to them when the value is a valid hex. Logo URLs render a live <img> preview against light/dark backgrounds so the user sees Claude's pick immediately. A "From Claude:" panel up top surfaces the confidence-notes paragraph.

  • saving — spinner.

  • saved — green checkmark, "Brand config saved" with the YAML path.

Errors bubble inline at the appropriate step (URL validation, fetch failure, save failure). The modal can be closed at every step except fetching and saving (where the action is mid-flight).

Frontend — entry points

  • JobView.svelte — when status === 'completed' and an outputDir is set, the success banner renders with a green CTA on the right: "Make this look like your firm →". Click → flow.startBrandSetup(activeFirm) → modal opens over the JobView. The user is in the moment of "wow, my memo is done" — that's the moment to ask for brand polish.

  • Settings.svelte — when an active firm is selected, a "Brand setup →" button appears under the firm dropdown with a one-line hint. Click → transitions the flow store AND goto('/') so the modal renders over the gallery (Settings is a sibling route; we navigate home where the modal layer lives).

  • flow.svelte.ts — new brand_setup flow stage carrying just { kind, firm }. New startBrandSetup(firm) method. The modal's internal step state machine handles the multi-stage flow without polluting the global flow store.


Design Decisions

Tool-use over single-shot extraction. Considered a simpler design where the server fetches the homepage once + pre-parses meta tags + sends one Claude prompt. Rejected because (a) Claude's judgment on which secondary pages to fetch is genuinely useful (sometimes /about has the tagline, sometimes the homepage hero does), and (b) tool-use lets us cap iterations and dedupe URLs cleanly without rebuilding the loop ourselves. The cost difference is small (3 fetches vs 1) at Haiku pricing, and the result quality is materially better for firms whose homepage is image-heavy.

Haiku 4.5, not Sonnet. Brand extraction is a low-judgment task: read HTML, find the primary color, return it. Haiku is fast and cheap and works for this. If a firm's website is unusually unstructured (heavy SPA, image-only branding), an env override to Sonnet is a one-line escape hatch. We're not trying to be clever about model selection; we're starting cheap.

Two endpoints, not one. Fetch returns the structured config without writing. Save writes the user-confirmed version. The split is the entire point of the feature — brand managers must have final say. A combined "fetch and save" endpoint would be tempting and wrong.

Deep merge on save, not replace. The firm-creation modal already wrote company.conventional_name. Brand setup runs later and must not clobber it. The _deep_merge helper handles this generally; the special-case for conventional_name is a belt-and-suspenders catch in case anyone in the future reshapes the company block.

Editable form, not a confirmation dialog. A "we found these values, click Confirm or Cancel" UI would be lazy. Brand managers are picky, and they'll find at least one thing wrong (a hex that's off by one shade, a logo that's the favicon instead of the brandmark). Every field editable, with swatches and live previews, is the only design that respects the audience.

autofocus on the URL field. Triggers an a11y warning that we accept (consistent with FirmCreationModal and DealCreationModal). The first thing the user does in this modal is type a URL; making them tab into the field is hostile.


How to Test

TEXT
1. Quit MemoPop fully (Cmd+Q). The CloseRequested handler kills the
   sidecar reliably; the next launch will spawn fresh Python with the
   new endpoints registered.
2. Open MemoPop.
3. Click an outline → Try this on a company → fill the deal form → Generate.
4. After the run completes, the success banner shows a green
   "Make this look like your firm →" CTA. Click it.
5. Type your firm's URL (https://...). Hit "Fetch brand".
6. ~10–20 seconds: confirmation form appears.
7. Edit anything. Verify swatches and logo previews update live.
8. Save.
9. Verify `io/{firm}/configs/brand-{firm}-config.yaml` has the new fields
   AND retains company.conventional_name from when the firm was created.

Or, skip the memo entirely:

TEXT
1. Settings (gear icon).
2. Confirm an active firm is set.
3. Click "Brand setup →" — same modal, same flow, same outcome.

Verified end-to-end on a real firm tonight: ~15 seconds wall time, ~$0.07 charged to the orchestrator's Anthropic key, populated colors + fonts + logo + tagline, every field correctly editable, saved YAML deep-merged with the existing scaffold.


What's Next

  1. Logo upload (not just URL). Tonight's flow takes a remote URL for the logo. Some firms have a brand asset folder on disk (templates/trademarks/); a file picker that uploads the SVG to io/{firm}/assets/ and writes the relative path into the YAML would close that loop. Tauri's tauri-plugin-dialog already gives us the picker; the storage side is a small action.

  2. Re-fetch for an existing brand. Right now if you run brand setup on a firm that already has a config, the modal starts blank and the save merges. A "load existing" path that pre-fills the form from the on-disk YAML — so the user is editing a known config, not starting from a Claude guess — is the natural follow-up.

  3. Auto-derive dark-mode colors. Claude's dark-mode guesses are weakest (most websites only advertise their light theme). A post-fetch step that, when dark-mode fields are empty, computes a sensible inversion (swap text↔background, retain accents) would land closer to "polished" without requiring user intervention.

  4. The first non-Mike user. This is the moment that brand setup actually matters — until someone other than us types a real URL, everything we know about the failure modes is hypothetical. The next changelog will probably include a "what surprised us" section after that first real test.