@frankieai/react 0.2.8 → 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.
- package/dist/frankie.css +1 -1
- package/dist/index.cjs +596 -203
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -10
- package/dist/index.d.ts +114 -10
- package/dist/index.js +579 -189
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/components/chat/ChatPanel.tsx +9 -1
- package/src/components/chat/Choices.tsx +52 -0
- package/src/components/chat/Composer.tsx +33 -18
- package/src/components/chat/FrankieChatPage.tsx +4 -1
- package/src/components/chat/FrankieInline.tsx +9 -3
- package/src/components/chat/FrankieWidget.tsx +12 -3
- package/src/components/chat/Markdown.tsx +15 -8
- package/src/components/chat/MessageBubble.tsx +53 -10
- 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/hooks/useFrankieChat.ts +57 -1
- package/src/index.ts +4 -1
- package/src/lib/choices.ts +49 -0
- 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 +83 -0
- package/src/lib/theme-inherit.test.ts +68 -0
- package/src/lib/theme-inherit.ts +89 -0
- package/src/styles/frankie.css +136 -3
- package/src/styles-ready.test.tsx +98 -0
- package/src/widget.test.tsx +218 -0
|
@@ -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
|
@@ -71,6 +71,13 @@
|
|
|
71
71
|
.frankie-root button:disabled { opacity:.5; cursor:not-allowed; }
|
|
72
72
|
@media(max-width:760px) { .frankie-history-desktop { display:none; }.frankie-mobile-only { display:inline-flex!important; }.frankie-header-caption { display:none; }.frankie-page-header { padding:6px 10px; }.frankie-history-heading button { border:0; background:transparent; min-width:44px; min-height:44px; align-items:center; justify-content:center; }.frankie-page-prompts { gap:8px; }.frankie-widget-panel { width:calc(100vw - 32px); } }
|
|
73
73
|
.frankie-root {
|
|
74
|
+
/* Sentinel. The widget mounts into someone else's bundler, where this sheet
|
|
75
|
+
can land after React has already rendered — a client-side navigation that
|
|
76
|
+
code-splits it is enough. `useStylesReady` probes this property and holds
|
|
77
|
+
the first paint, so the panel never shows as raw HTML. Keep the name and
|
|
78
|
+
the value in step with that hook. */
|
|
79
|
+
--frankie-styles-ready: 1;
|
|
80
|
+
|
|
74
81
|
--frankie-background: #ffffff;
|
|
75
82
|
--frankie-foreground: #0a0a0a;
|
|
76
83
|
--frankie-primary: #171717;
|
|
@@ -136,22 +143,60 @@
|
|
|
136
143
|
/* Typography for model output. Scoped so the host's prose styles do not leak
|
|
137
144
|
in and ours do not leak out. */
|
|
138
145
|
.frankie-prose > * + * {
|
|
139
|
-
margin-top: 0.
|
|
146
|
+
margin-top: 0.6rem;
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
.frankie-prose p {
|
|
143
150
|
margin: 0;
|
|
151
|
+
line-height: 1.6;
|
|
144
152
|
white-space: pre-wrap;
|
|
145
153
|
overflow-wrap: anywhere;
|
|
146
154
|
}
|
|
147
155
|
|
|
156
|
+
/* Headings give replies a real hierarchy instead of a wall of bold text.
|
|
157
|
+
Extra space above sets each section apart; the first one never adds a gap. */
|
|
158
|
+
.frankie-prose > .frankie-h {
|
|
159
|
+
margin-top: 1.15rem;
|
|
160
|
+
line-height: 1.3;
|
|
161
|
+
letter-spacing: -0.01em;
|
|
162
|
+
}
|
|
163
|
+
.frankie-prose > .frankie-h:first-child {
|
|
164
|
+
margin-top: 0;
|
|
165
|
+
}
|
|
166
|
+
.frankie-prose h3.frankie-h {
|
|
167
|
+
font-size: 1.06em;
|
|
168
|
+
font-weight: 700;
|
|
169
|
+
}
|
|
170
|
+
.frankie-prose h4.frankie-h {
|
|
171
|
+
font-size: 1em;
|
|
172
|
+
font-weight: 600;
|
|
173
|
+
}
|
|
174
|
+
.frankie-prose h5.frankie-h {
|
|
175
|
+
font-size: 0.92em;
|
|
176
|
+
font-weight: 600;
|
|
177
|
+
color: var(--frankie-muted-foreground);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.frankie-prose strong {
|
|
181
|
+
font-weight: 600;
|
|
182
|
+
}
|
|
183
|
+
|
|
148
184
|
.frankie-prose ul,
|
|
149
185
|
.frankie-prose ol {
|
|
150
186
|
margin: 0;
|
|
151
|
-
padding-left: 1.
|
|
187
|
+
padding-left: 1.4rem;
|
|
152
188
|
display: flex;
|
|
153
189
|
flex-direction: column;
|
|
154
|
-
gap: 0.
|
|
190
|
+
gap: 0.35rem;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.frankie-prose li {
|
|
194
|
+
line-height: 1.55;
|
|
195
|
+
padding-left: 0.15rem;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.frankie-prose li::marker {
|
|
199
|
+
color: var(--frankie-muted-foreground);
|
|
155
200
|
}
|
|
156
201
|
|
|
157
202
|
.frankie-prose ul {
|
|
@@ -353,6 +398,23 @@
|
|
|
353
398
|
|
|
354
399
|
/* Shared conversation surface: SDK pages, embedded panels and Frankie's app. */
|
|
355
400
|
.frankie-root.frankie-chat-panel { min-height:0; position:relative; }
|
|
401
|
+
|
|
402
|
+
/* Floating widget panel: a pronounced, layered lift plus a hairline ring so the
|
|
403
|
+
assistant clearly reads as an overlay hovering above the host page — not a
|
|
404
|
+
flat box flush with it. Scoped to the widget so the full-page /chat surface
|
|
405
|
+
(which is the page, not an overlay) stays shadow-free. */
|
|
406
|
+
.frankie-root.frankie-widget-panel {
|
|
407
|
+
box-shadow:
|
|
408
|
+
0 32px 72px -18px color-mix(in srgb, var(--frankie-primary) 22%, rgba(15,23,42,0.42)),
|
|
409
|
+
0 12px 28px -12px rgba(15,23,42,0.28),
|
|
410
|
+
0 0 0 1px color-mix(in srgb, var(--frankie-foreground) 8%, transparent);
|
|
411
|
+
}
|
|
412
|
+
.frankie-root.frankie-widget-panel.dark {
|
|
413
|
+
box-shadow:
|
|
414
|
+
0 34px 80px -16px rgba(0,0,0,0.72),
|
|
415
|
+
0 14px 32px -12px rgba(0,0,0,0.6),
|
|
416
|
+
0 0 0 1px color-mix(in srgb, var(--frankie-foreground) 12%, transparent);
|
|
417
|
+
}
|
|
356
418
|
.frankie-root .frankie-agent-context-wrap { flex:none; display:flex; justify-content:center; padding:12px 10px; }
|
|
357
419
|
.frankie-root .frankie-agent-context { position:relative; max-width:100%; color:var(--frankie-foreground); font-size:13px; }
|
|
358
420
|
.frankie-root .frankie-agent-context > summary { display:flex; align-items:center; justify-content:center; gap:10px; min-height:44px; padding:7px 13px; border:1px solid var(--frankie-border); border-radius:999px; background:var(--frankie-background); box-shadow:0 1px 3px #0000000d; cursor:pointer; list-style:none; white-space:nowrap; }
|
|
@@ -384,6 +446,14 @@
|
|
|
384
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; }
|
|
385
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; }
|
|
386
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; }
|
|
387
457
|
.frankie-root .frankie-history-desktop { width:290px; background:var(--frankie-background); padding:20px 12px; }
|
|
388
458
|
.frankie-root .frankie-history-collapsed { width:56px; padding:20px 8px; }
|
|
389
459
|
.frankie-root .frankie-new-chat { justify-content:flex-start; border:0; background:transparent; padding:8px 12px; font-size:15px; }
|
|
@@ -412,3 +482,66 @@
|
|
|
412
482
|
|
|
413
483
|
.frankie-root .frankie-agent-context > summary > span:first-of-type { border-right:1px solid var(--frankie-border); padding-right:10px; }
|
|
414
484
|
@media(max-width:760px) { .frankie-root .frankie-page-header button > span { display:none; } }
|
|
485
|
+
|
|
486
|
+
/* Interactive answer options offered via a frankie:choices block. Numbered,
|
|
487
|
+
full-width rows that read as pick-one buttons, matching the message surface. */
|
|
488
|
+
.frankie-root .frankie-choices { display:flex; flex-direction:column; gap:8px; margin-top:12px; }
|
|
489
|
+
.frankie-root .frankie-choice {
|
|
490
|
+
display:flex; align-items:center; gap:12px; width:100%; padding:11px 14px; text-align:left; cursor:pointer;
|
|
491
|
+
border:1px solid var(--frankie-border); border-radius:var(--frankie-radius); background:var(--frankie-background);
|
|
492
|
+
color:var(--frankie-foreground); font:inherit; font-size:14px; line-height:1.4;
|
|
493
|
+
transition:border-color .15s ease, background-color .15s ease, transform .12s ease;
|
|
494
|
+
}
|
|
495
|
+
.frankie-root .frankie-choice:hover:not(:disabled) {
|
|
496
|
+
border-color:color-mix(in srgb, var(--frankie-primary) 55%, var(--frankie-border));
|
|
497
|
+
background:color-mix(in srgb, var(--frankie-primary) 6%, var(--frankie-background));
|
|
498
|
+
}
|
|
499
|
+
.frankie-root .frankie-choice:active:not(:disabled) { transform:scale(.99); }
|
|
500
|
+
.frankie-root .frankie-choice:focus-visible { outline:none; box-shadow:0 0 0 2px color-mix(in srgb, var(--frankie-primary) 40%, transparent); }
|
|
501
|
+
.frankie-root .frankie-choice:disabled { opacity:.6; cursor:default; }
|
|
502
|
+
.frankie-root .frankie-choice-index {
|
|
503
|
+
flex:none; display:grid; place-items:center; width:24px; height:24px; border-radius:50%;
|
|
504
|
+
border:1px solid var(--frankie-border); background:var(--frankie-muted);
|
|
505
|
+
color:var(--frankie-muted-foreground); font-size:12px; font-weight:600;
|
|
506
|
+
transition:background-color .15s ease, color .15s ease, border-color .15s ease;
|
|
507
|
+
}
|
|
508
|
+
.frankie-root .frankie-choice:hover:not(:disabled) .frankie-choice-index {
|
|
509
|
+
background:var(--frankie-primary); border-color:var(--frankie-primary); color:var(--frankie-primary-foreground);
|
|
510
|
+
}
|
|
511
|
+
.frankie-root .frankie-choice-label { flex:1; min-width:0; }
|
|
512
|
+
.frankie-root .frankie-choice-go { flex:none; color:var(--frankie-muted-foreground); opacity:0; transform:translateX(-4px); transition:opacity .15s ease, transform .15s ease; }
|
|
513
|
+
.frankie-root .frankie-choice:hover:not(:disabled) .frankie-choice-go { opacity:1; transform:none; color:var(--frankie-primary); }
|
|
514
|
+
@media (prefers-reduced-motion: reduce) {
|
|
515
|
+
.frankie-root .frankie-choice, .frankie-root .frankie-choice-go, .frankie-root .frankie-choice-index { transition:none; }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/* "Something else" escape hatch under the offered options: visually set apart
|
|
519
|
+
(dashed, muted) so it reads as "none of these — let me type" rather than a
|
|
520
|
+
real option. It focuses the composer instead of sending. */
|
|
521
|
+
.frankie-root .frankie-choice-other { border-style:dashed; color:var(--frankie-muted-foreground); }
|
|
522
|
+
.frankie-root .frankie-choice-other .frankie-choice-index { background:transparent; }
|
|
523
|
+
.frankie-root .frankie-choice-other:hover:not(:disabled) { color:var(--frankie-foreground); }
|
|
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); }
|
|
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
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useRef } from "react";
|
|
4
|
+
import { __resetFrankieStylesForTests } from "./lib/ensure-frankie-styles.js";
|
|
5
|
+
import { useStylesReady } from "./lib/styles-ready.js";
|
|
6
|
+
|
|
7
|
+
/** Stand-in for a widget root: reports what the hook decided. */
|
|
8
|
+
function Probe({ timeoutMs, injectFallback }: { timeoutMs?: number; injectFallback?: boolean }) {
|
|
9
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
10
|
+
const ready = useStylesReady(ref, timeoutMs, injectFallback);
|
|
11
|
+
return (
|
|
12
|
+
<div ref={ref} data-testid="root" data-frankie-styles={ready ? "ready" : "pending"}>
|
|
13
|
+
{ready ? "panel" : null}
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Make `getComputedStyle` answer as a browser would with/without our sheet. */
|
|
19
|
+
function withSentinel(present: boolean | (() => boolean)) {
|
|
20
|
+
const real = window.getComputedStyle.bind(window);
|
|
21
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element, pseudo?: string | null) => {
|
|
22
|
+
const style = real(el, pseudo ?? undefined);
|
|
23
|
+
return {
|
|
24
|
+
...style,
|
|
25
|
+
getPropertyValue: (property: string) =>
|
|
26
|
+
property === "--frankie-styles-ready"
|
|
27
|
+
? ((typeof present === "function" ? present() : present) ? "1" : "")
|
|
28
|
+
: style.getPropertyValue(property),
|
|
29
|
+
} as CSSStyleDeclaration;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
__resetFrankieStylesForTests();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("useStylesReady", () => {
|
|
39
|
+
it("renders immediately when the stylesheet is already applied", () => {
|
|
40
|
+
withSentinel(true);
|
|
41
|
+
render(<Probe />);
|
|
42
|
+
// Resolved inside a layout effect, so nothing unstyled is ever painted.
|
|
43
|
+
expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready");
|
|
44
|
+
expect(screen.getByText("panel")).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("holds the first paint while the stylesheet is still in flight", () => {
|
|
48
|
+
withSentinel(false);
|
|
49
|
+
render(<Probe />);
|
|
50
|
+
const root = screen.getByTestId("root");
|
|
51
|
+
expect(root.dataset.frankieStyles).toBe("pending");
|
|
52
|
+
expect(screen.queryByText("panel")).toBeNull();
|
|
53
|
+
// Hidden, not unmounted — the sentinel has to stay measurable on this node.
|
|
54
|
+
expect(root.isConnected).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders as soon as a late stylesheet lands", async () => {
|
|
58
|
+
let applied = false;
|
|
59
|
+
withSentinel(() => applied);
|
|
60
|
+
render(<Probe />);
|
|
61
|
+
expect(screen.getByTestId("root").dataset.frankieStyles).toBe("pending");
|
|
62
|
+
|
|
63
|
+
applied = true; // the CSS chunk arrives
|
|
64
|
+
await waitFor(() => expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready"));
|
|
65
|
+
expect(screen.getByText("panel")).toBeTruthy();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("gives up and renders for a host that ships no Frankie stylesheet", async () => {
|
|
69
|
+
withSentinel(false);
|
|
70
|
+
render(<Probe timeoutMs={0} />);
|
|
71
|
+
// A Tailwind v4 host compiles the utilities itself and never sets the
|
|
72
|
+
// sentinel; it must still get a widget rather than an invisible one.
|
|
73
|
+
await waitFor(() => expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready"));
|
|
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
|
+
});
|
|
98
|
+
});
|