@frockbot/applet-sdk 0.0.0 → 0.3.13

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,427 @@
1
+ /**
2
+ * `@frockbot/applet-sdk/kit` — the only components an Applet renders.
3
+ *
4
+ * Fourteen components on nine semantic tokens. They are versioned once here
5
+ * and never copied into an Applet (ADR 0022 decision 10), which is why the
6
+ * linter forbids raw colours and the props below are the whole vocabulary.
7
+ * `kit/README.md` is the reference the Bot reads.
8
+ */
9
+
10
+ import {
11
+ useEffect,
12
+ useRef,
13
+ type ButtonHTMLAttributes,
14
+ type InputHTMLAttributes,
15
+ type ReactNode,
16
+ type SelectHTMLAttributes,
17
+ type TextareaHTMLAttributes,
18
+ } from "react";
19
+
20
+ import { installKitStyles } from "./styles.js";
21
+
22
+ export { KIT_CSS, installKitStyles } from "./styles.js";
23
+
24
+ installKitStyles();
25
+
26
+ type Space = "none" | "small" | "medium" | "large";
27
+
28
+ const SPACE: Record<Space, string> = {
29
+ none: "0",
30
+ small: "4px",
31
+ medium: "8px",
32
+ large: "16px",
33
+ };
34
+
35
+ function classes(...values: Array<string | false | undefined>): string {
36
+ return values.filter(Boolean).join(" ");
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Layout
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export interface StackProps {
44
+ children?: ReactNode;
45
+ direction?: "row" | "column";
46
+ gap?: Space;
47
+ align?: "start" | "center" | "end" | "stretch";
48
+ justify?: "start" | "center" | "end" | "between";
49
+ wrap?: boolean;
50
+ /** Adds the kit's root class; put one at the top of the page. */
51
+ root?: boolean;
52
+ className?: string;
53
+ }
54
+
55
+ export function Stack({
56
+ children,
57
+ direction = "column",
58
+ gap = "medium",
59
+ align,
60
+ justify,
61
+ wrap,
62
+ root,
63
+ className,
64
+ }: StackProps) {
65
+ return (
66
+ <div
67
+ className={classes("fb-stack", root && "fb-root", className)}
68
+ data-direction={direction}
69
+ data-align={align}
70
+ data-justify={justify}
71
+ data-wrap={wrap ? "true" : undefined}
72
+ style={{ gap: SPACE[gap], ...(root ? { padding: "12px" } : null) }}
73
+ >
74
+ {children}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ export interface TextProps {
80
+ children?: ReactNode;
81
+ size?: "title" | "heading" | "body" | "small";
82
+ tone?: "default" | "muted";
83
+ as?: "p" | "span" | "div" | "h1" | "h2" | "h3";
84
+ className?: string;
85
+ }
86
+
87
+ export function Text({
88
+ children,
89
+ size = "body",
90
+ tone = "default",
91
+ as: Tag = "p",
92
+ className,
93
+ }: TextProps) {
94
+ return (
95
+ <Tag
96
+ className={classes("fb-text", className)}
97
+ data-size={size}
98
+ data-tone={tone}
99
+ >
100
+ {children}
101
+ </Tag>
102
+ );
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Controls
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export interface ButtonProps extends Omit<
110
+ ButtonHTMLAttributes<HTMLButtonElement>,
111
+ "className" | "style"
112
+ > {
113
+ children?: ReactNode;
114
+ variant?: "default" | "primary" | "ghost";
115
+ }
116
+
117
+ export function Button({
118
+ children,
119
+ variant = "default",
120
+ type = "button",
121
+ ...rest
122
+ }: ButtonProps) {
123
+ return (
124
+ <button className="fb-button" data-variant={variant} type={type} {...rest}>
125
+ {children}
126
+ </button>
127
+ );
128
+ }
129
+
130
+ interface FieldProps {
131
+ label?: string;
132
+ error?: string;
133
+ children: ReactNode;
134
+ }
135
+
136
+ function Field({ label, error, children }: FieldProps) {
137
+ return (
138
+ <label className="fb-field">
139
+ {label ? <span className="fb-label">{label}</span> : null}
140
+ {children}
141
+ {error ? <span className="fb-error">{error}</span> : null}
142
+ </label>
143
+ );
144
+ }
145
+
146
+ export interface InputProps extends Omit<
147
+ InputHTMLAttributes<HTMLInputElement>,
148
+ "className" | "style" | "onChange" | "type"
149
+ > {
150
+ label?: string;
151
+ error?: string;
152
+ /** Receives the value, not the event. */
153
+ onValueChange?: (value: string) => void;
154
+ }
155
+
156
+ export function Input({ label, error, onValueChange, ...rest }: InputProps) {
157
+ return (
158
+ <Field label={label} error={error}>
159
+ <input
160
+ className="fb-control"
161
+ type="text"
162
+ onChange={(event) => onValueChange?.(event.target.value)}
163
+ {...rest}
164
+ />
165
+ </Field>
166
+ );
167
+ }
168
+
169
+ export interface TextareaProps extends Omit<
170
+ TextareaHTMLAttributes<HTMLTextAreaElement>,
171
+ "className" | "style" | "onChange"
172
+ > {
173
+ label?: string;
174
+ error?: string;
175
+ onValueChange?: (value: string) => void;
176
+ }
177
+
178
+ export function Textarea({
179
+ label,
180
+ error,
181
+ onValueChange,
182
+ ...rest
183
+ }: TextareaProps) {
184
+ return (
185
+ <Field label={label} error={error}>
186
+ <textarea
187
+ className="fb-control"
188
+ onChange={(event) => onValueChange?.(event.target.value)}
189
+ {...rest}
190
+ />
191
+ </Field>
192
+ );
193
+ }
194
+
195
+ export interface SelectOption {
196
+ value: string;
197
+ label: string;
198
+ }
199
+
200
+ export interface SelectProps extends Omit<
201
+ SelectHTMLAttributes<HTMLSelectElement>,
202
+ "className" | "style" | "onChange" | "children"
203
+ > {
204
+ label?: string;
205
+ error?: string;
206
+ options: SelectOption[];
207
+ onValueChange?: (value: string) => void;
208
+ }
209
+
210
+ export function Select({
211
+ label,
212
+ error,
213
+ options,
214
+ onValueChange,
215
+ ...rest
216
+ }: SelectProps) {
217
+ return (
218
+ <Field label={label} error={error}>
219
+ <select
220
+ className="fb-control"
221
+ onChange={(event) => onValueChange?.(event.target.value)}
222
+ {...rest}
223
+ >
224
+ {options.map((option) => (
225
+ <option key={option.value} value={option.value}>
226
+ {option.label}
227
+ </option>
228
+ ))}
229
+ </select>
230
+ </Field>
231
+ );
232
+ }
233
+
234
+ export interface CheckboxProps {
235
+ checked: boolean;
236
+ onChange: (checked: boolean) => void;
237
+ label?: ReactNode;
238
+ disabled?: boolean;
239
+ /** Announced when there is no visible label. */
240
+ ariaLabel?: string;
241
+ }
242
+
243
+ export function Checkbox({
244
+ checked,
245
+ onChange,
246
+ label,
247
+ disabled,
248
+ ariaLabel,
249
+ }: CheckboxProps) {
250
+ return (
251
+ <label
252
+ className="fb-checkbox"
253
+ data-disabled={disabled ? "true" : undefined}
254
+ >
255
+ <input
256
+ type="checkbox"
257
+ checked={checked}
258
+ disabled={disabled}
259
+ aria-label={label === undefined ? ariaLabel : undefined}
260
+ onChange={(event) => onChange(event.target.checked)}
261
+ />
262
+ {label === undefined ? null : <span>{label}</span>}
263
+ </label>
264
+ );
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Surfaces
269
+ // ---------------------------------------------------------------------------
270
+
271
+ export interface CardProps {
272
+ children?: ReactNode;
273
+ title?: ReactNode;
274
+ }
275
+
276
+ export function Card({ children, title }: CardProps) {
277
+ return (
278
+ <section className="fb-card">
279
+ {title === undefined ? null : (
280
+ <div className="fb-card-title">{title}</div>
281
+ )}
282
+ {children}
283
+ </section>
284
+ );
285
+ }
286
+
287
+ export interface ToolbarProps {
288
+ children?: ReactNode;
289
+ /** Renders flush to the trailing edge, after a flexible spacer. */
290
+ end?: ReactNode;
291
+ }
292
+
293
+ export function Toolbar({ children, end }: ToolbarProps) {
294
+ return (
295
+ <div className="fb-toolbar">
296
+ {children}
297
+ {end === undefined ? null : (
298
+ <>
299
+ <span className="fb-toolbar-spacer" />
300
+ {end}
301
+ </>
302
+ )}
303
+ </div>
304
+ );
305
+ }
306
+
307
+ export interface ListProps {
308
+ children?: ReactNode;
309
+ /** Hairline between rows. Defaults to true. */
310
+ bordered?: boolean;
311
+ }
312
+
313
+ export function List({ children, bordered = true }: ListProps) {
314
+ return (
315
+ <ul className="fb-list" data-bordered={bordered ? "true" : "false"}>
316
+ {children}
317
+ </ul>
318
+ );
319
+ }
320
+
321
+ export interface ListItemProps {
322
+ children?: ReactNode;
323
+ /** Leading slot: a checkbox, a badge, an avatar. */
324
+ start?: ReactNode;
325
+ /** Trailing slot: actions. */
326
+ end?: ReactNode;
327
+ onClick?: () => void;
328
+ }
329
+
330
+ export function ListItem({ children, start, end, onClick }: ListItemProps) {
331
+ return (
332
+ <li
333
+ className="fb-list-item"
334
+ data-interactive={onClick ? "true" : undefined}
335
+ onClick={onClick}
336
+ >
337
+ {start}
338
+ <div className="fb-list-item-body">{children}</div>
339
+ {end === undefined ? null : <div className="fb-list-item-end">{end}</div>}
340
+ </li>
341
+ );
342
+ }
343
+
344
+ export interface BadgeProps {
345
+ children?: ReactNode;
346
+ tone?: "default" | "accent";
347
+ }
348
+
349
+ export function Badge({ children, tone = "default" }: BadgeProps) {
350
+ return (
351
+ <span className="fb-badge" data-tone={tone}>
352
+ {children}
353
+ </span>
354
+ );
355
+ }
356
+
357
+ export interface EmptyStateProps {
358
+ title: string;
359
+ description?: string;
360
+ action?: ReactNode;
361
+ }
362
+
363
+ export function EmptyState({ title, description, action }: EmptyStateProps) {
364
+ return (
365
+ <div className="fb-empty">
366
+ <div className="fb-empty-title">{title}</div>
367
+ {description === undefined ? null : <div>{description}</div>}
368
+ {action}
369
+ </div>
370
+ );
371
+ }
372
+
373
+ export interface DialogProps {
374
+ open: boolean;
375
+ title?: ReactNode;
376
+ onClose: () => void;
377
+ children?: ReactNode;
378
+ /** Buttons for the trailing action row. */
379
+ actions?: ReactNode;
380
+ }
381
+
382
+ export function Dialog({
383
+ open,
384
+ title,
385
+ onClose,
386
+ children,
387
+ actions,
388
+ }: DialogProps) {
389
+ const surface = useRef<HTMLDivElement>(null);
390
+
391
+ useEffect(() => {
392
+ if (!open) return;
393
+ const onKey = (event: KeyboardEvent) => {
394
+ if (event.key === "Escape") onClose();
395
+ };
396
+ document.addEventListener("keydown", onKey);
397
+ surface.current?.focus();
398
+ return () => document.removeEventListener("keydown", onKey);
399
+ }, [open, onClose]);
400
+
401
+ if (!open) return null;
402
+ return (
403
+ <div
404
+ className="fb-dialog-backdrop fb-root"
405
+ onClick={(event) => {
406
+ if (event.target === event.currentTarget) onClose();
407
+ }}
408
+ >
409
+ <div
410
+ className="fb-dialog"
411
+ role="dialog"
412
+ aria-modal="true"
413
+ aria-label={typeof title === "string" ? title : undefined}
414
+ tabIndex={-1}
415
+ ref={surface}
416
+ >
417
+ {title === undefined ? null : (
418
+ <div className="fb-dialog-title">{title}</div>
419
+ )}
420
+ {children}
421
+ {actions === undefined ? null : (
422
+ <div className="fb-dialog-actions">{actions}</div>
423
+ )}
424
+ </div>
425
+ </div>
426
+ );
427
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The kit's one stylesheet, as a string so the bundle stays a single file and
3
+ * no build step has to know about CSS.
4
+ *
5
+ * Every colour, radius, and edge resolves through one of the nine semantic
6
+ * tokens the host injects as `--frockbot-*`; the fallbacks are what the page
7
+ * looks like before `init` arrives, or in a plain browser tab. Nothing here
8
+ * spells a colour that is not a fallback, which is the rule the linter enforces
9
+ * for Applet code too.
10
+ */
11
+ export const KIT_CSS = `
12
+ .fb-root, .fb-root * { box-sizing: border-box; }
13
+ .fb-root {
14
+ --fb-surface: var(--frockbot-surface, #ffffff);
15
+ --fb-surface-raised: var(--frockbot-surface-raised, #ffffff);
16
+ --fb-surface-subtle: var(--frockbot-surface-subtle, #f3f4f6);
17
+ --fb-text: var(--frockbot-text, #16181d);
18
+ --fb-text-muted: var(--frockbot-text-muted, #5b616b);
19
+ --fb-border: var(--frockbot-border, #d8dbe0);
20
+ --fb-accent-surface: var(--frockbot-accent-surface, #2f6feb);
21
+ --fb-accent-text: var(--frockbot-accent-text, #ffffff);
22
+ --fb-radius: var(--frockbot-radius-card, 10px);
23
+ --fb-radius-sm: calc(var(--fb-radius) / 2);
24
+ --fb-gap: 8px;
25
+ color: var(--fb-text);
26
+ background: var(--fb-surface);
27
+ font: 400 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
28
+ min-height: 100%;
29
+ }
30
+
31
+ .fb-stack { display: flex; }
32
+ .fb-stack[data-direction="column"] { flex-direction: column; }
33
+ .fb-stack[data-direction="row"] { flex-direction: row; align-items: center; }
34
+ .fb-stack[data-wrap="true"] { flex-wrap: wrap; }
35
+ .fb-stack[data-align="start"] { align-items: flex-start; }
36
+ .fb-stack[data-align="center"] { align-items: center; }
37
+ .fb-stack[data-align="end"] { align-items: flex-end; }
38
+ .fb-stack[data-align="stretch"] { align-items: stretch; }
39
+ .fb-stack[data-justify="start"] { justify-content: flex-start; }
40
+ .fb-stack[data-justify="center"] { justify-content: center; }
41
+ .fb-stack[data-justify="end"] { justify-content: flex-end; }
42
+ .fb-stack[data-justify="between"] { justify-content: space-between; }
43
+
44
+ .fb-text { margin: 0; color: var(--fb-text); }
45
+ .fb-text[data-tone="muted"] { color: var(--fb-text-muted); }
46
+ .fb-text[data-size="title"] { font-size: 20px; font-weight: 600; line-height: 1.3; }
47
+ .fb-text[data-size="heading"] { font-size: 16px; font-weight: 600; line-height: 1.4; }
48
+ .fb-text[data-size="body"] { font-size: 14px; }
49
+ .fb-text[data-size="small"] { font-size: 12px; }
50
+
51
+ .fb-button {
52
+ appearance: none;
53
+ border: 1px solid var(--fb-border);
54
+ border-radius: var(--fb-radius-sm);
55
+ background: var(--fb-surface-raised);
56
+ color: var(--fb-text);
57
+ font: inherit;
58
+ padding: 6px 12px;
59
+ min-height: 32px;
60
+ cursor: pointer;
61
+ display: inline-flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ gap: 6px;
65
+ }
66
+ .fb-button:hover:not(:disabled) { background: var(--fb-surface-subtle); }
67
+ .fb-button:focus-visible { outline: 2px solid var(--fb-accent-surface); outline-offset: 1px; }
68
+ .fb-button:disabled { opacity: 0.5; cursor: default; }
69
+ .fb-button[data-variant="primary"] {
70
+ background: var(--fb-accent-surface);
71
+ border-color: var(--fb-accent-surface);
72
+ color: var(--fb-accent-text);
73
+ }
74
+ .fb-button[data-variant="primary"]:hover:not(:disabled) { filter: brightness(0.94); }
75
+ .fb-button[data-variant="ghost"] { background: transparent; border-color: transparent; }
76
+ .fb-button[data-variant="ghost"]:hover:not(:disabled) { background: var(--fb-surface-subtle); }
77
+
78
+ .fb-field { display: flex; flex-direction: column; gap: 4px; }
79
+ .fb-label { font-size: 12px; color: var(--fb-text-muted); }
80
+ .fb-control {
81
+ appearance: none;
82
+ border: 1px solid var(--fb-border);
83
+ border-radius: var(--fb-radius-sm);
84
+ background: var(--fb-surface-raised);
85
+ color: var(--fb-text);
86
+ font: inherit;
87
+ padding: 6px 10px;
88
+ min-height: 32px;
89
+ width: 100%;
90
+ }
91
+ .fb-control:focus-visible { outline: 2px solid var(--fb-accent-surface); outline-offset: -1px; }
92
+ .fb-control:disabled { opacity: 0.5; }
93
+ textarea.fb-control { min-height: 72px; resize: vertical; }
94
+ .fb-error { font-size: 12px; color: var(--fb-accent-surface); }
95
+
96
+ .fb-checkbox { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
97
+ .fb-checkbox input { accent-color: var(--fb-accent-surface); width: 16px; height: 16px; margin: 0; }
98
+ .fb-checkbox[data-disabled="true"] { opacity: 0.5; cursor: default; }
99
+
100
+ .fb-card {
101
+ border: 1px solid var(--fb-border);
102
+ border-radius: var(--fb-radius);
103
+ background: var(--fb-surface-raised);
104
+ padding: 12px;
105
+ display: flex;
106
+ flex-direction: column;
107
+ gap: var(--fb-gap);
108
+ }
109
+ .fb-card-title { font-size: 16px; font-weight: 600; }
110
+
111
+ .fb-toolbar {
112
+ display: flex;
113
+ align-items: center;
114
+ gap: var(--fb-gap);
115
+ padding: 8px 0;
116
+ border-bottom: 1px solid var(--fb-border);
117
+ }
118
+ .fb-toolbar-spacer { flex: 1 1 auto; }
119
+
120
+ .fb-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
121
+ .fb-list[data-bordered="true"] > .fb-list-item + .fb-list-item { border-top: 1px solid var(--fb-border); }
122
+ .fb-list-item {
123
+ display: flex;
124
+ align-items: center;
125
+ gap: var(--fb-gap);
126
+ padding: 8px 4px;
127
+ min-height: 36px;
128
+ }
129
+ .fb-list-item[data-interactive="true"] { cursor: pointer; border-radius: var(--fb-radius-sm); }
130
+ .fb-list-item[data-interactive="true"]:hover { background: var(--fb-surface-subtle); }
131
+ .fb-list-item-body { flex: 1 1 auto; min-width: 0; }
132
+ .fb-list-item-end { flex: 0 0 auto; display: flex; align-items: center; gap: 4px; }
133
+
134
+ .fb-badge {
135
+ display: inline-flex;
136
+ align-items: center;
137
+ border-radius: 999px;
138
+ border: 1px solid var(--fb-border);
139
+ background: var(--fb-surface-subtle);
140
+ color: var(--fb-text-muted);
141
+ font-size: 12px;
142
+ line-height: 1;
143
+ padding: 3px 8px;
144
+ }
145
+ .fb-badge[data-tone="accent"] {
146
+ background: var(--fb-accent-surface);
147
+ border-color: var(--fb-accent-surface);
148
+ color: var(--fb-accent-text);
149
+ }
150
+
151
+ .fb-empty {
152
+ display: flex;
153
+ flex-direction: column;
154
+ align-items: center;
155
+ gap: 6px;
156
+ text-align: center;
157
+ padding: 32px 16px;
158
+ color: var(--fb-text-muted);
159
+ border: 1px dashed var(--fb-border);
160
+ border-radius: var(--fb-radius);
161
+ }
162
+ .fb-empty-title { font-size: 15px; font-weight: 600; color: var(--fb-text); }
163
+
164
+ .fb-dialog-backdrop {
165
+ position: fixed;
166
+ inset: 0;
167
+ display: flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ padding: 16px;
171
+ background: color-mix(in srgb, var(--frockbot-text, #16181d) 45%, transparent);
172
+ z-index: 10;
173
+ }
174
+ .fb-dialog {
175
+ background: var(--fb-surface-raised);
176
+ border: 1px solid var(--fb-border);
177
+ border-radius: var(--fb-radius);
178
+ width: min(420px, 100%);
179
+ max-height: 100%;
180
+ overflow: auto;
181
+ padding: 16px;
182
+ display: flex;
183
+ flex-direction: column;
184
+ gap: 12px;
185
+ }
186
+ .fb-dialog-title { font-size: 16px; font-weight: 600; }
187
+ .fb-dialog-actions { display: flex; justify-content: flex-end; gap: var(--fb-gap); }
188
+ `;
189
+
190
+ const STYLE_MARKER = "data-frockbot-kit";
191
+
192
+ /** Inject the stylesheet once. Called on import; safe outside a browser. */
193
+ export function installKitStyles(): void {
194
+ if (typeof document === "undefined") return;
195
+ if (document.head.querySelector(`style[${STYLE_MARKER}]`)) return;
196
+ const style = document.createElement("style");
197
+ style.setAttribute(STYLE_MARKER, "1");
198
+ style.textContent = KIT_CSS;
199
+ document.head.append(style);
200
+ }