@frankieai/react 0.2.9 → 0.2.11
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 +268 -59
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +55 -3
- package/dist/index.d.ts +55 -3
- package/dist/index.js +268 -60
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/chat/Composer.tsx +49 -42
- package/src/components/chat/FrankieChatPage.tsx +1 -1
- package/src/components/chat/FrankieInline.tsx +3 -2
- package/src/components/chat/FrankieWidget.tsx +2 -2
- package/src/components/chat/MessageBubble.tsx +24 -5
- package/src/components/chat/ToolActivityList.tsx +2 -1
- package/src/generated/css-content.ts +2 -0
- package/src/hooks/FrankieProvider.tsx +30 -3
- package/src/index.ts +1 -1
- package/src/lib/client.ts +136 -5
- package/src/lib/ensure-frankie-styles.ts +32 -0
- package/src/lib/identity.ts +39 -0
- package/src/lib/styles-ready.ts +19 -7
- package/src/lib/theme-inherit.test.ts +68 -0
- package/src/lib/theme-inherit.ts +89 -0
- package/src/styles/frankie.css +34 -0
- package/src/styles/host-safety.test.ts +57 -0
- package/src/styles-ready.test.tsx +30 -3
- package/src/widget.test.tsx +218 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { FRANKIE_CSS_CONTENT } from "../generated/css-content.js";
|
|
2
|
+
|
|
3
|
+
const STYLE_ELEMENT_ID = "frankie-fallback-styles";
|
|
4
|
+
let attempted = false;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Last resort for a host that never loaded `@frankieai/react/styles.css` —
|
|
8
|
+
* forgot the import, or its CSS chunk hasn't landed by the time
|
|
9
|
+
* `useStylesReady`'s bounded wait gives up. Injects the same stylesheet the
|
|
10
|
+
* package ships as `./styles.css`, so the widget still renders correctly
|
|
11
|
+
* instead of as raw, unstyled HTML.
|
|
12
|
+
*
|
|
13
|
+
* Only called when a host hasn't opted out via `injectStyles={false}` — the
|
|
14
|
+
* documented escape hatch for a host that compiles Frankie's classes itself
|
|
15
|
+
* (e.g. a Tailwind v4 host `@source`-ing this package) and would not want a
|
|
16
|
+
* second, independently-themed copy of the stylesheet appended.
|
|
17
|
+
*/
|
|
18
|
+
export function ensureFrankieStyles(): void {
|
|
19
|
+
if (attempted || typeof document === "undefined") return;
|
|
20
|
+
attempted = true;
|
|
21
|
+
if (document.getElementById(STYLE_ELEMENT_ID)) return;
|
|
22
|
+
const style = document.createElement("style");
|
|
23
|
+
style.id = STYLE_ELEMENT_ID;
|
|
24
|
+
style.textContent = FRANKIE_CSS_CONTENT;
|
|
25
|
+
document.head.appendChild(style);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Test-only: clears the injected-once guard so each test starts clean. */
|
|
29
|
+
export function __resetFrankieStylesForTests(): void {
|
|
30
|
+
attempted = false;
|
|
31
|
+
document.getElementById(STYLE_ELEMENT_ID)?.remove();
|
|
32
|
+
}
|
|
@@ -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
|
+
}
|
package/src/lib/styles-ready.ts
CHANGED
|
@@ -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.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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()
|
|
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
|
+
}
|
package/src/styles/frankie.css
CHANGED
|
@@ -446,6 +446,18 @@
|
|
|
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
|
+
/* The bottom-left toolbar: attach + model picker on one row (not stacked). In
|
|
450
|
+
the full-page composer the textarea floats to its own top row (order:-1), so
|
|
451
|
+
this group and the send button share the row below — group left, send right. */
|
|
452
|
+
.frankie-root .frankie-composer-actions { display:flex; align-items:center; gap:6px; min-width:0; }
|
|
453
|
+
/* Compact model picker — a subtle bottom-left control (like Claude's), not a
|
|
454
|
+
full-height dropdown crowding the send row. */
|
|
455
|
+
.frankie-root .frankie-composer-meta { display:flex; align-items:center; gap:8px; margin-top:6px; padding:0 2px; }
|
|
456
|
+
.frankie-root .frankie-model-picker { display:inline-flex; align-items:center; min-width:0; }
|
|
457
|
+
.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; }
|
|
458
|
+
.frankie-root .frankie-model-select:hover { background-color:var(--frankie-muted); color:var(--frankie-foreground); }
|
|
459
|
+
.frankie-root .frankie-model-select:disabled { opacity:.5; cursor:not-allowed; }
|
|
460
|
+
.frankie-root .frankie-model-select option { color:var(--frankie-foreground); background:var(--frankie-background); font-weight:400; }
|
|
449
461
|
.frankie-root .frankie-history-desktop { width:290px; background:var(--frankie-background); padding:20px 12px; }
|
|
450
462
|
.frankie-root .frankie-history-collapsed { width:56px; padding:20px 8px; }
|
|
451
463
|
.frankie-root .frankie-new-chat { justify-content:flex-start; border:0; background:transparent; padding:8px 12px; font-size:15px; }
|
|
@@ -515,3 +527,25 @@
|
|
|
515
527
|
.frankie-root .frankie-choice-other:hover:not(:disabled) { color:var(--frankie-foreground); }
|
|
516
528
|
.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
529
|
.frankie-root .frankie-choice-hint { flex:none; font-size:12px; color:var(--frankie-muted-foreground); }
|
|
530
|
+
|
|
531
|
+
/* Loading skeleton shown while a reply is still being produced (before the
|
|
532
|
+
first words, or in the pause after a tool result). Shimmering placeholder
|
|
533
|
+
tiles so the gap reads as "loading" rather than a frozen chat. */
|
|
534
|
+
.frankie-root .frankie-skeleton { display:flex; flex-direction:column; gap:8px; padding:4px 0 6px; max-width:min(420px, 100%); }
|
|
535
|
+
.frankie-root .frankie-skeleton-line {
|
|
536
|
+
height:12px; border-radius:6px;
|
|
537
|
+
background:linear-gradient(90deg,
|
|
538
|
+
color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 25%,
|
|
539
|
+
color-mix(in srgb, var(--frankie-muted-foreground) 26%, transparent) 37%,
|
|
540
|
+
color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 63%);
|
|
541
|
+
background-size:400% 100%;
|
|
542
|
+
animation:frankie-shimmer 1.4s ease infinite;
|
|
543
|
+
}
|
|
544
|
+
@keyframes frankie-shimmer { 0% { background-position:100% 50%; } 100% { background-position:0 50%; } }
|
|
545
|
+
@keyframes frankie-skeleton-pulse { 0%,100% { opacity:.45; } 50% { opacity:.85; } }
|
|
546
|
+
@media (prefers-reduced-motion: reduce) {
|
|
547
|
+
.frankie-root .frankie-skeleton-line {
|
|
548
|
+
background:color-mix(in srgb, var(--frankie-muted-foreground) 16%, transparent);
|
|
549
|
+
animation:frankie-skeleton-pulse 1.6s ease-in-out infinite;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The widget stylesheet is loaded into arbitrary host pages. It must never
|
|
8
|
+
* restyle a host's own elements — every rule has to be scoped to
|
|
9
|
+
* `.frankie-root`. This guards the two ways that broke before: a Tailwind
|
|
10
|
+
* Preflight-style reset of bare elements, and (the one that actually shipped)
|
|
11
|
+
* the design tokens Tailwind emits onto a global `:root,:host`, which reset a
|
|
12
|
+
* Tailwind-v4 host's spacing/typography scale. See scripts/scope-host-safe.mjs.
|
|
13
|
+
*/
|
|
14
|
+
const root = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
15
|
+
const css = readFileSync(join(root, "dist", "frankie.css"), "utf8");
|
|
16
|
+
// The same string is embedded into the JS bundle for the fallback injector.
|
|
17
|
+
const embedded = readFileSync(join(root, "src", "generated", "css-content.ts"), "utf8");
|
|
18
|
+
|
|
19
|
+
/** Selectors at a rule boundary that would reach the host document. */
|
|
20
|
+
function globalSelectors(source: string): string[] {
|
|
21
|
+
const stripped = source.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
22
|
+
const hits: string[] = [];
|
|
23
|
+
// A selector list before `{`, at the start or just after `}` / `{`.
|
|
24
|
+
const rule = /(?:^|[}{])\s*([^@{}][^{}]*?)\s*\{/g;
|
|
25
|
+
let m: RegExpExecArray | null;
|
|
26
|
+
while ((m = rule.exec(stripped))) {
|
|
27
|
+
const selector = (m[1] ?? "").trim();
|
|
28
|
+
// Fine: anything scoped to the widget root, or Tailwind's registered
|
|
29
|
+
// custom properties / keyframes (those never target host elements).
|
|
30
|
+
if (selector.includes(".frankie-root")) continue;
|
|
31
|
+
// A host-reaching selector: a global root, a bare element, `*`, `html`,
|
|
32
|
+
// `body`, or an attribute/class not under `.frankie-root`.
|
|
33
|
+
if (/(^|,)\s*(:root|:host|\*|html|body|h[1-6]|p|ul|ol|li|a|img|button|input|figure|blockquote|table)\b/.test(selector)
|
|
34
|
+
|| /(^|,)\s*:(root|host)\b/.test(selector)) {
|
|
35
|
+
hits.push(selector);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return hits;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("widget stylesheet is host-safe", () => {
|
|
42
|
+
it("dist/frankie.css has no selector that reaches the host document", () => {
|
|
43
|
+
expect(globalSelectors(css)).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("never emits a global :root/:host token block (the LessonCast breakage)", () => {
|
|
47
|
+
expect(css).not.toMatch(/(?:^|[}{])\s*:(?:root|host)(?:\s*,\s*:(?:root|host))*\s*\{/);
|
|
48
|
+
// --spacing drives every Tailwind spacing utility; it must live on the
|
|
49
|
+
// widget root, never on the host's.
|
|
50
|
+
expect(css).toMatch(/\.frankie-root\{[^}]*--spacing:/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("the JS-embedded fallback copy is scoped the same way", () => {
|
|
54
|
+
expect(embedded).not.toMatch(/:(?:root|host)\s*[,{]/);
|
|
55
|
+
expect(embedded).toContain(".frankie-root");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -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(() =>
|
|
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
|
});
|