Skip to content

feat(embeds): brand-asset preview — full-palette white-label theming + stateless AI theme suggestion#91

Open
cm2489 wants to merge 7 commits into
mainfrom
feat/embeds-brand-preview
Open

feat(embeds): brand-asset preview — full-palette white-label theming + stateless AI theme suggestion#91
cm2489 wants to merge 7 commits into
mainfrom
feat/embeds-brand-preview

Conversation

@cm2489

@cm2489 cm2489 commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Queue item 2 (2026-07-16 priority list), built from the approved plan (we-need-planning-mode, decisions locked at plan review: BRAND_MODEL = claude-sonnet-5, full-palette scope, same PR).

What this delivers

Part A — the theme surface widens to spec §3.4's full set. New validated knobs on all three widgets: surface + ink color pair (accepted only as a pair meeting WCAG AA 4.5:1 — else discarded, fail-closed), fonts 2→4 system-only stacks (humanist, geometric — never a webfont; that would leak visitor IPs to a font host), and forced mode=light|dark|auto. Everything else derives in CSS (color-mix() behind @supports, static fallbacks for pre-Safari-16.2). Delivery moved from per-widget inline vars to one validated <style> tag at :root (EmbedThemeStyle + buildThemeCss — contents are exclusively re-verified hex + closed-map literals + color-scheme; unit-tested that hostile input can never surface). That fixes the unthemed body band below short content (permanent on action-panel refusal states) and deletes the three duplicated themeStyle blocks; widgets no longer take a theme prop at all.

Part B — POST /api/brand + "Match your site" on /embeds. An org enters its site URL → SSRF-guarded stateless fetch of the homepage (+ ≤2 same-origin stylesheets) → regex signal extraction → one claude-sonnet-5 call maps candidates onto the closed knobs → server re-validates through the same widget-boundary validators (contrast repaired here, with an honesty note — the widgets still reject) → configurator autofills, live preview re-keys, minimal mock "on your site" strip (their logo, client-side, never proxied).

$ decision (surfaced per operating rules)

Second Anthropic-calling endpoint. BRAND_MODEL = 'claude-sonnet-5' (owner decision at plan review; one-line constant swap). ~$0.008/preview uncached at intro pricing. Caps: 5/10min per IP + global 250/day breaker (brand-global constant key) + per-instance 15-min LRU → worst-case day ≈ $2; realistic traffic pennies. noteBrandPreview() counts actual spend only (<env>:usage:brand:<day>).

Privacy & security design

  • The submitted URL is never stored or logged: POST body (never query), truncated to origin inside normalizeBrandUrl (the path doesn't exist past that call), log-nothing catches throughout (district doctrine), cache is in-memory only — never Upstash.
  • SSRF guard is rebinding-proof: node:https with a guarded DNS lookup — validation is pinned to the exact addresses the socket connects to. Full v4/v6 deny table (incl. mapped-v4, NAT64, metadata range), IP-literal/localhost/single-label/userinfo/port rejection pre-DNS, manual redirects ≤3 with per-hop re-guard, byte caps with Accept-Encoding: identity, honest UA. No guard-bypass test seam exists — CI proves rejection (400s for private/metadata/localhost targets); the happy path is deliberately manual-only.
  • The model cannot emit CSS: it returns 3 hexes + 3 enums, all through the fail-closed validators; garbage → 502 generation_failed. Raw HTML never reaches the API (compact candidates JSON only, ~500–900 tokens).

Visual deltas to eyeball (sanctioned, flagged)

  1. Default dark mode: primary buttons/phone links invert (light-on-dark instead of dark-on-dark) — consequence of ink/surface inversion, which is what makes tenant palettes re-key everything.
  2. Avatar initials drop their dark-mode accent variant (mode consistency requires zero prefers-color-scheme in component rules).
  3. Border tint now derives from ink via color-mix (was static blue-gray rgba) — near-invisible at 15–25% alpha.
  4. embed.js is 5,026 bytes (< 5KB budget, test-asserted; docsLoaderBody claim stays true).

Tests

61 new/extended: contrast math, validator/resolver/CSS-builder injection matrix, SSRF guard tables (incl. ::ffff:169.254.169.254, NAT64, mixed-answer rebinding shape), extraction fixtures (dark/CJK/tag-soup/truncated), prompt/parse/finalize, LRU, route taxonomy + limiter e2e, theme-surface e2e (incl. action-panel refusal theming, forced modes vs emulated OS preference), loader forwarding, configurator autofill/errors/a11y (EN+ES). Full suite locally: 1,563 passed / 1 flaky-passed-on-retry / 14 skipped; all seven CI gates + typecheck + lint clean.

Manual verification checklist (localhost, ANTHROPIC_API_KEY set)

  1. /embeds → Match your site with 2–3 real org URLs incl. a dark site: autofill, preview re-key, adjusted note; error copy on a bot-blocked site.
  2. /embed/rep-lookup?mode=dark on light OS (whole iframe incl. band below content); custom dark pair ?surface=%230f1a2b&ink=%23f5f7fa — inputs/toggles/buttons/focus/chip readable; font=humanist|geometric visibly differ.
  3. /embed/action-panel?token=garbage&mode=dark&surface=…&ink=… — refusal card fully themed.
  4. Default-look regression: /embed/bill-card?slug=hr-5582-119 no params, light + dark OS — pixel-familiar except deltas 1–3 above.
  5. /es/embeds copy; paste a full-knob snippet into a scratch page on another port.

Out of scope (explicit): tenant webfonts, logo-in-widget (Pro provisioning per spec §2.3), TenantRecord theme fields, Upstash brand cache, CSS @import following.

🤖 Generated with Claude Code

cm2489 and others added 7 commits July 16, 2026 18:10
…api/brand, and configurator

Pure, dependency-free, deliberately not server-only: the configurator's live
contrast warning must agree exactly with the server's discard decision, so
both call this one implementation. adjustInkForContrast is guaranteed to
converge for the 4.5:1 AA threshold (better-of-black/white floor is sqrt(21)).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s, forced light/dark/auto mode

Spec §3.4's planned expansion, delivered as one validated :root style tag
(EmbedThemeStyle) instead of per-widget inline vars: fixes the unthemed
body band below short content (permanent on action-panel refusal states)
and collapses three duplicated themeStyle blocks + the ActionPanelMessage
special case. embed.css re-keys onto internal tokens (ONE dark block; no
component rule touches prefers-color-scheme anymore — forced mode can't
drift), color-mix() derivations behind @supports with static fallbacks.

Contrast is rejected at this boundary (pair-or-nothing, AA 4.5:1), per the
embed-theme fail-closed doctrine. Visual deltas flagged for review: primary
buttons invert ink/surface in default dark mode; avatar initials drop their
dark-mode accent variant; border tint now derives from ink.

embed.js forwards data-surface/-ink/-mode and mirrors forced mode onto
iframe colorScheme; 5,026 bytes (budget 5KB, test-asserted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mode select, the two new font stacks, and a custom surface/ink pair gated
client-side by the exact lib/contrast math the server enforces — a failing
pair is warned about AND omitted from both the preview URL and the snippet,
so the configurator can never emit values the server would discard. Fixes
the stale S16 header comment claiming rep-lookup has no theming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l extraction, AI mapping, LRU cache

lib/brand-fetch: node:https with a guarded DNS lookup (validation pinned to
the exact addresses the socket connects to — rebinding-proof, stdlib-only),
manual redirects with per-hop re-guard, byte caps with truncation-as-
success, log-nothing doctrine cloned from /api/district. URLs truncate to
origin at parse time; the path never exists past normalizeBrandUrl.

lib/brand-extract: pure regex harvesting (candidates for a language model,
not ground truth — no parser dep), bounded character-class scans only.
lib/brandprompt: BRAND_MODEL = claude-sonnet-5 (owner decision, plan
2026-07-16), compact candidates JSON only — raw HTML never reaches the API;
output re-validated through the widget-boundary validators, contrast
repaired (the one surface that adjusts instead of rejecting).
lib/brand-cache: per-instance in-memory LRU — never Upstash, so 'the
address is never stored' stays literally true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The second Anthropic-calling endpoint. district doctrine strengthened: POST
+ log-nothing catches + origin truncation at parse + in-memory-only cache.
Spend surfaced: per-IP 5/10min + GLOBAL 250/day breaker ('brand-global'
constant key) — worst-case day ≈ $2 on claude-sonnet-5; noteBrandPreview
counts actual spend only, via after(). CI proves the SSRF guard REJECTS
(private/metadata/localhost/userinfo/port targets -> 400 before any
socket); the happy path is deliberately not e2e — no guard-bypass test seam
will ever be added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… mock-site strip

The sales moment: an org enters its site URL, /api/brand suggests a theme,
and every control autofills (plain setState — manual edits keep working,
re-running overwrites). Adjusted-colors honesty note when the server
repaired contrast; four bilingual error strings keyed to the route
taxonomy; privacy line states the never-stored contract. The mock strip is
the minimal honest version: their surface color + their logo loaded
client-side from their own https host (never proxied), no fake chrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
oravan Ready Ready Preview, Comment Jul 17, 2026 12:04am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant