@personaliai/react-widget 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+ import { Image as ImageIcon, FileText, MapPin } from "lucide-react";
5
+
6
+ interface AttachMenuProps {
7
+ onPickImages: () => void;
8
+ onPickDocuments: () => void;
9
+ onShareLocation: () => void;
10
+ accentColor?: string;
11
+ }
12
+
13
+ const ITEMS = (accentColor: string) => [
14
+ { key: "images", label: "Photos & videos", icon: ImageIcon, bg: `${accentColor}1a`, fg: accentColor },
15
+ { key: "documents", label: "Documents", icon: FileText, bg: "#3b82f61a", fg: "#3b82f6" },
16
+ { key: "location", label: "Location", icon: MapPin, bg: "#22c55e1a", fg: "#22c55e" },
17
+ ];
18
+
19
+ const listVariants = {
20
+ hidden: {},
21
+ show: { transition: { staggerChildren: 0.05, delayChildren: 0.04 } },
22
+ };
23
+
24
+ const itemVariants = {
25
+ hidden: { opacity: 0, y: 10, scale: 0.7 },
26
+ show: { opacity: 1, y: 0, scale: 1, transition: { type: "spring" as const, stiffness: 420, damping: 16 } },
27
+ };
28
+
29
+ export function AttachMenu({ onPickImages, onPickDocuments, onShareLocation, accentColor = "#f97316" }: AttachMenuProps) {
30
+ const items = ITEMS(accentColor);
31
+ const handlers: Record<string, () => void> = {
32
+ images: onPickImages,
33
+ documents: onPickDocuments,
34
+ location: onShareLocation,
35
+ };
36
+
37
+ return (
38
+ <motion.div variants={listVariants} initial="hidden" animate="show" className="flex flex-col gap-1 p-1.5">
39
+ {items.map((item) => {
40
+ const Icon = item.icon;
41
+ return (
42
+ <motion.button
43
+ key={item.key}
44
+ type="button"
45
+ variants={itemVariants}
46
+ whileHover={{ scale: 1.03, x: 2 }}
47
+ whileTap={{ scale: 0.95 }}
48
+ onClick={handlers[item.key]}
49
+ className="flex items-center gap-2.5 px-2.5 py-2 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-800 text-left"
50
+ >
51
+ <span className="size-8 rounded-full flex items-center justify-center shrink-0" style={{ background: item.bg, color: item.fg }}>
52
+ <Icon className="size-4" />
53
+ </span>
54
+ <span className="text-xs font-semibold">{item.label}</span>
55
+ </motion.button>
56
+ );
57
+ })}
58
+ </motion.div>
59
+ );
60
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * WCAG-based "what text color goes on this background" helper. Used
3
+ * anywhere a widget preset paints an element's background with the
4
+ * business owner's arbitrary primaryColor (chat header, user bubble, the
5
+ * "Bold" preset) — a hardcoded text color there goes invisible the moment
6
+ * someone picks a color from the wrong half of the lightness spectrum.
7
+ */
8
+
9
+ export function hexToRgb(hex: string): [number, number, number] {
10
+ const clean = hex.replace("#", "").trim();
11
+ const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
12
+ const num = parseInt(full, 16);
13
+ if (full.length !== 6 || Number.isNaN(num)) return [249, 115, 22]; // fallback: brand orange
14
+ return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
15
+ }
16
+
17
+ function relativeLuminance(r: number, g: number, b: number): number {
18
+ const [rs, gs, bs] = [r, g, b].map((c) => {
19
+ const v = c / 255;
20
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
21
+ });
22
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
23
+ }
24
+
25
+ function contrastRatio(l1: number, l2: number): number {
26
+ const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
27
+ return (lighter + 0.05) / (darker + 0.05);
28
+ }
29
+
30
+ /**
31
+ * Returns white / near-black text for a given background hex.
32
+ *
33
+ * Deliberately NOT "whichever of white/black has the higher literal WCAG
34
+ * ratio" — that maximization picks black for nearly every saturated brand
35
+ * color (orange #f97316, green #10b981/#22c55e, blue #3b82f6, red #ef4444
36
+ * all land here: mid-lightness luminance means black's ratio against it
37
+ * edges out white's, even though white is the near-universal design-system
38
+ * choice for buttons/badges in these colors). Instead, favor white unless
39
+ * the background is light enough that white would actually wash out
40
+ * (pastels, near-white) — matching how colored UI chrome reads in practice.
41
+ */
42
+ export function getOnColor(backgroundHex: string): "#ffffff" | "#111827" {
43
+ const [r, g, b] = hexToRgb(backgroundHex);
44
+ const bgLum = relativeLuminance(r, g, b);
45
+ return bgLum > 0.55 ? "#111827" : "#ffffff";
46
+ }
47
+
48
+ /**
49
+ * --primary-color / --on-primary, the pair every widget preset
50
+ * (globals.css's .style-*) reads for its primaryColor-driven surfaces —
51
+ * one computation shared by every place that renders a preset (the real
52
+ * embedded widget, and the Customizer/Playground's non-iframe mock
53
+ * previews), so it can't drift between them.
54
+ *
55
+ * Surfaces that can't take the full saturated color without hurting
56
+ * legibility (a bot reply bubble, an input field) don't get a separate
57
+ * JS-precomputed tint — globals.css blends var(--primary-color) toward
58
+ * that surface's own curated color with CSS color-mix() instead, at a
59
+ * partial ratio. That keeps each preset's own light/dark character (a
60
+ * dark preset's bot-bubble tints toward primaryColor while staying dark;
61
+ * a light preset's stays light) without a fixed "tint toward white"
62
+ * assumption breaking already-dark designs like Dark Sleek.
63
+ */
64
+ export function primaryColorCssVars(primaryColor: string): Record<string, string> {
65
+ return {
66
+ "--primary-color": primaryColor,
67
+ "--on-primary": getOnColor(primaryColor),
68
+ };
69
+ }
70
+
71
+ // ── Per-section color scheme ────────────────────────────────────────────
72
+ // Section-by-section colors (header, bubbles, input bar, send button,
73
+ // launcher), each independently overridable in the Customizer, with a
74
+ // color-theory generator that fills in a full, harmonious set from one
75
+ // seed color — same hue throughout, only lightness/saturation shifted per
76
+ // surface, so "Auto-generate" never needs an actual model call.
77
+
78
+ function hexToHsl(hex: string): [number, number, number] {
79
+ const [r8, g8, b8] = hexToRgb(hex);
80
+ const r = r8 / 255, g = g8 / 255, b = b8 / 255;
81
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
82
+ const l = (max + min) / 2;
83
+ if (max === min) return [0, 0, l * 100];
84
+ const d = max - min;
85
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
86
+ let h: number;
87
+ switch (max) {
88
+ case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
89
+ case g: h = ((b - r) / d + 2) / 6; break;
90
+ default: h = ((r - g) / d + 4) / 6;
91
+ }
92
+ return [h * 360, s * 100, l * 100];
93
+ }
94
+
95
+ function hslToHex(h: number, s: number, l: number): string {
96
+ const S = s / 100, L = l / 100;
97
+ const c = (1 - Math.abs(2 * L - 1)) * S;
98
+ const hp = ((h % 360) + 360) % 360 / 60;
99
+ const x = c * (1 - Math.abs((hp % 2) - 1));
100
+ let [r1, g1, b1] = [0, 0, 0];
101
+ if (hp < 1) [r1, g1, b1] = [c, x, 0];
102
+ else if (hp < 2) [r1, g1, b1] = [x, c, 0];
103
+ else if (hp < 3) [r1, g1, b1] = [0, c, x];
104
+ else if (hp < 4) [r1, g1, b1] = [0, x, c];
105
+ else if (hp < 5) [r1, g1, b1] = [x, 0, c];
106
+ else [r1, g1, b1] = [c, 0, x];
107
+ const m = L - c / 2;
108
+ const toHex = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, "0");
109
+ return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
110
+ }
111
+
112
+ /** A section's background + the text/icon color(s) it needs. */
113
+ export interface SectionColors {
114
+ bg: string;
115
+ text: string;
116
+ icon?: string;
117
+ }
118
+
119
+ export interface WidgetColorScheme {
120
+ header: SectionColors;
121
+ botBubble: SectionColors;
122
+ userBubble: SectionColors;
123
+ inputBar: SectionColors;
124
+ sendBtn: SectionColors;
125
+ launcher: SectionColors;
126
+ }
127
+
128
+ /**
129
+ * Derives a full 6-section color scheme from one seed color — same hue
130
+ * throughout (color theory, not per-section arbitrary picks), lightness
131
+ * and saturation shifted per surface so bot-bubble/input-bar stay soft
132
+ * and legible instead of a jarring flat fill of the seed itself, and
133
+ * every text/icon color is computed (getOnColor) against its own actual
134
+ * background, never assumed.
135
+ */
136
+ export function generateColorScheme(seedHex: string): WidgetColorScheme {
137
+ const [h, s] = hexToHsl(seedHex);
138
+ const solid = seedHex;
139
+ const solidText = getOnColor(solid);
140
+ // Soft, slightly desaturated tint of the same hue for message/input
141
+ // surfaces — light enough to read as neutral chrome, still visibly
142
+ // tinted toward the brand hue rather than generic gray.
143
+ const softBg = hslToHex(h, Math.min(s, 45) * 0.5, 95);
144
+ const softText = hslToHex(h, Math.min(s, 45) * 0.6, 22);
145
+ return {
146
+ header: { bg: solid, text: solidText },
147
+ botBubble: { bg: softBg, text: softText },
148
+ userBubble: { bg: solid, text: solidText },
149
+ inputBar: { bg: hslToHex(h, Math.min(s, 30) * 0.35, 97.5), text: softText, icon: hslToHex(h, Math.min(s, 50), 45) },
150
+ sendBtn: { bg: solid, text: solidText },
151
+ launcher: { bg: solid, text: solidText },
152
+ };
153
+ }
154
+
155
+ const HEX_RE = /^#[0-9a-fA-F]{3,8}$/;
156
+ function safeHex(h?: string): string | null {
157
+ return h && HEX_RE.test(h) ? h : null;
158
+ }
159
+
160
+ /**
161
+ * Builds the !important CSS override block for a color scheme, scoped
162
+ * under `scopeSelector` (an id or class on the widget's root element) so
163
+ * it reliably beats globals.css's .style-* !important rules regardless of
164
+ * which design preset is active. Shared by every place that renders a
165
+ * scheme — the real embedded widget (EmbedClient.tsx) and the dashboard's
166
+ * Customizer/Playground non-iframe mock previews — so they can't drift.
167
+ * Launcher isn't included: it lives outside this DOM tree entirely
168
+ * (widget.js/page.tsx own that separately).
169
+ */
170
+ export function buildColorSchemeCss(scheme: WidgetColorScheme | null, scopeSelector: string): string {
171
+ if (!scheme) return "";
172
+ const rules: string[] = [];
173
+ const header = scheme.header, bg = safeHex(header?.bg), text = safeHex(header?.text);
174
+ if (bg && text) rules.push(`${scopeSelector} .chat-header { background: ${bg} !important; color: ${text} !important; }`);
175
+ const bot = scheme.botBubble, botBg = safeHex(bot?.bg), botText = safeHex(bot?.text);
176
+ if (botBg && botText) rules.push(`${scopeSelector} .bot-bubble { background-color: ${botBg} !important; color: ${botText} !important; }`);
177
+ const user = scheme.userBubble, userBg = safeHex(user?.bg), userText = safeHex(user?.text);
178
+ if (userBg && userText) rules.push(`${scopeSelector} .user-bubble { background-color: ${userBg} !important; color: ${userText} !important; }`);
179
+ const input = scheme.inputBar, inputBg = safeHex(input?.bg), inputText = safeHex(input?.text);
180
+ if (inputBg && inputText) rules.push(`${scopeSelector} .chat-input-bar { background-color: ${inputBg} !important; color: ${inputText} !important; }`);
181
+ const inputIcon = safeHex(input?.icon);
182
+ if (inputIcon) rules.push(`${scopeSelector} .chat-input-bar-icon { color: ${inputIcon} !important; }`);
183
+ const send = scheme.sendBtn, sendBg = safeHex(send?.bg), sendText = safeHex(send?.text);
184
+ if (sendBg && sendText) rules.push(`${scopeSelector} .send-btn { background-color: ${sendBg} !important; color: ${sendText} !important; }`);
185
+ return rules.join("\n");
186
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default as ChattyWidget, type ChatWidgetCoreProps as ChattyWidgetProps } from "./ChatWidgetCore";
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { Suspense, lazy, useEffect, useState } from "react";
4
+ import { Loader2 } from "lucide-react";
5
+ // Types/enums only (erased at compile time) — must NOT import any runtime
6
+ // value from "emoji-picker-react" here, or its whole (large) module gets
7
+ // pulled into the eagerly-loaded parts of the bundle, defeating the point
8
+ // of dynamically importing the actual <EmojiPicker> component below.
9
+ import type { EmojiClickData, Theme, SuggestionMode, EmojiStyle } from "emoji-picker-react";
10
+
11
+ // Code-split: emoji-picker-react's full Unicode dataset has no business
12
+ // being in the main bundle for a component that opens on a click. Its own
13
+ // `lazyLoadEmojis` further defers per-category emoji images until scrolled
14
+ // into view — this is what "efficient, fast opening" actually looks like
15
+ // for a *complete* emoji set (as opposed to the old hand-picked ~100-emoji
16
+ // list this file used to ship, which only felt fast because it was tiny).
17
+ //
18
+ // emojiStyle below is set to native rather than the library's APPLE
19
+ // default: the image styles (apple/twitter/facebook/google) render every
20
+ // emoji as its own separate PNG fetched from a jsdelivr CDN — dozens of
21
+ // individual network requests just to fill one category's visible grid,
22
+ // which is what actually showed up as a slow, empty-looking picker.
23
+ // Native emoji use the browser/OS's own emoji font: zero network requests,
24
+ // so the grid paints as fast as any other text on the page.
25
+ //
26
+ // Plain React.lazy (not next/dynamic): this component is also bundled
27
+ // standalone via Vite for the public widget (see widget-entry.tsx), which
28
+ // has no next/dynamic. next/dynamic's ssr:false behavior isn't needed here
29
+ // either way — the picker only ever mounts after emojiOpen flips true from
30
+ // a click, well after hydration, so there's nothing for SSR to render.
31
+ const EmojiPicker = lazy(() => import("emoji-picker-react"));
32
+ const EmojiPickerFallback = () => (
33
+ <div className="flex items-center justify-center h-full">
34
+ <Loader2 className="size-5 animate-spin text-neutral-300" />
35
+ </div>
36
+ );
37
+
38
+ interface QuickEmojiPickerProps {
39
+ onSelect: (emoji: string) => void;
40
+ accentColor?: string;
41
+ }
42
+
43
+ export function QuickEmojiPicker({ onSelect, accentColor = "#f97316" }: QuickEmojiPickerProps) {
44
+ // Matches the picker to the page's actual light/dark state (not just a
45
+ // media query) — same "dark" class toggling the rest of the dashboard uses.
46
+ const [isDark, setIsDark] = useState(false);
47
+ useEffect(() => {
48
+ const root = document.documentElement;
49
+ const update = () => setIsDark(root.classList.contains("dark"));
50
+ update();
51
+ const observer = new MutationObserver(update);
52
+ observer.observe(root, { attributes: true, attributeFilter: ["class"] });
53
+ return () => observer.disconnect();
54
+ }, []);
55
+
56
+ return (
57
+ <div className="flex flex-col h-full [&_.epr-main]:border-0 [&_.epr-main]:!h-full" style={{ ["--epr-highlight-color" as string]: accentColor }}>
58
+ <Suspense fallback={<EmojiPickerFallback />}>
59
+ <EmojiPicker
60
+ onEmojiClick={(data: EmojiClickData) => onSelect(data.emoji)}
61
+ theme={(isDark ? "dark" : "light") as Theme}
62
+ emojiStyle={"native" as EmojiStyle}
63
+ lazyLoadEmojis
64
+ autoFocusSearch
65
+ suggestedEmojisMode={"frequent" as SuggestionMode}
66
+ previewConfig={{ showPreview: false }}
67
+ skinTonesDisabled={false}
68
+ width="100%"
69
+ height="100%"
70
+ searchPlaceHolder="Search emoji…"
71
+ />
72
+ </Suspense>
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,44 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+
3
+ /**
4
+ * react-markdown (v6+) does not sanitize link URI schemes on its own — a
5
+ * `[click me](javascript:...)` link in assistant/bot output (LLM-generated,
6
+ * or sourced from a crawled page / uploaded document) would otherwise
7
+ * render as a clickable `javascript:` href. Only allow schemes that can't
8
+ * execute script when clicked.
9
+ */
10
+ const SAFE_SCHEMES = ["http:", "https:", "mailto:", "tel:"];
11
+
12
+ export function isSafeHref(href: string | undefined): boolean {
13
+ if (!href) return false;
14
+ const trimmed = href.trim();
15
+ // Relative/same-origin paths ("/foo", "#section", "?q=1") have no scheme
16
+ // to abuse and are always safe.
17
+ if (/^(\/|#|\?)/.test(trimmed)) return true;
18
+ try {
19
+ return SAFE_SCHEMES.includes(new URL(trimmed, "https://placeholder.invalid").protocol);
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ /** Drop-in `a` component for ReactMarkdown's `components` prop. Renders
26
+ * unsafe-scheme links as inert text instead of a clickable anchor. */
27
+ export function SafeMarkdownLink({
28
+ href,
29
+ children,
30
+ className,
31
+ style,
32
+ }: {
33
+ href?: string;
34
+ children?: ReactNode;
35
+ className?: string;
36
+ style?: CSSProperties;
37
+ }) {
38
+ if (!isSafeHref(href)) return <span className={className}>{children}</span>;
39
+ return (
40
+ <a href={href} target="_blank" rel="noopener noreferrer" className={className} style={style}>
41
+ {children}
42
+ </a>
43
+ );
44
+ }