@frankieai/react 0.2.15 → 0.2.17

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.
@@ -101,28 +101,79 @@ function declaredAccent(background: Rgba): Rgba | null {
101
101
  return null;
102
102
  }
103
103
 
104
+ /** A color custom property declared on <html> or <body> (shadcn's `--background`, Tailwind v4's `--color-background`). */
105
+ function declaredColor(names: string[]): Rgba | null {
106
+ for (const element of [document.documentElement, document.body]) {
107
+ const style = getComputedStyle(element);
108
+ if (typeof style.getPropertyValue !== "function") continue;
109
+ for (const name of names) {
110
+ const color = resolveColor(style.getPropertyValue(name));
111
+ if (color && color[3] >= 0.5) return color;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** An element's own background, when it's opaque enough to be a page surface. */
118
+ function solidBackground(element: Element | null | undefined): Rgba | null {
119
+ if (!element || element.closest(OWN_DOM)) return null;
120
+ const color = parseRgb(getComputedStyle(element).backgroundColor ?? "");
121
+ return color && color[3] >= 0.5 ? color : null;
122
+ }
123
+
124
+ /**
125
+ * The page's real background and text. Many sites leave <body> transparent
126
+ * and paint <html>, a top-level wrapper (`<div class="min-h-screen bg-…">`)
127
+ * or only declare `--background`, so each is tried in turn; <body>'s own
128
+ * text color stays the default unless the background came from elsewhere
129
+ * and the page declares `--foreground`.
130
+ */
131
+ function pageColors(bodyStyle: CSSStyleDeclaration): { background: Rgba; foreground: Rgba } | null {
132
+ const bodyText = parseRgb(bodyStyle.color);
133
+ const fromBody = parseRgb(bodyStyle.backgroundColor);
134
+ if (fromBody && fromBody[3] >= 0.5) return bodyText ? { background: fromBody, foreground: bodyText } : null;
135
+ const wrapper = document.body.firstElementChild;
136
+ const background = solidBackground(document.documentElement) ?? solidBackground(wrapper) ?? solidBackground(wrapper?.firstElementChild) ?? declaredColor(["--background", "--color-background"]);
137
+ const foreground = declaredColor(["--foreground", "--color-foreground"]) ?? bodyText;
138
+ return background && foreground ? { background, foreground } : null;
139
+ }
140
+
141
+ /** `a` moved `amount` of the way toward `b`: a surface or hairline in the page's own palette. */
142
+ function mix(a: Rgba, b: Rgba, amount: number): Rgba {
143
+ return [0, 1, 2].map((i) => Math.round(a[i]! + (b[i]! - a[i]!) * amount)).concat(1) as Rgba;
144
+ }
145
+
146
+ /** The page's corner radius in rem, from a shadcn-style `--radius` (rem or px), if it declares one. */
147
+ function declaredRadius(): string | undefined {
148
+ const raw = getComputedStyle(document.documentElement).getPropertyValue?.("--radius").trim();
149
+ const match = raw?.match(/^([\d.]+)(rem|px)$/);
150
+ if (!match) return undefined;
151
+ const rem = match[2] === "px" ? Number(match[1]) / 16 : Number(match[1]);
152
+ return rem >= 0 && rem <= 2 ? String(Math.round(rem * 1000) / 1000) : undefined;
153
+ }
154
+
104
155
  /**
105
156
  * Best-effort read of the host page's own background/text/accent colors, for
106
157
  * a workspace that chose "inherit my site's theme" over a manually configured
107
- * palette. Deliberately conservative: returns `null` — never a guess — the
108
- * moment anything sampled looks unreliable (no DOM to read, a transparent
109
- * body background, illegible contrast, no element that reads as an accent),
110
- * so the caller falls back to the configured or default theme rather than
111
- * shipping a widget that looks broken on a page this heuristic can't read.
158
+ * palette. Conservative: returns `null` — never a guess — when nothing
159
+ * sampled is reliable (no DOM to read, no opaque surface on <body>, <html>,
160
+ * the top wrapper or a declared `--background`, illegible contrast), so the
161
+ * caller falls back to the configured theme rather than shipping a widget
162
+ * that looks broken. With no distinct accent it still returns the page's
163
+ * surfaces and leaves the configured accent in place.
112
164
  *
113
165
  * Never attempts full brand-color extraction (logos, images, gradients) —
114
166
  * only colors real elements are already rendering with, via
115
- * `getComputedStyle`. Runs once per mount; it does not track the host
116
- * toggling its own light/dark mode after that.
167
+ * `getComputedStyle`. The provider re-runs it when the host switches its own
168
+ * theme (see `watchHostTheme`).
117
169
  */
118
170
  export function inferHostTheme(): Partial<WidgetTheme> | null {
119
171
  if (typeof document === "undefined" || typeof getComputedStyle !== "function") return null;
120
172
  try {
121
173
  const bodyStyle = getComputedStyle(document.body);
122
- const background = parseRgb(bodyStyle.backgroundColor);
123
- const foreground = parseRgb(bodyStyle.color);
124
- if (!background || !foreground) return null;
125
- if (background[3] < 0.5) return null; // a transparent/near-transparent body tells us nothing real
174
+ const colors = pageColors(bodyStyle);
175
+ if (!colors) return null; // no opaque surface anywhere: nothing real to read
176
+ const { background, foreground } = colors;
126
177
  if (contrastRatio(background, foreground) < 2) return null; // too low-contrast to trust as a real reading
127
178
 
128
179
  // An explicit opt-in wins, then the page's declared brand variable, then
@@ -144,15 +195,60 @@ export function inferHostTheme(): Partial<WidgetTheme> | null {
144
195
  const text = parseRgb(style.color);
145
196
  if (text && text[3] > 0.5 && !sameColor(text, foreground)) { accent = text; break; }
146
197
  }
147
- if (!accent) return null; // nothing distinct enough from the page's own surface to call an accent
148
198
 
199
+ // The page's light/dark decides the widget's, not the visitor's OS: a dark
200
+ // site on a light-mode machine must not get light borders and buttons.
201
+ // Muted surfaces and hairlines come from the page's own two colors so
202
+ // nothing in the widget keeps the default palette.
203
+ const font = bodyStyle.fontFamily?.trim();
204
+ const radius = declaredRadius();
205
+ // No accent distinct from the page: the page's surfaces still apply, and
206
+ // the configured accent (the fallback) stays for buttons and links.
149
207
  return {
208
+ mode: luminance(background) < 0.4 ? "dark" : "light",
150
209
  background: toCss(background),
151
210
  foreground: toCss(foreground),
152
- primary: toCss(accent),
153
- primaryForeground: luminance(accent) > 0.5 ? "#0a0a0a" : "#fafafa",
211
+ ...(accent ? {
212
+ primary: toCss(accent),
213
+ // Whichever of near-black or near-white reads better on the accent.
214
+ primaryForeground: contrastRatio(accent, [10, 10, 10, 1]) > contrastRatio(accent, [250, 250, 250, 1]) ? "#0a0a0a" : "#fafafa",
215
+ } : {}),
216
+ muted: toCss(mix(background, foreground, 0.07)),
217
+ border: toCss(mix(background, foreground, 0.14)),
218
+ ...(font ? { fontFamily: font } : {}),
219
+ ...(radius ? { radius } : {}),
154
220
  };
155
221
  } catch {
156
222
  return null; // any DOM access failure (unusual host environment): never guess
157
223
  }
158
224
  }
225
+
226
+ /**
227
+ * Call `onChange` when the host page may have switched its theme: a class,
228
+ * `data-*` or inline style change on <html> or <body> (how sites toggle light
229
+ * and dark), or the OS color scheme flipping. Coalesced to one call per
230
+ * frame. Watches only those two elements' own attributes, never the subtree,
231
+ * so the widget's own rendering can't trigger it. Returns a cleanup.
232
+ */
233
+ export function watchHostTheme(onChange: () => void): () => void {
234
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {};
235
+ let frame = 0;
236
+ const schedule = () => {
237
+ if (frame) return;
238
+ frame = (typeof requestAnimationFrame === "function" ? requestAnimationFrame : (fn: FrameRequestCallback) => setTimeout(fn, 16) as unknown as number)(() => {
239
+ frame = 0;
240
+ onChange();
241
+ });
242
+ };
243
+ const observer = new MutationObserver(schedule);
244
+ for (const element of [document.documentElement, document.body]) {
245
+ if (element) observer.observe(element, { attributes: true, attributeFilter: ["class", "style", "data-theme", "data-mode", "data-color-scheme", "data-color-mode"] });
246
+ }
247
+ const media = typeof window !== "undefined" && window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : undefined;
248
+ media?.addEventListener?.("change", schedule);
249
+ return () => {
250
+ observer.disconnect();
251
+ media?.removeEventListener?.("change", schedule);
252
+ if (frame && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame);
253
+ };
254
+ }
@@ -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
  });
@@ -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");