@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frankieai/react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "Embeddable, themeable chat widget for the Frankie onboarding agent — shadcn/ui components, works with or without Tailwind in the host app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"clsx": "^2.1.1",
|
|
32
32
|
"lucide-react": "^0.460.0",
|
|
33
33
|
"tailwind-merge": "^2.5.0",
|
|
34
|
-
"@frankieai/core": "0.2.
|
|
34
|
+
"@frankieai/core": "0.2.3"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@tailwindcss/cli": "^4.0.0",
|
|
@@ -46,8 +46,11 @@
|
|
|
46
46
|
"typescript": "^5.7.0",
|
|
47
47
|
"vitest": "^3.0.0"
|
|
48
48
|
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20"
|
|
51
|
+
},
|
|
49
52
|
"scripts": {
|
|
50
|
-
"build": "tsup && pnpm build:css",
|
|
53
|
+
"build": "pnpm build:css && node scripts/embed-css.mjs && tsup && pnpm build:css",
|
|
51
54
|
"build:css": "tailwindcss -i src/styles/frankie.css -o dist/frankie.css --minify",
|
|
52
55
|
"dev": "tsup --watch",
|
|
53
56
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -65,6 +65,9 @@ export function ChatPanel({
|
|
|
65
65
|
const features = config?.features;
|
|
66
66
|
const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
|
|
67
67
|
const [model, setModel] = useState<string | undefined>(undefined);
|
|
68
|
+
// A "Something else" choice focuses the composer through this handle.
|
|
69
|
+
const composerFocus = useRef<(() => void) | null>(null);
|
|
70
|
+
const focusComposer = () => composerFocus.current?.();
|
|
68
71
|
|
|
69
72
|
useEffect(() => {
|
|
70
73
|
const node = scrollRef.current;
|
|
@@ -138,7 +141,7 @@ export function ChatPanel({
|
|
|
138
141
|
))
|
|
139
142
|
) : (
|
|
140
143
|
<div className="frankie-message-list flex flex-col gap-4">
|
|
141
|
-
{messages.map((message) =>
|
|
144
|
+
{messages.map((message, index) =>
|
|
142
145
|
renderMessage ? (
|
|
143
146
|
<div key={message.id}>{renderMessage(message)}</div>
|
|
144
147
|
) : (
|
|
@@ -149,6 +152,10 @@ export function ChatPanel({
|
|
|
149
152
|
{...(config?.branding.avatarUrl ? { avatarUrl: config.branding.avatarUrl } : {})}
|
|
150
153
|
showToolActivity={config?.showToolActivity ?? true}
|
|
151
154
|
{...(renderTool ? { renderTool } : {})}
|
|
155
|
+
onChoose={send}
|
|
156
|
+
onRequestInput={focusComposer}
|
|
157
|
+
active={index === messages.length - 1}
|
|
158
|
+
busy={busy}
|
|
152
159
|
/>
|
|
153
160
|
),
|
|
154
161
|
)}
|
|
@@ -164,6 +171,7 @@ export function ChatPanel({
|
|
|
164
171
|
onSend={send}
|
|
165
172
|
onStop={stop}
|
|
166
173
|
busy={busy}
|
|
174
|
+
focusHandleRef={composerFocus}
|
|
167
175
|
disabled={loading || restoring || Boolean(configError)}
|
|
168
176
|
{...(placeholder ? { placeholder } : {})}
|
|
169
177
|
{...(autoFocus ? { autoFocus } : {})}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowRight, PencilLine } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Clickable answer options the agent offered via a `frankie:choices` block.
|
|
7
|
+
* Picking one sends it as the next message. A trailing "Something else" row
|
|
8
|
+
* doesn't send — it moves the caret to the composer so the user can type their
|
|
9
|
+
* own answer, while the offered options stay put in case they change their mind.
|
|
10
|
+
*/
|
|
11
|
+
export function Choices({
|
|
12
|
+
options,
|
|
13
|
+
onPick,
|
|
14
|
+
onSomethingElse,
|
|
15
|
+
disabled,
|
|
16
|
+
}: {
|
|
17
|
+
options: string[];
|
|
18
|
+
onPick: (choice: string) => void;
|
|
19
|
+
onSomethingElse?: () => void;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
}) {
|
|
22
|
+
if (options.length === 0) return null;
|
|
23
|
+
return (
|
|
24
|
+
<div className="frankie-choices" role="group" aria-label="Choose an option">
|
|
25
|
+
{options.map((option, index) => (
|
|
26
|
+
<button
|
|
27
|
+
key={`${index}-${option}`}
|
|
28
|
+
type="button"
|
|
29
|
+
className="frankie-choice"
|
|
30
|
+
disabled={disabled}
|
|
31
|
+
onClick={() => onPick(option)}
|
|
32
|
+
>
|
|
33
|
+
<span className="frankie-choice-index" aria-hidden>{index + 1}</span>
|
|
34
|
+
<span className="frankie-choice-label">{option}</span>
|
|
35
|
+
<ArrowRight className="frankie-choice-go size-4" aria-hidden />
|
|
36
|
+
</button>
|
|
37
|
+
))}
|
|
38
|
+
{onSomethingElse ? (
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
className="frankie-choice frankie-choice-other"
|
|
42
|
+
disabled={disabled}
|
|
43
|
+
onClick={onSomethingElse}
|
|
44
|
+
>
|
|
45
|
+
<span className="frankie-choice-index" aria-hidden><PencilLine className="size-3.5" /></span>
|
|
46
|
+
<span className="frankie-choice-label">Something else…</span>
|
|
47
|
+
<span className="frankie-choice-hint" aria-hidden>Type your own</span>
|
|
48
|
+
</button>
|
|
49
|
+
) : null}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { ArrowUp, Paperclip, Plus, Square, X } from "lucide-react";
|
|
4
|
-
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MutableRefObject } from "react";
|
|
5
5
|
import type { ChatAttachment } from "@frankieai/core";
|
|
6
6
|
import { Button } from "../ui/button.js";
|
|
7
7
|
import { Textarea } from "../ui/textarea.js";
|
|
@@ -31,6 +31,8 @@ export interface ComposerProps {
|
|
|
31
31
|
attachments?: ComposerAttachmentsConfig;
|
|
32
32
|
/** Enables a model dropdown when provided with at least one model. */
|
|
33
33
|
modelPicker?: ComposerModelPicker;
|
|
34
|
+
/** Populated with a function that focuses the input, so a "type your own" choice can jump here. */
|
|
35
|
+
focusHandleRef?: MutableRefObject<(() => void) | null>;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
interface PickedFile extends ChatAttachment {
|
|
@@ -39,13 +41,21 @@ interface PickedFile extends ChatAttachment {
|
|
|
39
41
|
|
|
40
42
|
const MAX_ROWS_PX = 140;
|
|
41
43
|
|
|
42
|
-
export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker }: ComposerProps) {
|
|
44
|
+
export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker, focusHandleRef }: ComposerProps) {
|
|
43
45
|
const [value, setValue] = useState("");
|
|
44
46
|
const [files, setFiles] = useState<PickedFile[]>([]);
|
|
45
47
|
const [fileError, setFileError] = useState<string | undefined>(undefined);
|
|
46
48
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
47
49
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
48
50
|
|
|
51
|
+
// Publish a focus handle so a "Something else" choice elsewhere in the panel
|
|
52
|
+
// can move the caret here without reaching into the DOM.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!focusHandleRef) return;
|
|
55
|
+
focusHandleRef.current = () => textareaRef.current?.focus();
|
|
56
|
+
return () => { focusHandleRef.current = null; };
|
|
57
|
+
}, [focusHandleRef]);
|
|
58
|
+
|
|
49
59
|
const canAttach = Boolean(attachments?.enabled);
|
|
50
60
|
const models = modelPicker?.models ?? [];
|
|
51
61
|
|
|
@@ -187,22 +197,6 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
|
|
|
187
197
|
aria-label="Message"
|
|
188
198
|
/>
|
|
189
199
|
|
|
190
|
-
{models.length > 0 ? (
|
|
191
|
-
<select
|
|
192
|
-
value={modelPicker?.value ?? models[0]?.id}
|
|
193
|
-
onChange={(event) => modelPicker?.onChange(event.target.value)}
|
|
194
|
-
disabled={disabled}
|
|
195
|
-
aria-label="Model"
|
|
196
|
-
className="frankie-focus h-9 max-w-[8rem] rounded-[var(--frankie-radius)] border border-border bg-background px-2 text-sm"
|
|
197
|
-
>
|
|
198
|
-
{models.map((model) => (
|
|
199
|
-
<option key={model.id} value={model.id}>
|
|
200
|
-
{model.label}
|
|
201
|
-
</option>
|
|
202
|
-
))}
|
|
203
|
-
</select>
|
|
204
|
-
) : null}
|
|
205
|
-
|
|
206
200
|
{busy ? (
|
|
207
201
|
<Button type="button" size="icon" variant="outline" onClick={onStop} aria-label="Stop">
|
|
208
202
|
<Square />
|
|
@@ -213,6 +207,27 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
|
|
|
213
207
|
</Button>
|
|
214
208
|
)}
|
|
215
209
|
</div>
|
|
210
|
+
|
|
211
|
+
{models.length > 0 ? (
|
|
212
|
+
<div className="frankie-composer-meta">
|
|
213
|
+
<label className="frankie-model-picker" title="Model">
|
|
214
|
+
<span className="frankie-sr-only">Model</span>
|
|
215
|
+
<select
|
|
216
|
+
value={modelPicker?.value ?? models[0]?.id}
|
|
217
|
+
onChange={(event) => modelPicker?.onChange(event.target.value)}
|
|
218
|
+
disabled={disabled}
|
|
219
|
+
aria-label="Model"
|
|
220
|
+
className="frankie-model-select"
|
|
221
|
+
>
|
|
222
|
+
{models.map((model) => (
|
|
223
|
+
<option key={model.id} value={model.id}>
|
|
224
|
+
{model.label}
|
|
225
|
+
</option>
|
|
226
|
+
))}
|
|
227
|
+
</select>
|
|
228
|
+
</label>
|
|
229
|
+
</div>
|
|
230
|
+
) : null}
|
|
216
231
|
</form>
|
|
217
232
|
);
|
|
218
233
|
}
|
|
@@ -5,6 +5,7 @@ import { ChevronsLeft, ChevronsRight, History, Menu, Plus, Settings, X } from "l
|
|
|
5
5
|
import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
|
|
6
6
|
import { FrankieProvider, useFrankie, useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
|
|
7
7
|
import { useFrankieChat } from "../../hooks/useFrankieChat.js";
|
|
8
|
+
import { useStylesReady } from "../../lib/styles-ready.js";
|
|
8
9
|
import { themeToCssVars, resolveMode } from "../../lib/theme.js";
|
|
9
10
|
import { FrankieApiError, type ChatConversation } from "../../lib/client.js";
|
|
10
11
|
import { FrankieInline } from "./FrankieInline.js";
|
|
@@ -104,7 +105,9 @@ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...pa
|
|
|
104
105
|
export function FrankieChatPage({ className, style, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus, showHistory = true, navigation, ...provider }: FrankieChatPageProps) {
|
|
105
106
|
const [id, setId] = useState(() => provider.conversationId ?? crypto.randomUUID());
|
|
106
107
|
const onNew = () => setId(crypto.randomUUID());
|
|
107
|
-
|
|
108
|
+
const pageRef = useRef<HTMLDivElement>(null);
|
|
109
|
+
const stylesReady = useStylesReady(pageRef, undefined, provider.injectStyles);
|
|
110
|
+
return <div ref={pageRef} className={["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" ")} style={{ height: "100%", minHeight: "min(36rem, 100dvh)", width: "100%", ...style, ...(stylesReady ? null : { opacity: 0 as const }) }} data-frankie-styles={stylesReady ? "ready" : "pending"}>
|
|
108
111
|
<FrankieProvider {...provider} conversationId={id} storageNamespace={(provider.storageNamespace ?? "frankie." + provider.publishableKey) + "." + id}>
|
|
109
112
|
<PageChrome showHistory={showHistory} navigation={navigation} currentId={id} onSelect={setId} onNew={onNew} header={header} footer={footer} emptyState={emptyState} renderMessage={renderMessage} renderTool={renderTool} placeholder={placeholder} autoFocus={autoFocus} />
|
|
110
113
|
</FrankieProvider>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import type
|
|
4
|
-
import { useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
|
|
3
|
+
import { useRef, type CSSProperties } from "react";
|
|
4
|
+
import { useFrankie, useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
|
|
5
|
+
import { useStylesReady } from "../../lib/styles-ready.js";
|
|
5
6
|
import { resolveMode, themeToCssVars } from "../../lib/theme.js";
|
|
6
7
|
import { cn } from "../../lib/utils.js";
|
|
7
8
|
import { ChatPanel, type ChatPanelProps } from "./ChatPanel.js";
|
|
@@ -21,13 +22,18 @@ export interface FrankieInlineProps extends ChatPanelProps {
|
|
|
21
22
|
* </div>
|
|
22
23
|
*/
|
|
23
24
|
export function FrankieInline({ className, style, ...panelProps }: FrankieInlineProps) {
|
|
25
|
+
const { injectStyles } = useFrankie();
|
|
24
26
|
const theme = useFrankieTheme();
|
|
25
27
|
const mode = resolveMode(theme?.mode);
|
|
28
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
const stylesReady = useStylesReady(rootRef, undefined, injectStyles);
|
|
26
30
|
return (
|
|
27
31
|
<div
|
|
32
|
+
ref={rootRef}
|
|
28
33
|
className={cn("frankie-root frankie-inline", mode === "dark" && "dark", className)}
|
|
29
|
-
style={{ ...themeToCssVars(theme), display: "flex", height: "100%", width: "100%", ...style }}
|
|
34
|
+
style={{ ...themeToCssVars(theme), display: "flex", height: "100%", width: "100%", ...style, ...(stylesReady ? null : { opacity: 0 as const }) }}
|
|
30
35
|
data-frankie-mode={mode}
|
|
36
|
+
data-frankie-styles={stylesReady ? "ready" : "pending"}
|
|
31
37
|
>
|
|
32
38
|
<ChatPanel {...panelProps} fill />
|
|
33
39
|
</div>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { MessageCircle, X } from "lucide-react";
|
|
4
|
-
import { useEffect, useState } from "react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
import { useFrankie, useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
|
|
6
|
+
import { useStylesReady } from "../../lib/styles-ready.js";
|
|
6
7
|
import { resolveMode, themeToCssVars } from "../../lib/theme.js";
|
|
7
8
|
import { cn } from "../../lib/utils.js";
|
|
8
9
|
import { Button } from "../ui/button.js";
|
|
@@ -38,8 +39,11 @@ export function FrankieWidget({
|
|
|
38
39
|
renderLauncher,
|
|
39
40
|
...panelProps
|
|
40
41
|
}: FrankieWidgetProps) {
|
|
41
|
-
const { config } = useFrankie();
|
|
42
|
+
const { config, injectStyles } = useFrankie();
|
|
42
43
|
const theme = useFrankieTheme();
|
|
44
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
// Never paint the panel as raw HTML while the host's CSS chunk is still in flight.
|
|
46
|
+
const stylesReady = useStylesReady(rootRef, undefined, injectStyles);
|
|
43
47
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
44
48
|
const open = controlledOpen ?? uncontrolledOpen;
|
|
45
49
|
|
|
@@ -65,9 +69,14 @@ export function FrankieWidget({
|
|
|
65
69
|
|
|
66
70
|
return (
|
|
67
71
|
<div
|
|
72
|
+
ref={rootRef}
|
|
68
73
|
className={cn("frankie-root frankie-widget", mode === "dark" && "dark")}
|
|
69
|
-
|
|
74
|
+
// `opacity` rather than `visibility` or unmounting: the sentinel has to stay
|
|
75
|
+
// measurable on this element, and the two alternatives both drop the widget
|
|
76
|
+
// out of the accessibility tree for the wait.
|
|
77
|
+
style={{ ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none", ...(stylesReady ? null : { opacity: 0 as const }) }}
|
|
70
78
|
data-frankie-mode={mode}
|
|
79
|
+
data-frankie-styles={stylesReady ? "ready" : "pending"}
|
|
71
80
|
>
|
|
72
81
|
<div className={cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end")}>
|
|
73
82
|
{open ? <div style={{ pointerEvents: "auto", maxHeight: "calc(100dvh - 100px)", overflow: "hidden" }}><ChatPanel {...panelProps} className={cn("frankie-widget-panel", panelProps.className)} onClose={() => setOpen(false)} autoFocus /></div> : null}
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Fragment, type ReactNode } from "react";
|
|
3
|
+
import { Fragment, memo, useMemo, type ReactNode } from "react";
|
|
4
4
|
import { cn } from "../../lib/utils.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* A deliberately small Markdown subset — paragraphs, lists, fenced
|
|
8
|
-
* italic, inline code and links.
|
|
7
|
+
* A deliberately small Markdown subset — headings, paragraphs, lists, fenced
|
|
8
|
+
* code, bold, italic, inline code and links.
|
|
9
9
|
*
|
|
10
10
|
* Everything is rendered as React elements; nothing goes through
|
|
11
11
|
* `dangerouslySetInnerHTML`. Model output lands in someone else's production
|
|
12
12
|
* page, so it is never trusted as markup.
|
|
13
|
+
*
|
|
14
|
+
* Memoized on `text`: while a reply streams, the parent re-renders every frame,
|
|
15
|
+
* but the block tree is only recomputed when the text actually grows — the
|
|
16
|
+
* settled messages above never re-parse at all.
|
|
13
17
|
*/
|
|
14
|
-
export function Markdown({ text, className }: { text: string; className?: string }) {
|
|
15
|
-
|
|
16
|
-
}
|
|
18
|
+
export const Markdown = memo(function Markdown({ text, className }: { text: string; className?: string }) {
|
|
19
|
+
const blocks = useMemo(() => renderBlocks(text), [text]);
|
|
20
|
+
return <div className={cn("frankie-prose", className)}>{blocks}</div>;
|
|
21
|
+
});
|
|
17
22
|
|
|
18
23
|
function renderBlocks(text: string): ReactNode[] {
|
|
19
24
|
const lines = text.split("\n");
|
|
@@ -75,11 +80,13 @@ function renderBlocks(text: string): ReactNode[] {
|
|
|
75
80
|
continue;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
const heading =
|
|
83
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
79
84
|
if (heading) {
|
|
80
85
|
flushParagraph();
|
|
81
86
|
flushList();
|
|
82
|
-
|
|
87
|
+
const level = (heading[1] ?? "#").length;
|
|
88
|
+
const Tag = level <= 1 ? "h3" : level === 2 ? "h4" : "h5";
|
|
89
|
+
blocks.push(<Tag key={`h${blocks.length}`} className="frankie-h">{renderInline(heading[2] ?? "")}</Tag>);
|
|
83
90
|
continue;
|
|
84
91
|
}
|
|
85
92
|
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { Check, Copy, Paperclip } from "lucide-react";
|
|
4
4
|
import "./message-actions.css";
|
|
5
|
-
import { useState, type ReactNode } from "react";
|
|
5
|
+
import { memo, useState, type ReactNode } from "react";
|
|
6
6
|
import { cn } from "../../lib/utils.js";
|
|
7
|
+
import { extractChoices } from "../../lib/choices.js";
|
|
7
8
|
import type { ChatMessage, ToolActivity } from "../../lib/messages.js";
|
|
8
9
|
import { Avatar } from "../ui/avatar.js";
|
|
10
|
+
import { Choices } from "./Choices.js";
|
|
9
11
|
import { Markdown } from "./Markdown.js";
|
|
10
12
|
import { ToolActivityList } from "./ToolActivityList.js";
|
|
11
13
|
|
|
@@ -15,14 +17,29 @@ export interface MessageBubbleProps {
|
|
|
15
17
|
avatarUrl?: string;
|
|
16
18
|
showToolActivity: boolean;
|
|
17
19
|
renderTool?: (tool: ToolActivity) => ReactNode;
|
|
20
|
+
/** Send a picked choice as the next message. Omitted when the host renders its own. */
|
|
21
|
+
onChoose?: (choice: string) => void;
|
|
22
|
+
/** Focus the composer so the user can type their own answer instead of picking. */
|
|
23
|
+
onRequestInput?: () => void;
|
|
24
|
+
/** True only for the newest message, so stale choices from earlier turns aren't clickable. */
|
|
25
|
+
active?: boolean;
|
|
26
|
+
/** A turn is in flight, so choices should be disabled. */
|
|
27
|
+
busy?: boolean;
|
|
18
28
|
}
|
|
19
29
|
|
|
20
|
-
|
|
30
|
+
// Memoized: during a stream only the message whose object identity changed (the
|
|
31
|
+
// one being written) re-renders. Every earlier bubble keeps its parsed markdown,
|
|
32
|
+
// which is what keeps a long transcript from re-laying-out on every token.
|
|
33
|
+
export const MessageBubble = memo(function MessageBubble({
|
|
21
34
|
message,
|
|
22
35
|
assistantName,
|
|
23
36
|
avatarUrl,
|
|
24
37
|
showToolActivity,
|
|
25
38
|
renderTool,
|
|
39
|
+
onChoose,
|
|
40
|
+
onRequestInput,
|
|
41
|
+
active,
|
|
42
|
+
busy,
|
|
26
43
|
}: MessageBubbleProps) {
|
|
27
44
|
if (message.role === "user") {
|
|
28
45
|
return (
|
|
@@ -50,31 +67,42 @@ export function MessageBubble({
|
|
|
50
67
|
);
|
|
51
68
|
}
|
|
52
69
|
|
|
53
|
-
|
|
70
|
+
// Lift any offered options out of the prose. While the reply is still
|
|
71
|
+
// streaming, a half-written block is hidden (options stays null) so the raw
|
|
72
|
+
// fence never flashes. Options become clickable only on the newest, settled
|
|
73
|
+
// message so answering an earlier turn's question can't jump the transcript.
|
|
74
|
+
const { body, options } = extractChoices(message.text);
|
|
75
|
+
const showChoices = Boolean(active && onChoose && options && message.status === "done");
|
|
76
|
+
// A running tool row is its own progress indicator; otherwise, while the turn
|
|
77
|
+
// is still streaming with no visible text yet, the model is thinking (before
|
|
78
|
+
// its first words, or in the pause after a tool result while it composes the
|
|
79
|
+
// answer). Show a skeleton there so those gaps never look frozen.
|
|
80
|
+
const toolsRunning = message.tools.some((tool) => tool.status === "running");
|
|
81
|
+
const waiting = message.status === "streaming" && body.length === 0 && !toolsRunning;
|
|
54
82
|
|
|
55
83
|
return (
|
|
56
84
|
<div className="frankie-assistant-message flex gap-2">
|
|
57
85
|
<div className="frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1">
|
|
58
86
|
<Avatar {...(avatarUrl ? { src: avatarUrl } : {})} name={assistantName} />
|
|
59
|
-
{
|
|
87
|
+
{body ? <MessageCopyButton text={body} /> : null}
|
|
60
88
|
</div>
|
|
61
89
|
<div className="frankie-assistant-content min-w-0 max-w-[85%]">
|
|
62
90
|
{message.status === "error" ? (
|
|
63
91
|
<p className="rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
|
64
92
|
{message.error}
|
|
65
93
|
</p>
|
|
66
|
-
) :
|
|
67
|
-
<
|
|
68
|
-
) :
|
|
69
|
-
|
|
70
|
-
)}
|
|
94
|
+
) : body ? (
|
|
95
|
+
<Markdown text={body} className="text-sm" />
|
|
96
|
+
) : null}
|
|
97
|
+
{showChoices ? <Choices options={options ?? []} onPick={onChoose!} {...(onRequestInput ? { onSomethingElse: onRequestInput } : {})} disabled={busy} /> : null}
|
|
71
98
|
{showToolActivity ? (
|
|
72
99
|
<ToolActivityList tools={message.tools} {...(renderTool ? { renderTool } : {})} />
|
|
73
100
|
) : null}
|
|
101
|
+
{waiting ? <Thinking /> : null}
|
|
74
102
|
</div>
|
|
75
103
|
</div>
|
|
76
104
|
);
|
|
77
|
-
}
|
|
105
|
+
});
|
|
78
106
|
|
|
79
107
|
function MessageCopyButton({ text }: { text: string }) {
|
|
80
108
|
const [copied, setCopied] = useState(false);
|
|
@@ -103,3 +131,18 @@ export function TypingDots() {
|
|
|
103
131
|
);
|
|
104
132
|
}
|
|
105
133
|
|
|
134
|
+
/**
|
|
135
|
+
* A shimmering skeleton shown while the reply is still being produced — before
|
|
136
|
+
* the first words, or during the pause after a tool result while the model
|
|
137
|
+
* composes its answer — so those gaps read as "loading" rather than frozen.
|
|
138
|
+
*/
|
|
139
|
+
export function Thinking() {
|
|
140
|
+
return (
|
|
141
|
+
<div className="frankie-skeleton" role="status" aria-label="Thinking">
|
|
142
|
+
<span className="frankie-skeleton-line" style={{ width: "62%" }} />
|
|
143
|
+
<span className="frankie-skeleton-line" style={{ width: "92%" }} />
|
|
144
|
+
<span className="frankie-skeleton-line" style={{ width: "48%" }} />
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Check, ChevronDown, Loader2, ShieldAlert, X } from "lucide-react";
|
|
3
|
+
import { Check, ChevronDown, Loader2, ShieldAlert, Wrench, X } from "lucide-react";
|
|
4
4
|
import { Badge } from "../ui/badge.js";
|
|
5
5
|
import type { ToolActivity } from "../../lib/messages.js";
|
|
6
6
|
|
|
@@ -18,6 +18,7 @@ export function ToolActivityList({ tools, renderTool }: ToolActivityListProps) {
|
|
|
18
18
|
<details className="frankie-tool-activity mt-2" open={running}>
|
|
19
19
|
<summary className="inline-flex min-h-7 cursor-pointer list-none items-center gap-1.5 rounded-md px-1 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground [&::-webkit-details-marker]:hidden">
|
|
20
20
|
<ChevronDown className="frankie-tool-chevron size-3.5 transition-transform" aria-hidden="true" />
|
|
21
|
+
{running ? <Loader2 className="size-3.5 animate-spin" aria-hidden="true" /> : <Wrench className="size-3.5" aria-hidden="true" />}
|
|
21
22
|
<span>{running ? "Working" : "Activity"}</span>
|
|
22
23
|
<span className="text-[11px]">· {tools.length} tool call{tools.length === 1 ? "" : "s"}</span>
|
|
23
24
|
</summary>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Generated by scripts/embed-css.mjs from dist/frankie.css — do not edit by hand.
|
|
2
|
+
export const FRANKIE_CSS_CONTENT: string = "/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-medium:500;--font-weight-semibold:600;--leading-tight:1.25;--radius-md:.375rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer components{.frankie-page-shell{background:var(--frankie-background);height:100%;min-height:0;color:var(--frankie-foreground);flex:1;display:flex}.frankie-page-main{flex-direction:column;flex:1;min-width:0;min-height:0;display:flex}.frankie-history-desktop{border-right:1px solid var(--frankie-border);background:var(--frankie-secondary);flex-direction:column;flex-shrink:0;gap:16px;width:260px;padding:20px 14px;transition:width .16s,padding .16s;display:flex;overflow:auto}.frankie-history-toggle{border:1px solid var(--frankie-border);background:var(--frankie-background);width:32px;height:32px;color:var(--frankie-muted-foreground);cursor:pointer;border-radius:8px;flex-shrink:0;justify-content:center;align-self:flex-end;align-items:center;display:flex}.frankie-history-toggle:hover{color:var(--frankie-foreground)}.frankie-history-collapsed{width:56px;padding:20px 8px;overflow:visible}.frankie-history-collapsed .frankie-history-toggle{align-self:center}.frankie-history-heading{justify-content:space-between;align-items:center;font-size:14px;display:flex}.frankie-new-chat{border:1px solid var(--frankie-border);background:var(--frankie-background);cursor:pointer;border-radius:10px;justify-content:center;align-items:center;gap:8px;min-height:44px;font-weight:600;display:flex}.frankie-history-search>span{color:var(--frankie-muted-foreground);margin-bottom:6px;font-size:12px;display:block}.frankie-history-search input{border:1px solid var(--frankie-border);background:var(--frankie-background);border-radius:8px;width:100%;min-height:40px;padding:8px 10px}.frankie-history-list{flex-direction:column;flex:1;gap:4px;display:flex}.frankie-history-list button{text-align:left;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:8px;flex-direction:column;align-items:flex-start;gap:5px;min-height:54px;padding:10px;display:flex}.frankie-history-list button span{text-overflow:ellipsis;white-space:nowrap;width:100%;font-size:13px;overflow:hidden}.frankie-history-list small{color:var(--frankie-muted-foreground);font-size:11px}.frankie-history-list button:hover,.frankie-history-list button[aria-current]{background:var(--frankie-background);border-color:var(--frankie-border)}.frankie-history-desktop>p,.frankie-history-dialog p{color:var(--frankie-muted-foreground);font-size:13px}.frankie-header-caption{color:var(--frankie-muted-foreground);font-size:12px}.frankie-page-header button{cursor:pointer;justify-content:center;align-items:center;gap:6px;min-width:40px;min-height:40px;display:inline-flex}.frankie-chat-settings{position:relative}.frankie-chat-settings summary{cursor:pointer;border-radius:8px;place-items:center;width:40px;height:40px;list-style:none;display:grid}.frankie-chat-settings summary::-webkit-details-marker{display:none}.frankie-chat-settings>div{z-index:5;border:1px solid var(--frankie-border);background:var(--frankie-background);border-radius:12px;gap:12px;width:min(280px,80vw);padding:16px;font-size:13px;display:grid;position:absolute;top:46px;right:0;box-shadow:0 10px 30px #0002}.frankie-chat-settings p{color:var(--frankie-muted-foreground);margin:0}.frankie-loaded-skills{width:100%;max-width:760px;color:var(--frankie-muted-foreground);font-size:12px}.frankie-loaded-skills summary{cursor:pointer;padding:6px 0}.frankie-loaded-skills ul{margin:0;padding:4px 0;list-style:none}.frankie-loaded-skills li{gap:8px;padding:4px 0;display:flex}.frankie-loaded-skills small{opacity:.7}.frankie-history-dialog{background:var(--frankie-secondary);width:min(320px,86vw);max-width:none;height:100dvh;max-height:none;color:var(--frankie-foreground);border:0;margin:0;padding:0}.frankie-history-dialog::backdrop{background:#0008}.frankie-history-dialog>aside{height:100%;padding:calc(20px + env(safe-area-inset-top)) 16px calc(20px + env(safe-area-inset-bottom));flex-direction:column;gap:16px;display:flex;overflow:auto}.frankie-mobile-only{display:none!important}.frankie-widget-panel{max-height:calc(100dvh - 100px)}.frankie-root button:focus-visible,.frankie-root summary:focus-visible,.frankie-root input:focus-visible{outline:2px solid var(--frankie-primary);outline-offset:3px}.frankie-root button:disabled{opacity:.5;cursor:not-allowed}@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{background:0 0;border:0;justify-content:center;align-items:center;min-width:44px;min-height:44px}.frankie-page-prompts{gap:8px}.frankie-widget-panel{width:calc(100vw - 32px)}}.frankie-root{--frankie-styles-ready:1;--frankie-background:#fff;--frankie-foreground:#0a0a0a;--frankie-primary:#171717;--frankie-primary-foreground:#fafafa;--frankie-secondary:#f5f5f5;--frankie-secondary-foreground:#171717;--frankie-muted:#f5f5f5;--frankie-muted-foreground:#737373;--frankie-border:#e5e5e5;--frankie-radius:.625rem;--frankie-font:ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;font-family:var(--frankie-font);color:var(--frankie-foreground);-webkit-font-smoothing:antialiased;line-height:1.5}.frankie-root.dark{--frankie-background:#0a0a0a;--frankie-foreground:#fafafa;--frankie-primary:#fafafa;--frankie-primary-foreground:#171717;--frankie-secondary:#262626;--frankie-secondary-foreground:#fafafa;--frankie-muted:#262626;--frankie-muted-foreground:#a3a3a3;--frankie-border:#262626}.frankie-root,.frankie-root *,.frankie-root :before,.frankie-root :after{box-sizing:border-box}.frankie-root button,.frankie-root textarea,.frankie-root input{font:inherit;color:inherit;margin:0}.frankie-root textarea{background-color:var(--frankie-background)}.frankie-focus:focus-visible{outline:2px solid var(--frankie-primary);outline-offset:2px}.frankie-scroll{scrollbar-width:thin;scrollbar-color:var(--frankie-border) transparent}.frankie-prose>*+*{margin-top:.6rem}.frankie-prose p{white-space:pre-wrap;overflow-wrap:anywhere;margin:0;line-height:1.6}.frankie-prose>.frankie-h{letter-spacing:-.01em;margin-top:1.15rem;line-height:1.3}.frankie-prose>.frankie-h:first-child{margin-top:0}.frankie-prose h3.frankie-h{font-size:1.06em;font-weight:700}.frankie-prose h4.frankie-h{font-size:1em;font-weight:600}.frankie-prose h5.frankie-h{color:var(--frankie-muted-foreground);font-size:.92em;font-weight:600}.frankie-prose strong{font-weight:600}.frankie-prose ul,.frankie-prose ol{flex-direction:column;gap:.35rem;margin:0;padding-left:1.4rem;display:flex}.frankie-prose li{padding-left:.15rem;line-height:1.55}.frankie-prose li::marker{color:var(--frankie-muted-foreground)}.frankie-prose ul{list-style:outside}.frankie-prose ol{list-style:decimal}.frankie-prose a{color:var(--frankie-primary);text-underline-offset:2px;text-decoration:underline}.frankie-prose code{background-color:var(--frankie-muted);border-radius:.25rem;padding:.1em .3em;font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,monospace;font-size:.85em}.frankie-prose pre{border-radius:var(--frankie-radius);background-color:var(--frankie-muted);margin:0;padding:.625rem .75rem;overflow-x:auto}.frankie-prose pre code{background:0 0;padding:0}.frankie-chat-page{background:radial-gradient(60rem 24rem at 50% -12rem, var(--frankie-primary), transparent 65%), var(--frankie-background);flex-direction:column;min-width:0;display:flex;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.frankie-chat-page{background:radial-gradient(60rem 24rem at 50% -12rem, color-mix(in srgb, var(--frankie-primary) 8%, transparent), transparent 65%), var(--frankie-background)}}.frankie-chat-page{color:var(--frankie-foreground)}.frankie-page-header{border-bottom:1px solid var(--frankie-border);background:var(--frankie-background);flex:0 0 56px;justify-content:space-between;align-items:center;gap:16px;min-height:56px;padding:0 24px;display:flex}@supports (color:color-mix(in lab, red, red)){.frankie-page-header{background:color-mix(in srgb, var(--frankie-background) 92%, transparent)}}.frankie-page-header{-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.frankie-page-header>div{align-items:center;gap:10px;display:flex}.frankie-page-header strong{font-size:.925rem;font-weight:650}.frankie-page-status{background:var(--frankie-primary);width:10px;height:10px;box-shadow:0 0 0 4px var(--frankie-primary);border-radius:999px}@supports (color:color-mix(in lab, red, red)){.frankie-page-status{box-shadow:0 0 0 4px color-mix(in srgb, var(--frankie-primary) 16%, transparent)}}.frankie-page-header button{border:1px solid var(--frankie-border);border-radius:calc(var(--frankie-radius) * .9);background:var(--frankie-secondary);cursor:pointer;min-height:36px;padding:0 13px}.frankie-page-header button:hover{border-color:var(--frankie-primary)}.frankie-page-canvas{flex:1;min-width:0;min-height:0;display:flex}.frankie-page-canvas>.frankie-inline{width:100%;max-width:56rem;margin:0 auto}.frankie-page-canvas .frankie-scroll{padding:2rem clamp(1rem,4vw,4rem) 1.25rem}.frankie-page-canvas .frankie-root>.border-t{background:linear-gradient(180deg, transparent, var(--frankie-background) 30%);border-top:0;padding:.75rem clamp(1rem,4vw,4rem) 1.5rem}.frankie-page-empty{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:1rem;width:min(100%,38rem);min-height:100%;margin:auto;padding:clamp(2rem,7vh,4.5rem) 1rem 2rem;display:flex}.frankie-page-avatar{border-radius:calc(var(--frankie-radius) * 1.4);background:var(--frankie-primary);width:48px;height:48px;color:var(--frankie-primary-foreground);place-items:center;font-weight:750;display:grid}.frankie-page-kicker{color:var(--frankie-primary);letter-spacing:.1em;text-transform:uppercase;margin:0 0 .4rem;font-size:.7rem;font-weight:750}.frankie-page-empty h1{letter-spacing:-.045em;margin:0;font-size:clamp(1.65rem,4vw,2.15rem);line-height:1.08}.frankie-page-empty h1+p{max-width:30rem;color:var(--frankie-muted-foreground);margin:.6rem auto 0}.frankie-page-prompts{gap:.55rem;width:100%;margin-top:.4rem;display:grid}.frankie-page-prompts button{border:1px solid var(--frankie-border);border-radius:calc(var(--frankie-radius) * 1.25);background:var(--frankie-secondary);text-align:left;cursor:pointer;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.65rem;min-height:60px;padding:.75rem .9rem;display:grid}.frankie-page-prompts button:hover{border-color:var(--frankie-primary)}@supports (color:color-mix(in lab, red, red)){.frankie-page-prompts button:hover{border-color:color-mix(in srgb, var(--frankie-primary) 58%, var(--frankie-border))}}.frankie-page-prompts button:hover{transform:translateY(-1px)}.frankie-page-prompts button>span{color:var(--frankie-muted-foreground);font-size:.68rem;font-weight:700}.frankie-page-prompts button>strong{font-size:.875rem}.frankie-page-prompts button>b{color:var(--frankie-primary);font-size:1rem}@media (max-width:640px){.frankie-page-header{flex-basis:52px;min-height:52px;padding:0 12px}.frankie-page-canvas .frankie-scroll{padding:1.25rem .75rem .5rem}.frankie-page-canvas .frankie-root>.border-t{padding:.65rem .75rem calc(.65rem + env(safe-area-inset-bottom))}}}@layer utilities;@scope(.frankie-root){.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.isolate{isolation:isolate}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mt-0{margin-top:0}.mt-0\\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.size-1{width:var(--spacing);height:var(--spacing)}.size-1\\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-full{width:100%;height:100%}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-\\[32rem\\]{height:32rem}.h-full{height:100%}.h-px{height:1px}.min-h-7{min-height:calc(var(--spacing) * 7)}.min-h-\\[2\\.25rem\\]{min-height:2.25rem}.w-\\[22rem\\]{width:22rem}.w-full{width:100%}.max-w-\\[12rem\\]{max-width:12rem}.max-w-\\[14rem\\]{max-width:14rem}.max-w-\\[85\\%\\]{max-width:85%}.max-w-\\[calc\\(100vw-2rem\\)\\]{max-width:calc(100vw - 2rem)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-1\\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.rounded{border-radius:.25rem}.rounded-\\[calc\\(var\\(--frankie-radius\\)\\+4px\\)\\]{border-radius:calc(var(--frankie-radius) + 4px)}.rounded-\\[calc\\(var\\(--frankie-radius\\)-2px\\)\\]{border-radius:calc(var(--frankie-radius) - 2px)}.rounded-\\[var\\(--frankie-radius\\)\\]{border-radius:var(--frankie-radius)}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.rounded-none{border-radius:0}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-border{border-color:var(--frankie-border)}.border-transparent{border-color:#0000}.bg-background{background-color:var(--frankie-background)}.bg-border{background-color:var(--frankie-border)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-muted{background-color:var(--frankie-muted)}.bg-muted-foreground{background-color:var(--frankie-muted-foreground)}.bg-primary{background-color:var(--frankie-primary)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-secondary{background-color:var(--frankie-secondary)}.object-cover{object-fit:cover}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:0}.py-0\\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.pl-2{padding-left:calc(var(--spacing) * 2)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\\[0\\.625rem\\]{font-size:.625rem}.text-\\[0\\.6875rem\\]{font-size:.6875rem}.text-\\[11px\\]{font-size:11px}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-nowrap{white-space:nowrap}.text-emerald-600{color:var(--color-emerald-600)}.text-foreground{color:var(--frankie-foreground)}.text-muted-foreground{color:var(--frankie-muted-foreground)}.text-primary{color:var(--frankie-primary)}.text-primary-foreground{color:var(--frankie-primary-foreground)}.text-red-600{color:var(--color-red-600)}.text-secondary-foreground{color:var(--frankie-secondary-foreground)}.underline-offset-4{text-underline-offset:4px}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.placeholder\\:text-muted-foreground::placeholder{color:var(--frankie-muted-foreground)}@media (hover:hover){.hover\\:border-border:hover{border-color:var(--frankie-border)}.hover\\:bg-muted:hover{background-color:var(--frankie-muted)}.hover\\:text-foreground:hover{color:var(--frankie-foreground)}.hover\\:underline:hover{text-decoration-line:underline}.hover\\:opacity-90:hover{opacity:.9}.hover\\:opacity-100:hover{opacity:1}}.focus-visible\\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\\:ring-primary:focus-visible{--tw-ring-color:var(--frankie-primary)}.focus-visible\\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\\:pointer-events-none:disabled{pointer-events:none}.disabled\\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\\:opacity-50:disabled{opacity:.5}.dark\\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\\:text-red-400:where(.dark,.dark *){color:var(--color-red-400)}.\\[\\&_svg\\]\\:pointer-events-none svg{pointer-events:none}.\\[\\&_svg\\]\\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\\[\\&_svg\\]\\:shrink-0 svg{flex-shrink:0}.\\[\\&\\:\\:-webkit-details-marker\\]\\:hidden::-webkit-details-marker{display:none}}.frankie-root.frankie-chat-panel{min-height:0;position:relative}.frankie-root.frankie-widget-panel{box-shadow:0 32px 72px -18px var(--frankie-primary), 0 12px 28px -12px #0f172a47, 0 0 0 1px var(--frankie-foreground)}@supports (color:color-mix(in lab, red, red)){.frankie-root.frankie-widget-panel{box-shadow:0 32px 72px -18px color-mix(in srgb, var(--frankie-primary) 22%, #0f172a6b), 0 12px 28px -12px #0f172a47, 0 0 0 1px color-mix(in srgb, var(--frankie-foreground) 8%, transparent)}}.frankie-root.frankie-widget-panel.dark{box-shadow:0 34px 80px -16px #000000b8, 0 14px 32px -12px #0009, 0 0 0 1px var(--frankie-foreground)}@supports (color:color-mix(in lab, red, red)){.frankie-root.frankie-widget-panel.dark{box-shadow:0 34px 80px -16px #000000b8, 0 14px 32px -12px #0009, 0 0 0 1px color-mix(in srgb, var(--frankie-foreground) 12%, transparent)}}.frankie-root .frankie-agent-context-wrap{flex:none;justify-content:center;padding:12px 10px;display:flex}.frankie-root .frankie-agent-context{max-width:100%;color:var(--frankie-foreground);font-size:13px;position:relative}.frankie-root .frankie-agent-context>summary{border:1px solid var(--frankie-border);background:var(--frankie-background);cursor:pointer;white-space:nowrap;border-radius:999px;justify-content:center;align-items:center;gap:10px;min-height:44px;padding:7px 13px;list-style:none;display:flex;box-shadow:0 1px 3px #0000000d}.frankie-root .frankie-agent-context>summary::-webkit-details-marker{display:none}.frankie-root .frankie-agent-context>summary>svg{color:var(--frankie-muted-foreground);flex:none}.frankie-root .frankie-context-count{background:var(--frankie-muted);color:var(--frankie-muted-foreground);border-radius:999px;padding:2px 10px;font-size:12px}.frankie-root .frankie-context-status{color:var(--frankie-muted-foreground);align-items:center;gap:6px;font-size:12px;display:flex}.frankie-root .frankie-context-status i{background:currentColor;border-radius:50%;width:7px;height:7px}.frankie-root .frankie-context-status[data-active=true]{color:#00976b}.frankie-root .frankie-context-detail{z-index:10;border:1px solid var(--frankie-border);background:var(--frankie-background);border-radius:16px;width:340px;max-width:calc(100vw - 32px);padding:18px;position:absolute;top:calc(100% + 10px);left:50%;transform:translate(-50%);box-shadow:0 12px 32px #00000016}.frankie-root .frankie-context-detail p{color:var(--frankie-muted-foreground);margin:10px 0 0;font-size:12px;line-height:1.6}.frankie-root .frankie-context-detail ul{max-height:220px;margin:12px 0;padding:0;list-style:none;overflow:auto}.frankie-root .frankie-context-detail li{border-bottom:1px solid var(--frankie-border);overflow-wrap:anywhere;justify-content:space-between;gap:12px;padding:8px 0;display:flex}.frankie-root .frankie-context-detail small{color:var(--frankie-muted-foreground);flex:none}.frankie-root .frankie-sr-only{clip:rect(0,0,0,0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.frankie-root.frankie-chat-panel-fill{--frankie-canvas:var(--frankie-background)}@supports (color:color-mix(in lab, red, red)){.frankie-root.frankie-chat-panel-fill{--frankie-canvas:color-mix(in srgb,var(--frankie-background) 60%,var(--frankie-muted))}}.frankie-root.frankie-chat-panel-fill{background:var(--frankie-canvas)}.frankie-root.frankie-chat-panel-fill .frankie-agent-context-wrap{padding:44px 16px 26px}.frankie-root.frankie-chat-panel-fill .frankie-transcript{padding:14px max(24px,50% - 480px) 24px}.frankie-root.frankie-chat-panel-fill .frankie-message-list{gap:38px}.frankie-root.frankie-chat-panel-fill .frankie-user-bubble{background:var(--frankie-muted);color:var(--frankie-foreground);white-space:pre-wrap;overflow-wrap:anywhere;border-radius:24px;padding:12px 20px;font-size:15px}.frankie-root.frankie-chat-panel-fill .frankie-assistant-content{flex:1;max-width:100%;font-size:15px;line-height:1.7}.frankie-root.frankie-chat-panel-fill .frankie-assistant-message{gap:14px}.frankie-root .frankie-thinking{color:var(--frankie-muted-foreground);gap:5px}.frankie-root .frankie-thinking-label{margin-right:6px;font-size:14px}.frankie-root.frankie-chat-panel-fill .frankie-composer-footer{background:0 0;border:0;flex:none;padding:16px max(24px,50% - 500px) 24px}.frankie-root.frankie-chat-panel-fill .frankie-composer{border:1px solid var(--frankie-border);background:var(--frankie-background);border-radius:28px;padding:14px;box-shadow:0 2px 4px #0000000a}.frankie-root.frankie-chat-panel-fill .frankie-composer:focus-within{border-color:var(--frankie-foreground)}@supports (color:color-mix(in lab, red, red)){.frankie-root.frankie-chat-panel-fill .frankie-composer:focus-within{border-color:color-mix(in srgb,var(--frankie-foreground) 32%,var(--frankie-border))}}.frankie-root.frankie-chat-panel-fill .frankie-composer:focus-within{box-shadow:0 0 0 2px var(--frankie-primary)}@supports (color:color-mix(in lab, red, red)){.frankie-root.frankie-chat-panel-fill .frankie-composer:focus-within{box-shadow:0 0 0 2px color-mix(in srgb,var(--frankie-primary) 8%,transparent)}}.frankie-root.frankie-chat-panel-fill .frankie-composer-controls{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.frankie-root.frankie-chat-panel-fill .frankie-composer textarea{resize:none;width:100%;min-height:32px;max-height:140px;color:var(--frankie-foreground);box-shadow:none;background:0 0;border:0;border-radius:0;outline:none;flex:0 0 100%;order:-1;margin-bottom:4px;padding:2px 6px;font-size:16px}.frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label=Send],.frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label=Stop]{background:var(--frankie-primary);width:42px;height:42px;color:var(--frankie-primary-foreground);border:0;border-radius:50%;margin-left:auto}.frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label=Attach\\ files]{border-radius:50%;width:42px;height:42px}.frankie-root .frankie-composer-meta{align-items:center;gap:8px;margin-top:6px;padding:0 2px;display:flex}.frankie-root .frankie-model-picker{align-items:center;min-width:0;display:inline-flex}.frankie-root .frankie-model-select{appearance:none;max-width:12rem;color:var(--frankie-muted-foreground);font:inherit;cursor:pointer;text-overflow:ellipsis;background-color:#0000;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-position:right 6px center;background-repeat:no-repeat;background-size:11px;border:0;border-radius:8px;padding:5px 22px 5px 8px;font-size:12px;font-weight:500;line-height:1;transition:background-color .12s,color .12s}.frankie-root .frankie-model-select:hover{background-color:var(--frankie-muted);color:var(--frankie-foreground)}.frankie-root .frankie-model-select:disabled{opacity:.5;cursor:not-allowed}.frankie-root .frankie-model-select option{color:var(--frankie-foreground);background:var(--frankie-background);font-weight:400}.frankie-root .frankie-history-desktop{background:var(--frankie-background);width:290px;padding:20px 12px}.frankie-root .frankie-history-collapsed{width:56px;padding:20px 8px}.frankie-root .frankie-new-chat{background:0 0;border:0;justify-content:flex-start;padding:8px 12px;font-size:15px}.frankie-root .frankie-history-heading{color:var(--frankie-muted-foreground);padding:0 12px;font-size:12px;font-weight:400}.frankie-root .frankie-history-list button{min-height:44px;padding:10px 12px}.frankie-root .frankie-history-list button span{font-size:14px}.frankie-root .frankie-history-list button[aria-current],.frankie-root .frankie-history-list button:hover{background:var(--frankie-muted);border-color:#0000}.frankie-root .frankie-history-list small{display:none}.frankie-root .frankie-page-header{background:var(--frankie-background);height:56px;min-height:56px}.frankie-root .frankie-page-header>div{min-width:0}.frankie-root .frankie-thread-title{text-overflow:ellipsis;white-space:nowrap;max-width:55vw;font-weight:500;overflow:hidden}.frankie-root .frankie-page-header button{background:0 0;border-color:#0000}.frankie-root .frankie-page-canvas>.frankie-inline{max-width:none}@media (max-width:760px){.frankie-root.frankie-chat-panel-fill .frankie-agent-context-wrap{padding:20px 8px 14px}.frankie-root.frankie-chat-panel-fill .frankie-transcript{padding:12px 16px 16px}.frankie-root.frankie-chat-panel-fill .frankie-composer-footer{padding:10px 12px calc(12px + env(safe-area-inset-bottom))}.frankie-root .frankie-agent-context>summary{gap:6px;padding:7px 9px;font-size:12px}.frankie-root .frankie-context-count{padding:2px 7px;font-size:11px}.frankie-root .frankie-context-status{gap:4px;font-size:11px}.frankie-root .frankie-agent-context>summary>svg{width:14px}.frankie-root .frankie-thread-title{max-width:42vw}}.frankie-root .frankie-agent-context>summary>svg:first-child{box-sizing:content-box;background:var(--frankie-muted);border-radius:50%;padding:4px}.frankie-root.frankie-chat-panel-fill .frankie-agent-context>summary{font-size:14px}.frankie-root .frankie-agent-context>summary>span:first-of-type{border-right:1px solid var(--frankie-border);padding-right:10px}@media (max-width:760px){.frankie-root .frankie-page-header button>span{display:none}}.frankie-root .frankie-choices{flex-direction:column;gap:8px;margin-top:12px;display:flex}.frankie-root .frankie-choice{text-align:left;cursor:pointer;border:1px solid var(--frankie-border);border-radius:var(--frankie-radius);background:var(--frankie-background);width:100%;color:var(--frankie-foreground);font:inherit;align-items:center;gap:12px;padding:11px 14px;font-size:14px;line-height:1.4;transition:border-color .15s,background-color .15s,transform .12s;display:flex}.frankie-root .frankie-choice:hover:not(:disabled){border-color:var(--frankie-primary)}@supports (color:color-mix(in lab, red, red)){.frankie-root .frankie-choice:hover:not(:disabled){border-color:color-mix(in srgb, var(--frankie-primary) 55%, var(--frankie-border))}}.frankie-root .frankie-choice:hover:not(:disabled){background:var(--frankie-primary)}@supports (color:color-mix(in lab, red, red)){.frankie-root .frankie-choice:hover:not(:disabled){background:color-mix(in srgb, var(--frankie-primary) 6%, var(--frankie-background))}}.frankie-root .frankie-choice:active:not(:disabled){transform:scale(.99)}.frankie-root .frankie-choice:focus-visible{box-shadow:0 0 0 2px var(--frankie-primary);outline:none}@supports (color:color-mix(in lab, red, red)){.frankie-root .frankie-choice:focus-visible{box-shadow:0 0 0 2px color-mix(in srgb, var(--frankie-primary) 40%, transparent)}}.frankie-root .frankie-choice:disabled{opacity:.6;cursor:default}.frankie-root .frankie-choice-index{border:1px solid var(--frankie-border);background:var(--frankie-muted);width:24px;height:24px;color:var(--frankie-muted-foreground);border-radius:50%;flex:none;place-items:center;font-size:12px;font-weight:600;transition:background-color .15s,color .15s,border-color .15s;display:grid}.frankie-root .frankie-choice:hover:not(:disabled) .frankie-choice-index{background:var(--frankie-primary);border-color:var(--frankie-primary);color:var(--frankie-primary-foreground)}.frankie-root .frankie-choice-label{flex:1;min-width:0}.frankie-root .frankie-choice-go{color:var(--frankie-muted-foreground);opacity:0;flex:none;transition:opacity .15s,transform .15s;transform:translate(-4px)}.frankie-root .frankie-choice:hover:not(:disabled) .frankie-choice-go{opacity:1;color:var(--frankie-primary);transform:none}@media (prefers-reduced-motion:reduce){.frankie-root .frankie-choice,.frankie-root .frankie-choice-go,.frankie-root .frankie-choice-index{transition:none}}.frankie-root .frankie-choice-other{color:var(--frankie-muted-foreground);border-style:dashed}.frankie-root .frankie-choice-other .frankie-choice-index{background:0 0}.frankie-root .frankie-choice-other:hover:not(:disabled){color:var(--frankie-foreground)}.frankie-root .frankie-choice-other:hover:not(:disabled) .frankie-choice-index{background:var(--frankie-muted);border-color:var(--frankie-border);color:var(--frankie-foreground)}.frankie-root .frankie-choice-hint{color:var(--frankie-muted-foreground);flex:none;font-size:12px}.frankie-root .frankie-skeleton{flex-direction:column;gap:8px;max-width:min(420px,100%);padding:4px 0 6px;display:flex}.frankie-root .frankie-skeleton-line{background:linear-gradient(90deg, var(--frankie-muted-foreground) 25%, var(--frankie-muted-foreground) 37%, var(--frankie-muted-foreground) 63%);border-radius:6px;height:12px}@supports (color:color-mix(in lab, red, red)){.frankie-root .frankie-skeleton-line{background:linear-gradient(90deg, color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 25%, color-mix(in srgb, var(--frankie-muted-foreground) 26%, transparent) 37%, color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 63%)}}.frankie-root .frankie-skeleton-line{background-size:400% 100%;animation:1.4s infinite frankie-shimmer}@keyframes frankie-shimmer{0%{background-position:100%}to{background-position:0}}@keyframes frankie-skeleton-pulse{0%,to{opacity:.45}50%{opacity:.85}}@media (prefers-reduced-motion:reduce){.frankie-root .frankie-skeleton-line{background:var(--frankie-muted-foreground)}@supports (color:color-mix(in lab, red, red)){.frankie-root .frankie-skeleton-line{background:color-mix(in srgb, var(--frankie-muted-foreground) 16%, transparent)}}.frankie-root .frankie-skeleton-line{animation:1.6s ease-in-out infinite frankie-skeleton-pulse}}@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-font-weight{syntax:\"*\";inherits:false}@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:\"*\";inherits:false}@property --tw-shadow-alpha{syntax:\"<percentage>\";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:\"*\";inherits:false}@property --tw-inset-shadow-alpha{syntax:\"<percentage>\";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:\"*\";inherits:false}@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:\"*\";inherits:false}@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:\"*\";inherits:false}@property --tw-ring-offset-width{syntax:\"<length>\";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:\"*\";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-ease{syntax:\"*\";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}";
|