@frankieai/react 0.2.9 → 0.2.10

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,39 @@
1
+ /**
2
+ * The stable identity a host-signed user token carries, used only to decide
3
+ * whether a cached chat session still belongs to the same person.
4
+ *
5
+ * The client never verifies the token — it has no secret, and the server
6
+ * re-verifies on every `/v1/session` anyway. It only needs a key that stays
7
+ * constant while the same user's token is re-minted. A JWT's `sub` is that
8
+ * key: hosts mint with `setIssuedAt()`, so the raw string changes every
9
+ * second even though nothing about the user did.
10
+ *
11
+ * Anything that isn't a readable JWT falls back to the raw token, which is
12
+ * exactly the previous behaviour — an opaque token that changes still resets
13
+ * the session, so this can only ever reuse a session, never leak one.
14
+ */
15
+ export function sessionIdentity(token: string | undefined): string | undefined {
16
+ if (!token) return undefined;
17
+ return jwtSubject(token) ?? token;
18
+ }
19
+
20
+ function jwtSubject(token: string): string | undefined {
21
+ const parts = token.split(".");
22
+ if (parts.length !== 3 || !parts[1]) return undefined;
23
+ try {
24
+ const payload = JSON.parse(base64UrlDecode(parts[1])) as { sub?: unknown };
25
+ return typeof payload.sub === "string" && payload.sub ? payload.sub : undefined;
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ function base64UrlDecode(segment: string): string {
32
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
33
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
34
+ if (typeof atob === "function") {
35
+ const bytes = atob(padded);
36
+ return decodeURIComponent(Array.from(bytes, (c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0")).join(""));
37
+ }
38
+ return Buffer.from(padded, "base64").toString("utf8");
39
+ }
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useLayoutEffect, useState, type RefObject } from "react";
2
+ import { ensureFrankieStyles } from "./ensure-frankie-styles.js";
2
3
 
3
4
  /** `useLayoutEffect` warns during SSR, where there is nothing to measure anyway. */
4
5
  const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -24,12 +25,15 @@ const SENTINEL = "--frankie-styles-ready";
24
25
  * unmounting: the sentinel must stay measurable on that element, and both of the
25
26
  * alternatives would also drop the widget out of the accessibility tree.
26
27
  *
27
- * The wait is deliberately bounded. A Tailwind v4 host is told it may skip our
28
- * stylesheet and compile the utilities itself, and such a page never sets the
29
- * sentinel; it must still get a widget. After `timeoutMs` we render regardless,
30
- * which is exactly today's behaviour.
28
+ * The wait is deliberately bounded. After `timeoutMs`, with no sentinel found,
29
+ * this injects the stylesheet itself (`ensureFrankieStyles`) as a fallback for
30
+ * a host that never loaded `styles.css` — the far more common way this shows
31
+ * up in practice than the one case it must not clobber: a Tailwind v4 host
32
+ * told it may skip our stylesheet and compile the same classes itself, which
33
+ * also never sets the sentinel. That host opts out with `injectStyles={false}`
34
+ * so it doesn't get a second, independently-themed copy of the stylesheet.
31
35
  */
32
- export function useStylesReady(ref: RefObject<HTMLElement | null>, timeoutMs = 600): boolean {
36
+ export function useStylesReady(ref: RefObject<HTMLElement | null>, timeoutMs = 600, injectFallback = true): boolean {
33
37
  const [ready, setReady] = useState(false);
34
38
 
35
39
  useIsomorphicLayoutEffect(() => {
@@ -51,7 +55,15 @@ export function useStylesReady(ref: RefObject<HTMLElement | null>, timeoutMs = 6
51
55
  const deadline = Date.now() + timeoutMs;
52
56
  let frame = 0;
53
57
  const poll = () => {
54
- if (applied() || Date.now() >= deadline) {
58
+ if (applied()) {
59
+ setReady(true);
60
+ return;
61
+ }
62
+ if (Date.now() >= deadline) {
63
+ // Inline `<style>` insertion applies synchronously, so re-check right
64
+ // after — but set `ready` regardless, matching the pre-existing
65
+ // "never hide the widget forever" guarantee.
66
+ if (injectFallback) ensureFrankieStyles();
55
67
  setReady(true);
56
68
  return;
57
69
  }
@@ -65,7 +77,7 @@ export function useStylesReady(ref: RefObject<HTMLElement | null>, timeoutMs = 6
65
77
  }
66
78
  frame = window.requestAnimationFrame(poll);
67
79
  return () => window.cancelAnimationFrame(frame);
68
- }, [ref, ready, timeoutMs]);
80
+ }, [ref, ready, timeoutMs, injectFallback]);
69
81
 
70
82
  return ready;
71
83
  }
@@ -0,0 +1,68 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { inferHostTheme } from "./theme-inherit.js";
3
+
4
+ /** Answer `getComputedStyle` with fixed colors per element, keyed by a data attribute. */
5
+ function stubComputedStyle(styles: Record<string, { backgroundColor: string; color: string }>) {
6
+ vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => {
7
+ const key = el === document.body ? "body" : (el as HTMLElement).dataset.stub;
8
+ const found = key ? styles[key] : undefined;
9
+ return (found ?? { backgroundColor: "rgba(0, 0, 0, 0)", color: "rgba(0, 0, 0, 0)" }) as CSSStyleDeclaration;
10
+ });
11
+ }
12
+
13
+ describe("inferHostTheme", () => {
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ document.body.innerHTML = "";
17
+ });
18
+
19
+ it("reads a light page's background, text and a button's accent color", () => {
20
+ document.body.innerHTML = '<button data-stub="accent">Buy now</button>';
21
+ stubComputedStyle({
22
+ body: { backgroundColor: "rgb(255, 255, 255)", color: "rgb(10, 10, 10)" },
23
+ accent: { backgroundColor: "rgb(37, 99, 235)", color: "rgb(255, 255, 255)" },
24
+ });
25
+ const theme = inferHostTheme();
26
+ expect(theme).toEqual({
27
+ background: "rgb(255, 255, 255)",
28
+ foreground: "rgb(10, 10, 10)",
29
+ primary: "rgb(37, 99, 235)",
30
+ primaryForeground: "#fafafa", // the accent itself is dark, so light text reads on it
31
+ });
32
+ });
33
+
34
+ it("prefers an explicit data-frankie-accent hook over a guessed button", () => {
35
+ document.body.innerHTML = '<a href="/pricing" data-stub="wrong-guess">Pricing</a><button data-frankie-accent data-stub="real-accent">Go</button>';
36
+ stubComputedStyle({
37
+ body: { backgroundColor: "rgb(255, 255, 255)", color: "rgb(10, 10, 10)" },
38
+ "wrong-guess": { backgroundColor: "rgb(255, 255, 255)", color: "rgb(80, 80, 80)" }, // no real accent — a plain link
39
+ "real-accent": { backgroundColor: "rgb(220, 38, 38)", color: "rgb(255, 255, 255)" },
40
+ });
41
+ const theme = inferHostTheme();
42
+ expect(theme?.primary).toBe("rgb(220, 38, 38)");
43
+ });
44
+
45
+ it("returns null for a transparent body background — nothing real to read", () => {
46
+ stubComputedStyle({ body: { backgroundColor: "rgba(0, 0, 0, 0)", color: "rgb(10, 10, 10)" } });
47
+ expect(inferHostTheme()).toBeNull();
48
+ });
49
+
50
+ it("returns null when background and text are too close to distinguish", () => {
51
+ stubComputedStyle({ body: { backgroundColor: "rgb(120, 120, 120)", color: "rgb(130, 130, 130)" } });
52
+ expect(inferHostTheme()).toBeNull();
53
+ });
54
+
55
+ it("returns null when nothing on the page reads as a distinct accent", () => {
56
+ document.body.innerHTML = '<button data-stub="blends-in">OK</button>';
57
+ stubComputedStyle({
58
+ body: { backgroundColor: "rgb(255, 255, 255)", color: "rgb(10, 10, 10)" },
59
+ "blends-in": { backgroundColor: "rgb(255, 255, 255)", color: "rgb(10, 10, 10)" }, // identical to the page — not an accent
60
+ });
61
+ expect(inferHostTheme()).toBeNull();
62
+ });
63
+
64
+ it("never guesses on a DOM access failure", () => {
65
+ vi.spyOn(window, "getComputedStyle").mockImplementation(() => { throw new Error("boom"); });
66
+ expect(inferHostTheme()).toBeNull();
67
+ });
68
+ });
@@ -0,0 +1,89 @@
1
+ import type { WidgetTheme } from "@frankieai/core";
2
+
3
+ type Rgba = [number, number, number, number];
4
+
5
+ /** Computed colors always come back as `rgb()`/`rgba()`, never a hex or name. */
6
+ function parseRgb(value: string): Rgba | null {
7
+ const match = value.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)/i);
8
+ if (!match) return null;
9
+ const alpha = match[4] === undefined ? 1 : match[4].endsWith("%") ? Number(match[4].slice(0, -1)) / 100 : Number(match[4]);
10
+ return [Number(match[1]), Number(match[2]), Number(match[3]), alpha];
11
+ }
12
+
13
+ function toCss([r, g, b, a]: Rgba): string {
14
+ return a >= 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${a})`;
15
+ }
16
+
17
+ function sameColor(a: Rgba, b: Rgba): boolean {
18
+ return Math.abs(a[0] - b[0]) < 12 && Math.abs(a[1] - b[1]) < 12 && Math.abs(a[2] - b[2]) < 12;
19
+ }
20
+
21
+ /** Relative luminance (WCAG), 0 = black, 1 = white. */
22
+ function luminance([r, g, b]: Rgba): number {
23
+ const channel = (c: number) => {
24
+ const v = c / 255;
25
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
26
+ };
27
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
28
+ }
29
+
30
+ function contrastRatio(a: Rgba, b: Rgba): number {
31
+ const la = luminance(a), lb = luminance(b);
32
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
33
+ }
34
+
35
+ /**
36
+ * Elements likely to carry a site's own accent color, checked in order.
37
+ * `data-frankie-accent` is an explicit opt-in hook a host can add to any
38
+ * element (e.g. their own primary button) to make detection reliable instead
39
+ * of guessed; everything after it is a heuristic guess at a "branded"
40
+ * element, weakest last.
41
+ */
42
+ const ACCENT_SELECTORS = ["[data-frankie-accent]", "button[type=submit]", "a[href]", "button", "[class*=primary]", "[class*=btn]"];
43
+
44
+ /**
45
+ * Best-effort read of the host page's own background/text/accent colors, for
46
+ * a workspace that chose "inherit my site's theme" over a manually configured
47
+ * palette. Deliberately conservative: returns `null` — never a guess — the
48
+ * moment anything sampled looks unreliable (no DOM to read, a transparent
49
+ * body background, illegible contrast, no element that reads as an accent),
50
+ * so the caller falls back to the configured or default theme rather than
51
+ * shipping a widget that looks broken on a page this heuristic can't read.
52
+ *
53
+ * Never attempts full brand-color extraction (logos, images, gradients) —
54
+ * 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.
57
+ */
58
+ export function inferHostTheme(): Partial<WidgetTheme> | null {
59
+ if (typeof document === "undefined" || typeof getComputedStyle !== "function") return null;
60
+ try {
61
+ const bodyStyle = getComputedStyle(document.body);
62
+ const background = parseRgb(bodyStyle.backgroundColor);
63
+ const foreground = parseRgb(bodyStyle.color);
64
+ if (!background || !foreground) return null;
65
+ if (background[3] < 0.5) return null; // a transparent/near-transparent body tells us nothing real
66
+ if (contrastRatio(background, foreground) < 2) return null; // too low-contrast to trust as a real reading
67
+
68
+ let accent: Rgba | null = null;
69
+ for (const selector of ACCENT_SELECTORS) {
70
+ const element = document.querySelector(selector);
71
+ if (!element) continue;
72
+ const style = getComputedStyle(element);
73
+ const surface = parseRgb(style.backgroundColor);
74
+ if (surface && surface[3] > 0.5 && !sameColor(surface, background)) { accent = surface; break; }
75
+ const text = parseRgb(style.color);
76
+ if (text && text[3] > 0.5 && !sameColor(text, foreground)) { accent = text; break; }
77
+ }
78
+ if (!accent) return null; // nothing distinct enough from the page's own surface to call an accent
79
+
80
+ return {
81
+ background: toCss(background),
82
+ foreground: toCss(foreground),
83
+ primary: toCss(accent),
84
+ primaryForeground: luminance(accent) > 0.5 ? "#0a0a0a" : "#fafafa",
85
+ };
86
+ } catch {
87
+ return null; // any DOM access failure (unusual host environment): never guess
88
+ }
89
+ }
@@ -446,6 +446,14 @@
446
446
  .frankie-root.frankie-chat-panel-fill .frankie-composer textarea { order:-1; flex:0 0 100%; width:100%; min-height:32px; max-height:140px; margin-bottom:4px; padding:2px 6px; border:0; border-radius:0; resize:none; background:transparent; color:var(--frankie-foreground); font-size:16px; box-shadow:none; outline:none; }
447
447
  .frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label="Send"],.frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label="Stop"] { margin-left:auto; width:42px; height:42px; border-radius:50%; background:var(--frankie-primary); color:var(--frankie-primary-foreground); border:0; }
448
448
  .frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label="Attach files"] { width:42px; height:42px; border-radius:50%; }
449
+ /* Compact model picker — a subtle bottom-left control (like Claude's), not a
450
+ full-height dropdown crowding the send row. */
451
+ .frankie-root .frankie-composer-meta { display:flex; align-items:center; gap:8px; margin-top:6px; padding:0 2px; }
452
+ .frankie-root .frankie-model-picker { display:inline-flex; align-items:center; min-width:0; }
453
+ .frankie-root .frankie-model-select { appearance:none; -webkit-appearance:none; max-width:12rem; border:0; border-radius:8px; background-color:transparent; color:var(--frankie-muted-foreground); font:inherit; font-size:12px; font-weight:500; line-height:1; padding:5px 22px 5px 8px; cursor:pointer; text-overflow:ellipsis; background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b8b8b' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>"); background-repeat:no-repeat; background-position:right 6px center; background-size:11px; transition:background-color .12s ease, color .12s ease; }
454
+ .frankie-root .frankie-model-select:hover { background-color:var(--frankie-muted); color:var(--frankie-foreground); }
455
+ .frankie-root .frankie-model-select:disabled { opacity:.5; cursor:not-allowed; }
456
+ .frankie-root .frankie-model-select option { color:var(--frankie-foreground); background:var(--frankie-background); font-weight:400; }
449
457
  .frankie-root .frankie-history-desktop { width:290px; background:var(--frankie-background); padding:20px 12px; }
450
458
  .frankie-root .frankie-history-collapsed { width:56px; padding:20px 8px; }
451
459
  .frankie-root .frankie-new-chat { justify-content:flex-start; border:0; background:transparent; padding:8px 12px; font-size:15px; }
@@ -515,3 +523,25 @@
515
523
  .frankie-root .frankie-choice-other:hover:not(:disabled) { color:var(--frankie-foreground); }
516
524
  .frankie-root .frankie-choice-other:hover:not(:disabled) .frankie-choice-index { background:var(--frankie-muted); border-color:var(--frankie-border); color:var(--frankie-foreground); }
517
525
  .frankie-root .frankie-choice-hint { flex:none; font-size:12px; color:var(--frankie-muted-foreground); }
526
+
527
+ /* Loading skeleton shown while a reply is still being produced (before the
528
+ first words, or in the pause after a tool result). Shimmering placeholder
529
+ tiles so the gap reads as "loading" rather than a frozen chat. */
530
+ .frankie-root .frankie-skeleton { display:flex; flex-direction:column; gap:8px; padding:4px 0 6px; max-width:min(420px, 100%); }
531
+ .frankie-root .frankie-skeleton-line {
532
+ height:12px; border-radius:6px;
533
+ background:linear-gradient(90deg,
534
+ color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 25%,
535
+ color-mix(in srgb, var(--frankie-muted-foreground) 26%, transparent) 37%,
536
+ color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 63%);
537
+ background-size:400% 100%;
538
+ animation:frankie-shimmer 1.4s ease infinite;
539
+ }
540
+ @keyframes frankie-shimmer { 0% { background-position:100% 50%; } 100% { background-position:0 50%; } }
541
+ @keyframes frankie-skeleton-pulse { 0%,100% { opacity:.45; } 50% { opacity:.85; } }
542
+ @media (prefers-reduced-motion: reduce) {
543
+ .frankie-root .frankie-skeleton-line {
544
+ background:color-mix(in srgb, var(--frankie-muted-foreground) 16%, transparent);
545
+ animation:frankie-skeleton-pulse 1.6s ease-in-out infinite;
546
+ }
547
+ }
@@ -1,12 +1,13 @@
1
1
  import { render, screen, waitFor } from "@testing-library/react";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import { useRef } from "react";
4
+ import { __resetFrankieStylesForTests } from "./lib/ensure-frankie-styles.js";
4
5
  import { useStylesReady } from "./lib/styles-ready.js";
5
6
 
6
7
  /** Stand-in for a widget root: reports what the hook decided. */
7
- function Probe({ timeoutMs }: { timeoutMs?: number }) {
8
+ function Probe({ timeoutMs, injectFallback }: { timeoutMs?: number; injectFallback?: boolean }) {
8
9
  const ref = useRef<HTMLDivElement>(null);
9
- const ready = useStylesReady(ref, timeoutMs);
10
+ const ready = useStylesReady(ref, timeoutMs, injectFallback);
10
11
  return (
11
12
  <div ref={ref} data-testid="root" data-frankie-styles={ready ? "ready" : "pending"}>
12
13
  {ready ? "panel" : null}
@@ -29,7 +30,10 @@ function withSentinel(present: boolean | (() => boolean)) {
29
30
  });
30
31
  }
31
32
 
32
- afterEach(() => vi.restoreAllMocks());
33
+ afterEach(() => {
34
+ vi.restoreAllMocks();
35
+ __resetFrankieStylesForTests();
36
+ });
33
37
 
34
38
  describe("useStylesReady", () => {
35
39
  it("renders immediately when the stylesheet is already applied", () => {
@@ -68,4 +72,27 @@ describe("useStylesReady", () => {
68
72
  // sentinel; it must still get a widget rather than an invisible one.
69
73
  await waitFor(() => expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready"));
70
74
  });
75
+
76
+ it("injects the stylesheet itself once the wait for a host's own copy runs out", async () => {
77
+ withSentinel(false);
78
+ render(<Probe timeoutMs={0} />);
79
+ await waitFor(() => expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready"));
80
+ expect(document.getElementById("frankie-fallback-styles")).not.toBeNull();
81
+ });
82
+
83
+ it("never injects a second copy for a host that opted out with injectStyles={false}", async () => {
84
+ withSentinel(false);
85
+ render(<Probe timeoutMs={0} injectFallback={false} />);
86
+ await waitFor(() => expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready"));
87
+ // The Tailwind v4 opt-out case: this host compiles the same classes itself
88
+ // and never sets the sentinel either, so a blind fallback would give it an
89
+ // unwanted, independently-themed second stylesheet.
90
+ expect(document.getElementById("frankie-fallback-styles")).toBeNull();
91
+ });
92
+
93
+ it("never injects the fallback when the host's own stylesheet is already applied", () => {
94
+ withSentinel(true);
95
+ render(<Probe />);
96
+ expect(document.getElementById("frankie-fallback-styles")).toBeNull();
97
+ });
71
98
  });
@@ -5,6 +5,7 @@ import { FrankieProvider } from "./hooks/FrankieProvider.jsx";
5
5
  import { ChatPanel } from "./components/chat/ChatPanel.js";
6
6
  import { FrankieChatPage } from "./components/chat/FrankieChatPage.js";
7
7
  import { Markdown } from "./components/chat/Markdown.js";
8
+ import { sessionIdentity } from "./lib/identity.js";
8
9
  import { FrankieClient, readEventStream } from "./lib/client.js";
9
10
  import { applyEvent, newAssistantMessage } from "./lib/messages.js";
10
11
  import { themeToCssVars } from "./lib/theme.js";
@@ -157,6 +158,75 @@ describe("FrankieClient", () => {
157
158
  expect(headers["x-frankie-key"]).toBe("pk_1");
158
159
  });
159
160
 
161
+ /** A JWT with the given claims; the signature is irrelevant to the client. */
162
+ const jwt = (claims: Record<string, unknown>) =>
163
+ `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`;
164
+
165
+ it("keeps the same session when the same user's token is re-minted between turns", async () => {
166
+ // Hosts mint with setIssuedAt(), so every getUserToken() call returns a
167
+ // different string for the same person. That must not start a new,
168
+ // empty conversation — the agent would never see the previous turn.
169
+ let iat = 1_700_000_000;
170
+ const getUserToken = () => jwt({ sub: "user_42", iat: iat++ });
171
+ let sessionCalls = 0;
172
+ const bearers: string[] = [];
173
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
174
+ const href = String(url);
175
+ if (href.endsWith("/v1/session")) {
176
+ sessionCalls += 1;
177
+ return Response.json({ sessionId: `s${sessionCalls}`, sessionToken: `bearer-${sessionCalls}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "user_42", anonymous: false } });
178
+ }
179
+ bearers.push((init?.headers as Record<string, string>)["authorization"] ?? "");
180
+ return new Response(sseStream([{ type: "text_delta", text: "ok" }]), { status: 200 });
181
+ }) as unknown as typeof fetch;
182
+
183
+ const client = new FrankieClient({ publishableKey: "pk_rotate", getUserToken, fetchImpl });
184
+ await client.send({ message: "first" }, () => {});
185
+ await client.send({ message: "second" }, () => {});
186
+
187
+ expect(sessionCalls).toBe(1);
188
+ expect(bearers).toEqual(["Bearer bearer-1", "Bearer bearer-1"]);
189
+ expect(client.sessionId).toBe("s1");
190
+ });
191
+
192
+ it("still starts a new session when a different user signs in", async () => {
193
+ let sub = "user_42";
194
+ const getUserToken = () => jwt({ sub, iat: 1 });
195
+ let sessionCalls = 0;
196
+ const fetchImpl = vi.fn(async (url: string | URL | Request) => {
197
+ if (String(url).endsWith("/v1/session")) {
198
+ sessionCalls += 1;
199
+ return Response.json({ sessionId: `s${sessionCalls}`, sessionToken: `bearer-${sessionCalls}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: sub, anonymous: false } });
200
+ }
201
+ return new Response(sseStream([{ type: "text_delta", text: "ok" }]), { status: 200 });
202
+ }) as unknown as typeof fetch;
203
+
204
+ const client = new FrankieClient({ publishableKey: "pk_switch", getUserToken, fetchImpl });
205
+ await client.send({ message: "as 42" }, () => {});
206
+ sub = "user_99"; // account change on the host
207
+ await client.send({ message: "as 99" }, () => {});
208
+
209
+ expect(sessionCalls).toBe(2);
210
+ expect(client.sessionId).toBe("s2");
211
+ });
212
+
213
+ it("falls back to comparing an opaque token, so a changed one still resets", async () => {
214
+ let token = "opaque-token-a";
215
+ let sessionCalls = 0;
216
+ const fetchImpl = vi.fn(async (url: string | URL | Request) => {
217
+ if (String(url).endsWith("/v1/session")) {
218
+ sessionCalls += 1;
219
+ return Response.json({ sessionId: `s${sessionCalls}`, sessionToken: `b${sessionCalls}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "u", anonymous: false } });
220
+ }
221
+ return new Response(sseStream([{ type: "text_delta", text: "ok" }]), { status: 200 });
222
+ }) as unknown as typeof fetch;
223
+ const client = new FrankieClient({ publishableKey: "pk_opaque", getUserToken: () => token, fetchImpl });
224
+ await client.send({ message: "a" }, () => {});
225
+ token = "opaque-token-b";
226
+ await client.send({ message: "b" }, () => {});
227
+ expect(sessionCalls).toBe(2);
228
+ });
229
+
160
230
  it("drops a rejected session so the next send re-authenticates", async () => {
161
231
  let sessionCalls = 0;
162
232
  const fetchImpl = vi.fn(async (url: string | URL | Request) => {
@@ -333,6 +403,49 @@ describe("ChatPanel", () => {
333
403
  expect(root.style.getPropertyValue("--frankie-primary")).toBe("rgb(255, 0, 128)");
334
404
  expect(root.classList.contains("dark")).toBe(true);
335
405
  });
406
+
407
+ it("inherits the host page's own colors once sampling succeeds", async () => {
408
+ document.body.innerHTML = '<button data-stub="host-accent">Buy</button>';
409
+ const real = window.getComputedStyle.bind(window);
410
+ vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => {
411
+ if (el === document.body) return { backgroundColor: "rgb(250, 250, 250)", color: "rgb(20, 20, 20)" } as CSSStyleDeclaration;
412
+ if ((el as HTMLElement).dataset?.stub === "host-accent") return { backgroundColor: "rgb(16, 185, 129)", color: "rgb(255, 255, 255)" } as CSSStyleDeclaration;
413
+ return real(el);
414
+ });
415
+ const { container } = render(
416
+ <FrankieProvider
417
+ baseUrl="https://agent.acme.com"
418
+ publishableKey="pk_1"
419
+ config={{ ...config, theme: { mode: "light", source: "inherit", primary: "#configured-fallback" } }}
420
+ >
421
+ <ChatPanel />
422
+ </FrankieProvider>,
423
+ );
424
+ const root = () => container.querySelector(".frankie-root") as HTMLElement;
425
+ // Sampling runs post-mount, so the first paint still shows the configured
426
+ // fallback — never a blank/undefined value — until inference resolves.
427
+ await waitFor(() => expect(root().style.getPropertyValue("--frankie-primary")).toBe("rgb(16, 185, 129)"));
428
+ expect(root().style.getPropertyValue("--frankie-background")).toBe("rgb(250, 250, 250)");
429
+ vi.restoreAllMocks();
430
+ });
431
+
432
+ it("keeps the configured fallback when the host page can't be read reliably", async () => {
433
+ vi.spyOn(window, "getComputedStyle").mockImplementation(() => ({ backgroundColor: "rgba(0, 0, 0, 0)", color: "rgba(0, 0, 0, 0)" }) as CSSStyleDeclaration);
434
+ const { container } = render(
435
+ <FrankieProvider
436
+ baseUrl="https://agent.acme.com"
437
+ publishableKey="pk_1"
438
+ config={{ ...config, theme: { mode: "light", source: "inherit", primary: "rgb(1, 2, 3)" } }}
439
+ >
440
+ <ChatPanel />
441
+ </FrankieProvider>,
442
+ );
443
+ const root = container.querySelector(".frankie-root") as HTMLElement;
444
+ // A transparent body background means inference declines to guess, so
445
+ // the configured value is what the widget shows — not left blank.
446
+ await waitFor(() => expect(root.style.getPropertyValue("--frankie-primary")).toBe("rgb(1, 2, 3)"));
447
+ vi.restoreAllMocks();
448
+ });
336
449
  });
337
450
 
338
451
  describe("FrankieChatPage", () => {
@@ -366,3 +479,108 @@ describe("FrankieChatPage", () => {
366
479
  cleanup();
367
480
  });
368
481
  });
482
+
483
+ describe("sessionIdentity", () => {
484
+ const jwt = (claims: Record<string, unknown>) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`;
485
+ it("reads the JWT subject", () => expect(sessionIdentity(jwt({ sub: "user_42", iat: 1 }))).toBe("user_42"));
486
+ it("is stable across re-minted tokens for the same subject", () =>
487
+ expect(sessionIdentity(jwt({ sub: "u", iat: 1 }))).toBe(sessionIdentity(jwt({ sub: "u", iat: 2 }))));
488
+ it("falls back to the raw token when there is no readable subject", () => {
489
+ expect(sessionIdentity("opaque")).toBe("opaque");
490
+ expect(sessionIdentity("a.b")).toBe("a.b");
491
+ expect(sessionIdentity(jwt({ iat: 1 }))).toBe(jwt({ iat: 1 }));
492
+ expect(sessionIdentity("h.%%%notbase64%%%.s")).toBe("h.%%%notbase64%%%.s");
493
+ });
494
+ it("maps a missing token to undefined", () => expect(sessionIdentity(undefined)).toBeUndefined());
495
+ });
496
+
497
+ describe("client-side tool execution", () => {
498
+ it("joinHostUrl joins base and relative path, and refuses an absolute one", async () => {
499
+ const { joinHostUrl } = await import("./lib/client.js");
500
+ expect(joinHostUrl("https://app.acme.com/api", "restaurants/1?x=2")).toBe("https://app.acme.com/api/restaurants/1?x=2");
501
+ expect(joinHostUrl("https://app.acme.com/api/", "/restaurants")).toBe("https://app.acme.com/api/restaurants");
502
+ expect(() => joinHostUrl("https://app.acme.com", "https://evil.com/steal")).toThrow(/relative/);
503
+ expect(() => joinHostUrl("https://app.acme.com", "//evil.com/steal")).toThrow(/relative/);
504
+ });
505
+
506
+ it("runs a tool_call_request against the host API and posts the outcome back", async () => {
507
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
508
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
509
+ const href = String(url);
510
+ calls.push({ url: href, ...(init ? { init } : {}) });
511
+ if (href.endsWith("/v1/session")) {
512
+ return Response.json({ sessionId: "s1", sessionToken: "bearer-1", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "u", anonymous: true } });
513
+ }
514
+ if (href.endsWith("/v1/chat")) {
515
+ return new Response(
516
+ sseStream([
517
+ { type: "tool_call_request", callId: "c-1", toolUseId: "c-1", name: "api__acme__getRestaurant", request: { source: "acme", method: "GET", path: "restaurants/9" } },
518
+ { type: "text_delta", text: "done" },
519
+ { type: "turn_end", stopReason: "end_turn" },
520
+ ]),
521
+ { status: 200 },
522
+ );
523
+ }
524
+ if (href.endsWith("/v1/chat/tool-result")) return Response.json({ ok: true });
525
+ // The host API call itself.
526
+ return new Response(JSON.stringify({ name: "Bistro" }), { status: 200 });
527
+ }) as unknown as typeof fetch;
528
+
529
+ const client = new FrankieClient({
530
+ baseUrl: "https://agent.acme.com",
531
+ publishableKey: "pk_client_exec",
532
+ api: { baseUrl: "https://app.acme.com/api", credentials: "include" },
533
+ fetchImpl,
534
+ persist: false,
535
+ });
536
+
537
+ await client.send({ message: "show me restaurant 9" }, () => {});
538
+
539
+ // The host API was called from the browser, at the configured base, with the
540
+ // app's own credentials — Frankie added no Authorization of its own.
541
+ await waitFor(() => expect(calls.some((c) => c.url === "https://app.acme.com/api/restaurants/9")).toBe(true));
542
+ const hostCall = calls.find((c) => c.url === "https://app.acme.com/api/restaurants/9")!;
543
+ expect(hostCall.init?.method).toBe("GET");
544
+ expect(hostCall.init?.credentials).toBe("include");
545
+ expect((hostCall.init?.headers as Record<string, string> | undefined)?.["authorization"]).toBeUndefined();
546
+
547
+ // The outcome was posted back with the matching callId and the host status.
548
+ await waitFor(() => expect(calls.some((c) => c.url.endsWith("/v1/chat/tool-result"))).toBe(true));
549
+ const post = calls.find((c) => c.url.endsWith("/v1/chat/tool-result"))!;
550
+ const body = JSON.parse(String(post.init?.body)) as { callId: string; result: { status: number; body: string } };
551
+ expect(body.callId).toBe("c-1");
552
+ expect(body.result.status).toBe(200);
553
+ expect(body.result.body).toContain("Bistro");
554
+ });
555
+
556
+ it("reports a transport failure when no host api client is configured", async () => {
557
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
558
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
559
+ const href = String(url);
560
+ calls.push({ url: href, ...(init ? { init } : {}) });
561
+ if (href.endsWith("/v1/session")) {
562
+ return Response.json({ sessionId: "s1", sessionToken: "b1", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "u", anonymous: true } });
563
+ }
564
+ if (href.endsWith("/v1/chat")) {
565
+ return new Response(
566
+ sseStream([
567
+ { type: "tool_call_request", callId: "c-2", toolUseId: "c-2", name: "api__acme__x", request: { source: "acme", method: "GET", path: "x" } },
568
+ { type: "turn_end", stopReason: "end_turn" },
569
+ ]),
570
+ { status: 200 },
571
+ );
572
+ }
573
+ return Response.json({ ok: true });
574
+ }) as unknown as typeof fetch;
575
+
576
+ // No `api` option configured.
577
+ const client = new FrankieClient({ baseUrl: "https://agent.acme.com", publishableKey: "pk_no_api", fetchImpl, persist: false });
578
+ await client.send({ message: "hi" }, () => {});
579
+
580
+ await waitFor(() => expect(calls.some((c) => c.url.endsWith("/v1/chat/tool-result"))).toBe(true));
581
+ const post = calls.find((c) => c.url.endsWith("/v1/chat/tool-result"))!;
582
+ const body = JSON.parse(String(post.init?.body)) as { result: { status: number; error?: string } };
583
+ expect(body.result.status).toBe(0);
584
+ expect(body.result.error).toMatch(/no host API client/i);
585
+ });
586
+ });