@frankieai/react 0.2.8 → 0.2.9

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.
@@ -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; }
@@ -412,3 +474,44 @@
412
474
 
413
475
  .frankie-root .frankie-agent-context > summary > span:first-of-type { border-right:1px solid var(--frankie-border); padding-right:10px; }
414
476
  @media(max-width:760px) { .frankie-root .frankie-page-header button > span { display:none; } }
477
+
478
+ /* Interactive answer options offered via a frankie:choices block. Numbered,
479
+ full-width rows that read as pick-one buttons, matching the message surface. */
480
+ .frankie-root .frankie-choices { display:flex; flex-direction:column; gap:8px; margin-top:12px; }
481
+ .frankie-root .frankie-choice {
482
+ display:flex; align-items:center; gap:12px; width:100%; padding:11px 14px; text-align:left; cursor:pointer;
483
+ border:1px solid var(--frankie-border); border-radius:var(--frankie-radius); background:var(--frankie-background);
484
+ color:var(--frankie-foreground); font:inherit; font-size:14px; line-height:1.4;
485
+ transition:border-color .15s ease, background-color .15s ease, transform .12s ease;
486
+ }
487
+ .frankie-root .frankie-choice:hover:not(:disabled) {
488
+ border-color:color-mix(in srgb, var(--frankie-primary) 55%, var(--frankie-border));
489
+ background:color-mix(in srgb, var(--frankie-primary) 6%, var(--frankie-background));
490
+ }
491
+ .frankie-root .frankie-choice:active:not(:disabled) { transform:scale(.99); }
492
+ .frankie-root .frankie-choice:focus-visible { outline:none; box-shadow:0 0 0 2px color-mix(in srgb, var(--frankie-primary) 40%, transparent); }
493
+ .frankie-root .frankie-choice:disabled { opacity:.6; cursor:default; }
494
+ .frankie-root .frankie-choice-index {
495
+ flex:none; display:grid; place-items:center; width:24px; height:24px; border-radius:50%;
496
+ border:1px solid var(--frankie-border); background:var(--frankie-muted);
497
+ color:var(--frankie-muted-foreground); font-size:12px; font-weight:600;
498
+ transition:background-color .15s ease, color .15s ease, border-color .15s ease;
499
+ }
500
+ .frankie-root .frankie-choice:hover:not(:disabled) .frankie-choice-index {
501
+ background:var(--frankie-primary); border-color:var(--frankie-primary); color:var(--frankie-primary-foreground);
502
+ }
503
+ .frankie-root .frankie-choice-label { flex:1; min-width:0; }
504
+ .frankie-root .frankie-choice-go { flex:none; color:var(--frankie-muted-foreground); opacity:0; transform:translateX(-4px); transition:opacity .15s ease, transform .15s ease; }
505
+ .frankie-root .frankie-choice:hover:not(:disabled) .frankie-choice-go { opacity:1; transform:none; color:var(--frankie-primary); }
506
+ @media (prefers-reduced-motion: reduce) {
507
+ .frankie-root .frankie-choice, .frankie-root .frankie-choice-go, .frankie-root .frankie-choice-index { transition:none; }
508
+ }
509
+
510
+ /* "Something else" escape hatch under the offered options: visually set apart
511
+ (dashed, muted) so it reads as "none of these — let me type" rather than a
512
+ real option. It focuses the composer instead of sending. */
513
+ .frankie-root .frankie-choice-other { border-style:dashed; color:var(--frankie-muted-foreground); }
514
+ .frankie-root .frankie-choice-other .frankie-choice-index { background:transparent; }
515
+ .frankie-root .frankie-choice-other:hover:not(:disabled) { color:var(--frankie-foreground); }
516
+ .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
+ .frankie-root .frankie-choice-hint { flex:none; font-size:12px; color:var(--frankie-muted-foreground); }
@@ -0,0 +1,71 @@
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 { useStylesReady } from "./lib/styles-ready.js";
5
+
6
+ /** Stand-in for a widget root: reports what the hook decided. */
7
+ function Probe({ timeoutMs }: { timeoutMs?: number }) {
8
+ const ref = useRef<HTMLDivElement>(null);
9
+ const ready = useStylesReady(ref, timeoutMs);
10
+ return (
11
+ <div ref={ref} data-testid="root" data-frankie-styles={ready ? "ready" : "pending"}>
12
+ {ready ? "panel" : null}
13
+ </div>
14
+ );
15
+ }
16
+
17
+ /** Make `getComputedStyle` answer as a browser would with/without our sheet. */
18
+ function withSentinel(present: boolean | (() => boolean)) {
19
+ const real = window.getComputedStyle.bind(window);
20
+ vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element, pseudo?: string | null) => {
21
+ const style = real(el, pseudo ?? undefined);
22
+ return {
23
+ ...style,
24
+ getPropertyValue: (property: string) =>
25
+ property === "--frankie-styles-ready"
26
+ ? ((typeof present === "function" ? present() : present) ? "1" : "")
27
+ : style.getPropertyValue(property),
28
+ } as CSSStyleDeclaration;
29
+ });
30
+ }
31
+
32
+ afterEach(() => vi.restoreAllMocks());
33
+
34
+ describe("useStylesReady", () => {
35
+ it("renders immediately when the stylesheet is already applied", () => {
36
+ withSentinel(true);
37
+ render(<Probe />);
38
+ // Resolved inside a layout effect, so nothing unstyled is ever painted.
39
+ expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready");
40
+ expect(screen.getByText("panel")).toBeTruthy();
41
+ });
42
+
43
+ it("holds the first paint while the stylesheet is still in flight", () => {
44
+ withSentinel(false);
45
+ render(<Probe />);
46
+ const root = screen.getByTestId("root");
47
+ expect(root.dataset.frankieStyles).toBe("pending");
48
+ expect(screen.queryByText("panel")).toBeNull();
49
+ // Hidden, not unmounted — the sentinel has to stay measurable on this node.
50
+ expect(root.isConnected).toBe(true);
51
+ });
52
+
53
+ it("renders as soon as a late stylesheet lands", async () => {
54
+ let applied = false;
55
+ withSentinel(() => applied);
56
+ render(<Probe />);
57
+ expect(screen.getByTestId("root").dataset.frankieStyles).toBe("pending");
58
+
59
+ applied = true; // the CSS chunk arrives
60
+ await waitFor(() => expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready"));
61
+ expect(screen.getByText("panel")).toBeTruthy();
62
+ });
63
+
64
+ it("gives up and renders for a host that ships no Frankie stylesheet", async () => {
65
+ withSentinel(false);
66
+ render(<Probe timeoutMs={0} />);
67
+ // A Tailwind v4 host compiles the utilities itself and never sets the
68
+ // sentinel; it must still get a widget rather than an invisible one.
69
+ await waitFor(() => expect(screen.getByTestId("root").dataset.frankieStyles).toBe("ready"));
70
+ });
71
+ });