@artkit-ai/react 0.0.0-stage → 0.1.0

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.
Files changed (118) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +145 -2
  3. package/dist/cache.d.ts +35 -0
  4. package/dist/cache.d.ts.map +1 -0
  5. package/dist/cache.js +116 -0
  6. package/dist/cache.js.map +1 -0
  7. package/dist/client.d.ts +79 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +260 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/components/approval.d.ts +34 -0
  12. package/dist/components/approval.d.ts.map +1 -0
  13. package/dist/components/approval.js +80 -0
  14. package/dist/components/approval.js.map +1 -0
  15. package/dist/components/card.d.ts +22 -0
  16. package/dist/components/card.d.ts.map +1 -0
  17. package/dist/components/card.js +16 -0
  18. package/dist/components/card.js.map +1 -0
  19. package/dist/components/editor.d.ts +28 -0
  20. package/dist/components/editor.d.ts.map +1 -0
  21. package/dist/components/editor.js +161 -0
  22. package/dist/components/editor.js.map +1 -0
  23. package/dist/components/icons.d.ts +8 -0
  24. package/dist/components/icons.d.ts.map +1 -0
  25. package/dist/components/icons.js +14 -0
  26. package/dist/components/icons.js.map +1 -0
  27. package/dist/components/panel.d.ts +34 -0
  28. package/dist/components/panel.d.ts.map +1 -0
  29. package/dist/components/panel.js +216 -0
  30. package/dist/components/panel.js.map +1 -0
  31. package/dist/components/shared.d.ts +71 -0
  32. package/dist/components/shared.d.ts.map +1 -0
  33. package/dist/components/shared.js +104 -0
  34. package/dist/components/shared.js.map +1 -0
  35. package/dist/components/version-diff.d.ts +35 -0
  36. package/dist/components/version-diff.d.ts.map +1 -0
  37. package/dist/components/version-diff.js +85 -0
  38. package/dist/components/version-diff.js.map +1 -0
  39. package/dist/components/view.d.ts +40 -0
  40. package/dist/components/view.d.ts.map +1 -0
  41. package/dist/components/view.js +75 -0
  42. package/dist/components/view.js.map +1 -0
  43. package/dist/context.d.ts +59 -0
  44. package/dist/context.d.ts.map +1 -0
  45. package/dist/context.js +13 -0
  46. package/dist/context.js.map +1 -0
  47. package/dist/controls.d.ts +145 -0
  48. package/dist/controls.d.ts.map +1 -0
  49. package/dist/controls.js +188 -0
  50. package/dist/controls.js.map +1 -0
  51. package/dist/download.d.ts +4 -0
  52. package/dist/download.d.ts.map +1 -0
  53. package/dist/download.js +20 -0
  54. package/dist/download.js.map +1 -0
  55. package/dist/editor-session.d.ts +184 -0
  56. package/dist/editor-session.d.ts.map +1 -0
  57. package/dist/editor-session.js +614 -0
  58. package/dist/editor-session.js.map +1 -0
  59. package/dist/hooks.d.ts +85 -0
  60. package/dist/hooks.d.ts.map +1 -0
  61. package/dist/hooks.js +297 -0
  62. package/dist/hooks.js.map +1 -0
  63. package/dist/i18n.d.ts +197 -0
  64. package/dist/i18n.d.ts.map +1 -0
  65. package/dist/i18n.js +207 -0
  66. package/dist/i18n.js.map +1 -0
  67. package/dist/index.d.ts +22 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +22 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/kit-css.d.ts +3 -0
  72. package/dist/kit-css.d.ts.map +1 -0
  73. package/dist/kit-css.js +5 -0
  74. package/dist/kit-css.js.map +1 -0
  75. package/dist/line-diff.d.ts +25 -0
  76. package/dist/line-diff.d.ts.map +1 -0
  77. package/dist/line-diff.js +96 -0
  78. package/dist/line-diff.js.map +1 -0
  79. package/dist/partial-json.d.ts +8 -0
  80. package/dist/partial-json.d.ts.map +1 -0
  81. package/dist/partial-json.js +176 -0
  82. package/dist/partial-json.js.map +1 -0
  83. package/dist/provider.d.ts +42 -0
  84. package/dist/provider.d.ts.map +1 -0
  85. package/dist/provider.js +95 -0
  86. package/dist/provider.js.map +1 -0
  87. package/dist/styles.d.ts +25 -0
  88. package/dist/styles.d.ts.map +1 -0
  89. package/dist/styles.js +203 -0
  90. package/dist/styles.js.map +1 -0
  91. package/dist/toolbar.d.ts +90 -0
  92. package/dist/toolbar.d.ts.map +1 -0
  93. package/dist/toolbar.js +233 -0
  94. package/dist/toolbar.js.map +1 -0
  95. package/package.json +50 -3
  96. package/src/cache.ts +132 -0
  97. package/src/client.ts +390 -0
  98. package/src/components/approval.tsx +219 -0
  99. package/src/components/card.tsx +87 -0
  100. package/src/components/editor.tsx +392 -0
  101. package/src/components/icons.tsx +14 -0
  102. package/src/components/panel.tsx +591 -0
  103. package/src/components/shared.tsx +207 -0
  104. package/src/components/version-diff.tsx +266 -0
  105. package/src/components/view.tsx +199 -0
  106. package/src/context.ts +82 -0
  107. package/src/controls.tsx +587 -0
  108. package/src/download.ts +23 -0
  109. package/src/editor-session.ts +738 -0
  110. package/src/hooks.ts +411 -0
  111. package/src/i18n.ts +217 -0
  112. package/src/index.ts +160 -0
  113. package/src/kit-css.ts +5 -0
  114. package/src/line-diff.ts +111 -0
  115. package/src/partial-json.ts +160 -0
  116. package/src/provider.tsx +176 -0
  117. package/src/styles.ts +222 -0
  118. package/src/toolbar.tsx +536 -0
package/src/context.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type {
2
+ AnyArtifactExporter,
3
+ ArtifactRegistry,
4
+ AssetResolver,
5
+ EditorBindings,
6
+ RenderContext,
7
+ Theme,
8
+ } from '@artkit-ai/core';
9
+ import { type ComponentType, createContext, useContext } from 'react';
10
+ import type { ArtifactCache } from './cache.js';
11
+ import type { ArtifactClient } from './client.js';
12
+ import type { Translate } from './i18n.js';
13
+
14
+ export interface RendererProps<Content = unknown> {
15
+ content: Content;
16
+ ctx: RenderContext;
17
+ className?: string;
18
+ }
19
+
20
+ /** What a kind's `/react` entry provides. */
21
+ export interface RendererModule {
22
+ // biome-ignore lint/suspicious/noExplicitAny: renderers are typed by their own kind.
23
+ Renderer: ComponentType<RendererProps<any>>;
24
+ css?: string;
25
+ }
26
+
27
+ /** What a kind's `/edit` entry provides. */
28
+ export interface EditorModule {
29
+ // biome-ignore lint/suspicious/noExplicitAny: editors are typed by their own kind.
30
+ Editor: ComponentType<EditorBindings<any, any>>;
31
+ css?: string;
32
+ }
33
+
34
+ /**
35
+ * Lazy loaders per kind, so the bundle only pulls what is shown (spec §13.6). Typical use:
36
+ * `renderers: { document: () => import('@artkit-ai/kind-document/react').then((m) => ({ Renderer: m.DocumentRenderer, css: m.documentCss })) }`.
37
+ */
38
+ export interface ArtifactLoaders {
39
+ renderers?: Record<string, () => Promise<RendererModule>>;
40
+ editors?: Record<string, () => Promise<EditorModule>>;
41
+ exporters?: Record<string, () => Promise<AnyArtifactExporter[]>>;
42
+ }
43
+
44
+ export type ColorScheme = 'light' | 'dark' | 'system';
45
+
46
+ export interface ArtkitContextValue {
47
+ registry: ArtifactRegistry;
48
+ client: ArtifactClient;
49
+ cache: ArtifactCache;
50
+ locale: string;
51
+ t: Translate;
52
+ appTheme: Theme;
53
+ colorScheme: ColorScheme;
54
+ /** Theme of an artifact: provider themes, then registry themes, then the application theme. */
55
+ themeFor(themeId: string | undefined): Theme;
56
+ resolveAssetUrl?: (ref: string) => string | undefined;
57
+ assets?: AssetResolver;
58
+ /** Formats the server can export, per kind. */
59
+ exportFormats: Record<string, readonly string[]>;
60
+ hasRenderer(kind: string): boolean;
61
+ hasEditor(kind: string): boolean;
62
+ hasExporters(kind: string): boolean;
63
+ loadRenderer(kind: string): Promise<RendererModule>;
64
+ loadEditor(kind: string): Promise<EditorModule>;
65
+ loadExporters(kind: string): Promise<AnyArtifactExporter[]>;
66
+ /** Forgets a failed load so it can be retried. */
67
+ resetLoad(type: 'renderer' | 'editor' | 'exporters', kind: string): void;
68
+ nonce?: string;
69
+ }
70
+
71
+ export const ArtkitContext = createContext<ArtkitContextValue | null>(null);
72
+
73
+ /** The artkit context, or null outside `<ArtifactProvider>` (for components that also work standalone). */
74
+ export function useOptionalArtkit(): ArtkitContextValue | null {
75
+ return useContext(ArtkitContext);
76
+ }
77
+
78
+ export function useArtkit(): ArtkitContextValue {
79
+ const value = useContext(ArtkitContext);
80
+ if (!value) throw new Error('artkit components and hooks must be rendered inside <ArtifactProvider>.');
81
+ return value;
82
+ }
@@ -0,0 +1,587 @@
1
+ import { COLOR_TOKENS, type Color, isColorToken, resolveColor, type Theme } from '@artkit-ai/core';
2
+ import type { LucideIcon } from 'lucide-react';
3
+ import {
4
+ type ButtonHTMLAttributes,
5
+ type ReactElement,
6
+ type ReactNode,
7
+ useEffect,
8
+ useId,
9
+ useState,
10
+ } from 'react';
11
+
12
+ /*
13
+ * Standard form controls for kind editors and side panels (Mouts design: 32px controls, 6px radius, accent
14
+ * pressed state, focus ring). Every kind uses these, so buttons, fields, selects, switches and property
15
+ * panels look the same in documents, sheets, slides and diagrams, in light and dark themes.
16
+ */
17
+
18
+ const pt = (locale: string | undefined) => (locale ?? '').toLowerCase().startsWith('pt');
19
+
20
+ export type ControlVariant = 'default' | 'primary' | 'quiet' | 'danger';
21
+ export type ControlSize = 'sm' | 'md';
22
+
23
+ export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> {
24
+ icon?: LucideIcon;
25
+ variant?: ControlVariant;
26
+ size?: ControlSize;
27
+ /** Toggle state (`aria-pressed`). */
28
+ pressed?: boolean;
29
+ type?: 'button' | 'submit';
30
+ }
31
+
32
+ export function Button({
33
+ icon: Icon,
34
+ variant = 'default',
35
+ size = 'md',
36
+ pressed,
37
+ className,
38
+ children,
39
+ type = 'button',
40
+ ...rest
41
+ }: ButtonProps): ReactElement {
42
+ return (
43
+ <button
44
+ type={type}
45
+ className={`ak-ctl-button${className ? ` ${className}` : ''}`}
46
+ data-variant={variant === 'default' ? undefined : variant}
47
+ data-size={size === 'md' ? undefined : size}
48
+ {...(pressed === undefined ? {} : { 'aria-pressed': pressed })}
49
+ {...rest}
50
+ >
51
+ {Icon ? <Icon aria-hidden="true" /> : null}
52
+ {children}
53
+ </button>
54
+ );
55
+ }
56
+
57
+ export interface IconButtonProps extends Omit<ButtonProps, 'icon' | 'children' | 'aria-label'> {
58
+ icon: LucideIcon;
59
+ label: string;
60
+ }
61
+
62
+ /** Square icon-only button; the label becomes the accessible name and tooltip. */
63
+ export function IconButton({ icon, label, title, ...rest }: IconButtonProps): ReactElement {
64
+ return <Button icon={icon} aria-label={label} title={title ?? label} data-icon-only="" {...rest} />;
65
+ }
66
+
67
+ export interface FieldProps {
68
+ label: ReactNode;
69
+ hint?: ReactNode;
70
+ htmlFor?: string;
71
+ children: ReactNode;
72
+ className?: string;
73
+ }
74
+
75
+ /** Label above a control (and an optional hint below it). */
76
+ export function Field({ label, hint, htmlFor, children, className }: FieldProps): ReactElement {
77
+ return (
78
+ <div className={`ak-ctl-field${className ? ` ${className}` : ''}`}>
79
+ <label className="ak-ctl-label" htmlFor={htmlFor}>
80
+ {label}
81
+ </label>
82
+ {children}
83
+ {hint ? <span className="ak-ctl-hint">{hint}</span> : null}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ /** Fields side by side, wrapping on narrow panels. */
89
+ export function FieldRow({ children, label }: { children: ReactNode; label?: string }): ReactElement {
90
+ return (
91
+ <div className="ak-ctl-row" {...(label ? { role: 'group', 'aria-label': label } : {})}>
92
+ {children}
93
+ </div>
94
+ );
95
+ }
96
+
97
+ export interface TextFieldProps {
98
+ label: string;
99
+ value: string;
100
+ onCommit(value: string): void;
101
+ multiline?: boolean;
102
+ disabled?: boolean;
103
+ placeholder?: string;
104
+ hint?: ReactNode;
105
+ rows?: number;
106
+ }
107
+
108
+ /** Text input that commits on blur (or Enter for single-line fields), not on every keystroke. */
109
+ export function TextField({
110
+ label,
111
+ value,
112
+ onCommit,
113
+ multiline,
114
+ disabled,
115
+ placeholder,
116
+ hint,
117
+ rows = 4,
118
+ }: TextFieldProps): ReactElement {
119
+ const id = useId();
120
+ const [draft, setDraft] = useState(value);
121
+ useEffect(() => setDraft(value), [value]);
122
+ const commit = () => {
123
+ if (draft !== value) onCommit(draft);
124
+ };
125
+ return (
126
+ <Field label={label} htmlFor={id} hint={hint}>
127
+ {multiline ? (
128
+ <textarea
129
+ id={id}
130
+ className="ak-ctl-textarea"
131
+ value={draft}
132
+ disabled={disabled}
133
+ placeholder={placeholder}
134
+ rows={rows}
135
+ onChange={(event) => setDraft(event.target.value)}
136
+ onBlur={commit}
137
+ />
138
+ ) : (
139
+ <input
140
+ id={id}
141
+ className="ak-ctl-input"
142
+ value={draft}
143
+ disabled={disabled}
144
+ placeholder={placeholder}
145
+ onChange={(event) => setDraft(event.target.value)}
146
+ onBlur={commit}
147
+ onKeyDown={(event) => {
148
+ if (event.key === 'Enter') commit();
149
+ }}
150
+ />
151
+ )}
152
+ </Field>
153
+ );
154
+ }
155
+
156
+ export interface NumberFieldProps {
157
+ label: string;
158
+ value: number | undefined;
159
+ onCommit(value: number): void;
160
+ min?: number;
161
+ max?: number;
162
+ step?: number;
163
+ disabled?: boolean;
164
+ placeholder?: string;
165
+ /** Unit shown after the value (e.g. "in", "pt", "%"). */
166
+ unit?: string;
167
+ }
168
+
169
+ /** Number input that commits on blur or Enter, clamped to min/max. */
170
+ export function NumberField({
171
+ label,
172
+ value,
173
+ onCommit,
174
+ min,
175
+ max,
176
+ step = 0.01,
177
+ disabled,
178
+ placeholder,
179
+ unit,
180
+ }: NumberFieldProps): ReactElement {
181
+ const id = useId();
182
+ const [draft, setDraft] = useState(value === undefined ? '' : String(value));
183
+ useEffect(() => setDraft(value === undefined ? '' : String(value)), [value]);
184
+ const commit = () => {
185
+ const parsed = Number(draft);
186
+ if (draft.trim() === '' || !Number.isFinite(parsed)) {
187
+ setDraft(value === undefined ? '' : String(value));
188
+ return;
189
+ }
190
+ const clamped = Math.max(min ?? -Infinity, Math.min(max ?? Infinity, parsed));
191
+ if (clamped !== value) onCommit(clamped);
192
+ setDraft(String(clamped));
193
+ };
194
+ const input = (
195
+ <input
196
+ id={id}
197
+ className="ak-ctl-input"
198
+ type="number"
199
+ inputMode="decimal"
200
+ value={draft}
201
+ min={min}
202
+ max={max}
203
+ step={step}
204
+ disabled={disabled}
205
+ placeholder={placeholder}
206
+ onChange={(event) => setDraft(event.target.value)}
207
+ onBlur={commit}
208
+ onKeyDown={(event) => {
209
+ if (event.key === 'Enter') commit();
210
+ }}
211
+ />
212
+ );
213
+ return (
214
+ <Field label={label} htmlFor={id}>
215
+ {unit ? (
216
+ <span className="ak-ctl-affix">
217
+ {input}
218
+ <span className="ak-ctl-unit" aria-hidden="true">
219
+ {unit}
220
+ </span>
221
+ </span>
222
+ ) : (
223
+ input
224
+ )}
225
+ </Field>
226
+ );
227
+ }
228
+
229
+ export interface SelectFieldProps<T extends string> {
230
+ label: string;
231
+ value: T | undefined;
232
+ options: readonly { value: T; label: string }[];
233
+ onChange(value: T): void;
234
+ disabled?: boolean;
235
+ hint?: ReactNode;
236
+ }
237
+
238
+ export function SelectField<T extends string>({
239
+ label,
240
+ value,
241
+ options,
242
+ onChange,
243
+ disabled,
244
+ hint,
245
+ }: SelectFieldProps<T>): ReactElement {
246
+ const id = useId();
247
+ return (
248
+ <Field label={label} htmlFor={id} hint={hint}>
249
+ <select
250
+ id={id}
251
+ className="ak-ctl-select"
252
+ value={value ?? ''}
253
+ disabled={disabled}
254
+ onChange={(event) => onChange(event.target.value as T)}
255
+ >
256
+ {value === undefined ? <option value="">—</option> : null}
257
+ {options.map((option) => (
258
+ <option key={option.value} value={option.value}>
259
+ {option.label}
260
+ </option>
261
+ ))}
262
+ </select>
263
+ </Field>
264
+ );
265
+ }
266
+
267
+ export interface SwitchProps {
268
+ label: string;
269
+ checked: boolean;
270
+ onChange(value: boolean): void;
271
+ disabled?: boolean;
272
+ }
273
+
274
+ /** On/off switch (`role="switch"`) with its label beside it. */
275
+ export function Switch({ label, checked, onChange, disabled }: SwitchProps): ReactElement {
276
+ return (
277
+ <button
278
+ type="button"
279
+ role="switch"
280
+ aria-checked={checked}
281
+ className="ak-ctl-switch"
282
+ disabled={disabled}
283
+ onClick={() => onChange(!checked)}
284
+ >
285
+ <span className="ak-ctl-switch-track" aria-hidden="true">
286
+ <span className="ak-ctl-switch-thumb" />
287
+ </span>
288
+ <span className="ak-ctl-switch-label">{label}</span>
289
+ </button>
290
+ );
291
+ }
292
+
293
+ export interface ToggleProps {
294
+ label: string;
295
+ pressed: boolean;
296
+ onChange(value: boolean): void;
297
+ disabled?: boolean;
298
+ /** Button content (e.g. an icon or "B"); without it the toggle is drawn as a switch. */
299
+ children?: ReactNode;
300
+ icon?: LucideIcon;
301
+ }
302
+
303
+ /** Pressed-state button (formatting toggles) or, without content, a labelled switch. */
304
+ export function Toggle({ label, pressed, onChange, disabled, children, icon }: ToggleProps): ReactElement {
305
+ if (children === undefined && icon === undefined)
306
+ return <Switch label={label} checked={pressed} onChange={onChange} {...(disabled ? { disabled } : {})} />;
307
+ return (
308
+ <Button
309
+ size="sm"
310
+ pressed={pressed}
311
+ aria-label={label}
312
+ title={label}
313
+ disabled={disabled}
314
+ data-icon-only=""
315
+ {...(icon ? { icon } : {})}
316
+ onClick={() => onChange(!pressed)}
317
+ >
318
+ {children}
319
+ </Button>
320
+ );
321
+ }
322
+
323
+ export interface SegmentedProps<T extends string> {
324
+ label: string;
325
+ value: T | undefined;
326
+ options: readonly { value: T; label: string; icon?: LucideIcon }[];
327
+ onChange(value: T): void;
328
+ disabled?: boolean;
329
+ }
330
+
331
+ /** Small set of mutually exclusive options shown as joined buttons. */
332
+ export function Segmented<T extends string>({
333
+ label,
334
+ value,
335
+ options,
336
+ onChange,
337
+ disabled,
338
+ }: SegmentedProps<T>): ReactElement {
339
+ return (
340
+ <div className="ak-ctl-segmented" role="radiogroup" aria-label={label}>
341
+ {options.map(({ value: option, label: text, icon: Icon }) => (
342
+ // biome-ignore lint/a11y/useSemanticElements: segmented buttons are styled radio buttons.
343
+ <button
344
+ key={option}
345
+ type="button"
346
+ role="radio"
347
+ aria-checked={value === option}
348
+ aria-label={Icon ? text : undefined}
349
+ title={text}
350
+ disabled={disabled}
351
+ onClick={() => onChange(option)}
352
+ >
353
+ {Icon ? <Icon aria-hidden="true" /> : text}
354
+ </button>
355
+ ))}
356
+ </div>
357
+ );
358
+ }
359
+
360
+ export interface ColorPickerProps {
361
+ label: string;
362
+ value: Color | undefined;
363
+ theme: Theme;
364
+ locale: string;
365
+ onChange(color: Color | undefined): void;
366
+ /** Offer a hex input besides the theme tokens. */
367
+ allowHex?: boolean;
368
+ /** Offer a "none" option (for optional fills). */
369
+ allowNone?: boolean;
370
+ disabled?: boolean;
371
+ }
372
+
373
+ const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
374
+
375
+ /** Theme token swatches with an optional hex field. */
376
+ export function ColorPicker({
377
+ label,
378
+ value,
379
+ theme,
380
+ locale,
381
+ onChange,
382
+ allowHex,
383
+ allowNone,
384
+ disabled,
385
+ }: ColorPickerProps): ReactElement {
386
+ const [hex, setHex] = useState(value && !isColorToken(value) ? value : '');
387
+ useEffect(() => setHex(value && !isColorToken(value) ? value : ''), [value]);
388
+ const none = pt(locale) ? 'Nenhuma' : 'None';
389
+ return (
390
+ <fieldset className="ak-ctl-colors" disabled={disabled}>
391
+ <legend className="ak-ctl-label">{label}</legend>
392
+ <div className="ak-ctl-swatches">
393
+ {allowNone ? (
394
+ <button
395
+ type="button"
396
+ aria-pressed={value === undefined}
397
+ className="ak-ctl-swatch ak-ctl-swatch-none"
398
+ aria-label={none}
399
+ title={none}
400
+ onClick={() => onChange(undefined)}
401
+ />
402
+ ) : null}
403
+ {COLOR_TOKENS.map((token) => (
404
+ <button
405
+ key={token}
406
+ type="button"
407
+ aria-pressed={value === token}
408
+ className="ak-ctl-swatch"
409
+ style={{ background: resolveColor(theme, token) }}
410
+ aria-label={token}
411
+ title={token}
412
+ onClick={() => onChange(token)}
413
+ />
414
+ ))}
415
+ </div>
416
+ {allowHex ? (
417
+ <span className="ak-ctl-affix ak-ctl-hex">
418
+ <span
419
+ className="ak-ctl-hex-preview"
420
+ aria-hidden="true"
421
+ style={HEX.test(hex) ? { background: hex } : undefined}
422
+ />
423
+ <input
424
+ className="ak-ctl-input"
425
+ data-size="sm"
426
+ aria-label={`${label} (hex)`}
427
+ placeholder="#RRGGBB"
428
+ value={hex}
429
+ onChange={(event) => setHex(event.target.value)}
430
+ onBlur={() => {
431
+ if (HEX.test(hex) && hex !== value) onChange(hex);
432
+ }}
433
+ onKeyDown={(event) => {
434
+ if (event.key === 'Enter' && HEX.test(hex)) onChange(hex);
435
+ }}
436
+ />
437
+ </span>
438
+ ) : null}
439
+ </fieldset>
440
+ );
441
+ }
442
+
443
+ export interface InspectorProps {
444
+ label: string;
445
+ children: ReactNode;
446
+ className?: string;
447
+ }
448
+
449
+ /** Side properties panel of an editor: stacked sections separated by rules. */
450
+ export function Inspector({ label, children, className }: InspectorProps): ReactElement {
451
+ return (
452
+ <aside className={`ak-ctl-inspector${className ? ` ${className}` : ''}`} aria-label={label}>
453
+ {children}
454
+ </aside>
455
+ );
456
+ }
457
+
458
+ export interface InspectorSectionProps {
459
+ title?: ReactNode;
460
+ /** Accessible name when the title is not plain text. */
461
+ label?: string;
462
+ actions?: ReactNode;
463
+ children: ReactNode;
464
+ className?: string;
465
+ }
466
+
467
+ export function InspectorSection({
468
+ title,
469
+ label,
470
+ actions,
471
+ children,
472
+ className,
473
+ }: InspectorSectionProps): ReactElement {
474
+ const ariaLabel = label ?? (typeof title === 'string' ? title : undefined);
475
+ return (
476
+ <section
477
+ className={`ak-ctl-section${className ? ` ${className}` : ''}`}
478
+ {...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
479
+ >
480
+ {title || actions ? (
481
+ <header className="ak-ctl-section-head">
482
+ {title ? <h3 className="ak-ctl-section-title">{title}</h3> : null}
483
+ {actions ? <div className="ak-ctl-section-actions">{actions}</div> : null}
484
+ </header>
485
+ ) : null}
486
+ {children}
487
+ </section>
488
+ );
489
+ }
490
+
491
+ /** Wrapping row of buttons. */
492
+ export function ButtonRow({ children, label }: { children: ReactNode; label?: string }): ReactElement {
493
+ return (
494
+ <div className="ak-ctl-buttons" {...(label ? { role: 'group', 'aria-label': label } : {})}>
495
+ {children}
496
+ </div>
497
+ );
498
+ }
499
+
500
+ /** Muted helper text for empty states and instructions. */
501
+ export function Hint({ children }: { children: ReactNode }): ReactElement {
502
+ return <p className="ak-ctl-empty">{children}</p>;
503
+ }
504
+
505
+ const BUTTON =
506
+ 'display:inline-flex;align-items:center;justify-content:center;gap:6px;height:32px;padding:0 12px;margin:0;font:inherit;font-size:13px;font-weight:600;line-height:1;white-space:nowrap;border:1px solid var(--ak-color-border,#dde0e2);border-radius:var(--ak-radius-sm,6px);background:var(--ak-color-background,#fff);color:var(--ak-color-text,#1d2f3c);cursor:pointer;transition:background-color .15s,border-color .15s,color .15s,box-shadow .15s';
507
+ const FIELD =
508
+ 'box-sizing:border-box;width:100%;min-width:0;height:32px;margin:0;padding:0 10px;font:inherit;font-size:13px;line-height:1.3;color:var(--ak-color-text,#1d2f3c);background:var(--ak-color-background,#fff);border:1px solid var(--ak-color-border-strong,#8e979d);border-radius:var(--ak-radius-sm,6px);transition:border-color .15s,box-shadow .15s';
509
+
510
+ /**
511
+ * Plain `<button>`, `<input>`, `<select>` and `<textarea>` without a class inside `.ak-ctl-scope` get the
512
+ * standard look too (zero specificity, so any kind class still wins).
513
+ */
514
+ const SCOPE = ':where(.ak-ctl-scope)';
515
+ const PLAIN_BUTTON = `${SCOPE} :where(button:not([class]))`;
516
+ const PLAIN_FIELD = `${SCOPE} :where(input:not([class]):not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="range"]):not([type="file"]),select:not([class]),textarea:not([class]))`;
517
+
518
+ export const controlsCss = `
519
+ .ak-ctl-button,${PLAIN_BUTTON}{${BUTTON}}
520
+ .ak-ctl-button svg,${PLAIN_BUTTON} svg{width:16px;height:16px;flex-shrink:0}
521
+ .ak-ctl-button:hover:not(:disabled),${PLAIN_BUTTON}:hover:not(:disabled){background:var(--ak-color-hover,#f2f3f4);border-color:var(--ak-color-border-strong,#8e979d)}
522
+ .ak-ctl-button:active:not(:disabled),${PLAIN_BUTTON}:active:not(:disabled){background:var(--ak-color-active,#e8eaec)}
523
+ .ak-ctl-button[aria-pressed="true"],${PLAIN_BUTTON}[aria-pressed="true"]{background:var(--ak-color-fill-accent,#e7f2ef);color:var(--ak-color-accent,#0f7a5a);border-color:var(--ak-color-accent,#0f7a5a)}
524
+ .ak-ctl-button:disabled,${PLAIN_BUTTON}:disabled{opacity:.4;cursor:not-allowed}
525
+ .ak-ctl-button:focus-visible,${PLAIN_BUTTON}:focus-visible,.ak-ctl-switch:focus-visible,.ak-ctl-swatch:focus-visible,.ak-ctl-segmented button:focus-visible{outline:2px solid var(--ak-color-focus,#0f7a5a);outline-offset:2px}
526
+ .ak-ctl-button[data-size="sm"]{height:28px;padding:0 10px;font-size:12px}
527
+ .ak-ctl-button[data-size="sm"] svg{width:14px;height:14px}
528
+ .ak-ctl-button[data-icon-only]{width:32px;padding:0}
529
+ .ak-ctl-button[data-icon-only][data-size="sm"]{width:28px}
530
+ .ak-ctl-button[data-variant="primary"]{border-color:transparent;background:var(--ak-gradient-button,#00dd80);color:var(--ak-color-on-primary,#1d2f3c)}
531
+ .ak-ctl-button[data-variant="primary"]:hover:not(:disabled){border-color:transparent;background:var(--ak-gradient-button-hover,#16a683)}
532
+ .ak-ctl-button[data-variant="quiet"]{border-color:transparent;background:transparent;color:var(--ak-color-muted,#56636d)}
533
+ .ak-ctl-button[data-variant="quiet"]:hover:not(:disabled){border-color:transparent;color:var(--ak-color-text,#1d2f3c)}
534
+ .ak-ctl-button[data-variant="quiet"][aria-pressed="true"]{border-color:var(--ak-color-accent,#0f7a5a);color:var(--ak-color-accent,#0f7a5a)}
535
+ .ak-ctl-button[data-variant="danger"]{color:var(--ak-color-danger,#9f343a)}
536
+ .ak-ctl-button[data-variant="danger"]:hover:not(:disabled){background:var(--ak-color-fill-danger,#f5ebeb);border-color:var(--ak-color-danger,#9f343a)}
537
+ .ak-ctl-input,.ak-ctl-select,.ak-ctl-textarea,${PLAIN_FIELD}{${FIELD}}
538
+ .ak-ctl-input:hover:not(:disabled),.ak-ctl-select:hover:not(:disabled),.ak-ctl-textarea:hover:not(:disabled),${PLAIN_FIELD}:hover:not(:disabled){border-color:var(--ak-color-muted,#56636d)}
539
+ .ak-ctl-input:focus,.ak-ctl-select:focus,.ak-ctl-textarea:focus,${PLAIN_FIELD}:focus{outline:none;border-color:var(--ak-color-accent,#0f7a5a);box-shadow:0 0 0 1px var(--ak-color-accent,#0f7a5a)}
540
+ .ak-ctl-input:disabled,.ak-ctl-select:disabled,.ak-ctl-textarea:disabled,${PLAIN_FIELD}:disabled{opacity:.5;cursor:not-allowed}
541
+ .ak-ctl-input::placeholder,.ak-ctl-textarea::placeholder{color:var(--ak-color-muted,#56636d);opacity:.8}
542
+ .ak-ctl-select,${SCOPE} :where(select:not([class])){cursor:pointer;padding-right:6px}
543
+ .ak-ctl-textarea,${SCOPE} :where(textarea:not([class])){height:auto;min-height:72px;padding:8px 10px;line-height:1.5;resize:vertical}
544
+ .ak-ctl-input[data-size="sm"],.ak-ctl-select[data-size="sm"]{height:28px;font-size:12px;padding:0 8px}
545
+ .ak-ctl-field{display:flex;flex-direction:column;gap:4px;min-width:0;margin:0}
546
+ .ak-ctl-label{display:block;padding:0;font-size:12px;font-weight:600;line-height:16px;color:var(--ak-color-muted,#56636d)}
547
+ .ak-ctl-hint{font-size:12px;line-height:16px;color:var(--ak-color-muted,#56636d)}
548
+ .ak-ctl-row{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end}
549
+ .ak-ctl-row>.ak-ctl-field{flex:1 1 64px}
550
+ .ak-ctl-buttons{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0;padding:0;border:0;min-inline-size:0}
551
+ .ak-ctl-affix{position:relative;display:flex;align-items:center;min-width:0}
552
+ .ak-ctl-affix .ak-ctl-input{padding-right:30px}
553
+ .ak-ctl-unit{position:absolute;right:8px;font-size:12px;color:var(--ak-color-muted,#56636d);pointer-events:none}
554
+ .ak-ctl-hex{max-width:160px}
555
+ .ak-ctl-hex .ak-ctl-input{padding-left:30px;padding-right:8px;font-family:var(--ak-font-mono,ui-monospace,monospace)}
556
+ .ak-ctl-hex-preview{position:absolute;left:7px;width:16px;height:16px;border-radius:4px;border:1px solid var(--ak-color-border-strong,#8e979d);background:repeating-conic-gradient(var(--ak-color-border,#dde0e2) 0 25%,transparent 0 50%) 50%/8px 8px}
557
+ .ak-ctl-colors{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;border:0;min-inline-size:0}
558
+ .ak-ctl-colors:disabled{opacity:.5}
559
+ .ak-ctl-swatches{display:flex;flex-wrap:wrap;gap:6px}
560
+ .ak-ctl-swatch{width:24px;height:24px;min-width:0;padding:0;border:1px solid var(--ak-color-border-strong,#8e979d);border-radius:var(--ak-radius-sm,6px);cursor:pointer;transition:transform .1s}
561
+ .ak-ctl-swatch:hover:not(:disabled){transform:scale(1.08)}
562
+ .ak-ctl-swatch[aria-pressed="true"]{border:2px solid var(--ak-color-accent,#0f7a5a);box-shadow:inset 0 0 0 2px var(--ak-color-background,#fff)}
563
+ .ak-ctl-swatch-none{background:linear-gradient(135deg,transparent 44%,var(--ak-color-danger,#9f343a) 44%,var(--ak-color-danger,#9f343a) 56%,transparent 56%),var(--ak-color-background,#fff)}
564
+ .ak-ctl-switch{display:inline-flex;align-items:center;gap:10px;min-height:28px;margin:0;padding:0;border:0;background:none;color:var(--ak-color-text,#1d2f3c);font:inherit;font-size:13px;font-weight:600;text-align:left;cursor:pointer;border-radius:var(--ak-radius-sm,6px)}
565
+ .ak-ctl-switch:disabled{opacity:.4;cursor:not-allowed}
566
+ .ak-ctl-switch-track{position:relative;flex-shrink:0;width:32px;height:18px;border-radius:999px;background:var(--ak-color-border-strong,#8e979d);transition:background-color .15s}
567
+ .ak-ctl-switch-thumb{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--ak-color-background,#fff);transition:transform .15s}
568
+ .ak-ctl-switch[aria-checked="true"] .ak-ctl-switch-track{background:var(--ak-color-primary-deep,#16a683)}
569
+ .ak-ctl-switch[aria-checked="true"] .ak-ctl-switch-thumb{transform:translateX(14px)}
570
+ .ak-ctl-segmented{display:inline-flex;gap:2px;padding:2px;border:1px solid var(--ak-color-border,#dde0e2);border-radius:var(--ak-radius-sm,6px);background:var(--ak-color-surface,#f4f7f7)}
571
+ .ak-ctl-segmented button{display:inline-flex;align-items:center;justify-content:center;gap:4px;min-width:28px;height:26px;padding:0 8px;border:0;border-radius:4px;background:transparent;color:var(--ak-color-muted,#56636d);font:inherit;font-size:12px;font-weight:600;cursor:pointer}
572
+ .ak-ctl-segmented button svg{width:14px;height:14px}
573
+ .ak-ctl-segmented button:hover:not(:disabled){color:var(--ak-color-text,#1d2f3c)}
574
+ .ak-ctl-segmented button[aria-checked="true"]{background:var(--ak-color-background,#fff);color:var(--ak-color-accent,#0f7a5a);box-shadow:0 0 0 1px var(--ak-color-border,#dde0e2)}
575
+ .ak-ctl-inspector{display:flex;flex-direction:column;min-width:0;max-height:80vh;overflow-y:auto;background:var(--ak-color-background,#fff);color:var(--ak-color-text,#1d2f3c);border:1px solid var(--ak-color-border,#dde0e2);border-radius:var(--ak-radius-md,8px);font-size:13px}
576
+ .ak-ctl-section{display:flex;flex-direction:column;gap:12px;margin:0;padding:14px;border:0;border-top:1px solid var(--ak-color-border,#dde0e2)}
577
+ .ak-ctl-section:first-child{border-top:0}
578
+ .ak-ctl-section-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:20px}
579
+ .ak-ctl-section-title{margin:0;font-family:var(--ak-font-heading,"Sora",system-ui,sans-serif);font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--ak-color-text,#1d2f3c)}
580
+ .ak-ctl-section-actions{display:flex;gap:4px}
581
+ .ak-ctl-empty{margin:0;font-size:13px;line-height:1.55;color:var(--ak-color-muted,#56636d)}
582
+ .ak-ctl-card{display:flex;flex-direction:column;gap:8px;margin:0;padding:10px;border:1px solid var(--ak-color-border,#dde0e2);border-radius:var(--ak-radius-md,8px);background:var(--ak-color-surface,#f4f7f7);min-inline-size:0}
583
+ .ak-ctl-list{display:flex;flex-direction:column;gap:2px;margin:0;padding:0;list-style:none}
584
+ .ak-ctl-list-item{display:flex;align-items:center;gap:8px;width:100%;min-height:32px;padding:0 10px;border:1px solid transparent;border-radius:var(--ak-radius-sm,6px);background:none;color:var(--ak-color-text,#1d2f3c);font:inherit;font-size:13px;text-align:left;cursor:pointer}
585
+ .ak-ctl-list-item:hover:not(:disabled){background:var(--ak-color-hover,#f2f3f4)}
586
+ .ak-ctl-list-item[aria-current="true"]{background:var(--ak-color-fill-accent,#e7f2ef);border-color:var(--ak-color-accent,#0f7a5a);color:var(--ak-color-accent,#0f7a5a)}
587
+ `;
@@ -0,0 +1,23 @@
1
+ import type { ClientExportResult } from './client.js';
2
+
3
+ /** Saves exported bytes through a temporary object URL. No-op outside the browser. */
4
+ export function triggerDownload(file: Pick<ClientExportResult, 'bytes' | 'mimeType' | 'filename'>): void {
5
+ if (
6
+ typeof document === 'undefined' ||
7
+ typeof URL === 'undefined' ||
8
+ typeof URL.createObjectURL !== 'function'
9
+ )
10
+ return;
11
+ const blob = new Blob([file.bytes.slice()], { type: file.mimeType });
12
+ const url = URL.createObjectURL(blob);
13
+ const anchor = document.createElement('a');
14
+ anchor.href = url;
15
+ anchor.download = file.filename;
16
+ anchor.rel = 'noopener';
17
+ anchor.style.display = 'none';
18
+ document.body.appendChild(anchor);
19
+ anchor.click();
20
+ anchor.remove();
21
+ // Revoking synchronously can cancel the download in some browsers.
22
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
23
+ }