@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.
- package/dist/frankie.css +1 -1
- package/dist/index.cjs +111 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +111 -22
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/ChatPanel.tsx +5 -1
- package/src/components/chat/FrankieChatPage.tsx +32 -8
- package/src/components/chat/Markdown.tsx +4 -1
- package/src/generated/css-content.ts +1 -1
- package/src/hooks/FrankieProvider.tsx +20 -4
- package/src/lib/theme-inherit.test.ts +84 -3
- package/src/lib/theme-inherit.ts +110 -14
- package/src/styles/frankie.css +12 -1
- package/src/styles/host-safety.test.ts +10 -0
- package/src/widget.test.tsx +91 -0
package/src/lib/theme-inherit.ts
CHANGED
|
@@ -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.
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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`.
|
|
116
|
-
*
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
+
}
|
package/src/styles/frankie.css
CHANGED
|
@@ -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
|
});
|
package/src/widget.test.tsx
CHANGED
|
@@ -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");
|