@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.
@@ -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
+ }
@@ -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.5rem;
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.15rem;
187
+ padding-left: 1.4rem;
152
188
  display: flex;
153
189
  flex-direction: column;
154
- gap: 0.25rem;
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
+ });