diff --git a/example/wallet-only/src/App.tsx b/example/wallet-only/src/App.tsx index 2125934..ac4370b 100644 --- a/example/wallet-only/src/App.tsx +++ b/example/wallet-only/src/App.tsx @@ -22,6 +22,12 @@ const KEEP_ON_CLEAR: (keyof EmbedTheme)[] = [ 'incomingBubbleColor', 'fontFamily', 'fontSize', + // Blur is not a palette value — the engine cannot derive it — so it survives + // alongside the surface it frosts. The widget ground is a seed and stays, so + // its blur stays too; the header/composer blurs go with their (cleared) + // granular colours, since a frost over a re-derived opaque surface is dead + // weight in the payload. + 'backgroundBlur', ]; /** Cap on the undo/redo stack so a long colour-picker drag can't blow up memory. */ diff --git a/example/wallet-only/src/components/Marketing.tsx b/example/wallet-only/src/components/Marketing.tsx index a30b419..ccefe12 100644 --- a/example/wallet-only/src/components/Marketing.tsx +++ b/example/wallet-only/src/components/Marketing.tsx @@ -18,14 +18,40 @@ const SNIPPET_SEEDS: (keyof EmbedTheme)[] = [ 'incomingBubbleColor', ]; -function buildSnippet(theme: Partial, layout: EmbedLayout): string { +/** + * Surface colours worth showing next to the seeds: they are what a host passes + * to take a ground see-through, so they belong in the copy-paste snippet even + * though the rest of the granular palette does not. + */ +const SNIPPET_SURFACES: (keyof EmbedTheme)[] = ['headerColor', 'inputColor']; + +/** Numeric (px) keys — emitted bare, no quotes. */ +const SNIPPET_BLURS: (keyof EmbedTheme)[] = ['backgroundBlur', 'headerBlur', 'inputBlur']; + +/** Entry count past which the theme object reads better broken over lines. */ +const THEME_INLINE_MAX = 4; + +function buildSnippet( + theme: Partial, + layout: EmbedLayout, +): { code: string; shown: number; total: number } { const themeEntries: string[] = []; - for (const k of SNIPPET_SEEDS) { + for (const k of [...SNIPPET_SEEDS, ...SNIPPET_SURFACES]) { const v = theme[k]; if (typeof v === 'string' && v) themeEntries.push(`${k}: '${v}'`); } if (theme.gradients === 'on') themeEntries.push(`gradients: 'on'`); - const themeInline = themeEntries.length ? `{ ${themeEntries.join(', ')} }` : `{ mode: 'dark' }`; + for (const k of SNIPPET_BLURS) { + const v = theme[k]; + // Only real, non-zero amounts — a 0 blur is the same as not passing one. + const n = typeof v === 'number' ? v : typeof v === 'string' && v ? Number(v) : NaN; + if (Number.isFinite(n) && n > 0) themeEntries.push(`${k}: ${Math.round(n)}`); + } + const themeInline = !themeEntries.length + ? `{ mode: 'dark' }` + : themeEntries.length <= THEME_INLINE_MAX + ? `{ ${themeEntries.join(', ')} }` + : `{\n ${themeEntries.join(',\n ')},\n }`; const layoutEntries: string[] = []; if (layout.showHeader === false) layoutEntries.push('showHeader: false'); @@ -35,7 +61,12 @@ function buildSnippet(theme: Partial, layout: EmbedLayout): string { if (ht) layoutEntries.push(`headerTitle: '${ht.replace(/'/g, "\\'")}'`); const layoutLine = layoutEntries.length ? `\n layout: { ${layoutEntries.join(', ')} },` : ''; - return ` + const total = (Object.keys(theme) as (keyof EmbedTheme)[]).filter((k) => { + const v = theme[k]; + return v !== undefined && v !== ''; + }).length; + + const code = `
`; + + return { code, shown: themeEntries.length, total }; } /** @@ -53,6 +86,11 @@ function buildSnippet(theme: Partial, layout: EmbedLayout): string { * the integration snippet reflects the current seeds and non-default layout. */ export function Marketing({ theme, layout }: MarketingProps) { + const { code, shown, total } = buildSnippet(theme, layout); + // The snippet is the SEED-shaped integration a host would really write; a + // preset additionally pins granular slots. Say so out loud rather than let the + // snippet read as "everything the preview is running on". + const hidden = Math.max(0, total - shown); return (
@@ -84,10 +122,18 @@ export function Marketing({ theme, layout }: MarketingProps) {
Integration snippet
-          {buildSnippet(theme, layout)}
+          {code}
         

- Reflects the current seeds & layout. Full SDK reference & types on{' '} + Reflects the current seeds, surfaces & layout.{' '} + {hidden > 0 && ( + <> + The live preview is sending {hidden} further key{hidden === 1 ? '' : 's'} the + snippet leaves out (granular palette + typography) — Copy theme in the editor + gives the exact object.{' '} + + )} + Full SDK reference & types on{' '} ({ options, value, onChange, + compact, }: { options: readonly { v: T; label: string }[]; value: T | undefined; onChange: (v: T) => void; + /** Tighter padding — for 4-option groups that must fit the left pane. */ + compact?: boolean; }) { return ( - + {options.map((o) => (

)} + {/* ── Surfaces: opaque, see-through, frosted ──────────────────────── */} +
+ Basic — surfaces + + Each ground is one key, and the four modes are exactly what that key carries:{' '} + auto sends no key at all and lets the engine decide (the tone below it is read + back from the live theme, not sent); solid sends an opaque colour;{' '} + alpha sends a colour with an alpha channel — rgba(…) — so what sits + behind shows through while its RGB still anchors the derivation; transparent sends + the bare transparent keyword, handing the ground over entirely, in which + case the engine synthesizes a brand-toned anchor so the rest of the palette still has + something to derive from. Blur frosts whatever shows through: for the two inner + surfaces that is the transcript sliding underneath; for the widget ground it is the host + page, frosted by the SDK on the frame itself — a chat in an iframe cannot reach out and + blur the page it sits on. A blur over an opaque surface has nothing to sample, so the + slider goes inert and its key stays out of the payload. + +
+ {SURFACE_FIELDS.map((f) => { + const varName = PARAM_TO_VAR[f.k as keyof typeof PARAM_TO_VAR]; + return ( + + ); + })} + {/* ── Live payload summary ────────────────────────────────────────── */}
diff --git a/example/wallet-only/src/presets.ts b/example/wallet-only/src/presets.ts index 90d667c..8788ff6 100644 --- a/example/wallet-only/src/presets.ts +++ b/example/wallet-only/src/presets.ts @@ -1,9 +1,9 @@ import type { ThemePreset } from './types'; /** - * The starter themes. Each preset is a complete EmbedTheme — the demo - * reaches `cherry` by sending `{}` so the iframe falls through to its - * built-in defaults (which already match prod chat.cherry.fun). + * The starter themes. Each preset is a complete EmbedTheme and is sent VERBATIM + * to the iframe — there is no "empty payload" path: picking `cherry` puts every + * field below on the wire, and the editor shows exactly that. * * The constructor on the right of the page lets visitors tweak any field * after picking a preset; the override layer lives in React state and is @@ -16,18 +16,22 @@ export const PRESETS: ThemePreset[] = [ blurb: 'Default brand — pink on near-black.', swatches: ['#ff1493', '#4a1d56'], /** - * Every field here matches the iframe's built-in default exactly — - * filling them out so the editor surfaces the actual hex values - * (instead of leaving inputs blank with a placeholder). `ownBubbleColor` - * + `sendButtonColor` stay UNSET on purpose so the iframe falls back - * to its primary→accent gradient — set them to flatten into a solid - * tile. + * The brand palette, matching prod chat.cherry.fun. `ownBubbleColor` + + * `sendButtonColor` stay UNSET on purpose so the iframe falls back to its + * primary→accent gradient — set them to flatten into a solid tile. + * + * `headerColor` / `inputColor` are PINNED on purpose: a curated preset IS + * the ready-made theme, so it carries the exact production values and the + * engine does not derive them (Basic — surfaces honestly reads `solid`). + * Engine derivation is for custom seeds, not for curated palettes. */ theme: { mode: 'dark', primaryColor: '#ff1493', accentColor: '#c026d3', backgroundColor: '#0a0a0f', + headerColor: '#12111a', + inputColor: '#12111a', surfaceColor: '#12111a', borderColor: 'rgba(255, 255, 255, 0.08)', textColor: '#ffffff', @@ -37,9 +41,7 @@ export const PRESETS: ThemePreset[] = [ incomingBubbleColor: '#4a1d56', incomingBubbleBorderColor: '#6b2d7b', ownBubbleTextColor: '#ffffff', - headerColor: '#12111a', headerTextColor: '#ffffff', - inputColor: '#12111a', inputTextColor: '#ffffff', sendButtonTextColor: '#ffffff', ownEmbedBgColor: '#ff4dad', diff --git a/example/wallet-only/src/styles.css b/example/wallet-only/src/styles.css index d691bcd..70217cb 100644 --- a/example/wallet-only/src/styles.css +++ b/example/wallet-only/src/styles.css @@ -273,6 +273,11 @@ input[type='color']::-moz-color-swatch { border-color: var(--ring); } .info-pop { + /* The tooltip can sit inside uppercase micro-labels (.sg-title) - the + explainer text must not inherit their casing or tracking. */ + text-transform: none; + letter-spacing: normal; + position: absolute; top: calc(100% + 6px); left: 0; @@ -699,6 +704,152 @@ input[type='color']::-moz-color-swatch { .segmented button:not(.on):hover { color: var(--fg); } +/* 4-option groups (surface modes) — tighter so the row still fits the pane. */ +.segmented.seg-compact button { + padding: 4px 9px; + font-size: 10.5px; +} + +/* ── Surface rows (paint mode + tint + opacity + blur) ────────────────── */ +.surface-row { + display: flex; + flex-direction: column; + gap: 7px; + padding: 9px 11px; + margin: 6px 0; + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--r-md); +} +.surface-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.surface-head .pname { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} +.surface-head .pname-label { + font-size: 12px; + color: var(--fg); + white-space: nowrap; +} +.surface-head .pname-key { + font-family: var(--mono); + font-size: 9.5px; + color: var(--faint); + white-space: nowrap; +} +/* label | control | readout */ +.surface-ctl { + display: grid; + grid-template-columns: 48px 1fr auto; + gap: 9px; + align-items: center; +} +/* A blur with nothing to sample through — shown, but genuinely out of play: + dimmed AND unreachable, matching the fact that its key is not sent. The + slider itself carries `disabled`; this kills the cursor affordance too. */ +.surface-ctl.inert { + opacity: 0.45; +} +.surface-ctl.inert input[type='range'] { + pointer-events: none; + cursor: default; +} +.sc-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--faint); + font-weight: 600; +} +.sc-num { + font-family: var(--mono); + font-size: 10.5px; + color: var(--muted); + min-width: 38px; + text-align: right; +} +.sc-val { + font-family: var(--mono); + font-size: 10px; + color: var(--muted); + background: none; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + justify-self: end; + max-width: 150px; +} +/* Engine-derived tone read back from themeApplied — same grayed + italic + convention as the Advanced panel: "shown, but not sent". Wider than an + explicit value: it carries the `· derived` provenance marker, and a truncated + provenance marker is worse than none. */ +.sc-val.derived { + color: var(--faint); + font-style: italic; + max-width: 240px; +} +.surface-ctl input[type='color'] { + width: 26px; + height: 22px; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--r-sm); + background: none; + cursor: pointer; + justify-self: start; +} +/* Sliders — flat hairline track, white thumb (the portal's CTA inversion). */ +.surface-ctl input[type='range'] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + border-radius: var(--r-pill); + background: var(--line); + cursor: pointer; +} +.surface-ctl input[type='range']::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--fg); + border: none; + cursor: pointer; + transition: background 0.15s ease; +} +.surface-ctl input[type='range']::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--fg); + border: none; + cursor: pointer; +} +.surface-ctl input[type='range']:hover::-webkit-slider-thumb { + background: var(--brand); +} +.surface-ctl input[type='range']:hover::-moz-range-thumb { + background: var(--brand); +} +.surface-ctl input[type='range']:focus-visible { + box-shadow: 0 0 0 3px var(--ring); +} +/* Sub-group title that carries an ℹ tooltip inline. */ +.sg-title-tip { + display: flex; + align-items: center; + gap: 7px; +} /* ── Live payload chip + engine badge ────────────────────────────────── */ .payload-chip { diff --git a/example/wallet-only/src/themeMeta.ts b/example/wallet-only/src/themeMeta.ts index 66f958a..d247616 100644 --- a/example/wallet-only/src/themeMeta.ts +++ b/example/wallet-only/src/themeMeta.ts @@ -30,6 +30,89 @@ export const SEED_FIELDS: SeedField[] = [ }, ]; +/* ── Surfaces: how each of the three grounds is painted ─────────────────── */ + +/** + * How a surface is painted. Derived PURELY from the value sitting on the theme + * object, so the control is a view on the field — never a second source of truth. + * + * Every label is either the literal that goes on the wire or the honest umbrella + * for "no key at all" — nothing in the segmented control is a metaphor: + * + * default — labelled `auto`: key absent, the engine decides the tone. + * solid — an opaque colour; painted verbatim. + * alpha — a colour carrying an alpha channel (`rgba()` / `#RRGGBBAA`); + * the surface is see-through and its RGB channels still anchor + * the engine's derivation. + * transparent — the bare `transparent` keyword, sent verbatim; fully + * see-through, and the engine synthesizes a brand-toned anchor + * because the value carries no colour of its own. + */ +export type SurfaceMode = 'default' | 'solid' | 'alpha' | 'transparent'; + +export const SURFACE_MODES: readonly { v: SurfaceMode; label: string }[] = [ + { v: 'default', label: 'auto' }, + { v: 'solid', label: 'solid' }, + { v: 'alpha', label: 'alpha' }, + { v: 'transparent', label: 'transparent' }, +] as const; + +/** True when the surface is painted opaque — a blur behind it has nothing to sample. */ +export function isOpaqueMode(mode: SurfaceMode): boolean { + return mode === 'default' || mode === 'solid'; +} + +export interface SurfaceField { + /** Colour key that paints the surface. */ + k: Extract; + /** Companion blur key that frosts whatever shows through it. */ + blurKey: Extract; + label: string; + /** What the surface is (concept, not key names). */ + hint: string; + /** What the blur frosts — shown on the blur slider. */ + blurHint: string; +} + +/** + * The three surfaces that can be made see-through, each with the blur that + * frosts what shows through it. Background is also a derivation seed (it has a + * row in the seed list above); this section is about how it is PAINTED. + */ +export const SURFACE_FIELDS: SurfaceField[] = [ + { + k: 'backgroundColor', + blurKey: 'backgroundBlur', + label: 'Background', + hint: 'The widget ground — go see-through and the host page shows through the whole chat', + blurHint: 'Frosts the host page behind the widget (applied by the SDK to the iframe itself)', + }, + { + k: 'headerColor', + blurKey: 'headerBlur', + label: 'Header', + hint: 'The bar above the transcript', + blurHint: 'Frosts the transcript scrolling under the header', + }, + { + k: 'inputColor', + blurKey: 'inputBlur', + label: 'Composer', + hint: 'The message input strip', + blurHint: 'Frosts the transcript scrolling under the composer', + }, +]; + +/** Hard cap on a blur radius (px) — mirrors the embed's own clamp. */ +export const MAX_BLUR_PX = 40; + +/** + * Ceiling for the alpha slider. A surface at alpha 1 IS solid, and letting the + * slider reach it would flip the row's mode out from under the user's cursor + * mid-drag. + */ +export const MAX_SURFACE_ALPHA = 0.95; + /** Advanced granular groups — every remaining settable colour, by role. */ export interface GranularField { k: Extract; @@ -204,19 +287,24 @@ export function isSafeCssColor(v: string): boolean { if (/calc\s*\(/i.test(v)) return false; if (/\/\*|\*\//.test(v)) return false; if (/<|>|&|\\|;|\{|\}/.test(v)) return false; - if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(v)) return true; + // The bare keyword — the canonical way to hand a surface fully see-through. + if (/^transparent$/i.test(v.trim())) return true; + // 4- and 8-digit hex carry an alpha channel (the tinted see-through form). + if (/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(v)) return true; if (/^(rgb|hsl)a?\(\s*[\d.,%\s/]+\)$/i.test(v)) return true; return false; } /** Best-effort projection of any CSS colour onto a `#rrggbb` for the native - * swatch. Returns null when it can't (rgba w/ alpha, gradients, hsl). */ + * swatch. Returns null when it can't (the bare keyword, gradients, hsl). */ export function toHexForPicker(value: string | undefined | null): string | null { if (!value) return null; const v = value.trim(); if (/^#[0-9a-fA-F]{6}$/.test(v)) return v; - if (/^#[0-9a-fA-F]{3}$/.test(v)) { - return '#' + v.slice(1).split('').map((c) => c + c).join(''); + if (/^#[0-9a-fA-F]{3,4}$/.test(v)) { + // #RGB / #RGBA → expand, then drop any alpha nibble. + const full = '#' + v.slice(1).split('').map((c) => c + c).join(''); + return full.slice(0, 7); } if (/^#[0-9a-fA-F]{8}$/.test(v)) return v.slice(0, 7); const m = v.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); @@ -226,3 +314,81 @@ export function toHexForPicker(value: string | undefined | null): string | null } return null; } + +/* ── Value-form helpers for the surface controls ─────────────────────────── + * Mirrors of the embed's own readers, so the demo classifies a value exactly + * the way the engine will. */ + +/** True for the bare see-through keyword (case/space-insensitive). */ +export function isBareTransparent(value: string | undefined | null): boolean { + return typeof value === 'string' && value.trim().toLowerCase() === 'transparent'; +} + +/** + * Alpha channel (0..1) of any value the embed accepts. The bare keyword → 0; + * #RGBA / #RRGGBBAA → the trailing nibble/byte; rgba()/hsla() → the 4th + * component (fraction or %); everything else (incl. unparseable) → 1. + */ +export function colorAlpha(value: string | undefined | null): number { + if (typeof value !== 'string') return 1; + const v = value.trim().toLowerCase(); + if (v === '') return 1; + if (v === 'transparent') return 0; + if (v.startsWith('#')) { + let h = v.slice(1); + if (h.length === 4) h = h.split('').map((c) => c + c).join(''); + if (h.length === 8) { + const a = parseInt(h.slice(6, 8), 16); + return isNaN(a) ? 1 : a / 255; + } + return 1; + } + const fn = v.match(/^(?:rgba?|hsla?)\s*\(([^)]*)\)$/i); + if (fn) { + const parts = fn[1].split(/[\s,/]+/).filter(Boolean); + if (parts.length >= 4) { + const a = parts[3]; + const n = a.endsWith('%') ? parseFloat(a) / 100 : parseFloat(a); + return isNaN(n) ? 1 : Math.min(1, Math.max(0, n)); + } + } + return 1; +} + +/** Classify a surface value into the mode its control should show. */ +export function surfaceMode(value: string | undefined): SurfaceMode { + if (value === undefined || value === '') return 'default'; + if (isBareTransparent(value)) return 'transparent'; + return colorAlpha(value) < 1 ? 'alpha' : 'solid'; +} + +/** + * Compose a see-through fill from an opaque base + an alpha. Emits `rgba(...)` + * rather than 8-digit hex: it is the self-documenting form in the integration + * snippet, and the embed accepts both. + */ +export function withAlpha(baseHex: string, alpha: number): string { + const hex = toHexForPicker(baseHex) ?? '#000000'; + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255; + const g = (n >> 8) & 255; + const b = n & 255; + // 2 decimals keeps the emitted string clean and the round-trip stable. + const a = Math.round(Math.min(1, Math.max(0, alpha)) * 100) / 100; + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +/** + * Mirror of the embed's blur sanitizer: a finite integer clamped 0–40, or null + * when the value is absent / empty / non-numeric. + */ +export function toBlurPx(value: unknown): number | null { + const n = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value.trim()) + : NaN; + if (!Number.isFinite(n)) return null; + return Math.min(MAX_BLUR_PX, Math.max(0, Math.round(n))); +} diff --git a/example/wallet-only/src/types.ts b/example/wallet-only/src/types.ts index b8ac5f9..282bd98 100644 --- a/example/wallet-only/src/types.ts +++ b/example/wallet-only/src/types.ts @@ -70,6 +70,20 @@ export interface EmbedTheme { fontFamily?: string; fontSize?: 'sm' | 'md' | 'lg'; + + /** + * Backdrop-blur radii in px (number or numeric string), clamped 0–40 by the + * embed. They are the companion to a see-through surface: a blur has nothing + * to sample through an opaque one. + * + * `headerBlur` / `inputBlur` frost the transcript INSIDE the iframe. + * `backgroundBlur` is applied HOST-side by the SDK to the iframe element + * itself — a cross-origin iframe cannot frost the page it sits on from the + * inside — so it frosts the host page behind a see-through widget. + */ + headerBlur?: number | string; + inputBlur?: number | string; + backgroundBlur?: number | string; } /** diff --git a/src/__tests__/embed-iframe-surface.test.ts b/src/__tests__/embed-iframe-surface.test.ts new file mode 100644 index 0000000..d4ab011 --- /dev/null +++ b/src/__tests__/embed-iframe-surface.test.ts @@ -0,0 +1,238 @@ +/** + * Tests for the iframe ELEMENT surface at its real seam — `createEmbedIframe` + * and the `CherryEmbed` theme lifecycle, not the helpers in isolation. + * + * Both halves of that surface (the ground and the host-side backdrop blur) are + * painted on the same element by the same pass, so they are easy to make each + * other's undoing — a stray `background` shorthand next to the ground, or a + * second call site that only refreshes one of them. Asserting through + * `createEmbedIframe` is what makes that visible; helper-level tests cannot see + * it, because the element they inspect was never built by the real code path. + * + * jsdom normalises assigned colours (`#abc` → `rgb(170, 187, 204)`), so + * assertions compare against a probe element fed the same input. + */ + +import './jsdomIframe'; +import { describe, it, expect } from 'vitest'; +import { CherryEmbed } from '../embed'; +import { createEmbedIframe } from '../iframe'; +import type { EmbedTheme } from '../types'; + +// The per-mode defaults in `iframe.ts` +const DEFAULT_BACKGROUND_DARK = '#0a0a0f'; +const DEFAULT_BACKGROUND_LIGHT = '#ffffff'; + +const EMBED_ORIGIN = 'https://embed.cherry.fun'; + +// What jsdom stores after assigning `value` to `style.backgroundColor` +function normalized(value: string): string { + const probe = document.createElement('div'); + probe.style.backgroundColor = value; + return probe.style.backgroundColor; +} + +function buildIframe(theme?: EmbedTheme): HTMLIFrameElement { + return createEmbedIframe({ + appId: 'app-surface', + container: document.createElement('div'), + position: 'inline', + theme, + }); +} + +function dispatchEmbedEvent(eventName: string, data?: unknown): void { + const msg = { type: 'cherry:event', event: eventName, data }; + window.dispatchEvent(new MessageEvent('message', { data: msg, origin: EMBED_ORIGIN })); +} + +async function mountEmbed( + theme?: EmbedTheme, +): Promise<{ chat: CherryEmbed; iframe: HTMLIFrameElement }> { + const container = document.createElement('div'); + document.body.appendChild(container); + const chat = new CherryEmbed({ appId: 'app-surface', container, theme }); + + const mountPromise = chat.mount(); + dispatchEmbedEvent('ready'); + await mountPromise; + + return { chat, iframe: container.querySelector('iframe')! }; +} + +// --------------------------------------------------------------------------- +// createEmbedIframe — the ground survives the whole construction pass +// --------------------------------------------------------------------------- + +describe('createEmbedIframe — ground', () => { + it('grounds a dark theme on the dark canvas', () => { + expect(buildIframe({ mode: 'dark' }).style.backgroundColor).toBe( + normalized(DEFAULT_BACKGROUND_DARK), + ); + }); + + it('grounds a light theme on white', () => { + expect(buildIframe({ mode: 'light' }).style.backgroundColor).toBe( + normalized(DEFAULT_BACKGROUND_LIGHT), + ); + }); + + it('grounds an unthemed embed on the dark canvas', () => { + expect(buildIframe().style.backgroundColor).toBe(normalized(DEFAULT_BACKGROUND_DARK)); + }); + + it('paints an explicit opaque background and nothing later wipes it', () => { + const iframe = buildIframe({ mode: 'light', backgroundColor: '#123456' }); + + // Regression lock: a `background` shorthand anywhere in this function resets + // background-color, so the ground would come out empty here. + expect(iframe.style.backgroundColor).toBe(normalized('#123456')); + }); + + it('leaves a transparent theme see-through', () => { + expect(buildIframe({ backgroundColor: 'transparent' }).style.backgroundColor).toBe(''); + }); + + it('leaves an alpha background see-through', () => { + expect(buildIframe({ backgroundColor: '#11223300' }).style.backgroundColor).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// createEmbedIframe — color-scheme follows the ground +// --------------------------------------------------------------------------- +// The element's color-scheme has to match the embed document (`dark`) for a +// see-through widget: a mismatch makes Chromium paint the frame canvas opaque, +// so the host page never shows through and the host-side blur has nothing to +// frost. Opaque grounds keep `normal` (light UA canvas under our own paint). + +describe('createEmbedIframe — color-scheme', () => { + it('forces normal under an opaque ground (default, mode, explicit colour)', () => { + expect(buildIframe().style.colorScheme).toBe('normal'); + expect(buildIframe({ mode: 'light' }).style.colorScheme).toBe('normal'); + expect(buildIframe({ backgroundColor: '#123456' }).style.colorScheme).toBe('normal'); + }); + + it('matches the embed document (dark) for a see-through theme so the canvas stays transparent', () => { + // '' would only inherit the host page's scheme — `normal` on an ordinary + // light site, i.e. the very mismatch that paints the frame canvas opaque. + expect(buildIframe({ backgroundColor: 'transparent' }).style.colorScheme).toBe('dark'); + expect(buildIframe({ backgroundColor: '#11223300' }).style.colorScheme).toBe('dark'); + expect(buildIframe({ backgroundColor: 'transparent', backgroundBlur: 12 }).style.colorScheme).toBe('dark'); + }); +}); + +// --------------------------------------------------------------------------- +// createEmbedIframe — host-side blur +// --------------------------------------------------------------------------- + +describe('createEmbedIframe — host-side blur', () => { + it('frosts the host page behind an overlay theme', () => { + const iframe = buildIframe({ backgroundColor: 'transparent', backgroundBlur: 12 }); + + expect(iframe.style.backdropFilter).toBe('blur(12px)'); + expect(iframe.style.backgroundColor).toBe(''); + }); + + it('accepts a numeric string and clamps to the engine ceiling', () => { + expect(buildIframe({ backgroundBlur: '8' }).style.backdropFilter).toBe('blur(8px)'); + expect(buildIframe({ backgroundBlur: 999 }).style.backdropFilter).toBe('blur(40px)'); + }); + + it('leaves no filter when unset, zero or unparseable', () => { + expect(buildIframe().style.backdropFilter).toBe(''); + expect(buildIframe({ backgroundBlur: 0 }).style.backdropFilter).toBe(''); + expect(buildIframe({ backgroundBlur: 'plenty' }).style.backdropFilter).toBe(''); + }); + + it('coexists with an opaque ground — both halves land', () => { + const iframe = buildIframe({ backgroundColor: '#123456', backgroundBlur: 10 }); + + expect(iframe.style.backgroundColor).toBe(normalized('#123456')); + expect(iframe.style.backdropFilter).toBe('blur(10px)'); + }); +}); + +// --------------------------------------------------------------------------- +// CherryEmbed — the surface tracks the theme lifecycle +// --------------------------------------------------------------------------- + +describe('CherryEmbed — surface over the theme lifecycle', () => { + it('repaints the ground on a mode-only setTheme', async () => { + const { chat, iframe } = await mountEmbed({ mode: 'dark' }); + expect(iframe.style.backgroundColor).toBe(normalized(DEFAULT_BACKGROUND_DARK)); + + chat.setTheme({ mode: 'light' }); + + expect(iframe.style.backgroundColor).toBe(normalized(DEFAULT_BACKGROUND_LIGHT)); + + chat.destroy(); + }); + + it('applies a blur added after mount', async () => { + const { chat, iframe } = await mountEmbed({ backgroundColor: 'transparent' }); + expect(iframe.style.backdropFilter).toBe(''); + + chat.setTheme({ backgroundBlur: 16 }); + + expect(iframe.style.backdropFilter).toBe('blur(16px)'); + + chat.destroy(); + }); + + it('keeps the blur across an unrelated setTheme', async () => { + const { chat, iframe } = await mountEmbed({ + backgroundColor: 'transparent', + backgroundBlur: 16, + }); + + chat.setTheme({ fontSize: 'lg' }); + + expect(iframe.style.backdropFilter).toBe('blur(16px)'); + expect(iframe.style.backgroundColor).toBe(''); + + chat.destroy(); + }); + + it('drops a see-through ground when the host switches to an opaque background', async () => { + const { chat, iframe } = await mountEmbed({ backgroundColor: 'transparent' }); + expect(iframe.style.colorScheme).toBe('dark'); + + chat.setTheme({ backgroundColor: '#123456' }); + + expect(iframe.style.backgroundColor).toBe(normalized('#123456')); + expect(iframe.style.colorScheme).toBe('normal'); + + chat.destroy(); + }); + + it('releases the forced scheme when the host goes see-through after an opaque mount', async () => { + const { chat, iframe } = await mountEmbed({ mode: 'dark' }); + expect(iframe.style.colorScheme).toBe('normal'); + + chat.setTheme({ backgroundColor: 'transparent', backgroundBlur: 8 }); + + expect(iframe.style.backgroundColor).toBe(''); + expect(iframe.style.colorScheme).toBe('dark'); + expect(iframe.style.backdropFilter).toBe('blur(8px)'); + + chat.destroy(); + }); + + it('clears both halves on resetTheme', async () => { + const { chat, iframe } = await mountEmbed({ + mode: 'light', + backgroundColor: 'transparent', + backgroundBlur: 16, + }); + expect(iframe.style.backdropFilter).toBe('blur(16px)'); + + chat.resetTheme(); + + expect(iframe.style.backdropFilter).toBe(''); + expect(iframe.style.backgroundColor).toBe(normalized(DEFAULT_BACKGROUND_DARK)); + expect(iframe.style.colorScheme).toBe('normal'); + + chat.destroy(); + }); +}); diff --git a/src/embed.ts b/src/embed.ts index bfa4140..774b8d4 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -1,5 +1,5 @@ import { EmbedBridge, base64ToBytes, bytesToBase64 } from './bridge'; -import { createEmbedIframe, getEmbedOrigin, applyIframeBackground } from './iframe'; +import { createEmbedIframe, getEmbedOrigin, applyIframeSurface } from './iframe'; import type { CherryEmbedConfig, EmbedEventMap, @@ -81,9 +81,9 @@ export class CherryEmbed { embedUrl: this.config.embedUrl, container: this.containerEl, position: this.config.position ?? 'inline', - // Element-level ground so the host never shows through at document boundaries - backgroundColor: this.config.theme?.backgroundColor, - themeMode: this.config.theme?.mode, + // Carries the element-side half of the theme (ground + host-side blur) so the + // host never shows through at document boundaries — see applyIframeSurface. + theme: this.config.theme, }); // 2a. If mounted collapsed, hide IMMEDIATELY — before awaiting the @@ -169,12 +169,11 @@ export class CherryEmbed { const merged: EmbedTheme = { ...(this.config.theme ?? {}), ...theme }; (this.config as { theme?: EmbedTheme }).theme = merged; this.bridge?.sendCommand('setTheme', theme as Record); - // Re-resolve the element-side ground — the setTheme command can't reach it. - // Unguarded on purpose: `mode` alone re-picks the default ground, so keying - // this off `'backgroundColor' in theme` would miss a bare mode flip. - if (this.iframe) { - applyIframeBackground(this.iframe, merged.backgroundColor, merged.mode); - } + // Re-resolve the element-side half — the setTheme command can't reach it. + // Unguarded on purpose, and fed the MERGED theme: `mode` alone re-picks the + // default ground, so keying this off which fields the patch happens to carry + // would miss a bare mode flip. Re-applying an unchanged surface is a no-op. + if (this.iframe) applyIframeSurface(this.iframe, merged); } /** @@ -190,8 +189,10 @@ export class CherryEmbed { // reload doesn't re-apply a theme the user already cleared. (this.config as { theme?: EmbedTheme }).theme = undefined; this.bridge?.sendCommand('resetTheme', {}); - // Host-side half of the reset — back to the default dark ground - if (this.iframe) applyIframeBackground(this.iframe, undefined); + // The element-side half of the theme lives outside anything the `resetTheme` + // command can reach — back to the default ground, and drop the host-side blur + // or a frosted host page would survive the reset that cleared everything else. + if (this.iframe) applyIframeSurface(this.iframe, undefined); } setLayout(layout: Partial): void { diff --git a/src/iframe.ts b/src/iframe.ts index 42ba77d..ff826fb 100644 --- a/src/iframe.ts +++ b/src/iframe.ts @@ -1,3 +1,5 @@ +import type { EmbedTheme } from './types'; + const DEFAULT_EMBED_URL = 'https://embed.cherry.fun'; // The embed's own canvases, used when no theme sets a background. Must track the @@ -12,10 +14,8 @@ export function createEmbedIframe(config: { embedUrl?: string; container: HTMLElement; position: 'inline' | 'floating-right' | 'floating-left'; - /** Theme background, painted on the element itself — see applyIframeBackground. */ - backgroundColor?: string; - /** Theme mode — NOT the embed `mode` above. Picks the default ground. */ - themeMode?: 'dark' | 'light'; + /** The host theme — its element-side half is painted here, see applyIframeSurface. */ + theme?: EmbedTheme; }): HTMLIFrameElement { const iframe = document.createElement('iframe'); @@ -37,8 +37,7 @@ export function createEmbedIframe(config: { iframe.style.border = 'none'; iframe.style.width = '100%'; iframe.style.height = '100%'; - iframe.style.colorScheme = 'normal'; - applyIframeBackground(iframe, config.backgroundColor, config.themeMode); + applyIframeSurface(iframe, config.theme); if (config.position === 'inline') { iframe.style.display = 'block'; @@ -100,7 +99,10 @@ export function isOpaqueColor(value: string): boolean { // Ground the iframe ELEMENT: between documents (mount, reload, remount) the // embed has nothing painted yet and the host page would show straight through. -// Skipped for see-through theme backgrounds — that transparency is intentional. +// A see-through theme background CLEARS the ground instead — an iframe element +// with no background-color of its own is already transparent, which is what puts +// the widget in overlay mode. Never set the `background` shorthand here: it +// resets background-color and would silently undo this. // With no theme background the ground follows `mode`, like the engine's own // fallback — a light theme must not pre-paint near-black. export function applyIframeBackground( @@ -111,9 +113,70 @@ export function applyIframeBackground( if (backgroundColor === undefined || backgroundColor.trim() === '') { iframe.style.backgroundColor = mode === 'light' ? DEFAULT_BACKGROUND_LIGHT : DEFAULT_BACKGROUND_DARK; + iframe.style.colorScheme = 'normal'; return; } - iframe.style.backgroundColor = isOpaqueColor(backgroundColor) ? backgroundColor : ''; + const opaque = isOpaqueColor(backgroundColor); + iframe.style.backgroundColor = opaque ? backgroundColor : ''; + // The element's color-scheme must MATCH the embed document's for a see-through + // widget: the document declares `color-scheme: dark`, and Chromium paints an + // OPAQUE frame canvas whenever the two disagree — so the host page never shows + // through and the host-side blur has nothing to frost. `''` is not enough: it + // inherits the host page's scheme, which on an ordinary light site is `normal` + // again. Opaque grounds keep `normal` (light UA canvas under our own paint). + iframe.style.colorScheme = opaque ? 'normal' : 'dark'; +} + +/** + * Sanitize a backgroundBlur value to a px radius: a finite integer clamped 0–40, + * or null. Mirrors the embed engine's `sanitizeBlurPx` so host and iframe agree. + */ +function sanitizeBlurPx(value: unknown): number | null { + const n = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value.trim()) + : NaN; + if (!Number.isFinite(n)) return null; + return Math.min(40, Math.max(0, Math.round(n))); +} + +/** + * Apply (or clear) the host-side backdrop blur on the iframe element. A cross-origin + * iframe's OWN backdrop-filter cannot reach the host page, so frosting the host page + * behind a see-through widget has to happen on the IFRAME ELEMENT — it lives in the + * HOST document, so its backdrop IS the host page. A valid number > 0 sets + * `backdrop-filter: blur(Npx)` (+ the `-webkit-` prefix for Safari); an absent / + * invalid / zero value clears it. Only meaningful with a see-through theme + * background; harmless otherwise (the opaque ground paints over it). + */ +export function applyIframeBackdropBlur( + iframe: HTMLIFrameElement, + backgroundBlur: number | string | undefined, +): void { + const px = sanitizeBlurPx(backgroundBlur); + const filter = px !== null && px > 0 ? `blur(${px}px)` : ''; + iframe.style.backdropFilter = filter; + ( + iframe.style as CSSStyleDeclaration & { webkitBackdropFilter?: string } + ).webkitBackdropFilter = filter; +} + +/** + * The ONE place the iframe element's own surface is decided. Everything a theme + * paints outside the embed document — the ground and the host-side blur — is + * resolved here so mount, `setTheme` and `resetTheme` cannot drift apart or + * overwrite each other. `undefined` is the reset: default ground, no blur. + * In-iframe surface blurs (headerBlur / inputBlur) are NOT here; they travel with + * the theme over the `setTheme` command. + */ +export function applyIframeSurface( + iframe: HTMLIFrameElement, + theme: EmbedTheme | undefined, +): void { + applyIframeBackground(iframe, theme?.backgroundColor, theme?.mode); + applyIframeBackdropBlur(iframe, theme?.backgroundBlur); } export function getEmbedOrigin(embedUrl?: string): string { diff --git a/src/types.ts b/src/types.ts index b91a1f3..ade870d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -130,6 +130,26 @@ export interface EmbedTheme { // ── Typography ──────────────────────────────────────────────────────── fontFamily?: string; fontSize?: 'sm' | 'md' | 'lg'; + + // ── Backdrop blur (companion to per-surface transparency) ───────────── + // + // Blur amounts in px (number or numeric string). Sanitized by the embed to a + // finite integer clamped 0–40. All optional; unset means no blur. + /** + * In-iframe backdrop blur (px) on the HEADER surface — frosts the transcript + * behind a semi-transparent header. Best paired with a transparent / alpha + * `headerColor`. + */ + headerBlur?: number | string; + /** In-iframe backdrop blur (px) on the COMPOSER surface. Pairs with an alpha `inputColor`. */ + inputBlur?: number | string; + /** + * HOST-side blur (px). Applied by the SDK to the IFRAME ELEMENT itself (a + * cross-origin iframe cannot sample the host page from inside), frosting the + * host page behind the whole widget. Only meaningful with a transparent / alpha + * `backgroundColor` (overlay mode). + */ + backgroundBlur?: number | string; } export interface EmbedLayout {