// theme.jsx — Recadly design tokens + shared chrome primitives
// Semantic color tokens (light/dark), spacing, type scale, SF-style icons.
const TOKENS = {
light: {
bg: '#FFFFFF',
surface: '#F5F5F5',
surface2: '#FAFAFA', // very subtle layer above bg (compose bar, etc.)
heading: '#252525',
body: '#333333',
secondary: '#8A8A8A',
accent: '#FF6F61',
accentSoft:'#FF8885',
accentText:'#E55B50',
accentFill:'#FFF1EF', // tint for AI chips on light
separator: '#E8E8E8',
onAccent: '#FFFFFF',
chrome: '#FFFFFF', // nav bar bg
chromeBlur:'rgba(255,255,255,0.82)',
},
dark: {
bg: '#252525',
surface: '#333333',
surface2: '#2D2D2D',
heading: '#FFFFFF',
body: '#F5F5F5',
secondary: '#9A9A9A',
accent: '#FF6F61',
accentSoft:'#FF8885',
accentText:'#FF8885',
accentFill:'#3A2A28', // deep coral-tinted surface
separator: '#3D3D3D',
onAccent: '#FFFFFF',
chrome: '#252525',
chromeBlur:'rgba(37,37,37,0.82)',
},
};
const FONT = '-apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif';
// useT: returns the active token set for a given theme key
function useT(theme) { return TOKENS[theme === 'dark' ? 'dark' : 'light']; }
// ─────────────────────────────────────────────────────────────
// Icons — SF-Symbols-style line glyphs. stroke 1.8, rounded caps.
// All accept { size, color } and render at currentColor by default.
// ─────────────────────────────────────────────────────────────
const Icon = ({ d, size = 22, color = 'currentColor', fill = 'none', strokeWidth = 1.8, ...rest }) => (
{d}
);
const Icons = {
chevronLeft: (p) => } />,
chevronRight:(p) => } />,
chevronDown: (p) => } />,
ellipsis: (p) =>
>} />,
phone: (p) => } />,
video: (p) => >} />,
send: (p) => } />,
// Sparkle — the AI mark. Four-point star with two small accent sparkles.
sparkle: ({ size = 22, color = 'currentColor', strokeWidth = 1.6 }) => (
),
sparkleFill: ({ size = 22, color = 'currentColor' }) => (
),
search: (p) => >} />,
plus: (p) => >} />,
check: (p) => } />,
x: (p) => >} />,
pencil: (p) => >} />,
bell: (p) => >} />,
tag: (p) => >} />,
flow: (p) => >} />,
inbox: (p) => >} />,
settings: (p) => >} />,
instagram: ({ size = 22, color = 'currentColor', strokeWidth = 1.8 }) => (
// generic camera/square — placeholder for the "connect IG" CTA, not the
// real Instagram glyph (we explicitly avoid copying that branding).
),
shieldCheck: (p) => >} />,
bolt: (p) => } />,
globe: (p) => >} />,
chat: (p) => } />,
// Two-bubble comment glyph — distinct from "chat" so the inbox can
// visually separate post/reel comments from DMs.
comment: (p) => >} />,
heart: (p) => } />,
};
// ─────────────────────────────────────────────────────────────
// Avatar — letter monogram on a soft tinted disc. No fake faces.
// ─────────────────────────────────────────────────────────────
const AVATAR_TINTS = [
['#F5D6CF', '#9E3A2E'], // peach
['#D8E4D2', '#3A6B3F'], // sage
['#D7DCE8', '#3B4A6B'], // slate
['#EDDCC5', '#7A4E1F'], // sand
['#E2D6E6', '#5B3A6B'], // mauve
['#CFD9D9', '#2F4F4F'], // teal-grey
['#E8D6D1', '#7A3525'], // terracotta
];
function avatarTint(seed) {
let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
return AVATAR_TINTS[Math.abs(h) % AVATAR_TINTS.length];
}
function Avatar({ name, size = 36, ring }) {
const [bg, fg] = avatarTint(name || '?');
const initials = (name || '?').split(/\s+/).slice(0,2).map(s=>s[0]).join('').toUpperCase();
return (
{initials}
);
}
// ─────────────────────────────────────────────────────────────
// Bubble — chat message bubble. side: 'in' | 'out'. tail squares
// the bottom-outer corner for a familiar messaging silhouette.
// ─────────────────────────────────────────────────────────────
function Bubble({ side, theme, children, tail = true, maxWidth = '76%' }) {
const t = useT(theme);
const out = side === 'out';
const bg = out ? t.accent : t.surface;
const fg = out ? t.onAccent : t.body;
const r = 20, tip = tail ? 6 : 20;
const radius = out
? `${r}px ${r}px ${tip}px ${r}px`
: `${r}px ${r}px ${r}px ${tip}px`;
return (
{children}
);
}
// ─────────────────────────────────────────────────────────────
// Chip — small rounded pill used for AI suggestions, filters, etc.
// variant: 'suggest' (coral-tinted) | 'plain' (surface) | 'accent' (filled)
// ─────────────────────────────────────────────────────────────
function Chip({ theme, variant = 'plain', icon, children, style = {} }) {
const t = useT(theme);
const styles = {
suggest: {
background: t.accentFill,
color: t.accentText,
border: `1px solid ${theme === 'dark' ? 'rgba(255,136,133,0.18)' : 'rgba(229,91,80,0.16)'}`,
},
plain: { background: t.surface, color: t.body, border: 'none' },
accent:{ background: t.accent, color: t.onAccent, border: 'none' },
}[variant];
return (
{icon}
{children}
);
}
// ─────────────────────────────────────────────────────────────
// StatusBar + HomeIndicator — minimal, theme-aware. The starter's
// IOSDevice provides these too, but we want custom-styled screens
// without its nav bar, so we re-render here scoped to the screen.
// ─────────────────────────────────────────────────────────────
function StatusBar({ theme, time = '9:41' }) {
const c = theme === 'dark' ? '#fff' : '#000';
return (
);
}
function HomeIndicator({ theme }) {
return (
);
}
// ─────────────────────────────────────────────────────────────
// Phone — minimal phone shell. We do NOT use the starter IOSDevice
// directly because most screens want full-bleed custom nav rather
// than its built-in nav bar. width 402 / height 874 matches it.
// ─────────────────────────────────────────────────────────────
function Phone({ theme, children, statusBar = true, dynamicIsland = true }) {
const t = useT(theme);
return (
{dynamicIsland && (
)}
{statusBar &&
}
{children}
);
}
Object.assign(window, { TOKENS, FONT, useT, Icons, Avatar, Bubble, Chip, StatusBar, HomeIndicator, Phone });