From d26e5ea11c2fe15df53fade3ce66e467accec47e Mon Sep 17 00:00:00 2001 From: Colby Maxwell Date: Thu, 16 Jul 2026 18:10:27 -0400 Subject: [PATCH 1/7] =?UTF-8?q?feat(theme):=20WCAG=20contrast=20math=20mod?= =?UTF-8?q?ule=20=E2=80=94=20shared=20by=20widget=20resolver,=20/api/brand?= =?UTF-8?q?,=20and=20configurator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/contrast.ts | 106 ++++++++++++++++++++++++++++++++++++ tests/contrast.unit.spec.ts | 97 +++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 lib/contrast.ts create mode 100644 tests/contrast.unit.spec.ts diff --git a/lib/contrast.ts b/lib/contrast.ts new file mode 100644 index 0000000..dc174f2 --- /dev/null +++ b/lib/contrast.ts @@ -0,0 +1,106 @@ +/** + * WCAG 2.x color math for the embed theming surface (brand-preview build). + * Pure and dependency-free on purpose: three consumers share it — the + * embed-theme resolver (server pages), the /api/brand finalizer, and the + * configurator's live contrast readout (client). The client copy existing is + * the point: the configurator must warn/omit on EXACTLY the pairs the server + * would discard, so both sides call this one implementation rather than + * approximating each other. + * + * Deliberately NOT 'server-only' for that reason. Contains no secrets, no + * I/O, no logging. + */ + +/** Parsed sRGB channels 0-255, or null for anything that isn't #rgb/#rrggbb. */ +export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const m = /^#(?:([0-9a-f]{3})|([0-9a-f]{6}))$/i.exec(hex); + if (!m) return null; + const s = m[1] ? [...m[1]].map((c) => c + c).join('') : m[2]; + return { + r: parseInt(s.slice(0, 2), 16), + g: parseInt(s.slice(2, 4), 16), + b: parseInt(s.slice(4, 6), 16), + }; +} + +/** WCAG relative luminance (0 = black, 1 = white). */ +export function relativeLuminance(rgb: { r: number; g: number; b: number }): number { + const channel = (v: number) => { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + return 0.2126 * channel(rgb.r) + 0.7152 * channel(rgb.g) + 0.0722 * channel(rgb.b); +} + +/** + * WCAG contrast ratio between two hex colors, 1..21. Returns 0 (never a + * passing value) when either color fails to parse — callers treat 0 as + * "fails every threshold", which is the fail-closed behavior the theming + * doctrine requires. + */ +export function contrastRatio(hexA: string, hexB: string): number { + const a = hexToRgb(hexA); + const b = hexToRgb(hexB); + if (!a || !b) return 0; + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + const [hi, lo] = la >= lb ? [la, lb] : [lb, la]; + return (hi + 0.05) / (lo + 0.05); +} + +/** + * Whichever of the two candidate text colors reads better on `bgHex`. + * Defaults are the brand's near-white/near-black ink pair, so the shipped + * default accent (#82632a) keeps yielding today's #fbf8f0 chip text. + * Unparseable background falls back to the light candidate (matches the + * pre-existing hardcoded chip color, so a validation slip can only ever + * reproduce the old look, never invent a new one). + */ +export function pickTextColor(bgHex: string, light = '#fbf8f0', dark = '#1b1611'): string { + const bg = hexToRgb(bgHex); + if (!bg) return light; + return contrastRatio(bgHex, light) >= contrastRatio(bgHex, dark) ? light : dark; +} + +/** Linear blend of two parseable hex colors, t in [0,1], returned as #rrggbb. */ +function mixHex(fromHex: string, toHex: string, t: number): string { + const from = hexToRgb(fromHex)!; + const to = hexToRgb(toHex)!; + const ch = (a: number, b: number) => Math.round(a + (b - a) * t); + const out = [ch(from.r, to.r), ch(from.g, to.g), ch(from.b, to.b)]; + return `#${out.map((v) => v.toString(16).padStart(2, '0')).join('')}`; +} + +/** + * Nudge `ink` toward black or white (direction picked by the surface's + * luminance) until it clears `min` contrast against `surface`. Used ONLY by + * /api/brand to repair an AI-suggested palette — the widget boundary rejects + * instead (lib/embed-theme.ts), because a widget must never render colors + * the tenant didn't supply. + * + * Always converges for min <= 4.58: the better of pure black/white against + * ANY surface is >= sqrt(21) ~ 4.58 (worst case is the mid-luminance surface + * where both directions tie), so the extreme is a guaranteed final fallback + * for the 4.5 threshold. Returns null only for unparseable input. + */ +export function adjustInkForContrast( + ink: string, + surface: string, + min = 4.5 +): { ink: string; adjusted: boolean } | null { + if (!hexToRgb(ink) || !hexToRgb(surface)) return null; + if (contrastRatio(ink, surface) >= min) return { ink, adjusted: false }; + + const target = relativeLuminance(hexToRgb(surface)!) >= 0.1791 ? '#000000' : '#ffffff'; + const STEPS = 12; + for (let i = 1; i <= STEPS; i++) { + const candidate = mixHex(ink, target, i / STEPS); + if (contrastRatio(candidate, surface) >= min) return { ink: candidate, adjusted: true }; + } + // Unreachable for min <= 4.58 (the i = STEPS candidate IS the extreme), + // kept for callers that pass a stricter threshold. + const extreme = contrastRatio('#1b1611', surface) >= contrastRatio('#fbf8f0', surface) + ? '#1b1611' + : '#fbf8f0'; + return contrastRatio(extreme, surface) >= min ? { ink: extreme, adjusted: true } : null; +} diff --git a/tests/contrast.unit.spec.ts b/tests/contrast.unit.spec.ts new file mode 100644 index 0000000..fe745b1 --- /dev/null +++ b/tests/contrast.unit.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test'; +// Relative import on purpose: plain lib modules resolve under the Playwright +// runner without the @/ alias (same note as tests/embed-referrer.unit.spec.ts). +import { + adjustInkForContrast, + contrastRatio, + hexToRgb, + pickTextColor, + relativeLuminance, +} from '../lib/contrast'; + +test.describe('hexToRgb', () => { + test('parses #rrggbb and #rgb (shorthand doubles digits)', () => { + expect(hexToRgb('#2a2318')).toEqual({ r: 0x2a, g: 0x23, b: 0x18 }); + expect(hexToRgb('#fff')).toEqual({ r: 255, g: 255, b: 255 }); + expect(hexToRgb('#ABC')).toEqual({ r: 0xaa, g: 0xbb, b: 0xcc }); + }); + + test('rejects everything else, full-string only', () => { + for (const bad of ['fff', '#ffff', '#gggggg', '#fff "}', ' #fff', '#fffffff', 'rgb(0,0,0)', '']) { + expect(hexToRgb(bad)).toBeNull(); + } + }); +}); + +test.describe('relativeLuminance / contrastRatio', () => { + test('anchors: white=1, black=0, white-on-black=21, self=1', () => { + expect(relativeLuminance({ r: 255, g: 255, b: 255 })).toBeCloseTo(1, 5); + expect(relativeLuminance({ r: 0, g: 0, b: 0 })).toBeCloseTo(0, 5); + expect(contrastRatio('#ffffff', '#000000')).toBeCloseTo(21, 3); + expect(contrastRatio('#82632a', '#82632a')).toBeCloseTo(1, 5); + }); + + test('is symmetric and matches a known WCAG pair', () => { + expect(contrastRatio('#2a2318', '#f3ecdd')).toBeCloseTo(contrastRatio('#f3ecdd', '#2a2318'), 6); + // The brand's default ink/surface pair must itself clear AA body text — + // if this ever fails, the shipped default widget violates the constitution. + expect(contrastRatio('#2a2318', '#f3ecdd')).toBeGreaterThanOrEqual(4.5); + // And the default dark pair too. + expect(contrastRatio('#e4d9c0', '#1b1611')).toBeGreaterThanOrEqual(4.5); + }); + + test('unparseable input fails closed to 0 (never a passing ratio)', () => { + expect(contrastRatio('#zzz', '#ffffff')).toBe(0); + expect(contrastRatio('#ffffff', 'white')).toBe(0); + }); +}); + +test.describe('pickTextColor', () => { + test('default accent keeps the shipped chip color', () => { + expect(pickTextColor('#82632a')).toBe('#fbf8f0'); + }); + + test('light backgrounds get dark text, dark get light', () => { + expect(pickTextColor('#f3ecdd')).toBe('#1b1611'); + expect(pickTextColor('#ffe680')).toBe('#1b1611'); + expect(pickTextColor('#1b1611')).toBe('#fbf8f0'); + expect(pickTextColor('#0f1a2b')).toBe('#fbf8f0'); + }); + + test('unparseable background falls back to the light candidate', () => { + expect(pickTextColor('nope')).toBe('#fbf8f0'); + }); +}); + +test.describe('adjustInkForContrast', () => { + test('passing pair returns unchanged with adjusted:false', () => { + expect(adjustInkForContrast('#2a2318', '#f3ecdd')).toEqual({ ink: '#2a2318', adjusted: false }); + }); + + test('failing pair converges to >= 4.5 with adjusted:true', () => { + const result = adjustInkForContrast('#888888', '#999999'); + expect(result).not.toBeNull(); + expect(result!.adjusted).toBe(true); + expect(contrastRatio(result!.ink, '#999999')).toBeGreaterThanOrEqual(4.5); + }); + + test('converges for 4.5 even on the worst-case mid-gray surface', () => { + // sqrt(21) ~ 4.58 is the guaranteed floor for the better extreme against + // any surface; #757575 sits near the tie point where both directions are + // weakest. + const result = adjustInkForContrast('#757575', '#757575'); + expect(result).not.toBeNull(); + expect(contrastRatio(result!.ink, '#757575')).toBeGreaterThanOrEqual(4.5); + }); + + test('is idempotent: adjusting an adjusted ink changes nothing', () => { + const once = adjustInkForContrast('#6699cc', '#88aadd')!; + const twice = adjustInkForContrast(once.ink, '#88aadd')!; + expect(twice).toEqual({ ink: once.ink, adjusted: false }); + }); + + test('unparseable input returns null, never a guess', () => { + expect(adjustInkForContrast('junk', '#ffffff')).toBeNull(); + expect(adjustInkForContrast('#ffffff', 'junk')).toBeNull(); + }); +}); From 092a4d73267e710e1fdd812a3f748759340d3734 Mon Sep 17 00:00:00 2001 From: Colby Maxwell Date: Thu, 16 Jul 2026 18:30:56 -0400 Subject: [PATCH 2/7] =?UTF-8?q?feat(embeds):=20widen=20theme=20surface=20?= =?UTF-8?q?=E2=80=94=20surface/ink=20palette,=204=20font=20stacks,=20force?= =?UTF-8?q?d=20light/dark/auto=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/embed/action-panel/page.tsx | 76 +++++----- app/embed/bill-card/page.tsx | 37 ++--- app/embed/embed.css | 154 +++++++++---------- app/embed/rep-lookup/page.tsx | 31 ++-- components/embed/ActionPanelWidget.tsx | 19 +-- components/embed/BillCardWidget.tsx | 27 +--- components/embed/EmbedThemeStyle.tsx | 21 +++ components/embed/RepLookupWidget.tsx | 18 +-- lib/embed-theme.ts | 171 +++++++++++++++++++++- messages/en.json | 12 +- messages/es.json | 12 +- public/embed.js | 34 +++-- tests/embed-loader.spec.ts | 52 +++++++ tests/embed-theme-surface.spec.ts | 145 ++++++++++++++++++ tests/embed-theme.unit.spec.ts | 195 +++++++++++++++++++++++++ 15 files changed, 778 insertions(+), 226 deletions(-) create mode 100644 components/embed/EmbedThemeStyle.tsx create mode 100644 tests/embed-theme-surface.spec.ts create mode 100644 tests/embed-theme.unit.spec.ts 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/embed/ActionPanelWidget.tsx b/components/embed/ActionPanelWidget.tsx index 2ec7426..96f24a8 100644 --- a/components/embed/ActionPanelWidget.tsx +++ b/components/embed/ActionPanelWidget.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import en from '@/messages/en.json'; import es from '@/messages/es.json'; -import { FONT_VALUES, RADIUS_VALUES, type FontKey, type RadiusKey } from '@/lib/embed-theme'; import { officeHoursStatus } from '@/lib/office-hours'; import { SITE_ORIGIN } from '@/lib/site'; import type { Legislator, Stance } from '@/lib/types'; @@ -52,12 +51,6 @@ export interface ActionPanelBillData { officialTitle: string; } -export interface ActionPanelTheme { - accent?: string; - radiusKey: RadiusKey; - fontKey: FontKey; -} - function telHref(phone: string) { return `tel:+1${phone.replace(/\D/g, '')}`; } @@ -79,7 +72,6 @@ export function ActionPanelWidget({ initialLocale, token, bill, - theme, brandless = false, attribution = 'on', }: { @@ -87,7 +79,6 @@ export function ActionPanelWidget({ /** Held in component state, sent ONLY as the X-Oravan-Key header — never re-appended to a URL. */ token: string; bill: ActionPanelBillData; - theme: ActionPanelTheme; /** Removes the Oravan name from chrome (never the AI-integrity chip). */ brandless?: boolean; /** 'none' hides the Powered-by footer — licensed partners only (see /embeds docs). */ @@ -207,17 +198,11 @@ export function ActionPanelWidget({ const siteBase = `${SITE_ORIGIN}${locale === 'es' ? '/es' : ''}`; - const themeStyle: 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], - }; - const displayHeadline = bill.headline ?? bill.officialTitle; const officeHours = hydrated ? officeHoursStatus() : null; return ( -
+

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

diff --git a/components/embed/BillCardWidget.tsx b/components/embed/BillCardWidget.tsx index 59daeac..9e310a8 100644 --- a/components/embed/BillCardWidget.tsx +++ b/components/embed/BillCardWidget.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import { useEffect, useRef, useState } from 'react'; import en from '@/messages/en.json'; import es from '@/messages/es.json'; import { SITE_ORIGIN } from '@/lib/site'; -import { FONT_VALUES, RADIUS_VALUES, type FontKey, type RadiusKey } from '@/lib/embed-theme'; import type { BillStatus } from '@/lib/types'; /* @@ -33,13 +32,6 @@ export interface BillCardData { status: BillStatus; } -export interface BillCardTheme { - /** Already validated (lib/embed-theme's safeAccent) — a hex color or undefined. */ - accent?: string; - radiusKey: RadiusKey; - fontKey: FontKey; -} - /** next-intl-style `{token}` interpolation, without pulling in next-intl. */ function format(template: string, vars: Record) { return template.replace(/\{(\w+)\}/g, (match, key: string) => @@ -51,7 +43,6 @@ export function BillCardWidget({ initialLocale, bill, dataAsOf, - theme, brandless = false, attribution = 'on', }: { @@ -59,7 +50,6 @@ export function BillCardWidget({ bill: BillCardData | null; /** ISO timestamp from lib/freshness's getFreshness().checkedAt. */ dataAsOf: string; - theme: BillCardTheme; /** Removes the Oravan name from chrome (never the AI-integrity chip). */ brandless?: boolean; /** 'none' hides the Powered-by footer — licensed partners only (see /embeds docs). */ @@ -97,15 +87,8 @@ export function BillCardWidget({ return () => observer.disconnect(); }, []); - // The ONLY theming surface: three CSS custom properties, each already - // validated (lib/embed-theme) before this component ever sees them. No - // other tenant-supplied value is ever assigned to a style prop here. - const themeStyle: 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], - }; - + // Theming lives entirely at :root now (EmbedThemeStyle, rendered by the + // page) — no tenant-supplied value ever reaches a style prop here. const localeToggle = (
{(['en', 'es'] as const).map((l) => ( @@ -124,7 +107,7 @@ export function BillCardWidget({ if (!bill) { return ( -
+
{/* Brandless chrome drops the app-name fallback, not the layout */}

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

@@ -149,7 +132,7 @@ export function BillCardWidget({ }); return ( -
+

{bill.citation}

{localeToggle} diff --git a/components/embed/EmbedThemeStyle.tsx b/components/embed/EmbedThemeStyle.tsx new file mode 100644 index 0000000..f2789fe --- /dev/null +++ b/components/embed/EmbedThemeStyle.tsx @@ -0,0 +1,21 @@ +import { buildThemeCss, type ResolvedEmbedTheme } from '@/lib/embed-theme'; + +/* + * The single delivery point for embed theming (brand-preview build): one + * server-rendered '); + await page.goto( + `/embed/rep-lookup?locale=en&surface=${hostile}&ink=${hostile}&mode=${hostile}` + ); + await expect(page.locator('.re-root')).toBeVisible(); + const pwned = await page.evaluate(() => (window as { __pwned9?: number }).__pwned9); + expect(pwned).toBeUndefined(); + // The payload must never reach a STYLE surface. (page.content() would also + // match Next's RSC flight payload, which legitimately echoes searchParams + // as inert, escaped string data — that's not a style/script surface.) + const styleText = await page.evaluate(() => + Array.from(document.querySelectorAll('style')) + .map((s) => s.textContent ?? '') + .join('\n') + ); + expect(styleText).not.toContain('display:none'); + expect(styleText).not.toContain('pwned'); + expect(await page.content()).not.toContain('', +]; + +test.describe('safeSurface / safeInk', () => { + test('accept exactly the safeAccent hex shapes', () => { + expect(safeSurface('#0f1a2b')).toBe('#0f1a2b'); + expect(safeInk('#F5F7FA')).toBe('#F5F7FA'); + expect(safeSurface('#abc')).toBe('#abc'); + }); + + test('fail closed on the hostile payload family', () => { + for (const value of HOSTILE_VALUES) { + expect(safeSurface(value)).toBeUndefined(); + expect(safeInk(value)).toBeUndefined(); + } + }); +}); + +test.describe('safeModeKey / safeFontKey', () => { + test('mode: only the two exact tokens force; everything else is auto', () => { + expect(safeModeKey('light')).toBe('light'); + expect(safeModeKey('dark')).toBe('dark'); + for (const junk of ['Dark', 'DARK', 'night', 'auto', '', undefined, null, '1']) { + expect(safeModeKey(junk as string | undefined | null)).toBe('auto'); + } + }); + + test('font: the four closed keys pass, everything else is system', () => { + expect(safeFontKey('serif')).toBe('serif'); + expect(safeFontKey('humanist')).toBe('humanist'); + expect(safeFontKey('geometric')).toBe('geometric'); + for (const junk of ['Humanist', 'comic-sans', 'Arial, sans-serif', '', undefined]) { + expect(safeFontKey(junk as string | undefined)).toBe('system'); + } + }); + + test('the two new stacks exist and are double-quoted (computed-style parity)', () => { + expect(FONT_VALUES.humanist).toContain('"Gill Sans Nova"'); + expect(FONT_VALUES.geometric).toContain('"URW Gothic"'); + expect(FONT_VALUES.humanist).not.toContain("'"); + expect(FONT_VALUES.geometric).not.toContain("'"); + }); +}); + +test.describe('resolveEmbedTheme', () => { + test('a lone surface or lone ink is discarded (pair-or-nothing)', () => { + expect(resolveEmbedTheme({ surface: '#ffffff' }).surface).toBeUndefined(); + expect(resolveEmbedTheme({ ink: '#000000' }).ink).toBeUndefined(); + }); + + test('a pair below 4.5:1 is discarded as a pair', () => { + const theme = resolveEmbedTheme({ surface: '#888888', ink: '#999999' }); + expect(theme.surface).toBeUndefined(); + expect(theme.ink).toBeUndefined(); + }); + + test('a passing pair survives intact — never adjusted at this boundary', () => { + const theme = resolveEmbedTheme({ surface: '#0f1a2b', ink: '#f5f7fa' }); + expect(theme.surface).toBe('#0f1a2b'); + expect(theme.ink).toBe('#f5f7fa'); + }); + + test('forced mode with no pair pins that mode default palette', () => { + const dark = resolveEmbedTheme({ mode: 'dark' }); + expect(dark.surface).toBe(MODE_DEFAULTS.dark.surface); + expect(dark.ink).toBe(MODE_DEFAULTS.dark.ink); + const light = resolveEmbedTheme({ mode: 'light' }); + expect(light.surface).toBe(MODE_DEFAULTS.light.surface); + // Both shipped default palettes must themselves clear the bar they enforce. + expect(contrastRatio(MODE_DEFAULTS.dark.ink, MODE_DEFAULTS.dark.surface)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(MODE_DEFAULTS.light.ink, MODE_DEFAULTS.light.surface)).toBeGreaterThanOrEqual(4.5); + }); + + test('forced mode with a valid pair keeps the tenant pair (pair wins)', () => { + const theme = resolveEmbedTheme({ mode: 'dark', surface: '#ffffff', ink: '#111111' }); + expect(theme.surface).toBe('#ffffff'); + expect(theme.ink).toBe('#111111'); + expect(theme.mode).toBe('dark'); + }); + + test('auto mode with no pair leaves surface/ink unset (media query rules)', () => { + const theme = resolveEmbedTheme({}); + expect(theme.surface).toBeUndefined(); + expect(theme.ink).toBeUndefined(); + expect(theme.mode).toBe('auto'); + }); + + test('accentInk derives from accent by contrast; focus needs a surface', () => { + const noSurface = resolveEmbedTheme({ accent: '#82632a' }); + expect(noSurface.accentInk).toBe('#fbf8f0'); // the shipped default chip text + expect(noSurface.focus).toBeUndefined(); + + const light = resolveEmbedTheme({ accent: '#ffe680' }); + expect(light.accentInk).toBe('#1b1611'); + + // Accent readable on the surface -> focus = accent. + const readable = resolveEmbedTheme({ accent: '#82632a', surface: '#ffffff', ink: '#111111' }); + expect(readable.focus).toBe('#82632a'); + // Accent illegible on the surface -> focus falls back to ink. + const illegible = resolveEmbedTheme({ accent: '#f0e9da', surface: '#f3ecdd', ink: '#2a2318' }); + expect(illegible.focus).toBe('#2a2318'); + }); +}); + +test.describe('buildThemeCss', () => { + test('defaults emit exactly radius + font, no accent/surface/ink/scheme', () => { + const css = buildThemeCss(resolveEmbedTheme({})); + expect(css).toBe( + `:root:root{--oravan-radius:${RADIUS_VALUES.soft};--oravan-font:${FONT_VALUES.system}}` + ); + }); + + test('forced dark pins palette + color-scheme', () => { + const css = buildThemeCss(resolveEmbedTheme({ mode: 'dark' })); + expect(css).toContain(`--oravan-surface:${MODE_DEFAULTS.dark.surface}`); + expect(css).toContain(`--oravan-ink:${MODE_DEFAULTS.dark.ink}`); + expect(css).toContain('color-scheme:dark'); + }); + + test('a tenant pair under auto derives its scheme from surface luminance', () => { + expect(buildThemeCss(resolveEmbedTheme({ surface: '#0f1a2b', ink: '#f5f7fa' }))).toContain( + 'color-scheme:dark' + ); + expect(buildThemeCss(resolveEmbedTheme({ surface: '#ffffff', ink: '#111111' }))).toContain( + 'color-scheme:light' + ); + }); + + test('output is a single closed rule made only of validated declarations', () => { + const css = buildThemeCss( + resolveEmbedTheme({ + accent: '#336699', + surface: '#ffffff', + ink: '#111111', + mode: 'dark', + radius: 'round', + font: 'geometric', + }) + ); + // Exactly one rule block, no HTML-significant characters, no selectors + // beyond the fixed :root:root prefix. + expect(css.startsWith(':root:root{')).toBe(true); + expect(css.endsWith('}')).toBe(true); + expect(css.indexOf('{')).toBe(css.lastIndexOf('{')); + expect(css).not.toContain('<'); + expect(css).not.toContain('>'); + expect(css).toContain('--oravan-accent:#336699'); + expect(css).toContain(`--oravan-radius:${RADIUS_VALUES.round}`); + expect(css).toContain(`--oravan-font:${FONT_VALUES.geometric}`); + }); + + test('property test: hostile raw values can never surface in the CSS text', () => { + for (const value of HOSTILE_VALUES) { + const css = buildThemeCss( + resolveEmbedTheme({ accent: value, surface: value, ink: value, mode: value, radius: value, font: value }) + ); + expect(css).not.toContain('evil'); + expect(css).not.toContain('expression'); + expect(css).not.toContain('<'); + expect(css).not.toContain('display:none'); + // Fail-closed means fully default output. + expect(css).toBe( + `:root:root{--oravan-radius:${RADIUS_VALUES.soft};--oravan-font:${FONT_VALUES.system}}` + ); + } + }); +}); From e06128c8d23aeee4e9fb5e46d7c756549d207ba4 Mon Sep 17 00:00:00 2001 From: Colby Maxwell Date: Thu, 16 Jul 2026 18:40:10 -0400 Subject: [PATCH 3/7] feat(embeds): configurator controls for the widened theme knobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/EmbedConfigurator.tsx | 131 +++++++++++++++++++++++++++--- tests/embeds-configurator.spec.ts | 104 ++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 13 deletions(-) diff --git a/components/EmbedConfigurator.tsx b/components/EmbedConfigurator.tsx index 1eadfac..fa8faa2 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,6 +77,10 @@ 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); @@ -69,6 +91,13 @@ export function EmbedConfigurator({ bills }: { bills: FeedTeaser[] }) { // 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 +157,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 +182,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 +196,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; @@ -338,12 +377,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')} +

+ )} +
+ )} )} diff --git a/tests/embeds-configurator.spec.ts b/tests/embeds-configurator.spec.ts index 48dc5f6..f861ce4 100644 --- a/tests/embeds-configurator.spec.ts +++ b/tests/embeds-configurator.spec.ts @@ -188,3 +188,107 @@ test('footer Embeds link is reachable and clickable on mobile', async ({ page, i await link.click(); await expect(page).toHaveURL(/\/embeds$/); }); + +/* + * Brand-preview build — the widened theme controls: color mode, the two new + * font stacks, and the custom surface/ink pair gated by the same lib/contrast + * math the server enforces (warn + omit on a failing pair, so the snippet can + * never carry values the server would discard). + */ +test.describe('widened theme controls (mode, new fonts, custom surface/ink pair)', () => { + // React controlled color inputs need the native value setter + a bubbling + // input event; locator.fill() doesn't support type="color". + async function setColor(page: import('@playwright/test').Page, id: string, value: string) { + await page.locator(`#${id}`).evaluate((el, v) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; + setter.call(el, v); + el.dispatchEvent(new Event('input', { bubbles: true })); + }, value); + } + + test('mode select emits data-mode only when forced', async ({ page }) => { + await page.goto('/embeds'); + const snippet = page.locator('pre code'); + await expect(snippet).not.toContainText('data-mode'); + await page.getByLabel(en.embeds.modeLabel).selectOption('dark'); + await expect(snippet).toContainText('data-mode="dark"'); + await page.getByLabel(en.embeds.modeLabel).selectOption('auto'); + await expect(snippet).not.toContainText('data-mode'); + }); + + test('the two new font stacks are selectable and land in the snippet', async ({ page }) => { + await page.goto('/embeds'); + const fontSelect = page.getByLabel(en.embeds.fontLabel); + await expect(fontSelect.locator('option')).toHaveCount(4); + await fontSelect.selectOption('humanist'); + await expect(page.locator('pre code')).toContainText('data-font="humanist"'); + await fontSelect.selectOption('geometric'); + await expect(page.locator('pre code')).toContainText('data-font="geometric"'); + }); + + test('custom colors: hidden by default, defaults pass contrast, snippet carries the pair', async ({ + page, + }) => { + await page.goto('/embeds'); + await expect(page.locator('#oravan-surface')).toHaveCount(0); + await expect(page.locator('pre code')).not.toContainText('data-surface'); + + await page.getByLabel(en.embeds.customColorsToggle).check(); + await expect(page.getByLabel(en.embeds.surfaceLabel, { exact: true })).toBeVisible(); + await expect(page.getByLabel(en.embeds.inkLabel, { exact: true })).toBeVisible(); + // The prefilled defaults are the brand pair — they pass AA, no warning. + await expect(page.getByText(en.embeds.contrastWarning)).toHaveCount(0); + await expect(page.locator('pre code')).toContainText('data-surface="#f3ecdd"'); + await expect(page.locator('pre code')).toContainText('data-ink="#2a2318"'); + }); + + test('a failing pair warns AND is omitted from snippet + preview', async ({ page }) => { + await page.goto('/embeds'); + await page.getByLabel(en.embeds.customColorsToggle).check(); + await setColor(page, 'oravan-ink', '#dddddd'); // ~1.2:1 against the default cream + await expect(page.getByText(en.embeds.contrastWarning)).toBeVisible(); + await expect(page.locator('pre code')).not.toContainText('data-surface'); + await expect(page.locator('pre code')).not.toContainText('data-ink'); + const previewSrc = await page.locator('iframe[title]').first().getAttribute('src'); + expect(previewSrc).not.toContain('surface='); + expect(previewSrc).not.toContain('ink='); + // Repairing the pair clears the warning and restores the knobs. + await setColor(page, 'oravan-ink', '#111111'); + await expect(page.getByText(en.embeds.contrastWarning)).toHaveCount(0); + await expect(page.locator('pre code')).toContainText('data-ink="#111111"'); + }); + + test('a valid custom pair + forced mode reach the live preview iframe', async ({ page }) => { + await page.goto('/embeds'); + await page.getByLabel(en.embeds.customColorsToggle).check(); + await setColor(page, 'oravan-surface', '#0f1a2b'); + await setColor(page, 'oravan-ink', '#f5f7fa'); + await page.getByLabel(en.embeds.modeLabel).selectOption('dark'); + const previewSrc = await page.locator('iframe[title]').first().getAttribute('src'); + expect(previewSrc).toContain('surface=%230f1a2b'); + expect(previewSrc).toContain('ink=%23f5f7fa'); + expect(previewSrc).toContain('mode=dark'); + }); + + test('ES locale renders the new control labels', async ({ page }) => { + await page.goto('/es/embeds'); + await expect(page.getByLabel(es.embeds.modeLabel)).toBeVisible(); + await expect(page.getByLabel(es.embeds.fontLabel)).toBeVisible(); + await expect(page.getByText(es.embeds.customColorsToggle)).toBeVisible(); + }); + + test('new controls meet the 44px touch-target bar', async ({ page }) => { + await page.goto('/embeds'); + // Native 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 */} {(
@@ -470,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 ? (