@frankieai/react 0.2.14 → 0.2.16

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.
@@ -39,7 +39,81 @@ function contrastRatio(a: Rgba, b: Rgba): number {
39
39
  * of guessed; everything after it is a heuristic guess at a "branded"
40
40
  * element, weakest last.
41
41
  */
42
- const ACCENT_SELECTORS = ["[data-frankie-accent]", "button[type=submit]", "a[href]", "button", "[class*=primary]", "[class*=btn]"];
42
+ const ACCENT_SELECTORS = ["button[type=submit]", "a[href]", "button", "[class*=primary]", "[class*=btn]"];
43
+
44
+ /**
45
+ * Brand-color custom properties, in the same order the dashboard's
46
+ * server-side preview reads them (`inferTheme` in @frankieai/core), so the
47
+ * live widget and the Appearance preview agree.
48
+ */
49
+ const PRIMARY_VARS = ["--primary", "--color-primary", "--brand", "--color-brand", "--accent", "--color-accent"];
50
+
51
+ /**
52
+ * Frankie's own DOM is never "the host's" color: once the chat is open, its
53
+ * send button (a `button[type=submit]` filled with the fallback accent) would
54
+ * otherwise be the first match and the widget would inherit itself.
55
+ */
56
+ const OWN_DOM = ".frankie-root, [data-frankie-ignore]";
57
+
58
+ function firstHostElement(selector: string): Element | null {
59
+ for (const element of document.querySelectorAll(selector)) if (!element.closest(OWN_DOM)) return element;
60
+ return null;
61
+ }
62
+
63
+ /** Resolve any CSS color (hex, hsl, oklch, shadcn's bare "221 83% 53%") to RGBA, or null. */
64
+ function resolveColor(raw: string): Rgba | null {
65
+ const value = raw.trim();
66
+ if (!value) return null;
67
+ const candidate = /^[\d.]+(?:deg)?\s+[\d.]+%\s+[\d.]+%(?:\s*\/\s*[\d.]+%?)?$/.test(value) ? `hsl(${value})` : value;
68
+ const probe = document.createElement("span");
69
+ probe.style.color = candidate;
70
+ if (!probe.style.color) return null; // not a color
71
+ probe.style.display = "none";
72
+ probe.setAttribute("data-frankie-ignore", "");
73
+ document.body.appendChild(probe);
74
+ const computed = getComputedStyle(probe).color;
75
+ probe.remove();
76
+ const rgb = parseRgb(computed);
77
+ if (rgb) return rgb;
78
+ // Newer browsers keep oklch()/lab() as-is in computed styles: rasterize one pixel instead.
79
+ try {
80
+ const context = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
81
+ if (!context) return null;
82
+ context.fillStyle = computed;
83
+ context.fillRect(0, 0, 1, 1);
84
+ const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
85
+ return a ? [r!, g!, b!, a / 255] : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /** The page's declared brand color (`--primary` and friends on :root or body), if any. */
92
+ function declaredAccent(background: Rgba): Rgba | null {
93
+ for (const element of [document.documentElement, document.body]) {
94
+ const style = getComputedStyle(element);
95
+ if (typeof style.getPropertyValue !== "function") continue;
96
+ for (const name of PRIMARY_VARS) {
97
+ const color = resolveColor(style.getPropertyValue(name));
98
+ if (color && color[3] > 0.5 && !sameColor(color, background)) return color;
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /** `a` moved `amount` of the way toward `b`: a surface or hairline in the page's own palette. */
105
+ function mix(a: Rgba, b: Rgba, amount: number): Rgba {
106
+ return [0, 1, 2].map((i) => Math.round(a[i]! + (b[i]! - a[i]!) * amount)).concat(1) as Rgba;
107
+ }
108
+
109
+ /** The page's corner radius in rem, from a shadcn-style `--radius` (rem or px), if it declares one. */
110
+ function declaredRadius(): string | undefined {
111
+ const raw = getComputedStyle(document.documentElement).getPropertyValue?.("--radius").trim();
112
+ const match = raw?.match(/^([\d.]+)(rem|px)$/);
113
+ if (!match) return undefined;
114
+ const rem = match[2] === "px" ? Number(match[1]) / 16 : Number(match[1]);
115
+ return rem >= 0 && rem <= 2 ? String(Math.round(rem * 1000) / 1000) : undefined;
116
+ }
43
117
 
44
118
  /**
45
119
  * Best-effort read of the host page's own background/text/accent colors, for
@@ -52,8 +126,8 @@ const ACCENT_SELECTORS = ["[data-frankie-accent]", "button[type=submit]", "a[hre
52
126
  *
53
127
  * Never attempts full brand-color extraction (logos, images, gradients) —
54
128
  * only colors real elements are already rendering with, via
55
- * `getComputedStyle`. Runs once per mount; it does not track the host
56
- * toggling its own light/dark mode after that.
129
+ * `getComputedStyle`. The provider re-runs it when the host switches its own
130
+ * theme (see `watchHostTheme`).
57
131
  */
58
132
  export function inferHostTheme(): Partial<WidgetTheme> | null {
59
133
  if (typeof document === "undefined" || typeof getComputedStyle !== "function") return null;
@@ -65,9 +139,18 @@ export function inferHostTheme(): Partial<WidgetTheme> | null {
65
139
  if (background[3] < 0.5) return null; // a transparent/near-transparent body tells us nothing real
66
140
  if (contrastRatio(background, foreground) < 2) return null; // too low-contrast to trust as a real reading
67
141
 
142
+ // An explicit opt-in wins, then the page's declared brand variable, then
143
+ // a guess from rendered elements (never Frankie's own).
68
144
  let accent: Rgba | null = null;
69
- for (const selector of ACCENT_SELECTORS) {
70
- const element = document.querySelector(selector);
145
+ const marked = firstHostElement("[data-frankie-accent]");
146
+ if (marked) {
147
+ const style = getComputedStyle(marked);
148
+ const surface = parseRgb(style.backgroundColor);
149
+ accent = surface && surface[3] > 0.5 && !sameColor(surface, background) ? surface : parseRgb(style.color);
150
+ }
151
+ accent ??= declaredAccent(background);
152
+ for (const selector of accent ? [] : ACCENT_SELECTORS) {
153
+ const element = firstHostElement(selector);
71
154
  if (!element) continue;
72
155
  const style = getComputedStyle(element);
73
156
  const surface = parseRgb(style.backgroundColor);
@@ -77,13 +160,55 @@ export function inferHostTheme(): Partial<WidgetTheme> | null {
77
160
  }
78
161
  if (!accent) return null; // nothing distinct enough from the page's own surface to call an accent
79
162
 
163
+ // The page's light/dark decides the widget's, not the visitor's OS: a dark
164
+ // site on a light-mode machine must not get light borders and buttons.
165
+ // Muted surfaces and hairlines come from the page's own two colors so
166
+ // nothing in the widget keeps the default palette.
167
+ const font = bodyStyle.fontFamily?.trim();
168
+ const radius = declaredRadius();
80
169
  return {
170
+ mode: luminance(background) < 0.4 ? "dark" : "light",
81
171
  background: toCss(background),
82
172
  foreground: toCss(foreground),
83
173
  primary: toCss(accent),
84
- primaryForeground: luminance(accent) > 0.5 ? "#0a0a0a" : "#fafafa",
174
+ // Whichever of near-black or near-white reads better on the accent.
175
+ primaryForeground: contrastRatio(accent, [10, 10, 10, 1]) > contrastRatio(accent, [250, 250, 250, 1]) ? "#0a0a0a" : "#fafafa",
176
+ muted: toCss(mix(background, foreground, 0.07)),
177
+ border: toCss(mix(background, foreground, 0.14)),
178
+ ...(font ? { fontFamily: font } : {}),
179
+ ...(radius ? { radius } : {}),
85
180
  };
86
181
  } catch {
87
182
  return null; // any DOM access failure (unusual host environment): never guess
88
183
  }
89
184
  }
185
+
186
+ /**
187
+ * Call `onChange` when the host page may have switched its theme: a class,
188
+ * `data-*` or inline style change on <html> or <body> (how sites toggle light
189
+ * and dark), or the OS color scheme flipping. Coalesced to one call per
190
+ * frame. Watches only those two elements' own attributes, never the subtree,
191
+ * so the widget's own rendering can't trigger it. Returns a cleanup.
192
+ */
193
+ export function watchHostTheme(onChange: () => void): () => void {
194
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {};
195
+ let frame = 0;
196
+ const schedule = () => {
197
+ if (frame) return;
198
+ frame = (typeof requestAnimationFrame === "function" ? requestAnimationFrame : (fn: FrameRequestCallback) => setTimeout(fn, 16) as unknown as number)(() => {
199
+ frame = 0;
200
+ onChange();
201
+ });
202
+ };
203
+ const observer = new MutationObserver(schedule);
204
+ for (const element of [document.documentElement, document.body]) {
205
+ if (element) observer.observe(element, { attributes: true, attributeFilter: ["class", "style", "data-theme", "data-mode", "data-color-scheme", "data-color-mode"] });
206
+ }
207
+ const media = typeof window !== "undefined" && window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : undefined;
208
+ media?.addEventListener?.("change", schedule);
209
+ return () => {
210
+ observer.disconnect();
211
+ media?.removeEventListener?.("change", schedule);
212
+ if (frame && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame);
213
+ };
214
+ }
@@ -126,6 +126,14 @@
126
126
  margin: 0;
127
127
  }
128
128
 
129
+ /* A button with no fill of its own (ghost, icon) must not show the
130
+ browser's native gray face and border, which the host's color scheme
131
+ decides. Utilities like bg-primary and border still win. */
132
+ .frankie-root button {
133
+ background-color: transparent;
134
+ border: 0 solid;
135
+ }
136
+
129
137
  .frankie-root textarea {
130
138
  background-color: var(--frankie-background);
131
139
  }
@@ -312,11 +320,14 @@
312
320
  display: flex;
313
321
  width: min(100%, 44rem);
314
322
  min-height: 100%;
323
+ /* Grow with its content instead of shrinking into the small panel, where
324
+ centered content overflowed upward, past the top you can scroll to. */
325
+ flex-shrink: 0;
315
326
  margin: auto;
316
327
  padding: clamp(2rem, 7vh, 4.5rem) 1rem 2rem;
317
328
  flex-direction: column;
318
329
  align-items: center;
319
- justify-content: center;
330
+ justify-content: safe center;
320
331
  gap: 2rem;
321
332
  text-align: center;
322
333
  /* Size the suggestion grid to the surface, not the viewport: one column in
@@ -81,4 +81,14 @@ describe("widget stylesheet is host-safe", () => {
81
81
  expect(embedded).not.toMatch(/:(?:root|host)\s*[,{]/);
82
82
  expect(embedded).toContain(".frankie-root");
83
83
  });
84
+
85
+ it("gives the widget's own buttons no native face, so ghost buttons stay transparent in any host color scheme", () => {
86
+ expect(css).toMatch(/\.frankie-root button\s*\{[^}]*background-color:\s*(?:transparent|#0000)/);
87
+ expect(css).toMatch(/\.frankie-root button\s*\{[^}]*border:\s*0(?:px)? solid/);
88
+ });
89
+
90
+ it("never lets the welcome screen overflow above the top of the small panel", () => {
91
+ expect(css).toMatch(/\.frankie-page-empty\s*\{[^}]*flex-shrink:\s*0/);
92
+ expect(css).toMatch(/\.frankie-page-empty\s*\{[^}]*justify-content:\s*safe center/);
93
+ });
84
94
  });
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { inferHostTheme } from "./lib/theme-inherit.js";
3
+
4
+ afterEach(() => {
5
+ document.body.innerHTML = "";
6
+ document.body.removeAttribute("style");
7
+ document.documentElement.removeAttribute("style");
8
+ });
9
+
10
+ function page(html: string) {
11
+ document.body.style.backgroundColor = "rgb(255, 255, 255)";
12
+ document.body.style.color = "rgb(17, 17, 17)";
13
+ document.body.innerHTML = html;
14
+ }
15
+
16
+ describe("inferHostTheme", () => {
17
+ it("never inherits Frankie's own widget colors", () => {
18
+ page(`
19
+ <div class="frankie-root"><button type="submit" style="background-color: rgb(245, 196, 0)">Send</button></div>
20
+ <a href="/pricing" style="color: rgb(79, 70, 229)">Pricing</a>`);
21
+ expect(inferHostTheme()?.primary).toBe("rgb(79, 70, 229)");
22
+ });
23
+
24
+ it("prefers the page's declared brand variable over a guess from elements", () => {
25
+ page(`<a href="/" style="color: rgb(220, 38, 38)">Home</a>`);
26
+ document.documentElement.style.setProperty("--primary", "#4f46e5");
27
+ expect(inferHostTheme()?.primary).toBe("rgb(79, 70, 229)");
28
+ });
29
+
30
+ it("reads shadcn-style bare HSL variables", () => {
31
+ page("");
32
+ document.documentElement.style.setProperty("--primary", "243 75% 59%");
33
+ const primary = inferHostTheme()?.primary;
34
+ expect(primary).toMatch(/^rgb\(/);
35
+ expect(primary).not.toBe("rgb(255, 255, 255)");
36
+ });
37
+
38
+ it("an explicit data-frankie-accent element wins over everything", () => {
39
+ page(`<span data-frankie-accent style="background-color: rgb(22, 163, 74)"></span>`);
40
+ document.documentElement.style.setProperty("--primary", "#4f46e5");
41
+ expect(inferHostTheme()?.primary).toBe("rgb(22, 163, 74)");
42
+ });
43
+ });
@@ -261,6 +261,19 @@ describe("Markdown", () => {
261
261
  expect(screen.getByRole("link", { name: "docs" })).toHaveProperty("rel", "noopener noreferrer nofollow");
262
262
  });
263
263
 
264
+ it("renders *italic* but leaves a spaced asterisk and bold alone", () => {
265
+ render(<Markdown text="See *Key rotation* — 2 * 3 is **six**" />);
266
+ expect(screen.getByText("Key rotation").tagName).toBe("EM");
267
+ expect(screen.getByText("six").tagName).toBe("STRONG");
268
+ expect(screen.getByText(/2 \* 3 is/)).toBeTruthy();
269
+ });
270
+
271
+ it("keeps runs of asterisks literal instead of eating them as italic", () => {
272
+ const { container } = render(<Markdown text="Divider *** here and *x* too" />);
273
+ expect(container.textContent).toContain("Divider *** here");
274
+ expect(screen.getByText("x").tagName).toBe("EM");
275
+ });
276
+
264
277
  it("refuses to linkify a javascript: url", () => {
265
278
  render(<Markdown text="[click](javascript:alert(1))" />);
266
279
  expect(screen.queryByRole("link")).toBeNull();
@@ -297,6 +310,19 @@ describe("ChatPanel", () => {
297
310
  expect(screen.queryByText("1 skill loaded")).toBeNull();
298
311
  cleanup();
299
312
  });
313
+ it("opens the welcome screen at its top, not scrolled to the bottom", async () => {
314
+ cleanup();
315
+ const scrolls: number[] = [];
316
+ const { container } = render(<FrankieProvider publishableKey="pk_empty_fixture" config={config} fetchImpl={vi.fn()} persist={false}><ChatPanel /></FrankieProvider>);
317
+ const scroller = container.querySelector(".frankie-transcript") as HTMLElement;
318
+ Object.defineProperty(scroller, "scrollHeight", { configurable: true, get: () => 900 });
319
+ Object.defineProperty(scroller, "scrollTop", { configurable: true, get: () => scrolls.at(-1) ?? 0, set: (value: number) => { scrolls.push(value); } });
320
+ // A re-render with still no messages (e.g. config settling) must not jump to the bottom.
321
+ fireEvent.scroll(scroller);
322
+ await new Promise((resolve) => setTimeout(resolve, 20));
323
+ expect(scrolls.every((value) => value === 0)).toBe(true);
324
+ cleanup();
325
+ });
300
326
  it("keeps following new messages even when one update grows the transcript past the near-bottom threshold", async () => {
301
327
  cleanup();
302
328
  const fetchImpl = vi.fn(async (url: string | URL | Request) =>
@@ -429,6 +455,32 @@ describe("ChatPanel", () => {
429
455
  vi.restoreAllMocks();
430
456
  });
431
457
 
458
+ it("follows the host when it switches between light and dark, mode included", async () => {
459
+ document.body.innerHTML = '<button data-stub="host-accent">Buy</button>';
460
+ let dark = false;
461
+ const real = window.getComputedStyle.bind(window);
462
+ vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => {
463
+ if (el === document.body) return (dark ? { backgroundColor: "rgb(15, 15, 14)", color: "rgb(243, 242, 238)" } : { backgroundColor: "rgb(250, 250, 250)", color: "rgb(20, 20, 20)" }) as CSSStyleDeclaration;
464
+ if ((el as HTMLElement).dataset?.stub === "host-accent") return { backgroundColor: "rgb(16, 185, 129)", color: "rgb(255, 255, 255)" } as CSSStyleDeclaration;
465
+ return real(el);
466
+ });
467
+ const { container } = render(
468
+ <FrankieProvider baseUrl="https://agent.acme.com" publishableKey="pk_1" config={{ ...config, theme: { mode: "light", source: "inherit" } }}>
469
+ <ChatPanel />
470
+ </FrankieProvider>,
471
+ );
472
+ const root = () => container.querySelector(".frankie-root") as HTMLElement;
473
+ await waitFor(() => expect(root().style.getPropertyValue("--frankie-background")).toBe("rgb(250, 250, 250)"));
474
+ expect(root().classList.contains("dark")).toBe(false);
475
+ // The site flips its own theme (as Frankie's dashboard does with data-theme).
476
+ dark = true;
477
+ document.documentElement.dataset.theme = "dark";
478
+ await waitFor(() => expect(root().style.getPropertyValue("--frankie-background")).toBe("rgb(15, 15, 14)"));
479
+ expect(root().classList.contains("dark")).toBe(true); // the configured "light" doesn't win over the page
480
+ delete document.documentElement.dataset.theme;
481
+ vi.restoreAllMocks();
482
+ });
483
+
432
484
  it("keeps the configured fallback when the host page can't be read reliably", async () => {
433
485
  vi.spyOn(window, "getComputedStyle").mockImplementation(() => ({ backgroundColor: "rgba(0, 0, 0, 0)", color: "rgba(0, 0, 0, 0)" }) as CSSStyleDeclaration);
434
486
  const { container } = render(
@@ -467,6 +519,45 @@ describe("FrankieChatPage", () => {
467
519
  cleanup();
468
520
  });
469
521
 
522
+ it("keeps the conversation list on screen while switching chats, and fetches the config once", async () => {
523
+ cleanup();
524
+ window.localStorage.clear();
525
+ const signedIn = { ...config, allowAnonymous: false };
526
+ const list = [
527
+ { id: "conv-a", title: "Hi, can you tell me what things I can do", createdAt: 1, updatedAt: Date.now() },
528
+ { id: "conv-b", title: "Hi", createdAt: 1, updatedAt: Date.now() - 3 * 86400000 },
529
+ ];
530
+ const session = (id: string) => ({ sessionId: id, sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: false } });
531
+ const calls: string[] = [];
532
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
533
+ const href = String(url);
534
+ calls.push(`${init?.method ?? "GET"} ${new URL(href).pathname}`);
535
+ if (href.endsWith("/v1/config")) return Response.json(signedIn);
536
+ if (href.endsWith("/v1/session")) return Response.json(session("s"));
537
+ if (href.endsWith("/v1/conversations")) return Response.json({ conversations: list });
538
+ if (/\/v1\/conversations\/[^/]+\/resume$/.test(href)) return Response.json({ session: session("r"), messages: [], title: "Hi" });
539
+ if (/\/v1\/conversations\/[^/]+$/.test(href)) return Response.json({ ok: true });
540
+ return new Response(sseStream([{ type: "turn_end", stopReason: "end_turn" }]));
541
+ });
542
+ const { container } = render(<FrankieChatPage publishableKey="pk_page_switch" conversationId="conv-a" fetchImpl={fetchImpl} persist={false} getUserToken={async () => "h.eyJzdWIiOiJvbmUifQ.s"} />);
543
+ const aside = () => container.querySelector(".frankie-history-desktop") as HTMLElement;
544
+ const items = () => within(aside()).queryAllByRole("button").filter((button) => button.textContent?.startsWith("Hi"));
545
+ await waitFor(() => expect(items()).toHaveLength(2));
546
+
547
+ // Click the other conversation. The list must still be there in the very
548
+ // next render — it used to empty and show "Loading history…" because the
549
+ // sidebar remounted with the conversation — and the page must not fetch
550
+ // the workspace config again.
551
+ const other = () => items().find((button) => button.querySelector("span")?.textContent === "Hi")!;
552
+ fireEvent.click(other());
553
+ expect(items()).toHaveLength(2);
554
+ expect(within(aside()).queryByText("Loading history…")).toBeNull();
555
+ await waitFor(() => expect(other().getAttribute("aria-current")).toBe("page"));
556
+ expect(items()).toHaveLength(2);
557
+ expect(calls.filter((call) => call.endsWith("/v1/config"))).toHaveLength(1);
558
+ cleanup();
559
+ });
560
+
470
561
  it("starts collapsed when a prior visit left the sidebar collapsed", async () => {
471
562
  cleanup();
472
563
  window.localStorage.setItem("frankie.history-collapsed", "1");