diff --git a/STATUS.md b/STATUS.md index efbde84..46c07d8 100644 --- a/STATUS.md +++ b/STATUS.md @@ -66,6 +66,7 @@ Off-plan riders (pulled forward, rename-independent): - 2026-07-11: S12 ruling — quiet-submit now (registry + directory submissions run this week; review/listing latency burns down while unannounced), the public announcement itself still holds for the late Aug–Sep window. - 2026-07-11: Pregen + Blob portraits — both armed, pending owner secrets (~$2.50–3.80/mo pregen + ~cents/mo Blob), surfaced and approved. - 2026-07-15: HCB application DENIED (HCB currently sponsors teen hacker builds only) — the citizen-donations rail is undecided again; About/footer copy scrubbed of every fiscal-sponsor/501(c)(3)/tax-deductible claim and `DonateSupport` retired in branch `feat/funding-transparency` (founder-funded framing is the single truthful surface); `DONATE_URL` stays null until a replacement rail is chosen. +- 2026-07-16: Embeds brand preview (planned session, plan `we-need-planning-mode`) — theme surface widened to the full spec-§3.4 set (surface/ink pair AA-gated 4.5:1, 4 system font stacks, forced light/dark/auto; delivered via one validated `:root` style tag, widgets lost the theme prop) + `POST /api/brand` (second Anthropic endpoint: SSRF-guarded stateless homepage fetch → regex signal extraction → `claude-sonnet-5` maps onto the closed knobs, ~$0.008/preview, per-IP 5/10min + global 250/day breaker ≈ $2/day worst case — surfaced and approved at plan review) + "Match your site" autofill UI on /embeds with mock-site strip. URL truncated to origin at parse, never stored/logged; cache is in-memory-only. Branch `feat/embeds-brand-preview`. ## Issues & learnings ledger (compound these) diff --git a/app/api/brand/route.ts b/app/api/brand/route.ts new file mode 100644 index 0000000..d477b81 --- /dev/null +++ b/app/api/brand/route.ts @@ -0,0 +1,141 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { after, NextRequest, NextResponse } from 'next/server'; +import { createLruCache } from '@/lib/brand-cache'; +import { extractFromHtml, mergeCssSignals, type BrandCandidates } from '@/lib/brand-extract'; +import { fetchGuarded, normalizeBrandUrl } from '@/lib/brand-fetch'; +import { + BRAND_MAX_TOKENS, + BRAND_MODEL, + buildBrandPrompt, + finalizeBrandTheme, + parseBrandResponse, + type BrandTheme, +} from '@/lib/brandprompt'; +import { callerIp, createRateLimiter, createTenantRateLimiter } from '@/lib/ratelimit'; +import { noteBrandPreview } from '@/lib/usage'; + +/* + * The brand-preview endpoint (brand-preview build): an org considering the + * white-label tier submits its site URL from /embeds and gets back a theme + * suggestion in the closed knob set. The SECOND Anthropic-calling endpoint + * in Oravan (after /api/script) — spend posture below. + * + * Stateless by the /api/district doctrine, strengthened: POST so the URL + * never lands in an access log; the URL is truncated to its ORIGIN inside + * normalizeBrandUrl (the path never exists past that call); the only cache + * is a per-instance in-memory LRU (lib/brand-cache — never a database, so + * the /embeds privacy copy "the address is never stored" is literally + * true); and NO catch below logs anything, not even the error object — a + * fetch error can embed the hostname. + * + * Spend posture (every $ decision surfaced — orchestrator rule): + * - per-IP limiter 5/10min ('brand') — tighter than district's 10, + * because every miss spends money (fetch + one BRAND_MODEL call); + * - a GLOBAL daily breaker 250/day ('brand-day' via the tenant limiter + * keyed by the constant 'brand-global') — unauthenticated spending + * endpoint, no cross-user cache to blunt a distributed farm. Worst-case + * day ≈ 250 × ~$0.008 ≈ $2 on claude-sonnet-5. + * - usage counter (noteBrandPreview) fires via after() on the actual- + * spend path only, mirroring /api/script's noteScriptGeneration. + * + * Error taxonomy (uniform bodies, nothing caller-specific ever echoed): + * 400 bad_request — malformed body/URL, or the SSRF guard refused + * 429 rate_limited — either limiter tripped (deliberately not + * distinguished, same rule as /api/script) + * 502 unavailable — the site couldn't be fetched (block/timeout/ + * TLS/not-HTML) — soft dead-end, the UI says + * "set the colors manually" + * 502 generation_failed — Anthropic failed, or its output didn't survive + * the fail-closed validators + */ + +const anthropic = new Anthropic(); + +const limiter = createRateLimiter({ route: 'brand', max: 5, windowSec: 600 }); +const dayLimiter = createTenantRateLimiter({ route: 'brand-day', max: 250, windowSec: 86400 }); +/** The global-breaker key: a constant, not caller/content material. */ +const GLOBAL_BUCKET = 'brand-global'; + +interface BrandResponse { + theme: BrandTheme; + site: { name?: string; logoUrl?: string }; + adjusted: boolean; +} + +const cache = createLruCache({ max: 100, ttlMs: 15 * 60 * 1000 }); + +const HTML_FETCH = { + maxBytes: 1_572_864, // 1.5 MB — truncation is success + timeoutMs: 8000, + maxRedirects: 3, + contentTypes: ['text/html', 'application/xhtml+xml'], +}; + +const CSS_FETCH = { + maxBytes: 262_144, // 256 KB per stylesheet, max 2 stylesheets + timeoutMs: 4000, + maxRedirects: 2, + contentTypes: ['text/css'], +}; + +export async function POST(req: NextRequest) { + const ip = callerIp(req.headers); + if ((await limiter.isLimited(ip)) || (await dayLimiter.isLimited(GLOBAL_BUCKET))) { + return NextResponse.json({ error: 'rate_limited' }, { status: 429 }); + } + + let body: { url?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'bad_request' }, { status: 400 }); + } + + const normalized = normalizeBrandUrl(body.url); + if (!normalized.ok) { + return NextResponse.json({ error: 'bad_request' }, { status: 400 }); + } + const { origin } = normalized; + + const hit = cache.get(origin); + if (hit) return NextResponse.json(hit); + + const page = await fetchGuarded(`${origin}/`, HTML_FETCH); + if (!page.ok) { + return NextResponse.json({ error: 'unavailable' }, { status: 502 }); + } + + let candidates: BrandCandidates = extractFromHtml(page.text, page.finalUrl); + for (const stylesheetUrl of candidates.stylesheets) { + // Same-origin was enforced at extraction; each fetch re-guards anyway. + // A failed stylesheet is just a skipped signal, never an error. + const css = await fetchGuarded(stylesheetUrl, CSS_FETCH); + if (css.ok) candidates = mergeCssSignals(candidates, css.text); + } + + try { + const msg = await anthropic.messages.create({ + model: BRAND_MODEL, + max_tokens: BRAND_MAX_TOKENS, + thinking: { type: 'disabled' }, + messages: [{ role: 'user', content: buildBrandPrompt(candidates) }], + }); + const text = msg.content[0].type === 'text' ? msg.content[0].text : ''; + const finalized = finalizeBrandTheme(parseBrandResponse(text)); + if (!finalized) throw new Error('unusable'); + + const response: BrandResponse = { + theme: finalized.theme, + site: { name: candidates.siteName, logoUrl: candidates.logoUrl }, + adjusted: finalized.adjusted, + }; + cache.set(origin, response); + // Count only actual spend (this is the cache-miss path by construction); + // after() so a slow counter write never delays the response. + after(() => noteBrandPreview()); + return NextResponse.json(response); + } catch { + // Log-nothing doctrine: the error object can embed the request context. + return NextResponse.json({ error: 'generation_failed' }, { status: 502 }); + } +} diff --git a/app/embed/action-panel/page.tsx b/app/embed/action-panel/page.tsx index d6610b3..a2622dd 100644 --- a/app/embed/action-panel/page.tsx +++ b/app/embed/action-panel/page.tsx @@ -8,18 +8,14 @@ import { formatCitation } from '@/lib/format'; import { registrableDomain } from '@/lib/embed-referrer'; import { noteImpression } from '@/lib/impressions'; import { - FONT_VALUES, - RADIUS_VALUES, - safeAccent, + resolveEmbedTheme, safeAttribution, safeBrandless, - safeFontKey, - safeRadiusKey, - type FontKey, - type RadiusKey, + type ResolvedEmbedTheme, } from '@/lib/embed-theme'; import { resolveTenantAccess } from '@/lib/tenancy'; import { ActionPanelWidget, type ActionPanelBillData } from '@/components/embed/ActionPanelWidget'; +import { EmbedThemeStyle } from '@/components/embed/EmbedThemeStyle'; export async function generateMetadata({ searchParams, @@ -40,12 +36,6 @@ function normalizeLocale(value: string | undefined): 'en' | 'es' { type EmbedLocale = 'en' | 'es'; const DICTS: Record = { en, es }; -interface EmbedThemeInput { - accent?: string; - radiusKey: RadiusKey; - fontKey: FontKey; -} - /* * The action-panel embed (S19, paid tier only). Same theming/locale * conventions as rep-lookup (S13) and bill-card (S14) - see those files' @@ -71,21 +61,21 @@ export default async function ActionPanelEmbedPage({ slug?: string; token?: string; accent?: string; + surface?: string; + ink?: string; + mode?: string; radius?: string; font?: string; brandless?: string; attribution?: string; }>; }) { - const { locale: localeParam, slug, token, accent, radius, font, brandless, attribution } = await searchParams; + const { locale: localeParam, slug, token, accent, surface, ink, mode, radius, font, brandless, attribution } = + await searchParams; const locale = normalizeLocale(localeParam); const t = DICTS[locale]; const brandlessFlag = safeBrandless(brandless); - const theme: EmbedThemeInput = { - accent: safeAccent(accent), - radiusKey: safeRadiusKey(radius), - fontKey: safeFontKey(font), - }; + const theme = resolveEmbedTheme({ accent, surface, ink, mode, radius, font }); const access = await resolveTenantAccess(token ?? null); if (!access.ok) { @@ -158,18 +148,26 @@ export default async function ActionPanelEmbedPage({ after(() => noteImpression(tenant.tenantId)); return ( - + <> + + + ); } -/** Shared server-rendered chrome for every non-live (refusal) state. */ +/** + * Shared server-rendered chrome for every non-live (refusal) state. Renders + * its own EmbedThemeStyle: refusal iframes never resize (no client widget + * mounts), so the :root-level style tag is the only thing keeping the whole + * fixed-height frame — including the band below this short content — on the + * tenant's palette. + */ function ActionPanelMessage({ locale, theme, @@ -177,22 +175,20 @@ function ActionPanelMessage({ children, }: { locale: EmbedLocale; - theme: EmbedThemeInput; + theme: ResolvedEmbedTheme; brandless: boolean; children: React.ReactNode; }) { const t = DICTS[locale]; - const themeStyle: React.CSSProperties = { - ...(theme.accent ? { ['--oravan-accent' as string]: theme.accent } : {}), - ['--oravan-radius' as string]: RADIUS_VALUES[theme.radiusKey], - ['--oravan-font' as string]: FONT_VALUES[theme.fontKey], - }; return ( -
-
-

{brandless ? '' : t.common.appName}

-
- {children} -
+ <> + +
+
+

{brandless ? '' : t.common.appName}

+
+ {children} +
+ ); } diff --git a/app/embed/bill-card/page.tsx b/app/embed/bill-card/page.tsx index cbb63b9..6dfd8cb 100644 --- a/app/embed/bill-card/page.tsx +++ b/app/embed/bill-card/page.tsx @@ -4,10 +4,11 @@ import { after } from 'next/server'; import { billSlug, getBill, localizeBill } from '@/lib/core'; import { formatCitation } from '@/lib/format'; import { getFreshness } from '@/lib/freshness'; -import { safeAccent, safeAttribution, safeBrandless, safeFontKey, safeRadiusKey } from '@/lib/embed-theme'; +import { resolveEmbedTheme, safeAttribution, safeBrandless } from '@/lib/embed-theme'; import { noteImpressionForToken } from '@/lib/impressions'; import { callerIp } from '@/lib/ratelimit'; import { BillCardWidget, type BillCardData } from '@/components/embed/BillCardWidget'; +import { EmbedThemeStyle } from '@/components/embed/EmbedThemeStyle'; export async function generateMetadata({ searchParams, @@ -28,9 +29,9 @@ function normalizeLocale(value: string | undefined): 'en' | 'es' { /* * The bill-card embed (S14). `locale` and `slug` are the content inputs a - * host page's iframe src (built by public/embed.js) supplies; `accent`, - * `radius`, and `font` are the theming inputs — every one of the three is - * validated through lib/embed-theme before it ever reaches a style prop + * host page's iframe src (built by public/embed.js) supplies; the theming + * inputs (accent/surface/ink/mode/radius/font) are each validated through + * lib/embed-theme's resolveEmbedTheme before they ever reach CSS * (CSS-custom-properties-only theming, no exceptions). An unknown or * missing slug renders the widget's own "bill not found" state rather than * calling notFound() — this route has no not-found boundary of its own @@ -51,13 +52,17 @@ export default async function BillCardEmbedPage({ slug?: string; token?: string; accent?: string; + surface?: string; + ink?: string; + mode?: string; radius?: string; font?: string; brandless?: string; attribution?: string; }>; }) { - const { locale: localeParam, slug, token, accent, radius, font, brandless, attribution } = await searchParams; + const { locale: localeParam, slug, token, accent, surface, ink, mode, radius, font, brandless, attribution } = + await searchParams; const locale = normalizeLocale(localeParam); const raw = typeof slug === 'string' && slug.length > 0 ? getBill(slug) : undefined; const bill = raw ? localizeBill(raw, locale) : null; @@ -78,17 +83,15 @@ export default async function BillCardEmbedPage({ : null; return ( - + <> + + + ); } diff --git a/app/embed/embed.css b/app/embed/embed.css index b831da2..239029c 100644 --- a/app/embed/embed.css +++ b/app/embed/embed.css @@ -6,10 +6,53 @@ * than pulling in the whole app theme for one widget. Colors below are the * same brand palette as app/globals.css's @theme block, hand-copied (not * shared custom properties) so this file has zero dependency on it. + * + * Palette architecture (brand-preview build): every color flows through the + * private --_* tokens below, which read the tenant-facing --oravan-* knobs + * (set by components/embed/EmbedThemeStyle.tsx from validated values only) + * with the brand palette as fallback. HARD RULE: component rules never use + * @media (prefers-color-scheme) — all light/dark difference lives in the + * ONE token block below, so a forced mode (EmbedThemeStyle pinning the + * knobs + color-scheme) re-keys everything with no drift. Derived tones use + * color-mix() behind @supports; older engines (pre-Safari 16.2) keep the + * static literals declared first, i.e. today's exact look. */ :root { color-scheme: light dark; + --_surface: var(--oravan-surface, #f3ecdd); + --_ink: var(--oravan-ink, #2a2318); + --_accent: var(--oravan-accent, #82632a); + --_accent-ink: var(--oravan-accent-ink, #fbf8f0); + --_focus: var(--oravan-focus, var(--_accent)); + /* Static fallbacks for engines without color-mix. */ + --_line: rgba(24, 32, 58, 0.15); + --_line-strong: rgba(24, 32, 58, 0.25); + --_line-faint: rgba(24, 32, 58, 0.08); + --_ink-hover: #1b1611; +} + +@media (prefers-color-scheme: dark) { + :root { + --_surface: var(--oravan-surface, #1b1611); + --_ink: var(--oravan-ink, #e4d9c0); + --_line: rgba(242, 235, 220, 0.2); + --_line-strong: rgba(242, 235, 220, 0.3); + --_line-faint: rgba(242, 235, 220, 0.1); + --_ink-hover: #f3ecdd; + } +} + +/* Last in source on purpose: same :root specificity as the blocks above, so + in color-mix-capable engines these derived tones win in BOTH modes (they + track --_ink/--_surface, which are already mode- and tenant-aware). */ +@supports (color: color-mix(in srgb, red 50%, blue)) { + :root { + --_line: color-mix(in srgb, var(--_ink) 15%, transparent); + --_line-strong: color-mix(in srgb, var(--_ink) 25%, transparent); + --_line-faint: color-mix(in srgb, var(--_ink) 8%, transparent); + --_ink-hover: color-mix(in srgb, var(--_ink) 85%, var(--_surface)); + } } * { @@ -27,15 +70,8 @@ body { system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.45; - color: #2a2318; - background: #f3ecdd; -} - -@media (prefers-color-scheme: dark) { - body { - color: #e4d9c0; - background: #1b1611; - } + color: var(--_ink); + background: var(--_surface); } @media (prefers-reduced-motion: reduce) { @@ -47,17 +83,17 @@ body { /* Visible focus everywhere, never removed - same rule as the main site. */ :focus-visible { - outline: 3px solid #82632a; + outline: 3px solid var(--_focus); outline-offset: 2px; border-radius: 2px; } /* - * S5a: the rep-lookup widget accepts the same three validated theming - * custom properties as the bill card (--oravan-accent/-radius/-font, set - * inline on .re-root by RepLookupWidget). Fallbacks below preserve the - * exact pre-S5a look, and because .re-* classes are shared, a themed - * bill-card's shared chrome now follows its theme too. + * S5a: every widget accepts the same validated theming custom properties + * (--oravan-accent/-surface/-ink/-radius/-font + derived -accent-ink/-focus, + * rendered by EmbedThemeStyle at :root). Fallbacks preserve the exact + * pre-theming look, and because .re-* classes are shared, a themed + * bill-card's shared chrome follows its theme too. */ .re-root { padding: 16px; @@ -102,13 +138,14 @@ body { width: 100%; padding: 10px 12px; font-size: 1.05rem; - border: 2px solid rgba(24, 32, 58, 0.25); + border: 2px solid var(--_line-strong); border-radius: var(--oravan-radius, 8px); min-height: 44px; background: transparent; color: inherit; } +/* Primary buttons invert ink/surface, so a tenant palette re-keys them too. */ .re-btn { display: inline-flex; align-items: center; @@ -122,53 +159,40 @@ body { border-radius: var(--oravan-radius, 8px); border: 2px solid transparent; cursor: pointer; - background: #2a2318; - color: #f3ecdd; + background: var(--_ink); + color: var(--_surface); } .re-btn:hover { - background: #1b1611; + background: var(--_ink-hover); } .re-toggle { background: transparent; color: inherit; - border-color: rgba(24, 32, 58, 0.25); + border-color: var(--_line-strong); min-width: 44px; padding: 8px 12px; } -@media (prefers-color-scheme: dark) { - .re-toggle { - border-color: rgba(242, 235, 220, 0.3); - } -} - .re-toggle[aria-pressed='true'] { - background: var(--oravan-accent, #82632a); - border-color: var(--oravan-accent, #82632a); - color: #fbf8f0; + background: var(--_accent); + border-color: var(--_accent); + color: var(--_accent-ink); } .re-card { - border: 1px solid rgba(24, 32, 58, 0.15); + border: 1px solid var(--_line); border-radius: var(--oravan-radius, 12px); padding: 14px; margin-top: 12px; } -@media (prefers-color-scheme: dark) { - .re-card { - border-color: rgba(242, 235, 220, 0.2); - } -} - /* * Rep-card portrait/avatar (S15). `.re-avatar-img` only ever renders for a * bioguide with a mirrored (Vercel Blob) portrait, served same-origin via * app/embed/portrait/[bioguide]/route.ts - never a third-party hotlink. - * `.re-avatar-initials` is the graceful, zero-network fallback (today's - * shipped default: no Blob store exists yet) - see + * `.re-avatar-initials` is the graceful, zero-network fallback - see * components/embed/RepLookupWidget.tsx. */ .re-card-head { @@ -188,7 +212,7 @@ body { height: 54px; border-radius: 6px; object-fit: cover; - background: rgba(24, 32, 58, 0.08); + background: var(--_line-faint); } .re-avatar-initials { @@ -199,20 +223,13 @@ body { width: 44px; height: 54px; border-radius: 6px; - background: #2a2318; - color: #f3ecdd; + background: var(--_ink); + color: var(--_surface); font-size: 0.95rem; font-weight: 700; letter-spacing: 0.01em; } -@media (prefers-color-scheme: dark) { - .re-avatar-initials { - background: var(--oravan-accent, #82632a); - color: #fbf8f0; - } -} - .re-meta { font-size: 0.78rem; font-weight: 600; @@ -235,8 +252,8 @@ body { padding: 10px 14px; margin-top: 10px; border-radius: 8px; - background: #2a2318; - color: #f3ecdd; + background: var(--_ink); + color: var(--_surface); text-decoration: none; font-weight: 600; } @@ -256,6 +273,8 @@ body { color: inherit; } +/* Semantic status tints, NOT theme knobs: translucent amber/red read on any + surface, so these two deliberately stay literal. */ .re-note { border: 1px solid rgba(232, 163, 23, 0.55); background: rgba(232, 163, 23, 0.14); @@ -278,21 +297,14 @@ body { .re-footer { margin-top: 18px; padding-top: 10px; - border-top: 1px solid rgba(24, 32, 58, 0.12); + border-top: 1px solid var(--_line); font-size: 0.78rem; } -@media (prefers-color-scheme: dark) { - .re-footer { - border-color: rgba(242, 235, 220, 0.18); - } -} - /* - * Bill-card widget (S14). --oravan-accent/--oravan-radius/--oravan-font are - * the ONLY tenant-facing theming surface (lib/embed-theme.ts validates every - * value before it reaches a style prop) - every rule below reads them via - * var() with a safe literal fallback, never anything else host-supplied. + * Bill-card widget (S14). Every rule reads the --_* tokens (fed only by + * lib/embed-theme-validated values) with safe literal fallbacks, never + * anything else host-supplied. */ .bc-root { /* Match .re-root's inset so the citation eyebrow + Powered-by footer don't @@ -312,18 +324,12 @@ body { } .bc-card { - border: 1px solid rgba(24, 32, 58, 0.15); + border: 1px solid var(--_line); border-radius: var(--oravan-radius, 10px); padding: 16px; margin-top: 4px; } -@media (prefers-color-scheme: dark) { - .bc-card { - border-color: rgba(242, 235, 220, 0.2); - } -} - .bc-status { margin: 0; font-size: 0.78rem; @@ -345,8 +351,8 @@ body { margin-top: 10px; padding: 3px 10px; border-radius: var(--oravan-radius, 999px); - background: var(--oravan-accent, #82632a); - color: #fbf8f0; + background: var(--_accent); + color: var(--_accent-ink); font-size: 0.7rem; font-weight: 700; text-transform: uppercase; @@ -387,15 +393,9 @@ body { padding: 10px 12px; font: inherit; line-height: 1.5; - border: 2px solid rgba(24, 32, 58, 0.25); + border: 2px solid var(--_line-strong); border-radius: var(--oravan-radius, 8px); background: transparent; color: inherit; resize: vertical; } - -@media (prefers-color-scheme: dark) { - .ap-textarea { - border-color: rgba(242, 235, 220, 0.3); - } -} diff --git a/app/embed/rep-lookup/page.tsx b/app/embed/rep-lookup/page.tsx index c58784b..ec59e7b 100644 --- a/app/embed/rep-lookup/page.tsx +++ b/app/embed/rep-lookup/page.tsx @@ -2,9 +2,10 @@ import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { after } from 'next/server'; import { mirroredPortraitBioguides } from '@/lib/core'; -import { safeAccent, safeAttribution, safeBrandless, safeFontKey, safeRadiusKey } from '@/lib/embed-theme'; +import { resolveEmbedTheme, safeAttribution, safeBrandless } from '@/lib/embed-theme'; import { noteImpressionForToken } from '@/lib/impressions'; import { callerIp } from '@/lib/ratelimit'; +import { EmbedThemeStyle } from '@/components/embed/EmbedThemeStyle'; import { RepLookupWidget } from '@/components/embed/RepLookupWidget'; export async function generateMetadata({ @@ -47,13 +48,17 @@ export default async function RepLookupEmbedPage({ zip?: string; token?: string; accent?: string; + surface?: string; + ink?: string; + mode?: string; radius?: string; font?: string; brandless?: string; attribution?: string; }>; }) { - const { locale: localeParam, zip, token, accent, radius, font, brandless, attribution } = await searchParams; + const { locale: localeParam, zip, token, accent, surface, ink, mode, radius, font, brandless, attribution } = + await searchParams; const locale = normalizeLocale(localeParam); const initialZip = zip && /^\d{5}$/.test(zip) ? zip : null; @@ -63,17 +68,15 @@ export default async function RepLookupEmbedPage({ } return ( - + <> + + + ); } diff --git a/components/EmbedConfigurator.tsx b/components/EmbedConfigurator.tsx index 1eadfac..c73b414 100644 --- a/components/EmbedConfigurator.tsx +++ b/components/EmbedConfigurator.tsx @@ -3,7 +3,8 @@ import { useEffect, useId, useMemo, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { SITE_ORIGIN } from '@/lib/site'; -import { safeAccent, type FontKey, type RadiusKey } from '@/lib/embed-theme'; +import { safeAccent, type FontKey, type ModeKey, type RadiusKey } from '@/lib/embed-theme'; +import { contrastRatio } from '@/lib/contrast'; import type { FeedTeaser } from '@/lib/types'; /* @@ -24,24 +25,41 @@ import type { FeedTeaser } from '@/lib/types'; * than re-declaring the radius/font options here) means this configurator * can never drift out of sync with what the server actually accepts. * - * Theming note (a real, current gap - not fixed here, see the S16 report): - * only the bill-card widget accepts --oravan-accent/-radius/-font today; - * components/embed/RepLookupWidget.tsx has no theme prop at all, and - * public/embed.js's WIDGET_PARAM_ATTRS map has no 'rep-lookup' entry, so the - * loader wouldn't even forward theme data-attributes for it. The theme - * controls below are therefore only shown for the bill-card widget - this - * reflects what's actually shipped, not an aspiration. + * Theming (S5a + brand-preview build): every widget accepts the full + * validated knob set — accent/surface/ink/mode/radius/font — forwarded by + * public/embed.js and resolved server-side (lib/embed-theme). The custom + * surface/ink pair is gated client-side by the SAME lib/contrast math the + * server enforces: a pair below AA (4.5:1) is warned about AND omitted from + * both the preview and the snippet, so this configurator can never emit a + * snippet the server would discard. */ type WidgetType = 'rep-lookup' | 'bill-card'; type ConfigLocale = 'en' | 'es'; const DEFAULT_ACCENT = '#82632a'; // matches the widget CSS's own var(--oravan-accent, #82632a) fallback +const DEFAULT_SURFACE = '#f3ecdd'; // the light-mode token fallbacks in app/embed/embed.css +const DEFAULT_INK = '#2a2318'; const DEFAULT_HEIGHT = 480; // mirrors public/embed.js's own DEFAULT_HEIGHT const MAX_RESULTS = 25; +const MIN_PAIR_CONTRAST = 4.5; // WCAG AA — the exact server-side bar (lib/embed-theme) const RADIUS_KEYS: RadiusKey[] = ['sharp', 'soft', 'round']; -const FONT_KEYS: FontKey[] = ['system', 'serif']; +const FONT_KEYS: FontKey[] = ['system', 'serif', 'humanist', 'geometric']; +const MODE_KEYS: ModeKey[] = ['auto', 'light', 'dark']; + +const FONT_LABEL_KEYS = { + system: 'fontSystem', + serif: 'fontSerif', + humanist: 'fontHumanist', + geometric: 'fontGeometric', +} as const satisfies Record; + +const MODE_LABEL_KEYS = { + auto: 'modeAuto', + light: 'modeLight', + dark: 'modeDark', +} as const satisfies Record; export function EmbedConfigurator({ bills }: { bills: FeedTeaser[] }) { const t = useTranslations('embeds'); @@ -59,16 +77,87 @@ export function EmbedConfigurator({ bills }: { bills: FeedTeaser[] }) { const [accentInput, setAccentInput] = useState(DEFAULT_ACCENT); const [radius, setRadius] = useState('soft'); const [font, setFont] = useState('system'); + const [mode, setMode] = useState('auto'); + const [customColors, setCustomColors] = useState(false); + const [surfaceInput, setSurfaceInput] = useState(DEFAULT_SURFACE); + const [inkInput, setInkInput] = useState(DEFAULT_INK); const [brandless, setBrandless] = useState(false); const [copied, setCopied] = useState(false); const [previewHeight, setPreviewHeight] = useState(DEFAULT_HEIGHT); + // "Match your site" (brand-preview build): POST /api/brand, autofill the + // theme controls from the validated suggestion. Plain setState on the + // existing controls — manual edits afterward just work; re-running + // overwrites (the hint says so). + const [matchUrl, setMatchUrl] = useState(''); + const [matchStatus, setMatchStatus] = useState< + 'idle' | 'loading' | 'done' | 'bad_request' | 'rate_limited' | 'unavailable' | 'generation_failed' + >('idle'); + const [matchedSite, setMatchedSite] = useState<{ name?: string; logoUrl?: string } | null>(null); + const [adjusted, setAdjusted] = useState(false); + + async function suggestTheme() { + if (!matchUrl.trim() || matchStatus === 'loading') return; + setMatchStatus('loading'); + setMatchedSite(null); + setAdjusted(false); + try { + const res = await fetch('/api/brand', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url: matchUrl.trim() }), + }); + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { error?: string } | null; + setMatchStatus( + err?.error === 'rate_limited' || err?.error === 'unavailable' || err?.error === 'generation_failed' + ? err.error + : 'bad_request' + ); + return; + } + const data = (await res.json()) as { + theme: { surface: string; ink: string; accent: string; radius: RadiusKey; font: FontKey; mode: ModeKey }; + site: { name?: string; logoUrl?: string }; + adjusted: boolean; + }; + // Server-validated values, re-gated by the same client validators the + // manual controls use (belt-and-suspenders, one contrast bar). + setAccentInput(safeAccent(data.theme.accent) ?? DEFAULT_ACCENT); + setSurfaceInput(safeAccent(data.theme.surface) ?? DEFAULT_SURFACE); + setInkInput(safeAccent(data.theme.ink) ?? DEFAULT_INK); + setCustomColors(true); + setRadius(RADIUS_KEYS.includes(data.theme.radius) ? data.theme.radius : 'soft'); + setFont(FONT_KEYS.includes(data.theme.font) ? data.theme.font : 'system'); + setMode(MODE_KEYS.includes(data.theme.mode) ? data.theme.mode : 'auto'); + setMatchedSite(data.site ?? null); + setAdjusted(Boolean(data.adjusted)); + setMatchStatus('done'); + } catch { + setMatchStatus('unavailable'); + } + } + + const MATCH_ERROR_KEYS = { + bad_request: 'matchSiteErrorInvalid', + rate_limited: 'matchSiteErrorRateLimited', + unavailable: 'matchSiteErrorUnavailable', + generation_failed: 'matchSiteErrorFailed', + } as const; + // A malformed accent never reaches the preview/snippet - same fail-closed // rule lib/embed-theme.ts's safeAccent enforces server-side; this just // means the configurator's own live preview can't diverge from what the // server would actually render for the exact same input. const accent = safeAccent(accentInput) ?? DEFAULT_ACCENT; + // Custom surface/ink: gated by the SAME contrast math the server enforces + // (lib/contrast, one implementation), so a failing pair is warned about + // here and omitted from preview + snippet rather than silently discarded + // server-side later. Native color inputs always emit #rrggbb. + const pairPasses = contrastRatio(inkInput, surfaceInput) >= MIN_PAIR_CONTRAST; + const pairActive = customColors && pairPasses; + const filteredBills = useMemo(() => { const q = billQuery.trim().toLowerCase(); const pool = q @@ -128,16 +217,21 @@ export function EmbedConfigurator({ bills }: { bills: FeedTeaser[] }) { if (widget === 'bill-card' && !slug) return null; const params = new URLSearchParams({ locale }); if (widget === 'bill-card' && slug) params.set('slug', slug); - // S5a: both widgets take the same three validated theme params. + // Every widget takes the same validated theme params (S5a + brand-preview). params.set('accent', accent); params.set('radius', radius); params.set('font', font); + if (mode !== 'auto') params.set('mode', mode); + if (pairActive) { + params.set('surface', surfaceInput); + params.set('ink', inkInput); + } if (brandless) params.set('brandless', '1'); // Relative on purpose: the live preview must show THIS deployment's // widget (localhost, preview deploys, prod alike). Only the copy-paste // snippet below carries the absolute production origin. return `/embed/${widget}?${params.toString()}`; - }, [widget, locale, slug, accent, radius, font, brandless]); + }, [widget, locale, slug, accent, radius, font, mode, pairActive, surfaceInput, inkInput, brandless]); const snippet = useMemo(() => { if (widget === 'bill-card' && !slug) return null; @@ -148,6 +242,11 @@ export function EmbedConfigurator({ bills }: { bills: FeedTeaser[] }) { ]; if (widget === 'bill-card' && slug) attrs.push(`data-slug="${slug}"`); attrs.push(`data-accent="${accent}"`); + if (pairActive) { + attrs.push(`data-surface="${surfaceInput}"`); + attrs.push(`data-ink="${inkInput}"`); + } + if (mode !== 'auto') attrs.push(`data-mode="${mode}"`); attrs.push(`data-radius="${radius}"`); attrs.push(`data-font="${font}"`); if (brandless) attrs.push(`data-brandless="1"`); @@ -157,7 +256,7 @@ export function EmbedConfigurator({ bills }: { bills: FeedTeaser[] }) { ...attrs.map((a) => ` ${a}`), `>`, ].join('\n'); - }, [widget, targetId, locale, slug, accent, radius, font, brandless]); + }, [widget, targetId, locale, slug, accent, radius, font, mode, pairActive, surfaceInput, inkInput, brandless]); async function copySnippet() { if (!snippet) return; @@ -283,6 +382,59 @@ export function EmbedConfigurator({ bills }: { bills: FeedTeaser[] }) { )} +
+ {t('matchSiteHeading')} +

{t('matchSiteHint')}

+
+
+ + setMatchUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void suggestTheme(); + } + }} + className="min-h-[44px] w-full rounded-control border border-line bg-surface px-3 text-base" + /> +
+ +
+

{t('matchSitePrivacy')}

+
+ {matchStatus === 'loading' &&

{t('matchSiteLoading')}

} + {(matchStatus === 'bad_request' || + matchStatus === 'rate_limited' || + matchStatus === 'unavailable' || + matchStatus === 'generation_failed') && ( +

+ {t(MATCH_ERROR_KEYS[matchStatus])} +

+ )} + {matchStatus === 'done' && adjusted && ( +

+ {t('matchSiteAdjustedNote')} +

+ )} +
+
+ {/* S5a: both widgets take the same theme params, so no more gate */} {(
@@ -338,12 +490,78 @@ export function EmbedConfigurator({ bills }: { bills: FeedTeaser[] }) { > {FONT_KEYS.map((key) => ( + ))} + + +
+ +
+ + + {customColors && ( +
+
+ +
+ setSurfaceInput(e.target.value)} + className="h-11 w-14 cursor-pointer rounded-control border border-line bg-surface" + /> + {surfaceInput} +
+
+
+ +
+ setInkInput(e.target.value)} + className="h-11 w-14 cursor-pointer rounded-control border border-line bg-surface" + /> + {inkInput} +
+
+ {!pairPasses && ( +

+ {t('contrastWarning')} +

+ )} +
+ )}
)} @@ -365,6 +583,29 @@ export function EmbedConfigurator({ bills }: { bills: FeedTeaser[] }) {

{t('previewHeading')}

+ {/* Mock "on your site" strip (brand-preview build): the honest, + minimal version — their surface color, their logo (loaded + client-side straight from their https same-host URL, never + proxied or re-hosted), no fake browser chrome. Renders only + after a successful match with the pair active, so it always + shows colors that actually passed the contrast gate. */} + {matchedSite && pairActive && ( +
+ {matchedSite.logoUrl && ( + // eslint-disable-next-line @next/next/no-img-element -- external, unconfigurable host; plain img is the point (no proxying) + + )} + {matchedSite.name ?? ''} + {matchedSite.name && ( + + {t('matchSiteFrameNote', { name: matchedSite.name })} + + )} +
+ )}
{previewSrc ? (