@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
@@ -0,0 +1,207 @@
1
+ import type { ArtifactStatus, ChangeSummary } from '@artkit-ai/core';
2
+ import { isArtifactError } from '@artkit-ai/core';
3
+ import { RotateCw } from 'lucide-react';
4
+ import {
5
+ Component,
6
+ type CSSProperties,
7
+ type ErrorInfo,
8
+ type ReactNode,
9
+ useCallback,
10
+ useEffect,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
14
+ import { type ArtkitContextValue, useArtkit } from '../context.js';
15
+ import type { UiMessageKey } from '../i18n.js';
16
+
17
+ /**
18
+ * Customization without forking (spec §13.5): each named part of a component accepts extra classes and
19
+ * inline styles. Base classes (`ak-*`) stay, so overrides compose with the default CSS.
20
+ */
21
+ export type Slots<Part extends string> = Partial<Record<Part, { className?: string; style?: CSSProperties }>>;
22
+
23
+ export function cx(...classes: (string | false | null | undefined)[]): string {
24
+ return classes.filter(Boolean).join(' ');
25
+ }
26
+
27
+ export function slot<Part extends string>(
28
+ slots: Slots<Part> | undefined,
29
+ part: Part,
30
+ base: string,
31
+ ): { className: string; style?: CSSProperties } {
32
+ const custom = slots?.[part];
33
+ return { className: cx(base, custom?.className), ...(custom?.style ? { style: custom.style } : {}) };
34
+ }
35
+
36
+ /** Attributes of every artkit root element: theme scope and forced light/dark. */
37
+ export function rootAttributes(ctx: ArtkitContextValue, themeId?: string): Record<string, string> {
38
+ return {
39
+ ...(themeId ? { 'data-ak-theme': themeId } : {}),
40
+ ...(ctx.colorScheme !== 'system' ? { 'data-theme': ctx.colorScheme } : {}),
41
+ };
42
+ }
43
+
44
+ export function StatusBadge({ status, className }: { status: ArtifactStatus; className?: string }) {
45
+ const { t } = useArtkit();
46
+ return (
47
+ <span className={cx('ak-badge', className)} data-status={status}>
48
+ {t(`status_${status}` as UiMessageKey)}
49
+ </span>
50
+ );
51
+ }
52
+
53
+ export function errorMessage(error: unknown, fallback: string): string {
54
+ if (isArtifactError(error)) {
55
+ const hint = error.issues.find((issue) => issue.hint)?.hint;
56
+ return hint ? `${error.message} ${hint}` : error.message;
57
+ }
58
+ return fallback;
59
+ }
60
+
61
+ export function ErrorMessage({
62
+ error,
63
+ onRetry,
64
+ onDismiss,
65
+ className,
66
+ style,
67
+ }: {
68
+ error: unknown;
69
+ onRetry?: () => void;
70
+ onDismiss?: () => void;
71
+ className?: string;
72
+ style?: CSSProperties;
73
+ }) {
74
+ const { t } = useArtkit();
75
+ return (
76
+ <div role="alert" className={cx('ak-alert', className)} style={style}>
77
+ <span>{errorMessage(error, t('loadFailed'))}</span>{' '}
78
+ {onRetry ? (
79
+ <button type="button" className="ak-button" onClick={onRetry}>
80
+ <RotateCw aria-hidden="true" />
81
+ {t('retry')}
82
+ </button>
83
+ ) : null}
84
+ {onDismiss ? (
85
+ <button type="button" className="ak-button" onClick={onDismiss}>
86
+ {t('dismiss')}
87
+ </button>
88
+ ) : null}
89
+ </div>
90
+ );
91
+ }
92
+
93
+ export function Loading({
94
+ label,
95
+ className,
96
+ style,
97
+ }: {
98
+ label?: string;
99
+ className?: string;
100
+ style?: CSSProperties;
101
+ }) {
102
+ const { t } = useArtkit();
103
+ return (
104
+ <div className={cx('ak-loading', className)} style={style} role="status" aria-busy="true">
105
+ {label ?? t('loading')}
106
+ </div>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Polite live region for screen readers (spec §18.2). The text is cleared first so repeating the same
112
+ * message is announced again.
113
+ */
114
+ export function useAnnouncer(): [ReactNode, (message: string) => void] {
115
+ const [message, setMessage] = useState('');
116
+ const frame = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
117
+ useEffect(() => () => clearTimeout(frame.current), []);
118
+ const announce = useCallback((text: string) => {
119
+ setMessage('');
120
+ clearTimeout(frame.current);
121
+ frame.current = setTimeout(() => setMessage(text), 30);
122
+ }, []);
123
+ const node = (
124
+ <div className="ak-sr-only" role="status" aria-live="polite" aria-atomic="true">
125
+ {message}
126
+ </div>
127
+ );
128
+ return [node, announce];
129
+ }
130
+
131
+ export function ChangeList({
132
+ changes,
133
+ className,
134
+ style,
135
+ }: {
136
+ changes: ChangeSummary[];
137
+ className?: string;
138
+ style?: CSSProperties;
139
+ }) {
140
+ const { t } = useArtkit();
141
+ if (changes.length === 0) return <p className="ak-notice">{t('noChanges')}</p>;
142
+ return (
143
+ <ul className={cx('ak-changes', className)} style={style}>
144
+ {withKeys(changes).map(({ change, key }) => (
145
+ <li key={key} className="ak-change" data-type={change.type}>
146
+ <span className="ak-change-type">{t(`change_${change.type}` as UiMessageKey)}</span>
147
+ <span className="ak-change-target">{change.target}</span>
148
+ <span className="ak-change-summary">{change.summary}</span>
149
+ </li>
150
+ ))}
151
+ </ul>
152
+ );
153
+ }
154
+
155
+ // Targets can repeat (changed + moved); a per-target counter keeps keys stable across renders.
156
+ function withKeys(changes: ChangeSummary[]): { change: ChangeSummary; key: string }[] {
157
+ const seen = new Map<string, number>();
158
+ return changes.map((change) => {
159
+ const base = `${change.type}:${change.target}`;
160
+ const count = seen.get(base) ?? 0;
161
+ seen.set(base, count + 1);
162
+ return { change, key: count === 0 ? base : `${base}#${count}` };
163
+ });
164
+ }
165
+
166
+ interface BoundaryProps {
167
+ fallback: (error: unknown, reset: () => void) => ReactNode;
168
+ onReset?: () => void;
169
+ children?: ReactNode;
170
+ }
171
+
172
+ /** Catches loader and renderer failures so one broken kind does not take the page down. */
173
+ export class ErrorBoundary extends Component<BoundaryProps, { error: unknown; failed: boolean }> {
174
+ override state = { error: undefined as unknown, failed: false };
175
+
176
+ static getDerivedStateFromError(error: unknown) {
177
+ return { error, failed: true };
178
+ }
179
+
180
+ override componentDidCatch(_error: unknown, _info: ErrorInfo): void {
181
+ // Rendering the fallback is enough; the host app can wrap its own reporting boundary around us.
182
+ }
183
+
184
+ reset = () => {
185
+ this.props.onReset?.();
186
+ this.setState({ error: undefined, failed: false });
187
+ };
188
+
189
+ override render() {
190
+ if (this.state.failed) return this.props.fallback(this.state.error, this.reset);
191
+ return this.props.children;
192
+ }
193
+ }
194
+
195
+ export function formatDate(value: string, locale: string): string {
196
+ const date = new Date(value);
197
+ if (Number.isNaN(date.getTime())) return value;
198
+ try {
199
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeStyle: 'short' }).format(date);
200
+ } catch {
201
+ return date.toISOString();
202
+ }
203
+ }
204
+
205
+ export function kindLabel(ctx: ArtkitContextValue, kind: string): string {
206
+ return ctx.registry.has(kind) ? ctx.registry.get(kind).label : kind;
207
+ }
@@ -0,0 +1,266 @@
1
+ import type { Artifact, ChangeSummary } from '@artkit-ai/core';
2
+ import { Columns2, Rows2 } from 'lucide-react';
3
+ import { useMemo, useState } from 'react';
4
+ import { type ArtkitContextValue, useArtkit } from '../context.js';
5
+ import { useArtifact } from '../hooks.js';
6
+ import { diffLines, type LineDiffEntry, toSplitRows } from '../line-diff.js';
7
+ import { ChangeList, cx, ErrorMessage, Loading, rootAttributes, type Slots, slot } from './shared.js';
8
+
9
+ export type DiffLayout = 'unified' | 'split';
10
+ export type ArtifactVersionDiffPart = 'toolbar' | 'changes' | 'lines';
11
+
12
+ export interface ArtifactVersionDiffProps {
13
+ /** Load both versions by id. */
14
+ id?: string;
15
+ /** Older version; defaults to the one before `to`. */
16
+ from?: number;
17
+ /** Newer version; defaults to the latest. */
18
+ to?: number;
19
+ /** Or pass both artifacts directly. */
20
+ before?: Artifact;
21
+ after?: Artifact;
22
+ layout?: DiffLayout;
23
+ defaultLayout?: DiffLayout;
24
+ onLayoutChange?(layout: DiffLayout): void;
25
+ /** Show the structural change list from `kind.diff` (default true). */
26
+ showChanges?: boolean;
27
+ /** Show the line diff of the full projections (default true). */
28
+ showLines?: boolean;
29
+ className?: string;
30
+ slots?: Slots<ArtifactVersionDiffPart>;
31
+ }
32
+
33
+ export interface VersionComparison {
34
+ changes: ChangeSummary[];
35
+ lines: LineDiffEntry[];
36
+ }
37
+
38
+ /** Structural changes plus a line diff of the full text projections. `before` undefined means empty. */
39
+ export function compareVersions(
40
+ ctx: ArtkitContextValue,
41
+ before: Artifact | undefined,
42
+ after: Artifact,
43
+ ): VersionComparison {
44
+ const kind = ctx.registry.get(after.kind);
45
+ const beforeContent = before?.content ?? kind.empty();
46
+ const changes: ChangeSummary[] = [];
47
+ if (before && before.title !== after.title) {
48
+ changes.push({
49
+ type: 'changed',
50
+ target: 'envelope:title',
51
+ summary: `'${before.title}' → '${after.title}'`,
52
+ });
53
+ }
54
+ try {
55
+ changes.push(...kind.diff(beforeContent, after.content));
56
+ } catch {
57
+ // The text diff below still shows what changed.
58
+ }
59
+ const project = (content: unknown, artifact: Artifact) => {
60
+ try {
61
+ return kind.project(content, {
62
+ mode: 'full',
63
+ locale: artifact.locale,
64
+ theme: ctx.themeFor(artifact.themeId),
65
+ }).text;
66
+ } catch {
67
+ return JSON.stringify(content, null, 2);
68
+ }
69
+ };
70
+ const lines = diffLines(before ? project(beforeContent, before) : '', project(after.content, after));
71
+ return { changes, lines };
72
+ }
73
+
74
+ /** Side-by-side or highlighted comparison between two versions (spec §13.1). */
75
+ export function ArtifactVersionDiff(props: ArtifactVersionDiffProps) {
76
+ const ctx = useArtkit();
77
+ const { t } = ctx;
78
+ const [ownLayout, setOwnLayout] = useState<DiffLayout>(props.defaultLayout ?? 'unified');
79
+ const layout = props.layout ?? ownLayout;
80
+ const setLayout = (next: DiffLayout) => {
81
+ setOwnLayout(next);
82
+ props.onLayoutChange?.(next);
83
+ };
84
+
85
+ const afterQuery = useArtifact(
86
+ props.after ? undefined : props.id,
87
+ props.to === undefined ? {} : { version: props.to },
88
+ );
89
+ const after = props.after ?? afterQuery.artifact;
90
+ const fromVersion = props.from ?? (after && after.version > 1 ? after.version - 1 : undefined);
91
+ const beforeQuery = useArtifact(
92
+ props.before || fromVersion === undefined ? undefined : (props.id ?? after?.id),
93
+ {
94
+ ...(fromVersion === undefined ? {} : { version: fromVersion }),
95
+ },
96
+ );
97
+ const before = props.before ?? (fromVersion === undefined ? undefined : beforeQuery.artifact);
98
+
99
+ const comparison = useMemo(
100
+ () => (after && ctx.registry.has(after.kind) ? compareVersions(ctx, before, after) : undefined),
101
+ [ctx, before, after],
102
+ );
103
+
104
+ const error = afterQuery.error ?? beforeQuery.error;
105
+ const waiting = !after || (fromVersion !== undefined && !before);
106
+ const attrs = rootAttributes(ctx, after?.themeId);
107
+ if (error) {
108
+ return (
109
+ <div className={cx('ak-root ak-diff', props.className)} {...attrs}>
110
+ <ErrorMessage error={error} />
111
+ </div>
112
+ );
113
+ }
114
+ if (waiting || !comparison || !after) {
115
+ return (
116
+ <div className={cx('ak-root ak-diff', props.className)} {...attrs}>
117
+ <Loading />
118
+ </div>
119
+ );
120
+ }
121
+
122
+ const beforeLabel = before ? t('diffBefore', { version: before.version }) : '—';
123
+ const afterLabel = t('diffAfter', { version: after.version });
124
+
125
+ return (
126
+ <div className={cx('ak-root ak-diff', props.className)} {...attrs}>
127
+ {props.showChanges === false ? null : (
128
+ <section aria-label={t('changes')}>
129
+ <ChangeList changes={comparison.changes} {...slot(props.slots, 'changes', '')} />
130
+ </section>
131
+ )}
132
+ {props.showLines === false ? null : (
133
+ <>
134
+ <fieldset {...slot(props.slots, 'toolbar', 'ak-diff-toolbar')}>
135
+ <legend className="ak-sr-only">{t('diffViewLabel')}</legend>
136
+ {(['unified', 'split'] as const).map((option) => (
137
+ <button
138
+ key={option}
139
+ type="button"
140
+ className="ak-button"
141
+ aria-pressed={layout === option}
142
+ onClick={() => setLayout(option)}
143
+ >
144
+ {option === 'unified' ? <Rows2 aria-hidden="true" /> : <Columns2 aria-hidden="true" />}
145
+ {option === 'unified' ? t('diffUnified') : t('diffSplit')}
146
+ </button>
147
+ ))}
148
+ </fieldset>
149
+ {layout === 'unified' ? (
150
+ <UnifiedLines
151
+ lines={comparison.lines}
152
+ caption={`${beforeLabel} → ${afterLabel}`}
153
+ slots={props.slots}
154
+ />
155
+ ) : (
156
+ <SplitLines
157
+ lines={comparison.lines}
158
+ beforeLabel={beforeLabel}
159
+ afterLabel={afterLabel}
160
+ slots={props.slots}
161
+ />
162
+ )}
163
+ </>
164
+ )}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ function Marker({ type }: { type: LineDiffEntry['type'] }) {
170
+ const { t } = useArtkit();
171
+ if (type === 'equal') return <td className="ak-diff-marker" />;
172
+ return (
173
+ <td className="ak-diff-marker">
174
+ <span aria-hidden="true">{type === 'insert' ? '+' : '−'}</span>
175
+ <span className="ak-sr-only">{type === 'insert' ? t('lineAdded') : t('lineRemoved')}</span>
176
+ </td>
177
+ );
178
+ }
179
+
180
+ function UnifiedLines({
181
+ lines,
182
+ caption,
183
+ slots,
184
+ }: {
185
+ lines: LineDiffEntry[];
186
+ caption: string;
187
+ slots?: Slots<ArtifactVersionDiffPart> | undefined;
188
+ }) {
189
+ return (
190
+ <table {...slot(slots, 'lines', 'ak-diff-lines')} data-layout="unified">
191
+ <caption className="ak-sr-only">{caption}</caption>
192
+ <tbody>
193
+ {lines.map((line) => (
194
+ <tr
195
+ key={`${line.type}:${line.oldLine ?? '-'}:${line.newLine ?? '-'}`}
196
+ className="ak-diff-line"
197
+ data-type={line.type}
198
+ >
199
+ <th scope="row">{line.oldLine ?? ''}</th>
200
+ <th scope="row">{line.newLine ?? ''}</th>
201
+ <Marker type={line.type} />
202
+ <td>{line.text}</td>
203
+ </tr>
204
+ ))}
205
+ </tbody>
206
+ </table>
207
+ );
208
+ }
209
+
210
+ function SplitLines({
211
+ lines,
212
+ beforeLabel,
213
+ afterLabel,
214
+ slots,
215
+ }: {
216
+ lines: LineDiffEntry[];
217
+ beforeLabel: string;
218
+ afterLabel: string;
219
+ slots?: Slots<ArtifactVersionDiffPart> | undefined;
220
+ }) {
221
+ const rows = useMemo(() => toSplitRows(lines), [lines]);
222
+ return (
223
+ <table {...slot(slots, 'lines', 'ak-diff-lines')} data-layout="split">
224
+ <thead>
225
+ <tr>
226
+ <th scope="col" colSpan={3}>
227
+ {beforeLabel}
228
+ </th>
229
+ <th scope="col" colSpan={3}>
230
+ {afterLabel}
231
+ </th>
232
+ </tr>
233
+ </thead>
234
+ <tbody>
235
+ {rows.map((row) => (
236
+ <tr key={`${row.left?.oldLine ?? '-'}:${row.right?.newLine ?? '-'}`}>
237
+ <th scope="row">{row.left?.oldLine ?? ''}</th>
238
+ {row.left && row.left.type !== 'equal' ? (
239
+ <Marker type="delete" />
240
+ ) : (
241
+ <td className="ak-diff-marker" />
242
+ )}
243
+ <td
244
+ className="ak-diff-cell"
245
+ data-type={row.left && row.left.type !== 'equal' ? 'delete' : undefined}
246
+ >
247
+ {row.left?.text ?? ''}
248
+ </td>
249
+ <th scope="row">{row.right?.newLine ?? ''}</th>
250
+ {row.right && row.right.type !== 'equal' ? (
251
+ <Marker type="insert" />
252
+ ) : (
253
+ <td className="ak-diff-marker" />
254
+ )}
255
+ <td
256
+ className="ak-diff-cell"
257
+ data-type={row.right && row.right.type !== 'equal' ? 'insert' : undefined}
258
+ >
259
+ {row.right?.text ?? ''}
260
+ </td>
261
+ </tr>
262
+ ))}
263
+ </tbody>
264
+ </table>
265
+ );
266
+ }
@@ -0,0 +1,199 @@
1
+ import type { Artifact, RenderContext } from '@artkit-ai/core';
2
+ import { Suspense, use, useMemo, useRef } from 'react';
3
+ import { type ArtkitContextValue, useArtkit } from '../context.js';
4
+ import { useArtifact } from '../hooks.js';
5
+ import { parsePartialJson } from '../partial-json.js';
6
+ import { hashString } from '../styles.js';
7
+ import { cx, ErrorBoundary, ErrorMessage, Loading, rootAttributes, type Slots, slot } from './shared.js';
8
+
9
+ export type ArtifactViewPart = 'body' | 'badge' | 'loading' | 'error';
10
+
11
+ export interface ArtifactViewProps {
12
+ /** Committed artifact to show. */
13
+ artifact?: Artifact;
14
+ /** Alternatively, load by id (latest or `version`). */
15
+ id?: string;
16
+ version?: number;
17
+ /**
18
+ * Content still being streamed by the model: a partial object or the raw JSON text so far. It goes
19
+ * through `kind.parsePartial` and shows as a provisional preview until a newer committed version
20
+ * arrives (spec §13.4).
21
+ */
22
+ partial?: unknown;
23
+ /** Kind of the partial content when no artifact exists yet. */
24
+ kind?: string;
25
+ /** Theme and locale of the partial content when no artifact exists yet. */
26
+ themeId?: string;
27
+ locale?: string;
28
+ mode?: RenderContext['mode'];
29
+ className?: string;
30
+ slots?: Slots<ArtifactViewPart>;
31
+ }
32
+
33
+ /** Turns streamed content into renderable content, or undefined while nothing complete arrived yet. */
34
+ export function previewContent(ctx: ArtkitContextValue, kind: string, partial: unknown): unknown {
35
+ if (!ctx.registry.has(kind)) return undefined;
36
+ const raw = typeof partial === 'string' && /^\s*[[{]/.test(partial) ? parsePartialJson(partial) : partial;
37
+ if (raw === undefined) return undefined;
38
+ try {
39
+ return ctx.registry.get(kind).parsePartial(raw);
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ export function renderContext(
46
+ ctx: ArtkitContextValue,
47
+ themeId: string | undefined,
48
+ locale: string,
49
+ mode: RenderContext['mode'],
50
+ provisional: boolean,
51
+ ): RenderContext {
52
+ return {
53
+ theme: ctx.themeFor(themeId),
54
+ locale,
55
+ mode,
56
+ ...(provisional ? { provisional: true } : {}),
57
+ ...(ctx.resolveAssetUrl ? { resolveAssetUrl: ctx.resolveAssetUrl } : {}),
58
+ };
59
+ }
60
+
61
+ /** Read-only view of any artifact by kind (spec §13.1). */
62
+ export function ArtifactView(props: ArtifactViewProps) {
63
+ const ctx = useArtkit();
64
+ const { t } = ctx;
65
+ const loaded = useArtifact(
66
+ props.artifact ? undefined : props.id,
67
+ props.version === undefined ? {} : { version: props.version },
68
+ );
69
+ const committed = props.artifact ?? loaded.artifact;
70
+
71
+ // Remember which committed version was on screen when streaming started: any other version arriving
72
+ // afterwards is the commit that replaces the preview, even if the caller still passes `partial`.
73
+ const streamStart = useRef<{ version: number | undefined } | null>(null);
74
+ const stillLoading = !props.artifact && props.id !== undefined && loaded.loading;
75
+ if (props.partial === undefined) streamStart.current = null;
76
+ else if (streamStart.current === null && !stillLoading)
77
+ streamStart.current = { version: committed?.version };
78
+ const showPartial =
79
+ props.partial !== undefined &&
80
+ (streamStart.current === null || committed?.version === streamStart.current.version);
81
+
82
+ const kind = props.kind ?? committed?.kind;
83
+ const partialContent = useMemo(
84
+ () => (showPartial && kind ? previewContent(ctx, kind, props.partial) : undefined),
85
+ [ctx, kind, props.partial, showPartial],
86
+ );
87
+
88
+ const themeId = committed?.themeId ?? props.themeId;
89
+ const locale = committed?.locale ?? props.locale ?? ctx.locale;
90
+ const mode = props.mode ?? 'screen';
91
+ const rootClass = cx('ak-root ak-view', props.className);
92
+ const attrs = rootAttributes(ctx, themeId);
93
+
94
+ if (showPartial) {
95
+ return (
96
+ <div className={rootClass} {...attrs} data-provisional="true" lang={locale} aria-busy="true">
97
+ <span
98
+ {...slot(props.slots, 'badge', 'ak-badge ak-view-badge')}
99
+ data-variant="provisional"
100
+ role="status"
101
+ >
102
+ {t('provisional')}
103
+ </span>
104
+ {partialContent === undefined || !kind ? (
105
+ <Loading label={t('generating')} {...slot(props.slots, 'loading', '')} />
106
+ ) : (
107
+ <div {...slot(props.slots, 'body', 'ak-view-body')}>
108
+ <KindRenderer
109
+ kind={kind}
110
+ content={partialContent}
111
+ ctx={renderContext(ctx, themeId, locale, mode, true)}
112
+ />
113
+ </div>
114
+ )}
115
+ </div>
116
+ );
117
+ }
118
+
119
+ if (!committed) {
120
+ return (
121
+ <div className={rootClass} {...attrs}>
122
+ {loaded.error ? (
123
+ <ErrorMessage
124
+ error={loaded.error}
125
+ onRetry={() => void loaded.refresh().catch(() => undefined)}
126
+ {...slot(props.slots, 'error', '')}
127
+ />
128
+ ) : (
129
+ <Loading {...slot(props.slots, 'loading', '')} />
130
+ )}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ return (
136
+ <div
137
+ className={rootClass}
138
+ {...attrs}
139
+ lang={committed.locale}
140
+ data-kind={committed.kind}
141
+ data-version={committed.version}
142
+ >
143
+ <div {...slot(props.slots, 'body', 'ak-view-body')}>
144
+ <KindRenderer
145
+ kind={committed.kind}
146
+ content={committed.content}
147
+ ctx={renderContext(ctx, themeId, locale, mode, false)}
148
+ />
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ interface KindRendererProps {
155
+ kind: string;
156
+ content: unknown;
157
+ ctx: RenderContext;
158
+ className?: string;
159
+ }
160
+
161
+ /** Lazy kind renderer with a JSON fallback when the kind has no renderer loader. */
162
+ export function KindRenderer(props: KindRendererProps) {
163
+ const ctx = useArtkit();
164
+ if (!ctx.hasRenderer(props.kind)) {
165
+ return (
166
+ <div className={props.className}>
167
+ <p className="ak-notice">{ctx.t('noRenderer', { kind: props.kind })}</p>
168
+ <pre className="ak-code">
169
+ <code>{JSON.stringify(props.content, null, 2)}</code>
170
+ </pre>
171
+ </div>
172
+ );
173
+ }
174
+ return (
175
+ <ErrorBoundary
176
+ onReset={() => ctx.resetLoad('renderer', props.kind)}
177
+ fallback={(error, reset) => <ErrorMessage error={error} onRetry={reset} />}
178
+ >
179
+ <Suspense fallback={<Loading />}>
180
+ <LoadedRenderer {...props} />
181
+ </Suspense>
182
+ </ErrorBoundary>
183
+ );
184
+ }
185
+
186
+ function LoadedRenderer({ kind, content, ctx: renderCtx, className }: KindRendererProps) {
187
+ const ctx = useArtkit();
188
+ const { Renderer, css } = use(ctx.loadRenderer(kind));
189
+ return (
190
+ <>
191
+ {css ? (
192
+ <style href={`artkit-kind-${kind}-${hashString(css)}`} precedence="artkit" nonce={ctx.nonce}>
193
+ {css}
194
+ </style>
195
+ ) : null}
196
+ <Renderer content={content} ctx={renderCtx} {...(className ? { className } : {})} />
197
+ </>
198
+ );
199
+ }