@dowel-ui/react 0.1.0 → 0.2.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 (195) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +187 -0
  3. package/dist/components/ai-action-ledger/ai-action-ledger.d.ts +77 -0
  4. package/dist/components/ai-action-ledger/ai-action-ledger.d.ts.map +1 -0
  5. package/dist/components/ai-action-ledger/ai-action-ledger.js +224 -0
  6. package/dist/components/ai-action-ledger/ai-action-ledger.js.map +1 -0
  7. package/dist/components/ai-action-ledger/index.d.ts +2 -0
  8. package/dist/components/ai-action-ledger/index.js +2 -0
  9. package/dist/components/ai-action-ledger/meta.js +17 -0
  10. package/dist/components/ai-action-ledger/meta.js.map +1 -0
  11. package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
  12. package/dist/components/ai-inline-completion/ai-inline-completion.d.ts +45 -0
  13. package/dist/components/ai-inline-completion/ai-inline-completion.d.ts.map +1 -0
  14. package/dist/components/ai-inline-completion/ai-inline-completion.js +110 -0
  15. package/dist/components/ai-inline-completion/ai-inline-completion.js.map +1 -0
  16. package/dist/components/ai-inline-completion/index.d.ts +2 -0
  17. package/dist/components/ai-inline-completion/index.js +2 -0
  18. package/dist/components/ai-inline-completion/meta.js +17 -0
  19. package/dist/components/ai-inline-completion/meta.js.map +1 -0
  20. package/dist/components/ai-structured-output/ai-structured-output.d.ts +75 -0
  21. package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -0
  22. package/dist/components/ai-structured-output/ai-structured-output.js +126 -0
  23. package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -0
  24. package/dist/components/ai-structured-output/index.d.ts +2 -0
  25. package/dist/components/ai-structured-output/index.js +2 -0
  26. package/dist/components/ai-structured-output/meta.js +17 -0
  27. package/dist/components/ai-structured-output/meta.js.map +1 -0
  28. package/dist/components/ai-tool/ai-tool.d.ts +1 -1
  29. package/dist/components/alert/alert.d.ts +1 -1
  30. package/dist/components/avatar/avatar.d.ts +1 -1
  31. package/dist/components/badge/badge.d.ts +1 -1
  32. package/dist/components/button/button.d.ts +1 -1
  33. package/dist/components/meter/index.d.ts +2 -0
  34. package/dist/components/meter/index.js +2 -0
  35. package/dist/components/meter/meta.js +17 -0
  36. package/dist/components/meter/meta.js.map +1 -0
  37. package/dist/components/meter/meter.d.ts +69 -0
  38. package/dist/components/meter/meter.d.ts.map +1 -0
  39. package/dist/components/meter/meter.js +144 -0
  40. package/dist/components/meter/meter.js.map +1 -0
  41. package/dist/components/metric-delta/index.d.ts +2 -0
  42. package/dist/components/metric-delta/index.js +2 -0
  43. package/dist/components/metric-delta/meta.js +17 -0
  44. package/dist/components/metric-delta/meta.js.map +1 -0
  45. package/dist/components/metric-delta/metric-delta.d.ts +59 -0
  46. package/dist/components/metric-delta/metric-delta.d.ts.map +1 -0
  47. package/dist/components/metric-delta/metric-delta.js +82 -0
  48. package/dist/components/metric-delta/metric-delta.js.map +1 -0
  49. package/dist/components/progress/progress.d.ts +1 -1
  50. package/dist/components/record-diff/index.d.ts +2 -0
  51. package/dist/components/record-diff/index.js +2 -0
  52. package/dist/components/record-diff/meta.js +17 -0
  53. package/dist/components/record-diff/meta.js.map +1 -0
  54. package/dist/components/record-diff/record-diff.d.ts +56 -0
  55. package/dist/components/record-diff/record-diff.d.ts.map +1 -0
  56. package/dist/components/record-diff/record-diff.js +199 -0
  57. package/dist/components/record-diff/record-diff.js.map +1 -0
  58. package/dist/components/spinner/spinner.d.ts +1 -1
  59. package/dist/components/toast/toast.d.ts +1 -1
  60. package/dist/index.d.ts +13 -1
  61. package/dist/index.js +7 -1
  62. package/dist/registry/components.d.ts.map +1 -1
  63. package/dist/registry/components.js +57 -45
  64. package/dist/registry/components.js.map +1 -1
  65. package/package.json +40 -5
  66. package/src/components/ai-action-ledger/ai-action-ledger.tsx +381 -0
  67. package/src/components/ai-action-ledger/index.ts +14 -0
  68. package/src/components/ai-action-ledger/meta.ts +19 -0
  69. package/src/components/ai-inline-completion/ai-inline-completion.tsx +214 -0
  70. package/src/components/ai-inline-completion/index.ts +1 -0
  71. package/src/components/ai-inline-completion/meta.ts +20 -0
  72. package/src/components/ai-structured-output/ai-structured-output.tsx +244 -0
  73. package/src/components/ai-structured-output/index.ts +10 -0
  74. package/src/components/ai-structured-output/meta.ts +18 -0
  75. package/src/components/meter/index.ts +8 -0
  76. package/src/components/meter/meta.ts +20 -0
  77. package/src/components/meter/meter.tsx +219 -0
  78. package/src/components/metric-delta/index.ts +6 -0
  79. package/src/components/metric-delta/meta.ts +18 -0
  80. package/src/components/metric-delta/metric-delta.tsx +180 -0
  81. package/src/components/record-diff/index.ts +10 -0
  82. package/src/components/record-diff/meta.ts +19 -0
  83. package/src/components/record-diff/record-diff.tsx +341 -0
  84. package/src/index.ts +6 -0
  85. package/src/registry/components.ts +12 -0
  86. package/src/blocks/admin-users/admin-users.stories.tsx +0 -83
  87. package/src/blocks/admin-users/admin-users.test.tsx +0 -159
  88. package/src/blocks/ai-chat/ai-chat.stories.tsx +0 -149
  89. package/src/blocks/ai-chat/ai-chat.test.tsx +0 -166
  90. package/src/blocks/dashboard/dashboard.stories.tsx +0 -106
  91. package/src/blocks/dashboard/dashboard.test.tsx +0 -94
  92. package/src/blocks/forgot-password/forgot-password.stories.tsx +0 -41
  93. package/src/blocks/forgot-password/forgot-password.test.tsx +0 -62
  94. package/src/blocks/login/login.stories.tsx +0 -46
  95. package/src/blocks/login/login.test.tsx +0 -100
  96. package/src/blocks/pricing/pricing.stories.tsx +0 -62
  97. package/src/blocks/pricing/pricing.test.tsx +0 -101
  98. package/src/blocks/settings/settings.stories.tsx +0 -94
  99. package/src/blocks/settings/settings.test.tsx +0 -167
  100. package/src/blocks/signup/signup.stories.tsx +0 -27
  101. package/src/blocks/signup/signup.test.tsx +0 -97
  102. package/src/components/accordion/accordion.stories.tsx +0 -90
  103. package/src/components/accordion/accordion.test.tsx +0 -156
  104. package/src/components/activity-feed/activity-feed.stories.tsx +0 -163
  105. package/src/components/activity-feed/activity-feed.test.tsx +0 -125
  106. package/src/components/ai-agent-status/ai-agent-status.stories.tsx +0 -86
  107. package/src/components/ai-agent-status/ai-agent-status.test.tsx +0 -66
  108. package/src/components/ai-conversation/ai-conversation.stories.tsx +0 -126
  109. package/src/components/ai-conversation/ai-conversation.test.tsx +0 -144
  110. package/src/components/ai-message/ai-message.stories.tsx +0 -106
  111. package/src/components/ai-message/ai-message.test.tsx +0 -160
  112. package/src/components/ai-model-selector/ai-model-selector.stories.tsx +0 -66
  113. package/src/components/ai-model-selector/ai-model-selector.test.tsx +0 -120
  114. package/src/components/ai-prompt-input/ai-prompt-input.stories.tsx +0 -145
  115. package/src/components/ai-prompt-input/ai-prompt-input.test.tsx +0 -273
  116. package/src/components/ai-reasoning/ai-reasoning.stories.tsx +0 -60
  117. package/src/components/ai-reasoning/ai-reasoning.test.tsx +0 -95
  118. package/src/components/ai-response/ai-response.stories.tsx +0 -75
  119. package/src/components/ai-response/ai-response.test.tsx +0 -96
  120. package/src/components/ai-sources/ai-sources.stories.tsx +0 -93
  121. package/src/components/ai-sources/ai-sources.test.tsx +0 -102
  122. package/src/components/ai-token-usage/ai-token-usage.stories.tsx +0 -59
  123. package/src/components/ai-token-usage/ai-token-usage.test.tsx +0 -83
  124. package/src/components/ai-tool/ai-tool.stories.tsx +0 -96
  125. package/src/components/ai-tool/ai-tool.test.tsx +0 -137
  126. package/src/components/alert/alert.stories.tsx +0 -101
  127. package/src/components/alert/alert.test.tsx +0 -94
  128. package/src/components/avatar/avatar.stories.tsx +0 -58
  129. package/src/components/avatar/avatar.test.tsx +0 -64
  130. package/src/components/badge/badge.stories.tsx +0 -68
  131. package/src/components/badge/badge.test.tsx +0 -61
  132. package/src/components/button/button.stories.tsx +0 -128
  133. package/src/components/button/button.test.tsx +0 -224
  134. package/src/components/calendar/calendar.stories.tsx +0 -105
  135. package/src/components/calendar/calendar.test.tsx +0 -255
  136. package/src/components/card/card.stories.tsx +0 -61
  137. package/src/components/card/card.test.tsx +0 -66
  138. package/src/components/checkbox/checkbox.stories.tsx +0 -124
  139. package/src/components/checkbox/checkbox.test.tsx +0 -121
  140. package/src/components/code-block/code-block.stories.tsx +0 -62
  141. package/src/components/code-block/code-block.test.tsx +0 -139
  142. package/src/components/combobox/combobox.stories.tsx +0 -171
  143. package/src/components/combobox/combobox.test.tsx +0 -429
  144. package/src/components/command/command.stories.tsx +0 -152
  145. package/src/components/command/command.test.tsx +0 -380
  146. package/src/components/data-table/data-table.stories.tsx +0 -151
  147. package/src/components/data-table/data-table.test.tsx +0 -288
  148. package/src/components/date-picker/date-picker.stories.tsx +0 -103
  149. package/src/components/date-picker/date-picker.test.tsx +0 -205
  150. package/src/components/dialog/dialog.stories.tsx +0 -140
  151. package/src/components/dialog/dialog.test.tsx +0 -186
  152. package/src/components/drawer/drawer.stories.tsx +0 -111
  153. package/src/components/drawer/drawer.test.tsx +0 -187
  154. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +0 -128
  155. package/src/components/dropdown-menu/dropdown-menu.test.tsx +0 -321
  156. package/src/components/empty-state/empty-state.stories.tsx +0 -70
  157. package/src/components/empty-state/empty-state.test.tsx +0 -97
  158. package/src/components/form/form.stories.tsx +0 -144
  159. package/src/components/form/form.test.tsx +0 -203
  160. package/src/components/input/input.stories.tsx +0 -84
  161. package/src/components/input/input.test.tsx +0 -108
  162. package/src/components/label/label.stories.tsx +0 -51
  163. package/src/components/label/label.test.tsx +0 -55
  164. package/src/components/pagination/pagination.stories.tsx +0 -129
  165. package/src/components/pagination/pagination.test.tsx +0 -119
  166. package/src/components/popover/popover.stories.tsx +0 -84
  167. package/src/components/popover/popover.test.tsx +0 -163
  168. package/src/components/progress/progress.stories.tsx +0 -90
  169. package/src/components/progress/progress.test.tsx +0 -78
  170. package/src/components/radio-group/radio-group.stories.tsx +0 -102
  171. package/src/components/radio-group/radio-group.test.tsx +0 -152
  172. package/src/components/select/select.stories.tsx +0 -137
  173. package/src/components/select/select.test.tsx +0 -209
  174. package/src/components/separator/separator.stories.tsx +0 -42
  175. package/src/components/separator/separator.test.tsx +0 -46
  176. package/src/components/sheet/sheet.stories.tsx +0 -107
  177. package/src/components/sheet/sheet.test.tsx +0 -152
  178. package/src/components/skeleton/skeleton.stories.tsx +0 -46
  179. package/src/components/skeleton/skeleton.test.tsx +0 -57
  180. package/src/components/slider/slider.stories.tsx +0 -87
  181. package/src/components/slider/slider.test.tsx +0 -148
  182. package/src/components/spinner/spinner.stories.tsx +0 -47
  183. package/src/components/spinner/spinner.test.tsx +0 -41
  184. package/src/components/switch/switch.stories.tsx +0 -70
  185. package/src/components/switch/switch.test.tsx +0 -101
  186. package/src/components/table/table.stories.tsx +0 -131
  187. package/src/components/table/table.test.tsx +0 -153
  188. package/src/components/tabs/tabs.stories.tsx +0 -139
  189. package/src/components/tabs/tabs.test.tsx +0 -148
  190. package/src/components/toast/toast.stories.tsx +0 -167
  191. package/src/components/toast/toast.test.tsx +0 -306
  192. package/src/components/tooltip/tooltip.stories.tsx +0 -89
  193. package/src/components/tooltip/tooltip.test.tsx +0 -120
  194. package/src/lib/utils.test.ts +0 -32
  195. package/src/registry/meta.test.ts +0 -234
@@ -0,0 +1,381 @@
1
+ "use client";
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import { Collapsible as CollapsiblePrimitive } from "radix-ui";
5
+ import {
6
+ createContext,
7
+ useContext,
8
+ useId,
9
+ useMemo,
10
+ useState,
11
+ type ComponentPropsWithRef,
12
+ type ReactNode,
13
+ } from "react";
14
+
15
+ import { focusRing } from "@/lib/styles";
16
+ import { cn } from "@/lib/utils";
17
+
18
+ /**
19
+ * What an agent actually did, and what can be undone.
20
+ *
21
+ * Every AI component library stops at the conversation boundary: approve a tool
22
+ * call before it runs, then treat whatever it did to the database as the
23
+ * application's problem. But the thing that blocks agents from getting write
24
+ * access in real products is not "did I approve this", it is "it did the wrong
25
+ * thing, now what".
26
+ *
27
+ * The load-bearing idea is that undo is not uniform. Deleting a row can be
28
+ * reverted. A refund can be compensated by another transaction, which is not
29
+ * the same as never having happened. A sent email cannot be taken back at all.
30
+ * Presenting all three behind one "Undo" button is a lie the user only
31
+ * discovers after clicking, so reversibility is a required field and the
32
+ * affordance follows from it.
33
+ *
34
+ * This renders and selects. It performs nothing — `onRevert` receives the
35
+ * chosen actions and the application decides what that means.
36
+ */
37
+
38
+ export type Reversibility = "revertible" | "compensable" | "irreversible";
39
+
40
+ export type ActionStatus = "applied" | "reverting" | "reverted" | "failed";
41
+
42
+ export interface LedgerAction {
43
+ id: string;
44
+ /** What was done, in the user's language: "Deleted 3 contacts". */
45
+ summary: string;
46
+ reversibility: Reversibility;
47
+ status: ActionStatus;
48
+ /** What the action touched, for grouping and scanning. */
49
+ target?: string;
50
+ timestamp?: string;
51
+ /** Why a revert failed. Shown only when status is "failed". */
52
+ error?: string;
53
+ }
54
+
55
+ const REVERSIBILITY_LABEL: Record<Reversibility, string> = {
56
+ revertible: "Can be undone",
57
+ compensable: "Can be offset, not undone",
58
+ irreversible: "Cannot be undone",
59
+ };
60
+
61
+ const STATUS_LABEL: Record<ActionStatus, string> = {
62
+ applied: "Applied",
63
+ reverting: "Reverting",
64
+ reverted: "Reverted",
65
+ failed: "Revert failed",
66
+ };
67
+
68
+ /** Only an applied action can be selected — the rest are already resolved. */
69
+ function isSelectable(action: LedgerAction): boolean {
70
+ return action.status === "applied" && action.reversibility !== "irreversible";
71
+ }
72
+
73
+ const ledgerEntryVariants = cva("rounded-lg border text-sm transition-colors", {
74
+ variants: {
75
+ status: {
76
+ applied: "border-border bg-card",
77
+ reverting: "border-info/30 bg-info/5",
78
+ reverted: "border-border bg-muted/40",
79
+ failed: "border-destructive/40 bg-destructive/5",
80
+ },
81
+ },
82
+ defaultVariants: { status: "applied" },
83
+ });
84
+
85
+ interface LedgerContextValue {
86
+ actions: LedgerAction[];
87
+ selected: Set<string>;
88
+ toggle: (id: string) => void;
89
+ selectAll: () => void;
90
+ clear: () => void;
91
+ selectableIds: string[];
92
+ onRevert?: (actions: LedgerAction[]) => void;
93
+ }
94
+
95
+ const LedgerContext = createContext<LedgerContextValue | null>(null);
96
+
97
+ function useLedgerContext(component: string): LedgerContextValue {
98
+ const context = useContext(LedgerContext);
99
+ if (!context) {
100
+ throw new Error(`${component} must be rendered inside <ActionLedger>.`);
101
+ }
102
+ return context;
103
+ }
104
+
105
+ export interface ActionLedgerProps extends Omit<ComponentPropsWithRef<"div">, "onSelect"> {
106
+ actions: LedgerAction[];
107
+ /** Receives the selected actions. The application performs the revert. */
108
+ onRevert?: (actions: LedgerAction[]) => void;
109
+ children?: ReactNode;
110
+ }
111
+
112
+ export function ActionLedger({
113
+ className,
114
+ actions,
115
+ onRevert,
116
+ children,
117
+ ...props
118
+ }: ActionLedgerProps) {
119
+ const [selected, setSelected] = useState<Set<string>>(new Set());
120
+
121
+ const selectableIds = useMemo(
122
+ () => actions.filter(isSelectable).map((action) => action.id),
123
+ [actions],
124
+ );
125
+
126
+ const context = useMemo<LedgerContextValue>(
127
+ () => ({
128
+ actions,
129
+ selected,
130
+ selectableIds,
131
+ onRevert,
132
+ toggle: (id: string) => {
133
+ setSelected((current) => {
134
+ const next = new Set(current);
135
+ if (next.has(id)) next.delete(id);
136
+ else next.add(id);
137
+ return next;
138
+ });
139
+ },
140
+ selectAll: () => {
141
+ setSelected(new Set(selectableIds));
142
+ },
143
+ clear: () => {
144
+ setSelected(new Set());
145
+ },
146
+ }),
147
+ [actions, selected, selectableIds, onRevert],
148
+ );
149
+
150
+ return (
151
+ <LedgerContext.Provider value={context}>
152
+ <div
153
+ data-slot="action-ledger"
154
+ className={cn("flex flex-col gap-3", className)}
155
+ {...props}
156
+ >
157
+ {children}
158
+ </div>
159
+ </LedgerContext.Provider>
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Selection controls and the revert action.
165
+ *
166
+ * The count is stated on the button rather than beside it, so the accessible
167
+ * name says what pressing it will do.
168
+ */
169
+ export function ActionLedgerToolbar({ className, ...props }: ComponentPropsWithRef<"div">) {
170
+ const { selected, selectableIds, actions, selectAll, clear, onRevert } =
171
+ useLedgerContext("ActionLedgerToolbar");
172
+
173
+ const count = selected.size;
174
+ const allSelected = selectableIds.length > 0 && count === selectableIds.length;
175
+
176
+ return (
177
+ <div
178
+ data-slot="action-ledger-toolbar"
179
+ className={cn("flex flex-wrap items-center gap-2", className)}
180
+ {...props}
181
+ >
182
+ <button
183
+ type="button"
184
+ disabled={selectableIds.length === 0}
185
+ onClick={allSelected ? clear : selectAll}
186
+ className={cn(
187
+ "rounded-md border border-input bg-background px-2.5 py-1 text-xs font-medium",
188
+ "transition-colors hover:bg-accent hover:text-accent-foreground",
189
+ "disabled:pointer-events-none disabled:opacity-50",
190
+ focusRing,
191
+ )}
192
+ >
193
+ {allSelected
194
+ ? "Clear selection"
195
+ : `Select all ${String(selectableIds.length)} undoable`}
196
+ </button>
197
+
198
+ <button
199
+ type="button"
200
+ data-slot="action-ledger-revert"
201
+ disabled={count === 0}
202
+ onClick={() => {
203
+ onRevert?.(actions.filter((action) => selected.has(action.id)));
204
+ }}
205
+ className={cn(
206
+ "rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground",
207
+ "transition-colors hover:bg-primary-hover",
208
+ "disabled:pointer-events-none disabled:opacity-50",
209
+ focusRing,
210
+ )}
211
+ >
212
+ {count === 0 ? "Undo selected" : `Undo ${String(count)} selected`}
213
+ </button>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ export interface ActionLedgerEntryProps
219
+ extends
220
+ Omit<ComponentPropsWithRef<"li">, "children">,
221
+ VariantProps<typeof ledgerEntryVariants> {
222
+ action: LedgerAction;
223
+ /** The payload inspector. Collapsed, because it is provenance not content. */
224
+ children?: ReactNode;
225
+ }
226
+
227
+ export function ActionLedgerEntry({
228
+ className,
229
+ action,
230
+ children,
231
+ ...props
232
+ }: ActionLedgerEntryProps) {
233
+ const { selected, toggle } = useLedgerContext("ActionLedgerEntry");
234
+ const checkboxId = useId();
235
+ const selectable = isSelectable(action);
236
+ const isSelected = selected.has(action.id);
237
+
238
+ return (
239
+ <li
240
+ data-slot="action-ledger-entry"
241
+ data-status={action.status}
242
+ data-reversibility={action.reversibility}
243
+ className={cn(ledgerEntryVariants({ status: action.status }), className)}
244
+ {...props}
245
+ >
246
+ <div className="flex items-start gap-3 p-3">
247
+ {/* A native checkbox: this is a list of things to act on, and the
248
+ selection has to survive without JavaScript-driven roles. */}
249
+ <input
250
+ type="checkbox"
251
+ id={checkboxId}
252
+ disabled={!selectable}
253
+ checked={isSelected}
254
+ onChange={() => {
255
+ toggle(action.id);
256
+ }}
257
+ className={cn(
258
+ "mt-0.5 size-4 shrink-0 rounded border-input",
259
+ "disabled:cursor-not-allowed disabled:opacity-40",
260
+ focusRing,
261
+ )}
262
+ />
263
+
264
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
265
+ <label
266
+ htmlFor={checkboxId}
267
+ className={cn("font-medium", !selectable && "opacity-70")}
268
+ >
269
+ {action.summary}
270
+ </label>
271
+
272
+ <p className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
273
+ {/* Reversibility in words, always. It decides whether the undo
274
+ button means anything, so it cannot be a colour or an icon. */}
275
+ <span data-slot="action-ledger-reversibility">
276
+ {REVERSIBILITY_LABEL[action.reversibility]}
277
+ </span>
278
+ <span aria-hidden="true">·</span>
279
+ <span data-slot="action-ledger-status">{STATUS_LABEL[action.status]}</span>
280
+ {action.target ? (
281
+ <>
282
+ <span aria-hidden="true">·</span>
283
+ <span>{action.target}</span>
284
+ </>
285
+ ) : null}
286
+ {action.timestamp ? (
287
+ <>
288
+ <span aria-hidden="true">·</span>
289
+ <time dateTime={action.timestamp}>{action.timestamp}</time>
290
+ </>
291
+ ) : null}
292
+ </p>
293
+
294
+ {/* A failed revert is the state people most need to understand, so
295
+ the reason is shown rather than hidden behind the inspector. */}
296
+ {action.status === "failed" && action.error ? (
297
+ <p data-slot="action-ledger-error" className="text-xs text-destructive">
298
+ {action.error}
299
+ </p>
300
+ ) : null}
301
+
302
+ {children}
303
+ </div>
304
+ </div>
305
+ </li>
306
+ );
307
+ }
308
+
309
+ /** The list wrapper. A real list, so its length is announced. */
310
+ export function ActionLedgerList({ className, ...props }: ComponentPropsWithRef<"ul">) {
311
+ return (
312
+ <ul
313
+ data-slot="action-ledger-list"
314
+ className={cn("flex list-none flex-col gap-2", className)}
315
+ {...props}
316
+ />
317
+ );
318
+ }
319
+
320
+ /** Collapsed payload for one entry: the arguments, the result, the diff. */
321
+ export function ActionLedgerPayload({
322
+ className,
323
+ label = "Details",
324
+ children,
325
+ ...props
326
+ }: ComponentPropsWithRef<typeof CollapsiblePrimitive.Root> & { label?: string }) {
327
+ return (
328
+ <CollapsiblePrimitive.Root
329
+ data-slot="action-ledger-payload"
330
+ className={cn("mt-1", className)}
331
+ {...props}
332
+ >
333
+ <CollapsiblePrimitive.Trigger
334
+ className={cn(
335
+ "rounded-md text-xs text-muted-foreground transition-colors hover:text-foreground",
336
+ focusRing,
337
+ )}
338
+ >
339
+ {label}
340
+ </CollapsiblePrimitive.Trigger>
341
+ <CollapsiblePrimitive.Content className="pt-2">{children}</CollapsiblePrimitive.Content>
342
+ </CollapsiblePrimitive.Root>
343
+ );
344
+ }
345
+
346
+ /**
347
+ * A summary of what is selected and what it will and will not undo.
348
+ *
349
+ * This exists because "Undo 4 selected" is not enough information when one of
350
+ * the four can only be compensated. Saying so before the click is the whole
351
+ * difference between an honest control and a misleading one.
352
+ */
353
+ export function ActionLedgerSelectionSummary({
354
+ className,
355
+ ...props
356
+ }: ComponentPropsWithRef<"p">) {
357
+ const { actions, selected } = useLedgerContext("ActionLedgerSelectionSummary");
358
+
359
+ const chosen = actions.filter((action) => selected.has(action.id));
360
+ const compensable = chosen.filter((a) => a.reversibility === "compensable").length;
361
+
362
+ if (chosen.length === 0) return null;
363
+
364
+ return (
365
+ <p
366
+ data-slot="action-ledger-selection-summary"
367
+ // Selection changes are user-driven and the wording matters, so it is
368
+ // announced politely rather than silently updating under the button.
369
+ aria-live="polite"
370
+ className={cn("text-xs text-muted-foreground", className)}
371
+ {...props}
372
+ >
373
+ {`${String(chosen.length)} selected`}
374
+ {compensable > 0
375
+ ? `, of which ${String(compensable)} can only be offset by a further action, not undone`
376
+ : ""}
377
+ </p>
378
+ );
379
+ }
380
+
381
+ export { ledgerEntryVariants };
@@ -0,0 +1,14 @@
1
+ export {
2
+ ActionLedger,
3
+ ActionLedgerEntry,
4
+ ActionLedgerList,
5
+ ActionLedgerPayload,
6
+ ActionLedgerSelectionSummary,
7
+ ActionLedgerToolbar,
8
+ ledgerEntryVariants,
9
+ type ActionLedgerEntryProps,
10
+ type ActionLedgerProps,
11
+ type ActionStatus,
12
+ type LedgerAction,
13
+ type Reversibility,
14
+ } from "./ai-action-ledger";
@@ -0,0 +1,19 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "ai-action-ledger",
5
+ title: "AI Action Ledger",
6
+ description: "What an agent actually did, classified by what can be undone.",
7
+ category: "ai",
8
+ status: "stable",
9
+ dependencies: ["class-variance-authority", "radix-ui"],
10
+ registryDependencies: [],
11
+ files: ["ai-action-ledger.tsx"],
12
+ a11y:
13
+ "Reversibility is stated in words on every entry, never by colour or icon alone — it decides " +
14
+ "whether the undo control means anything, so it cannot be decoration. Selection uses native " +
15
+ "checkboxes with real labels, and the revert button carries the count in its accessible name " +
16
+ "so it says what pressing it will do. The selection summary is an aria-live region because " +
17
+ "the wording changes as the choice changes, and warns before the click when something can " +
18
+ "only be offset rather than undone.",
19
+ });
@@ -0,0 +1,214 @@
1
+ "use client";
2
+
3
+ import {
4
+ useId,
5
+ useRef,
6
+ useState,
7
+ type ChangeEvent,
8
+ type ComponentPropsWithRef,
9
+ type CompositionEvent,
10
+ type KeyboardEvent,
11
+ type UIEvent,
12
+ } from "react";
13
+
14
+ import { cn } from "@/lib/utils";
15
+
16
+ /**
17
+ * Ghost-text suggestion inside a real textarea or input.
18
+ *
19
+ * Outside chat this is the most-copied AI affordance in software — Smart
20
+ * Compose, Copilot's grey continuation, Notion and Linear's inline suggestions —
21
+ * and every team rebuilds the same overlay-mirror trick from the same handful of
22
+ * blog posts.
23
+ *
24
+ * Two deliberate limits, both stated rather than discovered:
25
+ *
26
+ * 1. It works on `textarea` and `input`, not on contenteditable. Ghost text in
27
+ * contenteditable means owning selection, undo and inline formatting, which
28
+ * is an editor, not a component. Anyone in that territory already has
29
+ * ProseMirror or Lexical and should extend it there.
30
+ * 2. The suggestion completes the END of the value. Mid-caret insertion needs
31
+ * per-character measurement of wrapped text; the ghost hides whenever the
32
+ * caret is not at the end rather than rendering in the wrong place.
33
+ *
34
+ * There is no WAI-ARIA pattern for generative ghost text. Combobox is the
35
+ * nearest and does not fit: the suggestion is not one of a known set of options,
36
+ * so a listbox would be a lie. What the standard does require is that the
37
+ * suggestion be perceivable and that the keyboard is never trapped — so the text
38
+ * is announced through a live description, and Escape always restores plain Tab.
39
+ */
40
+
41
+ export interface InlineCompletionProps extends Omit<
42
+ ComponentPropsWithRef<"textarea">,
43
+ "value" | "onChange" | "children"
44
+ > {
45
+ value: string;
46
+ onValueChange: (value: string) => void;
47
+ /**
48
+ * The continuation to show after the value. Empty or undefined shows nothing.
49
+ * The caller supplies this; the component never requests it.
50
+ */
51
+ suggestion?: string;
52
+ /** Called with the full text after the suggestion is taken. */
53
+ onAccept?: (value: string) => void;
54
+ /** Called when the reader rejects the suggestion outright. */
55
+ onDismiss?: () => void;
56
+ /** Renders a single-line input instead of a textarea. */
57
+ singleLine?: boolean;
58
+ }
59
+
60
+ /** The next word of a suggestion, including its leading space. */
61
+ function nextWord(suggestion: string): string {
62
+ const match = /^\s*\S+/.exec(suggestion);
63
+ return match ? match[0] : suggestion;
64
+ }
65
+
66
+ export function InlineCompletion({
67
+ className,
68
+ value,
69
+ onValueChange,
70
+ suggestion = "",
71
+ onAccept,
72
+ onDismiss,
73
+ singleLine = false,
74
+ onKeyDown,
75
+ onScroll,
76
+ ...props
77
+ }: InlineCompletionProps) {
78
+ const fieldRef = useRef<HTMLTextAreaElement | null>(null);
79
+ const mirrorRef = useRef<HTMLDivElement | null>(null);
80
+ const describedById = useId();
81
+
82
+ const [dismissed, setDismissed] = useState(false);
83
+ const [composing, setComposing] = useState(false);
84
+ const [caretAtEnd, setCaretAtEnd] = useState(true);
85
+
86
+ // A suggestion is only shown when it can be shown honestly: not mid-word of
87
+ // an IME composition, not after the reader has rejected it, and not when the
88
+ // caret sits somewhere the ghost would render in the wrong place.
89
+ const visible = suggestion.length > 0 && !dismissed && !composing && caretAtEnd;
90
+
91
+ function syncCaret() {
92
+ const field = fieldRef.current;
93
+ if (!field) return;
94
+ setCaretAtEnd(field.selectionStart === value.length && field.selectionEnd === value.length);
95
+ }
96
+
97
+ function accept(text: string) {
98
+ const next = value + text;
99
+ onValueChange(next);
100
+ onAccept?.(next);
101
+ setDismissed(false);
102
+ }
103
+
104
+ function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
105
+ onKeyDown?.(event);
106
+ if (event.defaultPrevented) return;
107
+
108
+ if (!visible) return;
109
+
110
+ // Word-at-a-time, before the plain Tab case so the modifier wins.
111
+ if (event.key === "ArrowRight" && (event.metaKey || event.altKey || event.ctrlKey)) {
112
+ event.preventDefault();
113
+ accept(nextWord(suggestion));
114
+ return;
115
+ }
116
+
117
+ if (event.key === "Tab") {
118
+ event.preventDefault();
119
+ accept(suggestion);
120
+ return;
121
+ }
122
+
123
+ if (event.key === "Escape") {
124
+ // The escape hatch. Without it, a keyboard user facing a suggestion has
125
+ // no way to leave the field: Tab would accept instead of moving focus.
126
+ event.preventDefault();
127
+ setDismissed(true);
128
+ onDismiss?.();
129
+ }
130
+ }
131
+
132
+ function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {
133
+ // Any edit invalidates the previous rejection: the reader has moved on and
134
+ // the next suggestion is a different one.
135
+ setDismissed(false);
136
+ onValueChange(event.target.value);
137
+ queueMicrotask(syncCaret);
138
+ }
139
+
140
+ function handleScroll(event: UIEvent<HTMLTextAreaElement>) {
141
+ onScroll?.(event);
142
+ // The mirror has to follow the field, or the ghost detaches from the text
143
+ // as soon as the content is taller than the box.
144
+ if (mirrorRef.current) {
145
+ mirrorRef.current.scrollTop = event.currentTarget.scrollTop;
146
+ mirrorRef.current.scrollLeft = event.currentTarget.scrollLeft;
147
+ }
148
+ }
149
+
150
+ function handleComposition(event: CompositionEvent<HTMLTextAreaElement>) {
151
+ setComposing(event.type === "compositionstart");
152
+ }
153
+
154
+ // Typography must match exactly between field and mirror or the ghost lands
155
+ // in the wrong place. Both take the same class string for that reason.
156
+ const shared = cn(
157
+ "w-full rounded-md border border-input px-3 py-2 text-sm",
158
+ singleLine ? "overflow-x-auto whitespace-pre" : "break-words whitespace-pre-wrap",
159
+ );
160
+
161
+ return (
162
+ <div data-slot="inline-completion" className={cn("relative", className)}>
163
+ {/* The mirror sits behind the field and holds the value in transparent
164
+ text purely to push the ghost to the caret position. */}
165
+ <div
166
+ ref={mirrorRef}
167
+ aria-hidden="true"
168
+ data-slot="inline-completion-ghost"
169
+ className={cn(
170
+ shared,
171
+ "pointer-events-none absolute inset-0 overflow-hidden border-transparent text-transparent select-none",
172
+ )}
173
+ >
174
+ {value}
175
+ {visible ? (
176
+ <span data-slot="inline-completion-suggestion" className="text-muted-foreground">
177
+ {suggestion}
178
+ </span>
179
+ ) : null}
180
+ </div>
181
+
182
+ <textarea
183
+ ref={fieldRef}
184
+ data-slot="inline-completion-field"
185
+ data-suggesting={visible || undefined}
186
+ rows={singleLine ? 1 : props.rows}
187
+ value={value}
188
+ onChange={handleChange}
189
+ onKeyDown={handleKeyDown}
190
+ onScroll={handleScroll}
191
+ onSelect={syncCaret}
192
+ onClick={syncCaret}
193
+ onCompositionStart={handleComposition}
194
+ onCompositionEnd={handleComposition}
195
+ aria-describedby={visible ? describedById : undefined}
196
+ className={cn(
197
+ shared,
198
+ "relative resize-none bg-transparent",
199
+ "placeholder:text-muted-foreground",
200
+ "outline-none focus-visible:ring-2 focus-visible:ring-ring/55",
201
+ singleLine && "resize-none",
202
+ )}
203
+ {...props}
204
+ />
205
+
206
+ {/* The suggestion as text, for anyone who cannot see grey glyphs behind a
207
+ field. Polite, so it does not interrupt typing, and it states the keys
208
+ because a gesture nobody knows about is not an affordance. */}
209
+ <span id={describedById} aria-live="polite" className="sr-only">
210
+ {visible ? `Suggestion: ${suggestion}. Press Tab to accept, Escape to dismiss.` : ""}
211
+ </span>
212
+ </div>
213
+ );
214
+ }
@@ -0,0 +1 @@
1
+ export { InlineCompletion, type InlineCompletionProps } from "./ai-inline-completion";
@@ -0,0 +1,20 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "ai-inline-completion",
5
+ title: "AI Inline Completion",
6
+ description: "Ghost-text suggestion inside a real textarea, accepted with Tab.",
7
+ category: "ai",
8
+ status: "beta",
9
+ dependencies: [],
10
+ registryDependencies: [],
11
+ files: ["ai-inline-completion.tsx"],
12
+ a11y:
13
+ "No WAI-ARIA pattern covers generative ghost text — combobox is the nearest and does not fit, " +
14
+ "because the suggestion is not one of a known set of options and a listbox would misdescribe " +
15
+ "it. So the suggestion is announced through a polite live description that names the keys, " +
16
+ "since a gesture nobody knows about is not an affordance, and the grey text itself is " +
17
+ "aria-hidden. Escape always dismisses and restores plain Tab, so the keyboard is never " +
18
+ "trapped in the field. The ghost hides during IME composition and whenever the caret is not " +
19
+ "at the end, rather than rendering somewhere it does not belong.",
20
+ });