Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions example/wallet-only/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
58 changes: 52 additions & 6 deletions example/wallet-only/src/components/Marketing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,40 @@ const SNIPPET_SEEDS: (keyof EmbedTheme)[] = [
'incomingBubbleColor',
];

function buildSnippet(theme: Partial<EmbedTheme>, 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<EmbedTheme>,
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');
Expand All @@ -35,7 +61,12 @@ function buildSnippet(theme: Partial<EmbedTheme>, layout: EmbedLayout): string {
if (ht) layoutEntries.push(`headerTitle: '${ht.replace(/'/g, "\\'")}'`);
const layoutLine = layoutEntries.length ? `\n layout: { ${layoutEntries.join(', ')} },` : '';

return `<script src="https://cdn.jsdelivr.net/npm/@cherrydotfun/chat-embed-sdk@0.1.5/dist/index.global.js"></script>
const total = (Object.keys(theme) as (keyof EmbedTheme)[]).filter((k) => {
const v = theme[k];
return v !== undefined && v !== '';
}).length;

const code = `<script src="https://cdn.jsdelivr.net/npm/@cherrydotfun/chat-embed-sdk@0.1.5/dist/index.global.js"></script>
<div id="cherry-chat" style="height: 600px"></div>
<script>
new window.CherryEmbedSDK.CherryEmbed({
Expand All @@ -45,6 +76,8 @@ function buildSnippet(theme: Partial<EmbedTheme>, layout: EmbedLayout): string {
theme: ${themeInline},${layoutLine}
}).mount();
</script>`;

return { code, shown: themeEntries.length, total };
}

/**
Expand All @@ -53,6 +86,11 @@ function buildSnippet(theme: Partial<EmbedTheme>, 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 (
<section className="marketing">
<div className="marketing-eyebrow">
Expand Down Expand Up @@ -84,10 +122,18 @@ export function Marketing({ theme, layout }: MarketingProps) {
<details className="snippet">
<summary>Integration snippet</summary>
<pre>
<code>{buildSnippet(theme, layout)}</code>
<code>{code}</code>
</pre>
<p className="snippet-foot">
Reflects the current seeds &amp; layout. Full SDK reference &amp; types on{' '}
Reflects the current seeds, surfaces &amp; layout.{' '}
{hidden > 0 && (
<>
The live preview is sending <b>{hidden}</b> further key{hidden === 1 ? '' : 's'} the
snippet leaves out (granular palette + typography) — <b>Copy theme</b> in the editor
gives the exact object.{' '}
</>
)}
Full SDK reference &amp; types on{' '}
<a
className="snippet-link"
href="https://github.com/cherrydotfun/chat-embed-sdk"
Expand Down
221 changes: 219 additions & 2 deletions example/wallet-only/src/components/ThemeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { EmbedTheme } from '../types';
import {
GRANULAR_GROUPS,
MAX_BLUR_PX,
MAX_SURFACE_ALPHA,
PARAM_TO_VAR,
SAFE_FONTS,
SEED_FIELDS,
SEED_KEYS,
SURFACE_FIELDS,
SURFACE_MODES,
colorAlpha,
isBareTransparent,
isOpaqueMode,
isSafeCssColor,
surfaceMode,
toBlurPx,
toHexForPicker,
withAlpha,
type SurfaceField,
type SurfaceMode,
} from '../themeMeta';

interface ThemeEditorProps {
Expand Down Expand Up @@ -73,13 +85,16 @@ function Segmented<T extends string>({
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 (
<span className="segmented">
<span className={compact ? 'segmented seg-compact' : 'segmented'}>
{options.map((o) => (
<button
key={o.v}
Expand Down Expand Up @@ -158,6 +173,165 @@ function ColorRow({ fieldKey, label, hint, value, derivedVal, isSeed, onChange }
);
}

/* ── One surface row: how a ground is painted + what it frosts ─────────── */
interface SurfaceRowProps {
field: SurfaceField;
/** Explicit colour on the theme object (undefined → engine derives it). */
value: string | undefined;
/** Explicit blur on the theme object. */
blurValue: number | string | undefined;
/** The tone the engine derived for this surface (read back from the embed). */
derivedVal: string | undefined;
onColorChange: (field: string, value: string | undefined) => void;
onBlurChange: (field: string, value: number | undefined) => void;
}
function SurfaceRow({
field,
value,
blurValue,
derivedVal,
onColorChange,
onBlurChange,
}: SurfaceRowProps) {
const mode = surfaceMode(value);

// The colour, alpha and blur a see-through surface goes BACK to. All three are
// read off the live theme whenever it carries them, and remembered across the
// modes that drop them (`transparent` carries no colour, `auto` carries
// nothing, an opaque surface carries no blur) so round-tripping through the
// segmented control never loses the user's pick.
const rememberedHex = useRef<string | null>(null);
const rememberedAlpha = useRef(0.6);
const rememberedBlur = useRef(0);
const parsedHex = toHexForPicker(value);
if (parsedHex) rememberedHex.current = parsedHex;
if (mode === 'alpha') rememberedAlpha.current = colorAlpha(value);

const baseHex = parsedHex ?? rememberedHex.current ?? toHexForPicker(derivedVal) ?? '#12111a';
const alpha = mode === 'alpha' ? colorAlpha(value) : rememberedAlpha.current;
const blurPx = toBlurPx(blurValue) ?? 0;
if (blurPx > 0) rememberedBlur.current = blurPx;

// A frost needs something to sample: an opaque surface (engine-derived or
// solid) has nothing behind it to blur, so the slider is shown but genuinely
// inert — disabled, and its key kept out of the payload — rather than hidden.
// The pairing is the thing worth teaching; a dimmed-but-draggable slider that
// still writes a dead key to the wire would teach the opposite.
const opaque = isOpaqueMode(mode);

// `auto` has no explicit value: what the row shows is the tone the engine
// derived, read back off `themeApplied` — the same grayed convention the
// Advanced panel uses. Editing the swatch promotes it to an explicit `solid`.
const derivedHex = toHexForPicker(derivedVal);
const tintHex = mode === 'default' ? (derivedHex ?? baseHex) : baseHex;

const setMode = (m: SurfaceMode) => {
if (m === 'default') onColorChange(field.k, undefined);
else if (m === 'solid') onColorChange(field.k, tintHex);
else if (m === 'alpha') onColorChange(field.k, withAlpha(tintHex, alpha));
else onColorChange(field.k, 'transparent');
// Keep the blur key in step with what the surface can actually sample:
// dropped the moment the ground goes opaque, restored (if the user ever set
// one) when it goes see-through again. Without this the payload keeps a
// frost the UI is showing as inert.
if (m === 'default' || m === 'solid') onBlurChange(field.blurKey, undefined);
else if (rememberedBlur.current > 0) onBlurChange(field.blurKey, rememberedBlur.current);
};

return (
<div className="surface-row">
<div className="surface-head">
<span className="pname">
<span className="pname-label" title={field.hint}>
{field.label}
</span>
<code className="pname-key">{field.k}</code>
</span>
<Segmented compact options={SURFACE_MODES} value={mode} onChange={setMode} />
</div>

{mode !== 'transparent' && (
<div className="surface-ctl">
<span className="sc-label">Tint</span>
<input
type="color"
value={tintHex}
aria-label={
mode === 'default'
? `${field.label} colour (engine-derived — pick one to override)`
: `${field.label} colour`
}
title={
mode === 'default'
? 'Engine-derived — not sent. Pick a colour to promote it to an explicit value.'
: undefined
}
onChange={(e) =>
onColorChange(
field.k,
mode === 'alpha' ? withAlpha(e.target.value, alpha) : e.target.value,
)
}
/>
{mode === 'default' ? (
<code className="sc-val derived" title={derivedVal ?? undefined}>
{derivedVal ? `${derivedVal} · derived` : 'waiting for themeApplied…'}
</code>
) : (
<code className="sc-val" title={value}>
{value}
</code>
)}
</div>
)}

{mode === 'alpha' && (
<div className="surface-ctl">
<span className="sc-label">Alpha</span>
<input
type="range"
min={0}
max={Math.round(MAX_SURFACE_ALPHA * 100)}
step={1}
value={Math.round(alpha * 100)}
aria-label={`${field.label} alpha`}
onChange={(e) => onColorChange(field.k, withAlpha(baseHex, Number(e.target.value) / 100))}
/>
<span className="sc-num">{Math.round(alpha * 100)}%</span>
</div>
)}

<div className={opaque ? 'surface-ctl inert' : 'surface-ctl'}>
<span className="sc-label" title={field.blurHint}>
Blur
</span>
<input
type="range"
min={0}
max={MAX_BLUR_PX}
step={1}
value={blurPx}
disabled={opaque}
aria-disabled={opaque || undefined}
aria-label={`${field.label} blur`}
title={
opaque
? `${field.blurHint} — nothing shows through an opaque surface, so ${field.blurKey} is not sent`
: field.blurHint
}
onChange={(e) => {
const px = Number(e.target.value);
// 0 means "no frost" — drop the key rather than send a no-op, so
// the payload (and the snippet) only ever carries real intent.
onBlurChange(field.blurKey, px > 0 ? px : undefined);
}}
/>
<span className="sc-num">{blurPx}px</span>
</div>
</div>
);
}

export function ThemeEditor({
effective,
derivedVars,
Expand Down Expand Up @@ -192,6 +366,13 @@ export function ThemeEditor({
[onFieldChange],
);

/** Same, for the numeric (blur) keys — they carry a px amount, not a colour. */
const changeNumber = useCallback(
(field: string, value: number | undefined) =>
onFieldChange(field as keyof EmbedTheme, value as EmbedTheme[keyof EmbedTheme] | undefined),
[onFieldChange],
);

const rowFor = (fieldKey: string, label: string, hint?: string, isSeed?: boolean) => {
const varName = PARAM_TO_VAR[fieldKey as keyof typeof PARAM_TO_VAR];
return (
Expand Down Expand Up @@ -219,9 +400,12 @@ export function ThemeEditor({
backgroundColor: 'bg',
accentColor: 'accent',
};
// A bare `transparent` background is a valid value but NOT a seed: it
// carries no colour, so the engine synthesizes its own anchor instead of
// deriving from it. Counting it as a seed chip would overstate the payload.
const present = SEED_KEYS.filter((k) => {
const v = effective[k];
return typeof v === 'string' && isSafeCssColor(v);
return typeof v === 'string' && isSafeCssColor(v) && !isBareTransparent(v);
});
return {
engineOn: present.length > 0,
Expand Down Expand Up @@ -307,6 +491,39 @@ export function ThemeEditor({
</div>
)}

{/* ── Surfaces: opaque, see-through, frosted ──────────────────────── */}
<div className="sg-title sg-title-tip">
Basic — surfaces
<InfoTip label="How see-through surfaces work">
Each ground is one key, and the four modes are exactly what that key carries:{' '}
<b>auto</b> sends no key at all and lets the engine decide (the tone below it is read
back from the live theme, not sent); <b>solid</b> sends an opaque colour;{' '}
<b>alpha</b> sends a colour with an alpha channel — <code>rgba(…)</code> — so what sits
behind shows through while its RGB still anchors the derivation; <b>transparent</b> sends
the bare <code>transparent</code> 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. <b>Blur</b> 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.
</InfoTip>
</div>
{SURFACE_FIELDS.map((f) => {
const varName = PARAM_TO_VAR[f.k as keyof typeof PARAM_TO_VAR];
return (
<SurfaceRow
key={f.k}
field={f}
value={effective[f.k as keyof EmbedTheme] as string | undefined}
blurValue={effective[f.blurKey as keyof EmbedTheme] as number | string | undefined}
derivedVal={varName && derivedVars ? derivedVars[varName] : undefined}
onColorChange={change}
onBlurChange={changeNumber}
/>
);
})}

{/* ── Live payload summary ────────────────────────────────────────── */}
<div className="payload-chip">
<span className={`engine-badge ${engineOn ? 'on' : 'off'}`}>
Expand Down
Loading