From e1c064d5cb3a7acce5808a6808783d318da8017e Mon Sep 17 00:00:00 2001 From: playerk17 Date: Sat, 25 Jul 2026 00:32:03 +0300 Subject: [PATCH] feat(example): surfaces panel with transparency and blur controls Per-surface controls auto, solid, alpha, transparent with tint and blur sliders wired live to the widget and the integration snippet. Curated presets stay fully pinned. SDK applies host-side backgroundBlur to the iframe and clears it on resetTheme. --- example/wallet-only/src/App.tsx | 6 + .../wallet-only/src/components/Marketing.tsx | 58 ++++- .../src/components/ThemeEditor.tsx | 221 +++++++++++++++++- example/wallet-only/src/presets.ts | 24 +- example/wallet-only/src/styles.css | 151 ++++++++++++ example/wallet-only/src/themeMeta.ts | 174 +++++++++++++- example/wallet-only/src/types.ts | 14 ++ src/embed.ts | 15 +- src/iframe.ts | 53 +++++ src/types.ts | 20 ++ 10 files changed, 712 insertions(+), 24 deletions(-) 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/embed.ts b/src/embed.ts index e9a00d9..ed508a4 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -1,5 +1,5 @@ import { EmbedBridge, base64ToBytes, bytesToBase64 } from './bridge'; -import { createEmbedIframe, getEmbedOrigin } from './iframe'; +import { createEmbedIframe, getEmbedOrigin, applyIframeBackdropBlur } from './iframe'; import type { CherryEmbedConfig, EmbedEventMap, @@ -81,6 +81,10 @@ export class CherryEmbed { embedUrl: this.config.embedUrl, container: this.containerEl, position: this.config.position ?? 'inline', + // Host-side frosted glass behind a see-through widget (only meaningful with + // a transparent / alpha theme background). In-iframe surface blurs + // (headerBlur / inputBlur) travel with the theme over setTheme instead. + backgroundBlur: this.config.theme?.backgroundBlur, }); // 2a. If mounted collapsed, hide IMMEDIATELY — before awaiting the @@ -166,6 +170,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); + // backgroundBlur is host-side (the iframe cannot frost the host page from + // inside), so re-apply it to the iframe element whenever the host tweaks it. + if (this.iframe && 'backgroundBlur' in theme) { + applyIframeBackdropBlur(this.iframe, merged.backgroundBlur); + } } /** @@ -181,6 +190,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', {}); + // The host-side half of the theme lives on the iframe ELEMENT, outside + // anything the `resetTheme` command can reach — clear it here or a frosted + // host page would survive the reset that cleared everything else. + if (this.iframe) applyIframeBackdropBlur(this.iframe, undefined); } setLayout(layout: Partial): void { diff --git a/src/iframe.ts b/src/iframe.ts index f8e08ab..b69579f 100644 --- a/src/iframe.ts +++ b/src/iframe.ts @@ -7,6 +7,11 @@ export function createEmbedIframe(config: { embedUrl?: string; container: HTMLElement; position: 'inline' | 'floating-right' | 'floating-left'; + /** + * Host-side backdrop blur (px). Applied to the iframe ELEMENT so it frosts the + * host page behind a see-through widget — see `applyIframeBackdropBlur`. + */ + backgroundBlur?: number | string; }): HTMLIFrameElement { const iframe = document.createElement('iframe'); @@ -30,6 +35,21 @@ export function createEmbedIframe(config: { iframe.style.height = '100%'; iframe.style.colorScheme = 'normal'; + // Transparent by default so a theme with an alpha / `transparent` background + // (Twitch-style overlay mode) composites the host page THROUGH the iframe. + // Harmless for opaque themes — the embed paints its own solid ground on top. + // `allowtransparency` is the legacy attribute some engines still require to + // honour a transparent iframe document background. + iframe.style.background = 'transparent'; + iframe.setAttribute('allowtransparency', 'true'); + + // Host-side backdrop blur. A cross-origin iframe's OWN backdrop-filter cannot + // reach the host page, so to frost the host page behind a see-through widget the + // blur must live on the IFRAME ELEMENT itself — it sits in the HOST document, so + // its backdrop IS the host page. Only meaningful with a transparent / alpha theme + // background; harmless otherwise (the opaque embed paints over it). + applyIframeBackdropBlur(iframe, config.backgroundBlur); + if (config.position === 'inline') { iframe.style.display = 'block'; } else { @@ -61,6 +81,39 @@ export function applyFloatingStyles( iframe.style.transition = 'opacity 0.2s, transform 0.2s'; } +/** + * 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 valid + * number > 0 sets `backdrop-filter: blur(Npx)` (+ the `-webkit-` prefix for + * Safari); an absent / invalid / zero value clears it. Exported so the SDK can + * re-apply it when a host calls `setTheme({ backgroundBlur })` after mount. + */ +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; +} + export function getEmbedOrigin(embedUrl?: string): string { try { return new URL(embedUrl || DEFAULT_EMBED_URL).origin; 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 {