@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.
@@ -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
+ }
@@ -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,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(() => 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
  });